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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-12-20 16:37:47 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-12-20 16:37:47 +0300
commitaee0a117a889461ce8ced6fcf73207fe017f1d99 (patch)
tree891d9ef189227a8445d83f35c1b0fc99573f4380 /spec/frontend
parent8d46af3258650d305f53b819eabf7ab18d22f59e (diff)
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/__helpers__/emoji.js46
-rw-r--r--spec/frontend/__helpers__/experimentation_helper.js2
-rw-r--r--spec/frontend/__helpers__/matchers.js (renamed from spec/frontend/matchers.js)0
-rw-r--r--spec/frontend/__helpers__/matchers_spec.js (renamed from spec/frontend/matchers_spec.js)0
-rw-r--r--spec/frontend/__helpers__/mock_apollo_helper.js4
-rw-r--r--spec/frontend/__helpers__/mocks/axios_utils.js (renamed from spec/frontend/mocks/ce/lib/utils/axios_utils.js)0
-rw-r--r--spec/frontend/__helpers__/shared_test_setup.js90
-rw-r--r--spec/frontend/access_tokens/components/token_spec.js65
-rw-r--r--spec/frontend/access_tokens/components/tokens_app_spec.js148
-rw-r--r--spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js10
-rw-r--r--spec/frontend/admin/deploy_keys/components/table_spec.js209
-rw-r--r--spec/frontend/admin/statistics_panel/components/app_spec.js7
-rw-r--r--spec/frontend/admin/users/components/actions/actions_spec.js7
-rw-r--r--spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap80
-rw-r--r--spec/frontend/admin/users/components/modals/delete_user_modal_spec.js29
-rw-r--r--spec/frontend/admin/users/components/users_table_spec.js6
-rw-r--r--spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap4
-rw-r--r--spec/frontend/alerts_settings/components/mocks/apollo_mock.js1
-rw-r--r--spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js7
-rw-r--r--spec/frontend/analytics/usage_trends/components/users_chart_spec.js7
-rw-r--r--spec/frontend/api/packages_api_spec.js53
-rw-r--r--spec/frontend/api_spec.js47
-rw-r--r--spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js1
-rw-r--r--spec/frontend/awards_handler_spec.js10
-rw-r--r--spec/frontend/behaviors/gl_emoji_spec.js15
-rw-r--r--spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap1
-rw-r--r--spec/frontend/blob/viewer/index_spec.js2
-rw-r--r--spec/frontend/blob_edit/edit_blob_spec.js35
-rw-r--r--spec/frontend/boards/board_list_helper.js24
-rw-r--r--spec/frontend/boards/board_list_spec.js30
-rw-r--r--spec/frontend/boards/components/board_content_sidebar_spec.js9
-rw-r--r--spec/frontend/boards/components/board_filtered_search_spec.js24
-rw-r--r--spec/frontend/boards/components/board_list_header_spec.js20
-rw-r--r--spec/frontend/boards/components/issue_board_filtered_search_spec.js4
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js168
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js163
-rw-r--r--spec/frontend/boards/mock_data.js60
-rw-r--r--spec/frontend/boards/stores/actions_spec.js95
-rw-r--r--spec/frontend/ci_lint/components/ci_lint_spec.js2
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js55
-rw-r--r--spec/frontend/clusters/agents/components/activity_events_list_spec.js102
-rw-r--r--spec/frontend/clusters/agents/components/activity_history_item_spec.js56
-rw-r--r--spec/frontend/clusters/agents/components/show_spec.js10
-rw-r--r--spec/frontend/clusters/mock_data.js165
-rw-r--r--spec/frontend/clusters_list/components/agent_empty_state_spec.js57
-rw-r--r--spec/frontend/clusters_list/components/agents_spec.js22
-rw-r--r--spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js71
-rw-r--r--spec/frontend/clusters_list/components/clusters_empty_state_spec.js54
-rw-r--r--spec/frontend/clusters_list/components/clusters_main_view_spec.js22
-rw-r--r--spec/frontend/clusters_list/components/install_agent_modal_spec.js265
-rw-r--r--spec/frontend/clusters_list/mocks/apollo.js1
-rw-r--r--spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap4
-rw-r--r--spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap2
-rw-r--r--spec/frontend/content_editor/markdown_processing_examples.js27
-rw-r--r--spec/frontend/content_editor/markdown_processing_spec.js28
-rw-r--r--spec/frontend/content_editor/markdown_processing_spec_helper.js86
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js33
-rw-r--r--spec/frontend/crm/contact_form_spec.js157
-rw-r--r--spec/frontend/crm/contacts_root_spec.js150
-rw-r--r--spec/frontend/crm/mock_data.js82
-rw-r--r--spec/frontend/crm/new_organization_form_spec.js109
-rw-r--r--spec/frontend/crm/organizations_root_spec.js109
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap28
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js2
-rw-r--r--spec/frontend/diffs/components/diff_discussions_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js2
-rw-r--r--spec/frontend/diffs/components/diff_row_spec.js33
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js1
-rw-r--r--spec/frontend/diffs/utils/discussions_spec.js133
-rw-r--r--spec/frontend/dropzone_input_spec.js29
-rw-r--r--spec/frontend/editor/helpers.js57
-rw-r--r--spec/frontend/editor/source_editor_ci_schema_ext_spec.js2
-rw-r--r--spec/frontend/editor/source_editor_extension_base_spec.js161
-rw-r--r--spec/frontend/editor/source_editor_extension_spec.js26
-rw-r--r--spec/frontend/editor/source_editor_instance_spec.js181
-rw-r--r--spec/frontend/editor/source_editor_markdown_ext_spec.js368
-rw-r--r--spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js421
-rw-r--r--spec/frontend/editor/source_editor_spec.js396
-rw-r--r--spec/frontend/editor/source_editor_yaml_ext_spec.js76
-rw-r--r--spec/frontend/emoji/index_spec.js108
-rw-r--r--spec/frontend/environments/confirm_rollback_modal_spec.js134
-rw-r--r--spec/frontend/environments/delete_environment_modal_spec.js64
-rw-r--r--spec/frontend/environments/enable_review_app_modal_spec.js15
-rw-r--r--spec/frontend/environments/environment_delete_spec.js64
-rw-r--r--spec/frontend/environments/environment_rollback_spec.js29
-rw-r--r--spec/frontend/environments/graphql/mock_data.js27
-rw-r--r--spec/frontend/environments/graphql/resolvers_spec.js136
-rw-r--r--spec/frontend/environments/new_environment_folder_spec.js7
-rw-r--r--spec/frontend/environments/new_environments_app_spec.js243
-rw-r--r--spec/frontend/experimentation/utils_spec.js49
-rw-r--r--spec/frontend/fixtures/api_deploy_keys.rb24
-rw-r--r--spec/frontend/fixtures/api_markdown.rb65
-rw-r--r--spec/frontend/fixtures/api_markdown.yml289
-rw-r--r--spec/frontend/fixtures/blob.rb2
-rw-r--r--spec/frontend/fixtures/projects.rb26
-rw-r--r--spec/frontend/fixtures/raw.rb22
-rw-r--r--spec/frontend/fixtures/tabs.rb26
-rw-r--r--spec/frontend/fixtures/timezones.rb10
-rw-r--r--spec/frontend/flash_spec.js189
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js8
-rw-r--r--spec/frontend/google_cloud/components/app_spec.js86
-rw-r--r--spec/frontend/google_cloud/components/errors/gcp_error_spec.js34
-rw-r--r--spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js33
-rw-r--r--spec/frontend/google_cloud/components/home_spec.js61
-rw-r--r--spec/frontend/google_cloud/components/service_accounts_form_spec.js59
-rw-r--r--spec/frontend/google_cloud/components/service_accounts_list_spec.js (renamed from spec/frontend/google_cloud/components/service_accounts_spec.js)6
-rw-r--r--spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap3
-rw-r--r--spec/frontend/header_search/components/app_spec.js242
-rw-r--r--spec/frontend/header_search/components/header_search_autocomplete_items_spec.js55
-rw-r--r--spec/frontend/header_search/components/header_search_default_items_spec.js27
-rw-r--r--spec/frontend/header_search/components/header_search_scoped_items_spec.js36
-rw-r--r--spec/frontend/header_search/mock_data.js78
-rw-r--r--spec/frontend/header_search/store/actions_spec.js18
-rw-r--r--spec/frontend/header_search/store/getters_spec.js127
-rw-r--r--spec/frontend/header_search/store/mutations_spec.js18
-rw-r--r--spec/frontend/ide/components/ide_tree_list_spec.js13
-rw-r--r--spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap10
-rw-r--r--spec/frontend/ide/components/pipelines/empty_state_spec.js44
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js8
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js85
-rw-r--r--spec/frontend/ide/ide_router_spec.js61
-rw-r--r--spec/frontend/ide/services/index_spec.js59
-rw-r--r--spec/frontend/ide/stores/actions/project_spec.js93
-rw-r--r--spec/frontend/ide/stores/mutations/project_spec.js37
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_spec.js107
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js11
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js59
-rw-r--r--spec/frontend/import_entities/import_groups/graphql/fixtures.js9
-rw-r--r--spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap3
-rw-r--r--spec/frontend/integrations/edit/components/active_checkbox_spec.js23
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js308
-rw-r--r--spec/frontend/integrations/edit/components/jira_issues_fields_spec.js13
-rw-r--r--spec/frontend/integrations/edit/mock_data.js6
-rw-r--r--spec/frontend/integrations/edit/store/actions_spec.js51
-rw-r--r--spec/frontend/integrations/edit/store/getters_spec.js32
-rw-r--r--spec/frontend/integrations/edit/store/mutations_spec.js16
-rw-r--r--spec/frontend/integrations/edit/store/state_spec.js1
-rw-r--r--spec/frontend/integrations/integration_settings_form_spec.js248
-rw-r--r--spec/frontend/integrations/overrides/components/integration_overrides_spec.js63
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js156
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js14
-rw-r--r--spec/frontend/issuable/bulk_update_sidebar/components/status_select_spec.js (renamed from spec/frontend/issuable_bulk_update_sidebar/components/status_select_spec.js)4
-rw-r--r--spec/frontend/issuable/components/issuable_header_warnings_spec.js (renamed from spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js)8
-rw-r--r--spec/frontend/issuable/components/issue_assignees_spec.js (renamed from spec/frontend/vue_shared/components/issue/issue_assignees_spec.js)2
-rw-r--r--spec/frontend/issuable/components/issue_milestone_spec.js (renamed from spec/frontend/vue_shared/components/issue/issue_milestone_spec.js)2
-rw-r--r--spec/frontend/issuable/components/related_issuable_item_spec.js (renamed from spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js)2
-rw-r--r--spec/frontend/issuable/components/related_issuable_mock_data.js (renamed from spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js)0
-rw-r--r--spec/frontend/issuable/issuable_form_spec.js (renamed from spec/frontend/issuable_form_spec.js)2
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_block_spec.js2
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_list_spec.js2
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_root_spec.js2
-rw-r--r--spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js2
-rw-r--r--spec/frontend/issuable_spec.js22
-rw-r--r--spec/frontend/issues/issue_spec.js (renamed from spec/frontend/issue_spec.js)2
-rw-r--r--spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap (renamed from spec/frontend/issuable_type_selector/components/__snapshots__/info_popover_spec.js.snap)6
-rw-r--r--spec/frontend/issues/new/components/title_suggestions_item_spec.js (renamed from spec/frontend/issuable_suggestions/components/item_spec.js)6
-rw-r--r--spec/frontend/issues/new/components/title_suggestions_spec.js (renamed from spec/frontend/issuable_suggestions/components/app_spec.js)10
-rw-r--r--spec/frontend/issues/new/components/type_popover_spec.js (renamed from spec/frontend/issuable_type_selector/components/info_popover_spec.js)6
-rw-r--r--spec/frontend/issues/new/mock_data.js (renamed from spec/frontend/issuable_suggestions/mock_data.js)0
-rw-r--r--spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js (renamed from spec/frontend/related_merge_requests/components/related_merge_requests_spec.js)6
-rw-r--r--spec/frontend/issues/related_merge_requests/store/actions_spec.js (renamed from spec/frontend/related_merge_requests/store/actions_spec.js)4
-rw-r--r--spec/frontend/issues/related_merge_requests/store/mutations_spec.js (renamed from spec/frontend/related_merge_requests/store/mutations_spec.js)4
-rw-r--r--spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js (renamed from spec/frontend/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js)2
-rw-r--r--spec/frontend/issues/show/components/app_spec.js (renamed from spec/frontend/issue_show/components/app_spec.js)53
-rw-r--r--spec/frontend/issues/show/components/delete_issue_modal_spec.js108
-rw-r--r--spec/frontend/issues/show/components/description_spec.js (renamed from spec/frontend/issue_show/components/description_spec.js)2
-rw-r--r--spec/frontend/issues/show/components/edit_actions_spec.js (renamed from spec/frontend/issue_show/components/edit_actions_spec.js)54
-rw-r--r--spec/frontend/issues/show/components/edited_spec.js (renamed from spec/frontend/issue_show/components/edited_spec.js)2
-rw-r--r--spec/frontend/issues/show/components/fields/description_spec.js (renamed from spec/frontend/issue_show/components/fields/description_spec.js)4
-rw-r--r--spec/frontend/issues/show/components/fields/description_template_spec.js (renamed from spec/frontend/issue_show/components/fields/description_template_spec.js)2
-rw-r--r--spec/frontend/issues/show/components/fields/title_spec.js (renamed from spec/frontend/issue_show/components/fields/title_spec.js)4
-rw-r--r--spec/frontend/issues/show/components/fields/type_spec.js (renamed from spec/frontend/issue_show/components/fields/type_spec.js)4
-rw-r--r--spec/frontend/issues/show/components/form_spec.js (renamed from spec/frontend/issue_show/components/form_spec.js)11
-rw-r--r--spec/frontend/issues/show/components/header_actions_spec.js (renamed from spec/frontend/issue_show/components/header_actions_spec.js)98
-rw-r--r--spec/frontend/issues/show/components/incidents/highlight_bar_spec.js (renamed from spec/frontend/issue_show/components/incidents/highlight_bar_spec.js)2
-rw-r--r--spec/frontend/issues/show/components/incidents/incident_tabs_spec.js (renamed from spec/frontend/issue_show/components/incidents/incident_tabs_spec.js)6
-rw-r--r--spec/frontend/issues/show/components/pinned_links_spec.js (renamed from spec/frontend/issue_show/components/pinned_links_spec.js)4
-rw-r--r--spec/frontend/issues/show/components/title_spec.js (renamed from spec/frontend/issue_show/components/title_spec.js)6
-rw-r--r--spec/frontend/issues/show/issue_spec.js (renamed from spec/frontend/issue_show/issue_spec.js)6
-rw-r--r--spec/frontend/issues/show/mock_data/apollo_mock.js (renamed from spec/frontend/issue_show/mock_data/apollo_mock.js)0
-rw-r--r--spec/frontend/issues/show/mock_data/mock_data.js (renamed from spec/frontend/issue_show/mock_data/mock_data.js)0
-rw-r--r--spec/frontend/issues/show/store_spec.js (renamed from spec/frontend/issue_show/store_spec.js)6
-rw-r--r--spec/frontend/issues/show/utils/update_description_spec.js (renamed from spec/frontend/issue_show/utils/update_description_spec.js)2
-rw-r--r--spec/frontend/issues_list/components/issuable_spec.js2
-rw-r--r--spec/frontend/issues_list/components/issuables_list_app_spec.js22
-rw-r--r--spec/frontend/issues_list/components/issue_card_time_info_spec.js2
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js227
-rw-r--r--spec/frontend/issues_list/mock_data.js5
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/app_spec.js18
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/user_link_spec.js91
-rw-r--r--spec/frontend/jira_connect/subscriptions/index_spec.js36
-rw-r--r--spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap4
-rw-r--r--spec/frontend/jobs/bridge/app_spec.js33
-rw-r--r--spec/frontend/jobs/bridge/components/empty_state_spec.js59
-rw-r--r--spec/frontend/jobs/bridge/components/sidebar_spec.js76
-rw-r--r--spec/frontend/jobs/bridge/mock_data.js3
-rw-r--r--spec/frontend/jobs/components/job_sidebar_details_container_spec.js11
-rw-r--r--spec/frontend/jobs/components/table/cells/actions_cell_spec.js28
-rw-r--r--spec/frontend/jobs/mock_data.js70
-rw-r--r--spec/frontend/labels/components/delete_label_modal_spec.js (renamed from spec/frontend/vue_shared/components/delete_label_modal_spec.js)4
-rw-r--r--spec/frontend/labels/components/promote_label_modal_spec.js (renamed from spec/frontend/pages/labels/components/promote_label_modal_spec.js)4
-rw-r--r--spec/frontend/labels/delete_label_modal_spec.js (renamed from spec/frontend/delete_label_modal_spec.js)2
-rw-r--r--spec/frontend/labels/labels_select_spec.js (renamed from spec/frontend/labels_select_spec.js)2
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js11
-rw-r--r--spec/frontend/lib/utils/dom_utils_spec.js12
-rw-r--r--spec/frontend/lib/utils/intersection_observer_spec.js86
-rw-r--r--spec/frontend/lib/utils/navigation_utility_spec.js89
-rw-r--r--spec/frontend/members/components/action_buttons/remove_member_button_spec.js19
-rw-r--r--spec/frontend/members/components/action_buttons/user_action_buttons_spec.js31
-rw-r--r--spec/frontend/members/components/table/member_action_buttons_spec.js1
-rw-r--r--spec/frontend/milestones/components/delete_milestone_modal_spec.js (renamed from spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js)4
-rw-r--r--spec/frontend/milestones/components/milestone_combobox_spec.js (renamed from spec/frontend/milestones/milestone_combobox_spec.js)2
-rw-r--r--spec/frontend/milestones/components/promote_milestone_modal_spec.js (renamed from spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js)2
-rw-r--r--spec/frontend/milestones/utils_spec.js (renamed from spec/frontend/milestones/milestone_utils_spec.js)2
-rw-r--r--spec/frontend/mocks/mocks_helper.js58
-rw-r--r--spec/frontend/mocks/mocks_helper_spec.js131
-rw-r--r--spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap14
-rw-r--r--spec/frontend/mr_popover/mr_popover_spec.js15
-rw-r--r--spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap17
-rw-r--r--spec/frontend/notes/components/discussion_filter_spec.js7
-rw-r--r--spec/frontend/notes/components/discussion_notes_spec.js4
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js10
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js18
-rw-r--r--spec/frontend/notes/stores/actions_spec.js10
-rw-r--r--spec/frontend/notes/stores/mutation_spec.js12
-rw-r--r--spec/frontend/packages/shared/utils_spec.js69
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js214
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap3
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js8
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js38
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/app_spec.js23
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/mock_data.js5
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js55
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap (renamed from spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap)10
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js (renamed from spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js)2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js (renamed from spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js)2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js (renamed from spec/frontend/packages/list/components/packages_list_app_spec.js)13
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js (renamed from spec/frontend/packages/list/components/packages_list_spec.js)22
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js (renamed from spec/frontend/packages/list/stores/actions_spec.js)8
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/getters_spec.js (renamed from spec/frontend/packages/list/stores/getters_spec.js)2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/mutations_spec.js (renamed from spec/frontend/packages/list/stores/mutations_spec.js)6
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/utils_spec.js (renamed from spec/frontend/packages/list/utils_spec.js)7
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/mock_data.js (renamed from spec/frontend/packages/mock_data.js)0
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap (renamed from spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap)4
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/shared/infrastructure_icon_and_name_spec.js (renamed from spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js)2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js (renamed from spec/frontend/packages/shared/components/package_list_row_spec.js)38
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/version_row_spec.js.snap2
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_title_spec.js2
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap8
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap1
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js30
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js4
-rw-r--r--spec/frontend/packages_and_registries/package_registry/mock_data.js6
-rw-r--r--spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap (renamed from spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap)14
-rw-r--r--spec/frontend/packages_and_registries/package_registry/pages/list_spec.js (renamed from spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js)23
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap2
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js184
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js23
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js20
-rw-r--r--spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js37
-rw-r--r--spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js19
-rw-r--r--spec/frontend/packages_and_registries/settings/group/mock_data.js24
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/mock_data.js1
-rw-r--r--spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap (renamed from spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap)1
-rw-r--r--spec/frontend/packages_and_registries/shared/components/registry_list_spec.js199
-rw-r--r--spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js (renamed from spec/frontend/packages/shared/components/package_icon_and_name_spec.js)2
-rw-r--r--spec/frontend/packages_and_registries/shared/package_path_spec.js (renamed from spec/frontend/packages/shared/components/package_path_spec.js)2
-rw-r--r--spec/frontend/packages_and_registries/shared/package_tags_spec.js (renamed from spec/frontend/packages/shared/components/package_tags_spec.js)4
-rw-r--r--spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js (renamed from spec/frontend/packages/shared/components/packages_list_loader_spec.js)2
-rw-r--r--spec/frontend/packages_and_registries/shared/publish_method_spec.js (renamed from spec/frontend/packages/shared/components/publish_method_spec.js)4
-rw-r--r--spec/frontend/packages_and_registries/shared/utils_spec.js30
-rw-r--r--spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js108
-rw-r--r--spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js2
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap212
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js56
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js44
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/mock_data.js4
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_form_spec.js172
-rw-r--r--spec/frontend/pipeline_editor/components/commit/commit_form_spec.js6
-rw-r--r--spec/frontend/pipeline_editor/components/commit/commit_section_spec.js149
-rw-r--r--spec/frontend/pipeline_editor/components/editor/text_editor_spec.js5
-rw-r--r--spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js105
-rw-r--r--spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js24
-rw-r--r--spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js4
-rw-r--r--spec/frontend/pipeline_editor/mock_data.js54
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_app_spec.js97
-rw-r--r--spec/frontend/pipelines/__snapshots__/utils_spec.js.snap47
-rw-r--r--spec/frontend/pipelines/components/jobs/jobs_app_spec.js106
-rw-r--r--spec/frontend/pipelines/graph/graph_component_wrapper_spec.js44
-rw-r--r--spec/frontend/pipelines/graph/graph_view_selector_spec.js36
-rw-r--r--spec/frontend/pipelines/graph/mock_data.js81
-rw-r--r--spec/frontend/pipelines/mock_data.js141
-rw-r--r--spec/frontend/projects/new/components/new_project_url_select_spec.js83
-rw-r--r--spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap10
-rw-r--r--spec/frontend/projects/pipelines/charts/components/app_spec.js22
-rw-r--r--spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js13
-rw-r--r--spec/frontend/projects/pipelines/charts/mock_data.js2
-rw-r--r--spec/frontend/projects/settings/components/transfer_project_form_spec.js68
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js13
-rw-r--r--spec/frontend/projects/storage_counter/components/app_spec.js150
-rw-r--r--spec/frontend/projects/storage_counter/components/storage_table_spec.js63
-rw-r--r--spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js41
-rw-r--r--spec/frontend/projects/storage_counter/mock_data.js92
-rw-r--r--spec/frontend/projects/storage_counter/utils_spec.js34
-rw-r--r--spec/frontend/releases/__snapshots__/util_spec.js.snap7
-rw-r--r--spec/frontend/releases/components/app_show_spec.js1
-rw-r--r--spec/frontend/releases/util_spec.js23
-rw-r--r--spec/frontend/repository/commits_service_spec.js7
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap6
-rw-r--r--spec/frontend/repository/components/blob_button_group_spec.js1
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js50
-rw-r--r--spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js59
-rw-r--r--spec/frontend/repository/components/blob_viewers/text_viewer_spec.js30
-rw-r--r--spec/frontend/repository/components/delete_blob_modal_spec.js26
-rw-r--r--spec/frontend/repository/components/table/row_spec.js25
-rw-r--r--spec/frontend/repository/components/tree_content_spec.js28
-rw-r--r--spec/frontend/repository/components/upload_blob_modal_spec.js4
-rw-r--r--spec/frontend/repository/mock_data.js10
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js6
-rw-r--r--spec/frontend/runner/components/cells/runner_actions_cell_spec.js350
-rw-r--r--spec/frontend/runner/components/runner_contacted_state_badge_spec.js86
-rw-r--r--spec/frontend/runner/components/runner_delete_modal_spec.js60
-rw-r--r--spec/frontend/runner/components/runner_filtered_search_bar_spec.js11
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js44
-rw-r--r--spec/frontend/runner/components/runner_status_badge_spec.js130
-rw-r--r--spec/frontend/runner/components/search_tokens/tag_token_spec.js2
-rw-r--r--spec/frontend/runner/components/stat/runner_online_stat_spec.js34
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js10
-rw-r--r--spec/frontend/security_configuration/components/app_spec.js55
-rw-r--r--spec/frontend/security_configuration/components/training_provider_list_spec.js88
-rw-r--r--spec/frontend/security_configuration/mock_data.js30
-rw-r--r--spec/frontend/security_configuration/utils_spec.js199
-rw-r--r--spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap1
-rw-r--r--spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap6
-rw-r--r--spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js39
-rw-r--r--spec/frontend/shortcuts_spec.js9
-rw-r--r--spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js2
-rw-r--r--spec/frontend/sidebar/components/attention_requested_toggle_spec.js (renamed from spec/frontend/sidebar/components/attention_required_toggle_spec.js)4
-rw-r--r--spec/frontend/sidebar/components/crm_contacts_spec.js87
-rw-r--r--spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js9
-rw-r--r--spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js12
-rw-r--r--spec/frontend/sidebar/components/mock_data.js56
-rw-r--r--spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js2
-rw-r--r--spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js16
-rw-r--r--spec/frontend/sidebar/components/time_tracking/mock_data.js10
-rw-r--r--spec/frontend/sidebar/mock_data.js31
-rw-r--r--spec/frontend/sidebar/sidebar_labels_spec.js190
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap1
-rw-r--r--spec/frontend/snippets/components/edit_spec.js1
-rw-r--r--spec/frontend/snippets/components/snippet_header_spec.js2
-rw-r--r--spec/frontend/snippets/test_utils.js1
-rw-r--r--spec/frontend/tabs/index_spec.js260
-rw-r--r--spec/frontend/terraform/components/terraform_list_spec.js1
-rw-r--r--spec/frontend/test_setup.js98
-rw-r--r--spec/frontend/token_access/mock_data.js13
-rw-r--r--spec/frontend/transfer_edit_spec.js10
-rw-r--r--spec/frontend/vue_mr_widget/components/extensions/utils_spec.js18
-rw-r--r--spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js9
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js42
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js12
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js6
-rw-r--r--spec/frontend/vue_mr_widget/mock_data.js2
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js60
-rw-r--r--spec/frontend/vue_mr_widget/test_extension.js39
-rw-r--r--spec/frontend/vue_mr_widget/test_extensions.js99
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/chronic_duration_input_spec.js390
-rw-r--r--spec/frontend/vue_shared/components/clipboard_button_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/confirm_modal_spec.js63
-rw-r--r--spec/frontend/vue_shared/components/design_management/__snapshots__/design_note_pin_spec.js.snap55
-rw-r--r--spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js (renamed from spec/frontend/design_management/components/design_note_pin_spec.js)12
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/dismissible_alert_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/dom_element_listener_spec.js116
-rw-r--r--spec/frontend/vue_shared/components/file_icon_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js104
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js57
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js169
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js116
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js231
-rw-r--r--spec/frontend/vue_shared/components/gl_modal_vuex_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/header_ci_component_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/line_numbers_spec.js71
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_spec.js39
-rw-r--r--spec/frontend/vue_shared/components/namespace_select/mock_data.js11
-rw-r--r--spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js86
-rw-r--r--spec/frontend/vue_shared/components/notes/placeholder_note_spec.js24
-rw-r--r--spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js (renamed from spec/frontend/import_entities/components/pagination_bar_spec.js)11
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_selector_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/registry/metadata_item_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js103
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js77
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js17
-rw-r--r--spec/frontend/vue_shared/components/source_viewer_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js137
-rw-r--r--spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js233
-rw-r--r--spec/frontend/vue_shared/components/user_select_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/vuex_module_provider_spec.js7
-rw-r--r--spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js11
-rw-r--r--spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js (renamed from spec/frontend/issuable_create/components/issuable_create_root_spec.js)4
-rw-r--r--spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js (renamed from spec/frontend/issuable_create/components/issuable_form_spec.js)2
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js (renamed from spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js)2
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js (renamed from spec/frontend/issuable_list/components/issuable_item_spec.js)109
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js (renamed from spec/frontend/issuable_list/components/issuable_list_root_spec.js)33
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js (renamed from spec/frontend/issuable_list/components/issuable_tabs_spec.js)2
-rw-r--r--spec/frontend/vue_shared/issuable/list/mock_data.js (renamed from spec/frontend/issuable_list/mock_data.js)0
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js (renamed from spec/frontend/issuable_show/components/issuable_body_spec.js)8
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js (renamed from spec/frontend/issuable_show/components/issuable_description_spec.js)2
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js (renamed from spec/frontend/issuable_show/components/issuable_edit_form_spec.js)4
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js (renamed from spec/frontend/issuable_show/components/issuable_header_spec.js)2
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js (renamed from spec/frontend/issuable_show/components/issuable_show_root_spec.js)8
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js (renamed from spec/frontend/issuable_show/components/issuable_title_spec.js)2
-rw-r--r--spec/frontend/vue_shared/issuable/show/mock_data.js (renamed from spec/frontend/issuable_show/mock_data.js)2
-rw-r--r--spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js (renamed from spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js)4
-rw-r--r--spec/frontend/vue_shared/security_reports/mock_data.js11
-rw-r--r--spec/frontend/vue_shared/translate_spec.js87
-rw-r--r--spec/frontend/work_items/components/item_title_spec.js56
-rw-r--r--spec/frontend/work_items/mock_data.js25
-rw-r--r--spec/frontend/work_items/pages/create_work_item_spec.js94
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js38
-rw-r--r--spec/frontend/work_items/router_spec.js7
451 files changed, 12321 insertions, 7217 deletions
diff --git a/spec/frontend/__helpers__/emoji.js b/spec/frontend/__helpers__/emoji.js
index a64135601ae..014a7854024 100644
--- a/spec/frontend/__helpers__/emoji.js
+++ b/spec/frontend/__helpers__/emoji.js
@@ -1,8 +1,7 @@
-import MockAdapter from 'axios-mock-adapter';
import { initEmojiMap, EMOJI_VERSION } from '~/emoji';
-import axios from '~/lib/utils/axios_utils';
+import { CACHE_VERSION_KEY, CACHE_KEY } from '~/emoji/constants';
-export const emojiFixtureMap = {
+export const validEmoji = {
atom: {
moji: '⚛',
description: 'atom symbol',
@@ -49,11 +48,39 @@ export const emojiFixtureMap = {
unicodeVersion: '5.1',
description: 'white medium star',
},
+ gay_pride_flag: {
+ moji: '🏳️‍🌈',
+ unicodeVersion: '7.0',
+ description: 'because it contains a zero width joiner',
+ },
+ family_mmb: {
+ moji: '👨‍👨‍👦',
+ unicodeVersion: '6.0',
+ description: 'because it contains multiple zero width joiners',
+ },
+};
+
+export const invalidEmoji = {
xss: {
moji: '<img src=x onerror=prompt(1)>',
unicodeVersion: '5.1',
description: 'xss',
},
+ non_moji: {
+ moji: 'I am not an emoji...',
+ unicodeVersion: '9.0',
+ description: '...and should be filtered out',
+ },
+ multiple_moji: {
+ moji: '🍂🏭',
+ unicodeVersion: '9.0',
+ description: 'Multiple separate emoji that are not joined by a zero width joiner',
+ },
+};
+
+export const emojiFixtureMap = {
+ ...validEmoji,
+ ...invalidEmoji,
};
export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => {
@@ -63,11 +90,14 @@ export const mockEmojiData = Object.keys(emojiFixtureMap).reduce((acc, k) => {
return acc;
}, {});
-export async function initEmojiMock(mockData = mockEmojiData) {
- const mock = new MockAdapter(axios);
- mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(mockData));
+export function clearEmojiMock() {
+ localStorage.clear();
+ initEmojiMap.promise = null;
+}
+export async function initEmojiMock(mockData = mockEmojiData) {
+ clearEmojiMock();
+ localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION);
+ localStorage.setItem(CACHE_KEY, JSON.stringify(mockData));
await initEmojiMap();
-
- return mock;
}
diff --git a/spec/frontend/__helpers__/experimentation_helper.js b/spec/frontend/__helpers__/experimentation_helper.js
index e0156226acc..d5044be88d7 100644
--- a/spec/frontend/__helpers__/experimentation_helper.js
+++ b/spec/frontend/__helpers__/experimentation_helper.js
@@ -25,7 +25,7 @@ export function stubExperiments(experiments = {}) {
window.gon.experiment = window.gon.experiment || {};
// Preferred
window.gl = window.gl || {};
- window.gl.experiments = window.gl.experiemnts || {};
+ window.gl.experiments = window.gl.experiments || {};
Object.entries(experiments).forEach(([name, variant]) => {
const experimentData = { experiment: name, variant };
diff --git a/spec/frontend/matchers.js b/spec/frontend/__helpers__/matchers.js
index 945abdafe9a..945abdafe9a 100644
--- a/spec/frontend/matchers.js
+++ b/spec/frontend/__helpers__/matchers.js
diff --git a/spec/frontend/matchers_spec.js b/spec/frontend/__helpers__/matchers_spec.js
index dfd6f754c72..dfd6f754c72 100644
--- a/spec/frontend/matchers_spec.js
+++ b/spec/frontend/__helpers__/matchers_spec.js
diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js
index 520d6c72541..ee4bbd42b1e 100644
--- a/spec/frontend/__helpers__/mock_apollo_helper.js
+++ b/spec/frontend/__helpers__/mock_apollo_helper.js
@@ -26,7 +26,5 @@ export function createMockClient(handlers = [], resolvers = {}, cacheOptions = {
export default function createMockApollo(handlers, resolvers, cacheOptions) {
const mockClient = createMockClient(handlers, resolvers, cacheOptions);
- const apolloProvider = new VueApollo({ defaultClient: mockClient });
-
- return apolloProvider;
+ return new VueApollo({ defaultClient: mockClient });
}
diff --git a/spec/frontend/mocks/ce/lib/utils/axios_utils.js b/spec/frontend/__helpers__/mocks/axios_utils.js
index 674563b9f28..674563b9f28 100644
--- a/spec/frontend/mocks/ce/lib/utils/axios_utils.js
+++ b/spec/frontend/__helpers__/mocks/axios_utils.js
diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js
new file mode 100644
index 00000000000..03389e16b65
--- /dev/null
+++ b/spec/frontend/__helpers__/shared_test_setup.js
@@ -0,0 +1,90 @@
+/* Common setup for both unit and integration test environments */
+import { config as testUtilsConfig } from '@vue/test-utils';
+import * as jqueryMatchers from 'custom-jquery-matchers';
+import Vue from 'vue';
+import 'jquery';
+import Translate from '~/vue_shared/translate';
+import setWindowLocation from './set_window_location_helper';
+import { setGlobalDateToFakeDate } from './fake_date';
+import { loadHTMLFixture, setHTMLFixture } from './fixtures';
+import { TEST_HOST } from './test_constants';
+import customMatchers from './matchers';
+
+import './dom_shims';
+import './jquery';
+import '~/commons/bootstrap';
+
+// This module has some fairly decent visual test coverage in it's own repository.
+jest.mock('@gitlab/favicon-overlay');
+
+process.on('unhandledRejection', global.promiseRejectionHandler);
+
+// Fake the `Date` for the rest of the jest spec runtime environment.
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39496#note_503084332
+setGlobalDateToFakeDate();
+
+Vue.config.devtools = false;
+Vue.config.productionTip = false;
+
+Vue.use(Translate);
+
+// convenience wrapper for migration from Karma
+Object.assign(global, {
+ loadFixtures: loadHTMLFixture,
+ setFixtures: setHTMLFixture,
+});
+
+const JQUERY_MATCHERS_TO_EXCLUDE = ['toHaveLength', 'toExist'];
+
+// custom-jquery-matchers was written for an old Jest version, we need to make it compatible
+Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => {
+ // Exclude these jQuery matchers
+ if (JQUERY_MATCHERS_TO_EXCLUDE.includes(matcherName)) {
+ return;
+ }
+
+ expect.extend({
+ [matcherName]: matcherFactory().compare,
+ });
+});
+
+expect.extend(customMatchers);
+
+testUtilsConfig.deprecationWarningHandler = (method, message) => {
+ const ALLOWED_DEPRECATED_METHODS = [
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/295679
+ 'finding components with `find` or `get`',
+
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/295680
+ 'finding components with `findAll`',
+ ];
+ if (!ALLOWED_DEPRECATED_METHODS.includes(method)) {
+ global.console.error(message);
+ }
+};
+
+Object.assign(global, {
+ requestIdleCallback(cb) {
+ const start = Date.now();
+ return setTimeout(() => {
+ cb({
+ didTimeout: false,
+ timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
+ });
+ });
+ },
+ cancelIdleCallback(id) {
+ clearTimeout(id);
+ },
+});
+
+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/access_tokens/components/token_spec.js b/spec/frontend/access_tokens/components/token_spec.js
new file mode 100644
index 00000000000..1af21aaa8cd
--- /dev/null
+++ b/spec/frontend/access_tokens/components/token_spec.js
@@ -0,0 +1,65 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+import Token from '~/access_tokens/components/token.vue';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+
+describe('Token', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ token: 'az4a2l5f8ssa0zvdfbhidbzlx',
+ inputId: 'feed_token',
+ inputLabel: 'Feed token',
+ copyButtonTitle: 'Copy feed token',
+ };
+
+ const defaultSlots = {
+ title: 'Feed token title',
+ description: 'Feed token description',
+ 'input-description': 'Feed token input description',
+ };
+
+ const createComponent = () => {
+ wrapper = mountExtended(Token, { propsData: defaultPropsData, slots: defaultSlots });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders title slot', () => {
+ createComponent();
+
+ expect(wrapper.findByText(defaultSlots.title, { selector: 'h4' }).exists()).toBe(true);
+ });
+
+ it('renders description slot', () => {
+ createComponent();
+
+ expect(wrapper.findByText(defaultSlots.description).exists()).toBe(true);
+ });
+
+ it('renders input description slot', () => {
+ createComponent();
+
+ expect(wrapper.findByText(defaultSlots['input-description']).exists()).toBe(true);
+ });
+
+ it('correctly passes props to `InputCopyToggleVisibility` component', () => {
+ createComponent();
+
+ const inputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility);
+
+ expect(inputCopyToggleVisibilityComponent.props()).toMatchObject({
+ formInputGroupProps: {
+ id: defaultPropsData.inputId,
+ },
+ value: defaultPropsData.token,
+ copyButtonTitle: defaultPropsData.copyButtonTitle,
+ });
+ expect(inputCopyToggleVisibilityComponent.attributes()).toMatchObject({
+ label: defaultPropsData.inputLabel,
+ 'label-for': defaultPropsData.inputId,
+ });
+ });
+});
diff --git a/spec/frontend/access_tokens/components/tokens_app_spec.js b/spec/frontend/access_tokens/components/tokens_app_spec.js
new file mode 100644
index 00000000000..d7acfbb47eb
--- /dev/null
+++ b/spec/frontend/access_tokens/components/tokens_app_spec.js
@@ -0,0 +1,148 @@
+import { merge } from 'lodash';
+
+import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+
+import TokensApp from '~/access_tokens/components/tokens_app.vue';
+import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '~/access_tokens/constants';
+
+describe('TokensApp', () => {
+ let wrapper;
+
+ const defaultProvide = {
+ tokenTypes: {
+ [FEED_TOKEN]: {
+ enabled: true,
+ token: 'DUKu345VD73Py7zz3z89',
+ resetPath: '/-/profile/reset_feed_token',
+ },
+ [INCOMING_EMAIL_TOKEN]: {
+ enabled: true,
+ token: 'az4a2l5f8ssa0zvdfbhidbzlx',
+ resetPath: '/-/profile/reset_incoming_email_token',
+ },
+ [STATIC_OBJECT_TOKEN]: {
+ enabled: true,
+ token: 'QHXwGHYioHTgxQnAcyZ-',
+ resetPath: '/-/profile/reset_static_object_token',
+ },
+ },
+ };
+
+ const createComponent = (options = {}) => {
+ wrapper = mountExtended(TokensApp, merge({}, { provide: defaultProvide }, options));
+ };
+
+ const expectTokenRendered = ({
+ testId,
+ expectedLabel,
+ expectedDescription,
+ expectedInputDescription,
+ expectedResetPath,
+ expectedResetConfirmMessage,
+ expectedProps,
+ }) => {
+ const container = extendedWrapper(wrapper.findByTestId(testId));
+
+ expect(container.findByText(expectedLabel, { selector: 'h4' }).exists()).toBe(true);
+ expect(container.findByText(expectedDescription).exists()).toBe(true);
+ expect(container.findByText(expectedInputDescription, { exact: false }).exists()).toBe(true);
+ expect(container.findByText('reset this token').attributes()).toMatchObject({
+ 'data-confirm': expectedResetConfirmMessage,
+ 'data-method': 'put',
+ href: expectedResetPath,
+ });
+ expect(container.props()).toMatchObject(expectedProps);
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders all enabled tokens', () => {
+ createComponent();
+
+ expectTokenRendered({
+ testId: TokensApp.htmlAttributes[FEED_TOKEN].containerTestId,
+ expectedLabel: TokensApp.i18n[FEED_TOKEN].label,
+ expectedDescription: TokensApp.i18n[FEED_TOKEN].description,
+ expectedInputDescription:
+ 'Keep this token secret. Anyone who has it can read activity and issue RSS feeds or your calendar feed as if they were you.',
+ expectedResetPath: defaultProvide.tokenTypes[FEED_TOKEN].resetPath,
+ expectedResetConfirmMessage: TokensApp.i18n[FEED_TOKEN].resetConfirmMessage,
+ expectedProps: {
+ token: defaultProvide.tokenTypes[FEED_TOKEN].token,
+ inputId: TokensApp.htmlAttributes[FEED_TOKEN].inputId,
+ inputLabel: TokensApp.i18n[FEED_TOKEN].label,
+ copyButtonTitle: TokensApp.i18n[FEED_TOKEN].copyButtonTitle,
+ },
+ });
+
+ expectTokenRendered({
+ testId: TokensApp.htmlAttributes[INCOMING_EMAIL_TOKEN].containerTestId,
+ expectedLabel: TokensApp.i18n[INCOMING_EMAIL_TOKEN].label,
+ expectedDescription: TokensApp.i18n[INCOMING_EMAIL_TOKEN].description,
+ expectedInputDescription:
+ 'Keep this token secret. Anyone who has it can create issues as if they were you.',
+ expectedResetPath: defaultProvide.tokenTypes[INCOMING_EMAIL_TOKEN].resetPath,
+ expectedResetConfirmMessage: TokensApp.i18n[INCOMING_EMAIL_TOKEN].resetConfirmMessage,
+ expectedProps: {
+ token: defaultProvide.tokenTypes[INCOMING_EMAIL_TOKEN].token,
+ inputId: TokensApp.htmlAttributes[INCOMING_EMAIL_TOKEN].inputId,
+ inputLabel: TokensApp.i18n[INCOMING_EMAIL_TOKEN].label,
+ copyButtonTitle: TokensApp.i18n[INCOMING_EMAIL_TOKEN].copyButtonTitle,
+ },
+ });
+
+ expectTokenRendered({
+ testId: TokensApp.htmlAttributes[STATIC_OBJECT_TOKEN].containerTestId,
+ expectedLabel: TokensApp.i18n[STATIC_OBJECT_TOKEN].label,
+ expectedDescription: TokensApp.i18n[STATIC_OBJECT_TOKEN].description,
+ expectedInputDescription:
+ 'Keep this token secret. Anyone who has it can access repository static objects as if they were you.',
+ expectedResetPath: defaultProvide.tokenTypes[STATIC_OBJECT_TOKEN].resetPath,
+ expectedResetConfirmMessage: TokensApp.i18n[STATIC_OBJECT_TOKEN].resetConfirmMessage,
+ expectedProps: {
+ token: defaultProvide.tokenTypes[STATIC_OBJECT_TOKEN].token,
+ inputId: TokensApp.htmlAttributes[STATIC_OBJECT_TOKEN].inputId,
+ inputLabel: TokensApp.i18n[STATIC_OBJECT_TOKEN].label,
+ copyButtonTitle: TokensApp.i18n[STATIC_OBJECT_TOKEN].copyButtonTitle,
+ },
+ });
+ });
+
+ it("doesn't render disabled tokens", () => {
+ createComponent({
+ provide: {
+ tokenTypes: {
+ [FEED_TOKEN]: {
+ enabled: false,
+ },
+ },
+ },
+ });
+
+ expect(
+ wrapper.findByTestId(TokensApp.htmlAttributes[FEED_TOKEN].containerTestId).exists(),
+ ).toBe(false);
+ });
+
+ describe('when there are tokens missing an `i18n` definition', () => {
+ it('renders without errors', () => {
+ createComponent({
+ provide: {
+ tokenTypes: {
+ fooBar: {
+ enabled: true,
+ token: 'rewjoa58dfm54jfkdlsdf',
+ resetPath: '/-/profile/foo_bar',
+ },
+ },
+ },
+ });
+
+ expect(
+ wrapper.findByTestId(TokensApp.htmlAttributes[FEED_TOKEN].containerTestId).exists(),
+ ).toBe(true);
+ });
+ });
+});
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 824eb033671..14f94e671a4 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,4 +1,4 @@
-import { GlTable, GlBadge, GlEmptyState } from '@gitlab/ui';
+import { GlTableLite, 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';
@@ -20,7 +20,7 @@ describe('DevopsScore', () => {
);
};
- const findTable = () => wrapper.findComponent(GlTable);
+ const findTable = () => wrapper.findComponent(GlTableLite);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findCol = (testId) => findTable().find(`[data-testid="${testId}"]`);
const findUsageCol = () => findCol('usageCol');
@@ -44,7 +44,7 @@ describe('DevopsScore', () => {
});
it('displays the correct message', () => {
- expect(findEmptyState().text()).toBe(
+ expect(findEmptyState().text().replace(/\s+/g, ' ')).toBe(
'Data is still calculating... It may be several days before you see feature usage data. See example DevOps Score page in our documentation.',
);
});
@@ -124,11 +124,11 @@ describe('DevopsScore', () => {
describe('table columns', () => {
describe('Your usage', () => {
- it('displays the corrrect value', () => {
+ it('displays the correct value', () => {
expect(findUsageCol().text()).toContain('3.2');
});
- it('displays the corrrect badge', () => {
+ it('displays the correct badge', () => {
const badge = findUsageCol().find(GlBadge);
expect(badge.exists()).toBe(true);
diff --git a/spec/frontend/admin/deploy_keys/components/table_spec.js b/spec/frontend/admin/deploy_keys/components/table_spec.js
index 3b3be488043..49bda7100fb 100644
--- a/spec/frontend/admin/deploy_keys/components/table_spec.js
+++ b/spec/frontend/admin/deploy_keys/components/table_spec.js
@@ -1,8 +1,19 @@
import { merge } from 'lodash';
-import { GlTable, GlButton } from '@gitlab/ui';
+import { GlLoadingIcon, GlEmptyState, GlPagination, GlModal } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import responseBody from 'test_fixtures/api/deploy_keys/index.json';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { stubComponent } from 'helpers/stub_component';
import DeployKeysTable from '~/admin/deploy_keys/components/table.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import Api, { DEFAULT_PER_PAGE } from '~/api';
+import createFlash from '~/flash';
+
+jest.mock('~/api');
+jest.mock('~/flash');
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('DeployKeysTable', () => {
let wrapper;
@@ -14,9 +25,60 @@ describe('DeployKeysTable', () => {
emptyStateSvgPath: '/assets/illustrations/empty-state/empty-deploy-keys.svg',
};
+ const deployKey = responseBody[0];
+ const deployKey2 = responseBody[1];
+
const createComponent = (provide = {}) => {
wrapper = mountExtended(DeployKeysTable, {
provide: merge({}, defaultProvide, provide),
+ stubs: {
+ GlModal: stubComponent(GlModal, {
+ template: `
+ <div>
+ <slot name="modal-title"></slot>
+ <slot></slot>
+ <slot name="modal-footer"></slot>
+ </div>`,
+ }),
+ },
+ });
+ };
+
+ const findEditButton = (index) =>
+ wrapper.findAllByLabelText(DeployKeysTable.i18n.edit, { selector: 'a' }).at(index);
+ const findRemoveButton = (index) =>
+ wrapper.findAllByLabelText(DeployKeysTable.i18n.delete, { selector: 'button' }).at(index);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findTimeAgoTooltip = (index) => wrapper.findAllComponents(TimeAgoTooltip).at(index);
+ const findPagination = () => wrapper.findComponent(GlPagination);
+
+ const expectDeployKeyIsRendered = (expectedDeployKey, expectedRowIndex) => {
+ const editButton = findEditButton(expectedRowIndex);
+ const timeAgoTooltip = findTimeAgoTooltip(expectedRowIndex);
+
+ expect(wrapper.findByText(expectedDeployKey.title).exists()).toBe(true);
+ expect(wrapper.findByText(expectedDeployKey.fingerprint, { selector: 'code' }).exists()).toBe(
+ true,
+ );
+ expect(timeAgoTooltip.exists()).toBe(true);
+ expect(timeAgoTooltip.props('time')).toBe(expectedDeployKey.created_at);
+ expect(editButton.exists()).toBe(true);
+ expect(editButton.attributes('href')).toBe(`/admin/deploy_keys/${expectedDeployKey.id}/edit`);
+ expect(findRemoveButton(expectedRowIndex).exists()).toBe(true);
+ };
+
+ const itRendersTheEmptyState = () => {
+ it('renders empty state', () => {
+ const emptyState = wrapper.findComponent(GlEmptyState);
+
+ expect(emptyState.exists()).toBe(true);
+ expect(emptyState.props()).toMatchObject({
+ svgPath: defaultProvide.emptyStateSvgPath,
+ title: DeployKeysTable.i18n.emptyStateTitle,
+ description: DeployKeysTable.i18n.emptyStateDescription,
+ primaryButtonText: DeployKeysTable.i18n.newDeployKeyButtonText,
+ primaryButtonLink: defaultProvide.createPath,
+ });
});
};
@@ -30,18 +92,149 @@ describe('DeployKeysTable', () => {
expect(wrapper.findByText(DeployKeysTable.i18n.pageTitle).exists()).toBe(true);
});
- it('renders table', () => {
+ it('renders `New deploy key` button', () => {
createComponent();
- expect(wrapper.findComponent(GlTable).exists()).toBe(true);
+ const newDeployKeyButton = wrapper.findByTestId('new-deploy-key-button');
+
+ expect(newDeployKeyButton.exists()).toBe(true);
+ expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath);
+ });
+
+ describe('when `/deploy_keys` API request is pending', () => {
+ beforeEach(() => {
+ Api.deployKeys.mockImplementation(() => new Promise(() => {}));
+ });
+
+ it('shows loading icon', async () => {
+ createComponent();
+
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
});
- it('renders `New deploy key` button', () => {
- createComponent();
+ describe('when `/deploy_keys` API request is successful', () => {
+ describe('when there are deploy keys', () => {
+ beforeEach(() => {
+ Api.deployKeys.mockResolvedValue({
+ data: responseBody,
+ headers: { 'x-total': `${responseBody.length}` },
+ });
- const newDeployKeyButton = wrapper.findComponent(GlButton);
+ createComponent();
+ });
- expect(newDeployKeyButton.text()).toBe(DeployKeysTable.i18n.newDeployKeyButtonText);
- expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath);
+ it('renders deploy keys in table', () => {
+ expectDeployKeyIsRendered(deployKey, 0);
+ expectDeployKeyIsRendered(deployKey2, 1);
+ });
+
+ describe('when delete button is clicked', () => {
+ it('asks user to confirm', async () => {
+ await findRemoveButton(0).trigger('click');
+
+ const modal = wrapper.findComponent(GlModal);
+ const form = modal.find('form');
+ const submitSpy = jest.spyOn(form.element, 'submit');
+
+ expect(modal.props('visible')).toBe(true);
+ expect(form.attributes('action')).toBe(`/admin/deploy_keys/${deployKey.id}`);
+ expect(form.find('input[name="_method"]').attributes('value')).toBe('delete');
+ expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
+ 'mock-csrf-token',
+ );
+
+ modal.vm.$emit('primary');
+
+ expect(submitSpy).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('pagination', () => {
+ beforeEach(() => {
+ Api.deployKeys.mockResolvedValueOnce({
+ data: [deployKey],
+ headers: { 'x-total': '2' },
+ });
+
+ createComponent();
+ });
+
+ it('renders pagination', () => {
+ const pagination = findPagination();
+ expect(pagination.exists()).toBe(true);
+ expect(pagination.props()).toMatchObject({
+ value: 1,
+ perPage: DEFAULT_PER_PAGE,
+ totalItems: responseBody.length,
+ nextText: DeployKeysTable.i18n.pagination.next,
+ prevText: DeployKeysTable.i18n.pagination.prev,
+ align: 'center',
+ });
+ });
+
+ describe('when pagination is changed', () => {
+ it('calls API with `page` parameter', async () => {
+ const pagination = findPagination();
+ expectDeployKeyIsRendered(deployKey, 0);
+
+ Api.deployKeys.mockResolvedValue({
+ data: [deployKey2],
+ headers: { 'x-total': '2' },
+ });
+
+ pagination.vm.$emit('input', 2);
+
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(pagination.exists()).toBe(false);
+
+ await waitForPromises();
+
+ expect(Api.deployKeys).toHaveBeenCalledWith({
+ page: 2,
+ public: true,
+ });
+ expectDeployKeyIsRendered(deployKey2, 0);
+ });
+ });
+ });
+
+ describe('when there are no deploy keys', () => {
+ beforeEach(() => {
+ Api.deployKeys.mockResolvedValue({
+ data: [],
+ headers: { 'x-total': '0' },
+ });
+
+ createComponent();
+ });
+
+ itRendersTheEmptyState();
+ });
+ });
+
+ describe('when `deploy_keys` API request is unsuccessful', () => {
+ const error = new Error('Network Error');
+
+ beforeEach(() => {
+ Api.deployKeys.mockRejectedValue(error);
+
+ createComponent();
+ });
+
+ itRendersTheEmptyState();
+
+ it('displays flash', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: DeployKeysTable.i18n.apiErrorMessage,
+ captureError: true,
+ error,
+ });
+ });
});
});
diff --git a/spec/frontend/admin/statistics_panel/components/app_spec.js b/spec/frontend/admin/statistics_panel/components/app_spec.js
index 9c424491d04..3cfb6feeb86 100644
--- a/spec/frontend/admin/statistics_panel/components/app_spec.js
+++ b/spec/frontend/admin/statistics_panel/components/app_spec.js
@@ -1,6 +1,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
+import Vue from 'vue';
import Vuex from 'vuex';
import StatisticsPanelApp from '~/admin/statistics_panel/components/app.vue';
import statisticsLabels from '~/admin/statistics_panel/constants';
@@ -9,8 +10,7 @@ import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import mockStatistics from '../mock_data';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
describe('Admin statistics app', () => {
let wrapper;
@@ -19,7 +19,6 @@ describe('Admin statistics app', () => {
const createComponent = () => {
wrapper = shallowMount(StatisticsPanelApp, {
- localVue,
store,
});
};
diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js
index 67dcf5c6149..fa485e73999 100644
--- a/spec/frontend/admin/users/components/actions/actions_spec.js
+++ b/spec/frontend/admin/users/components/actions/actions_spec.js
@@ -1,7 +1,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { kebabCase } from 'lodash';
import { nextTick } from 'vue';
+import { kebabCase } from 'lodash';
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';
@@ -39,9 +39,6 @@ describe('Action components', () => {
});
await nextTick();
-
- expect(wrapper.attributes('data-path')).toBe('/test');
- expect(wrapper.attributes('data-modal-attributes')).toContain('John Doe');
expect(findDropdownItem().exists()).toBe(true);
});
});
@@ -66,7 +63,6 @@ describe('Action components', () => {
});
await nextTick();
-
const sharedAction = wrapper.find(SharedDeleteAction);
expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block);
@@ -76,6 +72,7 @@ describe('Action components', () => {
expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe(
JSON.stringify(userDeletionObstacles),
);
+
expect(findDropdownItem().exists()).toBe(true);
},
);
diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap
index 472158a9b10..7a17ef2cc6c 100644
--- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap
+++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap
@@ -78,3 +78,83 @@ exports[`User Operation confirmation modal renders modal with form included 1`]
</gl-button-stub>
</div>
`;
+
+exports[`User Operation confirmation modal when user's name has leading and trailing whitespace displays user's name without whitespace 1`] = `
+<div>
+ <p>
+ content
+ </p>
+
+ <user-deletion-obstacles-list-stub
+ obstacles="schedule1,policy1"
+ username="John Smith"
+ />
+
+ <p>
+ To confirm, type
+ <code
+ class="gl-white-space-pre-wrap"
+ >
+ John Smith
+ </code>
+ </p>
+
+ <form
+ action="delete-url"
+ method="post"
+ >
+ <input
+ name="_method"
+ type="hidden"
+ value="delete"
+ />
+
+ <input
+ name="authenticity_token"
+ type="hidden"
+ value="csrf"
+ />
+
+ <gl-form-input-stub
+ autocomplete="off"
+ autofocus=""
+ name="username"
+ type="text"
+ value=""
+ />
+ </form>
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ icon=""
+ size="medium"
+ variant="default"
+ >
+ Cancel
+ </gl-button-stub>
+
+ <gl-button-stub
+ buttontextclasses=""
+ category="secondary"
+ disabled="true"
+ icon=""
+ size="medium"
+ variant="danger"
+ >
+
+ secondaryAction
+
+ </gl-button-stub>
+
+ <gl-button-stub
+ buttontextclasses=""
+ category="primary"
+ disabled="true"
+ icon=""
+ size="medium"
+ variant="danger"
+ >
+ action
+ </gl-button-stub>
+</div>
+`;
diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
index 82307c9e3b3..025ae825e0d 100644
--- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
+++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
@@ -1,4 +1,4 @@
-import { GlButton, GlFormInput } from '@gitlab/ui';
+import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
@@ -35,7 +35,7 @@ describe('User Operation confirmation modal', () => {
const badUsername = 'bad_username';
const userDeletionObstacles = '["schedule1", "policy1"]';
- const createComponent = (props = {}) => {
+ const createComponent = (props = {}, stubs = {}) => {
wrapper = shallowMount(DeleteUserModal, {
propsData: {
username,
@@ -51,6 +51,7 @@ describe('User Operation confirmation modal', () => {
},
stubs: {
GlModal: ModalStub,
+ ...stubs,
},
});
};
@@ -150,6 +151,30 @@ describe('User Operation confirmation modal', () => {
});
});
+ describe("when user's name has leading and trailing whitespace", () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ username: ' John Smith ',
+ },
+ { GlSprintf },
+ );
+ });
+
+ it("displays user's name without whitespace", () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it("shows enabled buttons when user's name is entered without whitespace", async () => {
+ setUsername('John Smith');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findPrimaryButton().attributes('disabled')).toBeUndefined();
+ expect(findSecondaryButton().attributes('disabled')).toBeUndefined();
+ });
+ });
+
describe('Related user-deletion-obstacles list', () => {
it('does NOT render the list when user has no related obstacles', () => {
createComponent({ userDeletionObstacles: '[]' });
diff --git a/spec/frontend/admin/users/components/users_table_spec.js b/spec/frontend/admin/users/components/users_table_spec.js
index 708c9e1979e..9ff5961c7ec 100644
--- a/spec/frontend/admin/users/components/users_table_spec.js
+++ b/spec/frontend/admin/users/components/users_table_spec.js
@@ -1,5 +1,5 @@
import { GlTable, GlSkeletonLoader } from '@gitlab/ui';
-import { createLocalVue } from '@vue/test-utils';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -16,8 +16,7 @@ import { users, paths, createGroupCountResponse } from '../mock_data';
jest.mock('~/flash');
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
describe('AdminUsersTable component', () => {
let wrapper;
@@ -48,7 +47,6 @@ describe('AdminUsersTable component', () => {
const initComponent = (props = {}, resolverMock = fetchGroupCountsResponse) => {
wrapper = mountExtended(AdminUsersTable, {
- localVue,
apolloProvider: createMockApolloProvider(resolverMock),
propsData: {
users,
diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
index f4d3fd97fd8..ec5b6a5597b 100644
--- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
+++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap
@@ -12,6 +12,7 @@ exports[`Alert integration settings form default state should match the default
<gl-form-group-stub
class="gl-pl-0"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-checkbox-stub
checked="true"
@@ -28,6 +29,7 @@ exports[`Alert integration settings form default state should match the default
label-for="alert-integration-settings-issue-template"
label-size="sm"
labeldescription=""
+ optionaltext="(optional)"
>
<label
class="gl-display-inline-flex"
@@ -83,6 +85,7 @@ exports[`Alert integration settings form default state should match the default
<gl-form-group-stub
class="gl-pl-0 gl-mb-5"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-checkbox-stub>
<span>
@@ -94,6 +97,7 @@ exports[`Alert integration settings form default state should match the default
<gl-form-group-stub
class="gl-pl-0 gl-mb-5"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-checkbox-stub
checked="true"
diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
index 828580a436b..e7ad2cd1d2a 100644
--- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
+++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
@@ -34,6 +34,7 @@ export const updatePrometheusVariables = {
export const getIntegrationsQueryResponse = {
data: {
project: {
+ id: '1',
alertManagementIntegrations: {
nodes: [
{
diff --git a/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js
index 7c2df3fe8c4..1a331100bb8 100644
--- a/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/usage_trends_count_chart_spec.js
@@ -1,6 +1,7 @@
import { GlAlert } from '@gitlab/ui';
import { GlLineChart } from '@gitlab/ui/dist/charts';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue';
@@ -9,8 +10,7 @@ import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleto
import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data';
import { mockCountsData1 } from '../mock_data';
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
const loadChartErrorMessage = 'My load error message';
const noDataMessage = 'My no data message';
@@ -39,7 +39,6 @@ describe('UsageTrendsCountChart', () => {
const createComponent = ({ responseHandler }) => {
return shallowMount(UsageTrendsCountChart, {
- localVue,
apolloProvider: createMockApollo([[statsQuery, responseHandler]]),
propsData: { ...mockChartConfig },
});
diff --git a/spec/frontend/analytics/usage_trends/components/users_chart_spec.js b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js
index 6adfcca11ac..04ea25a02d5 100644
--- a/spec/frontend/analytics/usage_trends/components/users_chart_spec.js
+++ b/spec/frontend/analytics/usage_trends/components/users_chart_spec.js
@@ -1,6 +1,7 @@
import { GlAlert } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import UsersChart from '~/analytics/usage_trends/components/users_chart.vue';
@@ -13,8 +14,7 @@ import {
roundedSortedCountsMonthlyChartData2,
} from '../mock_data';
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
describe('UsersChart', () => {
let wrapper;
@@ -34,7 +34,6 @@ describe('UsersChart', () => {
endDate: new Date(2020, 10, 1),
totalDataPoints: mockCountsData2.length,
},
- localVue,
apolloProvider: createMockApollo([[usersQuery, queryHandler]]),
data() {
return { loadingError };
diff --git a/spec/frontend/api/packages_api_spec.js b/spec/frontend/api/packages_api_spec.js
new file mode 100644
index 00000000000..3286dccb1b2
--- /dev/null
+++ b/spec/frontend/api/packages_api_spec.js
@@ -0,0 +1,53 @@
+import MockAdapter from 'axios-mock-adapter';
+import { publishPackage } from '~/api/packages_api';
+import axios from '~/lib/utils/axios_utils';
+import httpStatus from '~/lib/utils/http_status';
+
+describe('Api', () => {
+ const dummyApiVersion = 'v3000';
+ const dummyUrlRoot = '/gitlab';
+ const dummyGon = {
+ api_version: dummyApiVersion,
+ relative_url_root: dummyUrlRoot,
+ };
+ let originalGon;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ originalGon = window.gon;
+ window.gon = { ...dummyGon };
+ });
+
+ afterEach(() => {
+ mock.restore();
+ window.gon = originalGon;
+ });
+
+ describe('packages', () => {
+ const projectPath = 'project_a';
+ const name = 'foo';
+ const packageVersion = '0';
+ const apiResponse = [{ id: 1, name: 'foo' }];
+
+ describe('publishPackage', () => {
+ it('publishes the package', () => {
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/packages/generic/${name}/${packageVersion}/${name}`;
+
+ jest.spyOn(axios, 'put');
+ mock.onPut(expectedUrl).replyOnce(httpStatus.OK, apiResponse);
+
+ return publishPackage(
+ { projectPath, name, version: 0, fileName: name, files: [{}] },
+ { status: 'hidden', select: 'package_file' },
+ ).then(({ data }) => {
+ expect(data).toEqual(apiResponse);
+ expect(axios.put).toHaveBeenCalledWith(expectedUrl, expect.any(FormData), {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ params: { select: 'package_file', status: 'hidden' },
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index c3e5a2973d7..75faf6d66fa 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -1,5 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
-import Api from '~/api';
+import Api, { DEFAULT_PER_PAGE } from '~/api';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
@@ -1574,6 +1574,51 @@ describe('Api', () => {
});
});
+ describe('deployKeys', () => {
+ it('fetches deploy keys', async () => {
+ const deployKeys = [
+ {
+ id: 7,
+ title: 'My title 1',
+ created_at: '2021-10-29T16:59:55.229Z',
+ expires_at: null,
+ key:
+ 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDLvQzRX960N7dxPdge9o5a96+M4GEGQ7rxT2D3wAQDtQFjQV5ZcKb5wfeLtYLe3kRVI4lCO10PXeQppb1XBaYmVO31IaRkcgmMEPVyfp76Dp4CJZz6aMEbbcqfaHkDre0Fa8kzTXnBJVh2NeDbBfGMjFM5NRQLhKykodNsepO6dQ== dummy@gitlab.com',
+ fingerprint: '81:93:63:b9:1e:24:a2:aa:e0:87:d3:3f:42:81:f2:c2',
+ projects_with_write_access: [
+ {
+ id: 11,
+ description: null,
+ name: 'project1',
+ name_with_namespace: 'John Doe3 / project1',
+ path: 'project1',
+ path_with_namespace: 'namespace1/project1',
+ created_at: '2021-10-29T16:59:54.668Z',
+ },
+ {
+ id: 12,
+ description: null,
+ name: 'project2',
+ name_with_namespace: 'John Doe4 / project2',
+ path: 'project2',
+ path_with_namespace: 'namespace2/project2',
+ created_at: '2021-10-29T16:59:55.116Z',
+ },
+ ],
+ },
+ ];
+
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/deploy_keys`;
+ mock.onGet(expectedUrl).reply(httpStatus.OK, deployKeys);
+
+ const params = { page: 2, public: true };
+ const { data } = await Api.deployKeys(params);
+
+ expect(data).toEqual(deployKeys);
+ expect(mock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE });
+ });
+ });
+
describe('Feature Flag User List', () => {
let expectedUrl;
let projectId;
diff --git a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
index b0d1b70c198..bfa8274f0eb 100644
--- a/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
+++ b/spec/frontend/artifacts_settings/components/keep_latest_artifact_checkbox_spec.js
@@ -13,6 +13,7 @@ localVue.use(VueApollo);
const keepLatestArtifactProjectMock = {
data: {
project: {
+ id: '1',
ciCdSettings: { keepLatestArtifact: true },
},
},
diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js
index 09270174674..c4002ec11f3 100644
--- a/spec/frontend/awards_handler_spec.js
+++ b/spec/frontend/awards_handler_spec.js
@@ -1,15 +1,12 @@
-import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import Cookies from 'js-cookie';
+import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import loadAwardsHandler from '~/awards_handler';
-import { EMOJI_VERSION } from '~/emoji';
-import axios from '~/lib/utils/axios_utils';
window.gl = window.gl || {};
window.gon = window.gon || {};
-let mock;
let awardsHandler = null;
const urlRoot = gon.relative_url_root;
@@ -76,8 +73,7 @@ describe('AwardsHandler', () => {
};
beforeEach(async () => {
- mock = new MockAdapter(axios);
- mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData);
+ await initEmojiMock(emojiData);
loadFixtures('snippets/show.html');
@@ -89,7 +85,7 @@ describe('AwardsHandler', () => {
// restore original url root value
gon.relative_url_root = urlRoot;
- mock.restore();
+ clearEmojiMock();
// Undo what we did to the shared <body>
$('body').removeAttr('data-page');
diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js
index d23a0a84997..0f4e2e08dbd 100644
--- a/spec/frontend/behaviors/gl_emoji_spec.js
+++ b/spec/frontend/behaviors/gl_emoji_spec.js
@@ -1,15 +1,13 @@
-import MockAdapter from 'axios-mock-adapter';
+import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import waitForPromises from 'helpers/wait_for_promises';
import installGlEmojiElement from '~/behaviors/gl_emoji';
-import { initEmojiMap, EMOJI_VERSION } from '~/emoji';
+import { EMOJI_VERSION } from '~/emoji';
import * as EmojiUnicodeSupport from '~/emoji/support';
-import axios from '~/lib/utils/axios_utils';
jest.mock('~/emoji/support');
describe('gl_emoji', () => {
- let mock;
const emojiData = {
grey_question: {
c: 'symbols',
@@ -38,15 +36,12 @@ describe('gl_emoji', () => {
return div.firstElementChild;
}
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData);
-
- return initEmojiMap().catch(() => {});
+ beforeEach(async () => {
+ await initEmojiMock(emojiData);
});
afterEach(() => {
- mock.restore();
+ clearEmojiMock();
document.body.innerHTML = '';
});
diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
index dfa6b99080b..46a5631b028 100644
--- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
+++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap
@@ -34,6 +34,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
text="foo/bar/dummy.md"
title="Copy file path"
tooltipplacement="top"
+ variant="default"
/>
</div>
`;
diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js
index 705c4630a68..061ac7ad167 100644
--- a/spec/frontend/blob/viewer/index_spec.js
+++ b/spec/frontend/blob/viewer/index_spec.js
@@ -28,7 +28,7 @@ describe('Blob viewer', () => {
loadFixtures('blob/show_readme.html');
$('#modal-upload-blob').remove();
- mock.onGet(/blob\/master\/README\.md/).reply(200, {
+ mock.onGet(/blob\/.+\/README\.md/).reply(200, {
html: '<div>testing</div>',
});
diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js
index ebef0656750..9c974e79e6e 100644
--- a/spec/frontend/blob_edit/edit_blob_spec.js
+++ b/spec/frontend/blob_edit/edit_blob_spec.js
@@ -1,14 +1,29 @@
import waitForPromises from 'helpers/wait_for_promises';
import EditBlob from '~/blob_edit/edit_blob';
+import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
+import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import SourceEditor from '~/editor/source_editor';
jest.mock('~/editor/source_editor');
-jest.mock('~/editor/extensions/source_editor_markdown_ext');
+jest.mock('~/editor/extensions/source_editor_extension_base');
jest.mock('~/editor/extensions/source_editor_file_template_ext');
+jest.mock('~/editor/extensions/source_editor_markdown_ext');
+jest.mock('~/editor/extensions/source_editor_markdown_livepreview_ext');
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
+const defaultExtensions = [
+ { definition: SourceEditorExtension },
+ { definition: FileTemplateExtension },
+];
+const markdownExtensions = [
+ { definition: EditorMarkdownExtension },
+ {
+ definition: EditorMarkdownPreviewExtension,
+ setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH },
+ },
+];
describe('Blob Editing', () => {
const useMock = jest.fn();
@@ -29,7 +44,9 @@ describe('Blob Editing', () => {
jest.spyOn(SourceEditor.prototype, 'createInstance').mockReturnValue(mockInstance);
});
afterEach(() => {
+ SourceEditorExtension.mockClear();
EditorMarkdownExtension.mockClear();
+ EditorMarkdownPreviewExtension.mockClear();
FileTemplateExtension.mockClear();
});
@@ -45,26 +62,22 @@ describe('Blob Editing', () => {
await waitForPromises();
};
- it('loads FileTemplateExtension by default', async () => {
+ it('loads SourceEditorExtension and FileTemplateExtension by default', async () => {
await initEditor();
- expect(useMock).toHaveBeenCalledWith(expect.any(FileTemplateExtension));
- expect(FileTemplateExtension).toHaveBeenCalledTimes(1);
+ expect(useMock).toHaveBeenCalledWith(defaultExtensions);
});
describe('Markdown', () => {
- it('does not load MarkdownExtension by default', async () => {
+ it('does not load MarkdownExtensions by default', async () => {
await initEditor();
expect(EditorMarkdownExtension).not.toHaveBeenCalled();
+ expect(EditorMarkdownPreviewExtension).not.toHaveBeenCalled();
});
it('loads MarkdownExtension only for the markdown files', async () => {
await initEditor(true);
- expect(useMock).toHaveBeenCalledWith(expect.any(EditorMarkdownExtension));
- expect(EditorMarkdownExtension).toHaveBeenCalledTimes(1);
- expect(EditorMarkdownExtension).toHaveBeenCalledWith({
- instance: mockInstance,
- previewMarkdownPath: PREVIEW_MARKDOWN_PATH,
- });
+ expect(useMock).toHaveBeenCalledTimes(2);
+ expect(useMock.mock.calls[1]).toEqual([markdownExtensions]);
});
});
diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js
index 811f0043a01..d0f14bd37c1 100644
--- a/spec/frontend/boards/board_list_helper.js
+++ b/spec/frontend/boards/board_list_helper.js
@@ -1,4 +1,5 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import BoardCard from '~/boards/components/board_card.vue';
@@ -6,7 +7,15 @@ 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, mockGroupProjects } from './mock_data';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import {
+ mockList,
+ mockIssuesByListId,
+ issues,
+ mockGroupProjects,
+ boardListQueryResponse,
+} from './mock_data';
export default function createComponent({
listIssueProps = {},
@@ -15,16 +24,23 @@ export default function createComponent({
actions = {},
getters = {},
provide = {},
+ data = {},
state = defaultState,
stubs = {
BoardNewIssue,
BoardNewItem,
BoardCard,
},
+ issuesCount,
} = {}) {
const localVue = createLocalVue();
+ localVue.use(VueApollo);
localVue.use(Vuex);
+ const fakeApollo = createMockApollo([
+ [listQuery, jest.fn().mockResolvedValue(boardListQueryResponse(issuesCount))],
+ ]);
+
const store = new Vuex.Store({
state: {
selectedProject: mockGroupProjects[0],
@@ -68,6 +84,7 @@ export default function createComponent({
}
const component = shallowMount(BoardList, {
+ apolloProvider: fakeApollo,
localVue,
store,
propsData: {
@@ -87,6 +104,11 @@ export default function createComponent({
...provide,
},
stubs,
+ data() {
+ return {
+ ...data,
+ };
+ },
});
return component;
diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js
index 6f623eab1af..1981ed5ab7f 100644
--- a/spec/frontend/boards/board_list_spec.js
+++ b/spec/frontend/boards/board_list_spec.js
@@ -38,7 +38,7 @@ describe('Board list component', () => {
describe('When Expanded', () => {
beforeEach(() => {
- wrapper = createComponent();
+ wrapper = createComponent({ issuesCount: 1 });
});
it('renders component', () => {
@@ -97,14 +97,6 @@ describe('Board list component', () => {
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
});
-
- it('shows how many more issues to load', async () => {
- wrapper.vm.showCount = true;
- wrapper.setProps({ list: { issuesCount: 20 } });
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
- });
});
describe('load more issues', () => {
@@ -113,9 +105,7 @@ describe('Board list component', () => {
};
beforeEach(() => {
- wrapper = createComponent({
- listProps: { issuesCount: 25 },
- });
+ wrapper = createComponent();
});
it('does not load issues if already loading', () => {
@@ -131,13 +121,27 @@ describe('Board list component', () => {
it('shows loading more spinner', async () => {
wrapper = createComponent({
state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
+ data: {
+ showCount: true,
+ },
});
- wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(findIssueCountLoadingIcon().exists()).toBe(true);
});
+
+ it('shows how many more issues to load', async () => {
+ // wrapper.vm.showCount = true;
+ wrapper = createComponent({
+ data: {
+ showCount: true,
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
+ });
});
describe('max issue count warning', () => {
diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js
index 8a8250205d0..7b176cea2a3 100644
--- a/spec/frontend/boards/components/board_content_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_content_sidebar_spec.js
@@ -1,18 +1,20 @@
import { GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { MountingPortal } from 'portal-vue';
+import Vue from 'vue';
import Vuex from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
import { stubComponent } from 'helpers/stub_component';
import BoardContentSidebar from '~/boards/components/board_content_sidebar.vue';
-import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
+import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { mockActiveIssue, mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data';
+Vue.use(Vuex);
describe('BoardContentSidebar', () => {
let wrapper;
let store;
@@ -32,6 +34,7 @@ describe('BoardContentSidebar', () => {
groupPathForActiveIssue: () => mockIssueGroupPath,
projectPathForActiveIssue: () => mockIssueProjectPath,
isSidebarOpen: () => true,
+ isGroupBoard: () => false,
...mockGetters,
},
actions: mockActions,
@@ -115,8 +118,8 @@ describe('BoardContentSidebar', () => {
expect(wrapper.findComponent(SidebarTodoWidget).exists()).toBe(true);
});
- it('renders BoardSidebarLabelsSelect', () => {
- expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true);
+ it('renders SidebarLabelsWidget', () => {
+ expect(wrapper.findComponent(SidebarLabelsWidget).exists()).toBe(true);
});
it('renders BoardSidebarTitle', () => {
diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js
index b858d6e95a0..ea551e94f2f 100644
--- a/spec/frontend/boards/components/board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/board_filtered_search_spec.js
@@ -18,7 +18,7 @@ describe('BoardFilteredSearch', () => {
{
icon: 'labels',
title: __('Label'),
- type: 'label_name',
+ type: 'label',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
@@ -31,7 +31,7 @@ describe('BoardFilteredSearch', () => {
{
icon: 'pencil',
title: __('Author'),
- type: 'author_username',
+ type: 'author',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
@@ -97,7 +97,7 @@ describe('BoardFilteredSearch', () => {
createComponent({ props: { eeFilters: { labelName: ['label'] } } });
expect(findFilteredSearch().props('initialFilterValue')).toEqual([
- { type: 'label_name', value: { data: 'label', operator: '=' } },
+ { type: 'label', value: { data: 'label', operator: '=' } },
]);
});
});
@@ -117,12 +117,14 @@ describe('BoardFilteredSearch', () => {
it('sets the url params to the correct results', async () => {
const mockFilters = [
- { 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: 'author', value: { data: 'root', operator: '=' } },
+ { type: 'label', value: { data: 'label', operator: '=' } },
+ { type: 'label', value: { data: 'label2', operator: '=' } },
+ { type: 'milestone', value: { data: 'New Milestone', operator: '=' } },
+ { type: 'type', value: { data: 'INCIDENT', operator: '=' } },
{ type: 'weight', value: { data: '2', operator: '=' } },
+ { type: 'iteration', value: { data: '3341', operator: '=' } },
+ { type: 'release', value: { data: 'v1.0.0', operator: '=' } },
];
jest.spyOn(urlUtility, 'updateHistory');
findFilteredSearch().vm.$emit('onFilter', mockFilters);
@@ -131,7 +133,7 @@ describe('BoardFilteredSearch', () => {
title: '',
replace: true,
url:
- 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&milestone_title=New+Milestone&types=INCIDENT&weight=2',
+ 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label2&milestone_title=New+Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0',
});
});
});
@@ -145,8 +147,8 @@ describe('BoardFilteredSearch', () => {
it('passes the correct props to FilterSearchBar', () => {
expect(findFilteredSearch().props('initialFilterValue')).toEqual([
- { type: 'author_username', value: { data: 'root', operator: '=' } },
- { type: 'label_name', value: { data: 'label', operator: '=' } },
+ { type: 'author', value: { data: 'root', operator: '=' } },
+ { type: 'label', value: { data: 'label', operator: '=' } },
]);
});
});
diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js
index 0abb00e0fa5..148d0c5684d 100644
--- a/spec/frontend/boards/components/board_list_header_spec.js
+++ b/spec/frontend/boards/components/board_list_header_spec.js
@@ -1,18 +1,22 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import { mockLabelList } from 'jest/boards/mock_data';
+import { boardListQueryResponse, mockLabelList } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header.vue';
import { ListType } from '~/boards/constants';
+import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
-const localVue = createLocalVue();
-
-localVue.use(Vuex);
+Vue.use(VueApollo);
+Vue.use(Vuex);
describe('Board List Header Component', () => {
let wrapper;
let store;
+ let fakeApollo;
const updateListSpy = jest.fn();
const toggleListCollapsedSpy = jest.fn();
@@ -20,6 +24,7 @@ describe('Board List Header Component', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
+ fakeApollo = null;
localStorage.clear();
});
@@ -29,6 +34,7 @@ describe('Board List Header Component', () => {
collapsed = false,
withLocalStorage = true,
currentUserId = 1,
+ listQueryHandler = jest.fn().mockResolvedValue(boardListQueryResponse()),
} = {}) => {
const boardId = '1';
@@ -56,10 +62,12 @@ describe('Board List Header Component', () => {
getters: { isEpicBoard: () => false },
});
+ fakeApollo = createMockApollo([[listQuery, listQueryHandler]]);
+
wrapper = extendedWrapper(
shallowMount(BoardListHeader, {
+ apolloProvider: fakeApollo,
store,
- localVue,
propsData: {
disabled: false,
list: listMock,
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 45c5c87d800..76e8b84d8ef 100644
--- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
@@ -1,3 +1,4 @@
+import { orderBy } from 'lodash';
import { shallowMount } from '@vue/test-utils';
import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_search.vue';
import IssueBoardFilteredSpec from '~/boards/components/issue_board_filtered_search.vue';
@@ -16,6 +17,7 @@ describe('IssueBoardFilter', () => {
propsData: { fullPath: 'gitlab-org', boardType: 'group' },
provide: {
isSignedIn,
+ releasesFetchPath: '/releases',
},
});
};
@@ -61,7 +63,7 @@ describe('IssueBoardFilter', () => {
isSignedIn,
);
- expect(findBoardsFilteredSearch().props('tokens')).toEqual(tokens);
+ expect(findBoardsFilteredSearch().props('tokens')).toEqual(orderBy(tokens, ['title']));
},
);
});
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
deleted file mode 100644
index fb9d823107e..00000000000
--- a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js
+++ /dev/null
@@ -1,168 +0,0 @@
-import { GlLabel } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { TEST_HOST } from 'helpers/test_constants';
-import {
- labels as TEST_LABELS,
- mockIssue as TEST_ISSUE,
- mockIssueFullPath as TEST_ISSUE_FULLPATH,
-} from 'jest/boards/mock_data';
-import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
-import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
-import { createStore } from '~/boards/stores';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-
-const TEST_LABELS_PAYLOAD = TEST_LABELS.map((label) => ({ ...label, set: true }));
-const TEST_LABELS_TITLES = TEST_LABELS.map((label) => label.title);
-
-describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
- let wrapper;
- let store;
-
- afterEach(() => {
- wrapper.destroy();
- store = null;
- wrapper = null;
- });
-
- const createWrapper = ({ labels = [], providedValues = {} } = {}) => {
- store = createStore();
- store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } };
- store.state.activeId = TEST_ISSUE.id;
-
- wrapper = shallowMount(BoardSidebarLabelsSelect, {
- store,
- provide: {
- canUpdate: true,
- labelsManagePath: TEST_HOST,
- labelsFilterBasePath: TEST_HOST,
- ...providedValues,
- },
- stubs: {
- BoardEditableItem,
- LabelsSelect: true,
- },
- });
- };
-
- const findLabelsSelect = () => wrapper.find({ ref: 'labelsSelect' });
- const findLabelsTitles = () =>
- wrapper.findAll(GlLabel).wrappers.map((item) => item.props('title'));
- const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
-
- describe('when labelsFetchPath is provided', () => {
- it('uses injected labels fetch path', () => {
- createWrapper({ providedValues: { labelsFetchPath: 'foobar' } });
-
- expect(findLabelsSelect().props('labelsFetchPath')).toEqual('foobar');
- });
- });
-
- it('uses the default project label endpoint', () => {
- createWrapper();
-
- expect(findLabelsSelect().props('labelsFetchPath')).toEqual(
- `/${TEST_ISSUE_FULLPATH}/-/labels?include_ancestor_groups=true`,
- );
- });
-
- it('renders "None" when no labels are selected', () => {
- createWrapper();
-
- expect(findCollapsed().text()).toBe('None');
- });
-
- it('renders labels when set', () => {
- createWrapper({ labels: TEST_LABELS });
-
- expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
- });
-
- describe('when labels are submitted', () => {
- beforeEach(async () => {
- createWrapper();
-
- jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => TEST_LABELS);
- findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD);
- store.state.boardItems[TEST_ISSUE.id].labels = TEST_LABELS;
- await wrapper.vm.$nextTick();
- });
-
- it('collapses sidebar and renders labels', () => {
- expect(findCollapsed().isVisible()).toBe(true);
- expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
- });
-
- it('commits change to the server', () => {
- expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
- addLabelIds: TEST_LABELS.map((label) => label.id),
- projectPath: TEST_ISSUE_FULLPATH,
- removeLabelIds: [],
- iid: null,
- });
- });
- });
-
- describe('when labels are updated over existing labels', () => {
- const testLabelsPayload = [
- { id: 5, set: true },
- { id: 6, set: false },
- { id: 7, set: true },
- ];
- const expectedLabels = [{ id: 5 }, { id: 7 }];
-
- beforeEach(async () => {
- createWrapper({ labels: TEST_LABELS });
-
- jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => expectedLabels);
- findLabelsSelect().vm.$emit('updateSelectedLabels', testLabelsPayload);
- await wrapper.vm.$nextTick();
- });
-
- it('commits change to the server', () => {
- expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
- addLabelIds: [5, 7],
- removeLabelIds: [6],
- projectPath: TEST_ISSUE_FULLPATH,
- iid: null,
- });
- });
- });
-
- describe('when removing individual labels', () => {
- const testLabel = TEST_LABELS[0];
-
- beforeEach(async () => {
- createWrapper({ labels: [testLabel] });
-
- jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => {});
- });
-
- it('commits change to the server', () => {
- wrapper.find(GlLabel).vm.$emit('close', testLabel);
-
- expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
- removeLabelIds: [getIdFromGraphQLId(testLabel.id)],
- projectPath: TEST_ISSUE_FULLPATH,
- });
- });
- });
-
- describe('when the mutation fails', () => {
- beforeEach(async () => {
- createWrapper({ labels: TEST_LABELS });
-
- jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => {
- throw new Error(['failed mutation']);
- });
- jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
- findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]);
- await wrapper.vm.$nextTick();
- });
-
- it('collapses sidebar and renders former issue weight', () => {
- expect(findCollapsed().isVisible()).toBe(true);
- expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
- expect(wrapper.vm.setError).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js
deleted file mode 100644
index 6e1b528babc..00000000000
--- a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js
+++ /dev/null
@@ -1,163 +0,0 @@
-import { GlToggle, GlLoadingIcon } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
-import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
-import { createStore } from '~/boards/stores';
-import * as types from '~/boards/stores/mutation_types';
-import { mockActiveIssue } from '../../mock_data';
-
-Vue.use(Vuex);
-
-describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => {
- let wrapper;
- let store;
-
- const findNotificationHeader = () => wrapper.find("[data-testid='notification-header-text']");
- const findToggle = () => wrapper.findComponent(GlToggle);
- const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
-
- const createComponent = (activeBoardItem = { ...mockActiveIssue }) => {
- store = createStore();
- store.state.boardItems = { [activeBoardItem.id]: activeBoardItem };
- store.state.activeId = activeBoardItem.id;
-
- wrapper = mount(BoardSidebarSubscription, {
- store,
- provide: {
- emailsDisabled: false,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- store = null;
- jest.clearAllMocks();
- });
-
- describe('Board sidebar subscription component template', () => {
- it('displays "notifications" heading', () => {
- createComponent();
-
- expect(findNotificationHeader().text()).toBe('Notifications');
- });
-
- it('renders toggle with label', () => {
- createComponent();
-
- expect(findToggle().props('label')).toBe(BoardSidebarSubscription.i18n.header.title);
- });
-
- it('renders toggle as "off" when currently not subscribed', () => {
- createComponent();
-
- expect(findToggle().exists()).toBe(true);
- expect(findToggle().props('value')).toBe(false);
- });
-
- it('renders toggle as "on" when currently subscribed', () => {
- createComponent({
- ...mockActiveIssue,
- subscribed: true,
- });
-
- expect(findToggle().exists()).toBe(true);
- expect(findToggle().props('value')).toBe(true);
- });
-
- describe('when notification emails have been disabled', () => {
- beforeEach(() => {
- createComponent({
- ...mockActiveIssue,
- emailsDisabled: true,
- });
- });
-
- it('displays a message that notification have been disabled', () => {
- expect(findNotificationHeader().text()).toBe(
- 'Notifications have been disabled by the project or group owner',
- );
- });
-
- it('does not render the toggle button', () => {
- expect(findToggle().exists()).toBe(false);
- });
- });
- });
-
- describe('Board sidebar subscription component `behavior`', () => {
- const mockSetActiveIssueSubscribed = (subscribedState) => {
- jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => {
- store.commit(types.UPDATE_BOARD_ITEM_BY_ID, {
- itemId: mockActiveIssue.id,
- prop: 'subscribed',
- value: subscribedState,
- });
- });
- };
-
- it('subscribing to notification', async () => {
- createComponent();
- mockSetActiveIssueSubscribed(true);
-
- expect(findGlLoadingIcon().exists()).toBe(false);
-
- findToggle().vm.$emit('change');
-
- await wrapper.vm.$nextTick();
-
- expect(findGlLoadingIcon().exists()).toBe(true);
- expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({
- subscribed: true,
- projectPath: 'gitlab-org/test-subgroup/gitlab-test',
- });
-
- await wrapper.vm.$nextTick();
-
- expect(findGlLoadingIcon().exists()).toBe(false);
- expect(findToggle().props('value')).toBe(true);
- });
-
- it('unsubscribing from notification', async () => {
- createComponent({
- ...mockActiveIssue,
- subscribed: true,
- });
- mockSetActiveIssueSubscribed(false);
-
- expect(findGlLoadingIcon().exists()).toBe(false);
-
- findToggle().vm.$emit('change');
-
- await wrapper.vm.$nextTick();
-
- expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({
- subscribed: false,
- projectPath: 'gitlab-org/test-subgroup/gitlab-test',
- });
- expect(findGlLoadingIcon().exists()).toBe(true);
-
- await wrapper.vm.$nextTick();
-
- expect(findGlLoadingIcon().exists()).toBe(false);
- expect(findToggle().props('value')).toBe(false);
- });
-
- it('flashes an error message when setting the subscribed state fails', async () => {
- createComponent();
- jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => {
- throw new Error();
- });
- jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
-
- findToggle().vm.$emit('change');
-
- await wrapper.vm.$nextTick();
- expect(wrapper.vm.setError).toHaveBeenCalled();
- expect(wrapper.vm.setError.mock.calls[0][0].message).toBe(
- wrapper.vm.$options.i18n.updateSubscribedErrorMessage,
- );
- });
- });
-});
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 8fcad99f8a7..a081a60166b 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -2,12 +2,11 @@ import { GlFilteredSearchToken } from '@gitlab/ui';
import { keyBy } from 'lodash';
import { ListType } from '~/boards/constants';
import { __ } from '~/locale';
-import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_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';
+import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue';
export const boardObj = {
id: 1,
@@ -21,7 +20,6 @@ export const listObj = {
position: 0,
title: 'Test',
list_type: 'label',
- weight: 3,
label: {
id: 5000,
title: 'Test',
@@ -154,7 +152,6 @@ export const rawIssue = {
iid: '27',
dueDate: null,
timeEstimate: 0,
- weight: null,
confidential: false,
referencePath: 'gitlab-org/test-subgroup/gitlab-test#27',
path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27',
@@ -184,7 +181,6 @@ export const mockIssue = {
title: 'Issue 1',
dueDate: null,
timeEstimate: 0,
- weight: null,
confidential: false,
referencePath: `${mockIssueFullPath}#27`,
path: `/${mockIssueFullPath}/-/issues/27`,
@@ -216,7 +212,6 @@ export const mockIssue2 = {
title: 'Issue 2',
dueDate: null,
timeEstimate: 0,
- weight: null,
confidential: false,
referencePath: 'gitlab-org/test-subgroup/gitlab-test#28',
path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/28',
@@ -234,7 +229,6 @@ export const mockIssue3 = {
referencePath: '#29',
dueDate: null,
timeEstimate: 0,
- weight: null,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/28',
assignees,
@@ -249,7 +243,6 @@ export const mockIssue4 = {
referencePath: '#30',
dueDate: null,
timeEstimate: 0,
- weight: null,
confidential: false,
path: '/gitlab-org/gitlab-test/-/issues/28',
assignees,
@@ -551,7 +544,7 @@ export const mockMoveData = {
};
export const mockEmojiToken = {
- type: 'my_reaction_emoji',
+ type: 'my-reaction',
icon: 'thumb-up',
title: 'My-Reaction',
unique: true,
@@ -559,11 +552,24 @@ export const mockEmojiToken = {
fetchEmojis: expect.any(Function),
};
-export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji) => [
+export const mockConfidentialToken = {
+ type: 'confidential',
+ icon: 'eye-slash',
+ title: 'Confidential',
+ unique: true,
+ token: GlFilteredSearchToken,
+ operators: [{ value: '=', description: 'is' }],
+ options: [
+ { icon: 'eye-slash', value: 'yes', title: 'Yes' },
+ { icon: 'eye', value: 'no', title: 'No' },
+ ],
+};
+
+export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, isSignedIn) => [
{
icon: 'user',
title: __('Assignee'),
- type: 'assignee_username',
+ type: 'assignee',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
@@ -576,7 +582,7 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji)
{
icon: 'pencil',
title: __('Author'),
- type: 'author_username',
+ type: 'author',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
@@ -590,7 +596,7 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji)
{
icon: 'labels',
title: __('Label'),
- type: 'label_name',
+ type: 'label',
operators: [
{ value: '=', description: 'is' },
{ value: '!=', description: 'is not' },
@@ -600,21 +606,20 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji)
symbol: '~',
fetchLabels,
},
- ...(hasEmoji ? [mockEmojiToken] : []),
+ ...(isSignedIn ? [mockEmojiToken, mockConfidentialToken] : []),
{
icon: 'clock',
title: __('Milestone'),
symbol: '%',
- type: 'milestone_title',
+ type: 'milestone',
token: MilestoneToken,
unique: true,
- defaultMilestones: DEFAULT_MILESTONES_GRAPHQL,
fetchMilestones,
},
{
icon: 'issues',
title: __('Type'),
- type: 'types',
+ type: 'type',
token: GlFilteredSearchToken,
unique: true,
options: [
@@ -623,11 +628,11 @@ export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, hasEmoji)
],
},
{
- icon: 'weight',
- title: __('Weight'),
- type: 'weight',
- token: WeightToken,
- unique: true,
+ type: 'release',
+ title: __('Release'),
+ icon: 'rocket',
+ token: ReleaseToken,
+ fetchReleases: expect.any(Function),
},
];
@@ -670,3 +675,14 @@ export const mockGroupLabelsResponse = {
},
},
};
+
+export const boardListQueryResponse = (issuesCount = 20) => ({
+ data: {
+ boardList: {
+ __typename: 'BoardList',
+ id: 'gid://gitlab/BoardList/5',
+ totalWeight: 5,
+ issuesCount,
+ },
+ },
+});
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index e245325b956..51340a3ea4f 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -20,7 +20,7 @@ import {
formatIssue,
getMoveData,
updateListPosition,
-} from '~/boards/boards_util';
+} from 'ee_else_ce/boards/boards_util';
import { gqlClient } from '~/boards/graphql';
import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
@@ -1241,6 +1241,7 @@ describe('updateIssueOrder', () => {
moveBeforeId: undefined,
moveAfterId: undefined,
},
+ update: expect.anything(),
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
@@ -1447,6 +1448,7 @@ describe('addListNewIssue', () => {
variables: {
input: formatIssueInput(mockIssue, stateWithBoardConfig.boardConfig),
},
+ update: expect.anything(),
});
});
@@ -1478,6 +1480,7 @@ describe('addListNewIssue', () => {
variables: {
input: formatIssueInput(issue, stateWithBoardConfig.boardConfig),
},
+ update: expect.anything(),
});
expect(payload.labelIds).toEqual(['gid://gitlab/GroupLabel/4', 'gid://gitlab/GroupLabel/5']);
expect(payload.assigneeIds).toEqual(['gid://gitlab/User/1', 'gid://gitlab/User/2']);
@@ -1570,7 +1573,7 @@ describe('addListNewIssue', () => {
describe('setActiveIssueLabels', () => {
const state = { boardItems: { [mockIssue.id]: mockIssue } };
- const getters = { activeBoardItem: mockIssue };
+ const getters = { activeBoardItem: { ...mockIssue, labels } };
const testLabelIds = labels.map((label) => label.id);
const input = {
labelIds: testLabelIds,
@@ -1579,11 +1582,7 @@ describe('setActiveIssueLabels', () => {
labels,
};
- it('should assign labels on success', (done) => {
- jest
- .spyOn(gqlClient, 'mutate')
- .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } });
-
+ it('should assign labels', () => {
const payload = {
itemId: getters.activeBoardItem.id,
prop: 'labels',
@@ -1601,74 +1600,28 @@ describe('setActiveIssueLabels', () => {
},
],
[],
- done,
);
});
- it('throws error if fails', async () => {
- jest
- .spyOn(gqlClient, 'mutate')
- .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } });
-
- await expect(actions.setActiveIssueLabels({ getters }, input)).rejects.toThrow(Error);
- });
-
- describe('labels_widget FF on', () => {
- beforeEach(() => {
- window.gon = {
- features: { labelsWidget: true },
- };
-
- getters.activeBoardItem = { ...mockIssue, labels };
- });
-
- afterEach(() => {
- window.gon = {
- features: {},
- };
- });
-
- it('should assign labels', () => {
- const payload = {
- itemId: getters.activeBoardItem.id,
- prop: 'labels',
- value: labels,
- };
-
- testAction(
- actions.setActiveIssueLabels,
- input,
- { ...state, ...getters },
- [
- {
- type: types.UPDATE_BOARD_ITEM_BY_ID,
- payload,
- },
- ],
- [],
- );
- });
-
- it('should remove label', () => {
- const payload = {
- itemId: getters.activeBoardItem.id,
- prop: 'labels',
- value: [labels[1]],
- };
+ it('should remove label', () => {
+ const payload = {
+ itemId: getters.activeBoardItem.id,
+ prop: 'labels',
+ value: [labels[1]],
+ };
- testAction(
- actions.setActiveIssueLabels,
- { ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] },
- { ...state, ...getters },
- [
- {
- type: types.UPDATE_BOARD_ITEM_BY_ID,
- payload,
- },
- ],
- [],
- );
- });
+ testAction(
+ actions.setActiveIssueLabels,
+ { ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] },
+ { ...state, ...getters },
+ [
+ {
+ type: types.UPDATE_BOARD_ITEM_BY_ID,
+ payload,
+ },
+ ],
+ [],
+ );
});
});
diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js
index 36d860b1ccd..70d116c12d3 100644
--- a/spec/frontend/ci_lint/components/ci_lint_spec.js
+++ b/spec/frontend/ci_lint/components/ci_lint_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import CiLint from '~/ci_lint/components/ci_lint.vue';
import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue';
-import lintCIMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql';
+import lintCIMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
import { mockLintDataValid } from '../mock_data';
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 5c7404c1175..7c4ff67feb3 100644
--- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
@@ -1,9 +1,10 @@
import { GlButton, GlFormInput } from '@gitlab/ui';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import Vuex from 'vuex';
+import { mockTracking } from 'helpers/tracking_helper';
import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue';
import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue';
-import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants';
+import { AWS_ACCESS_KEY_ID, EVENT_LABEL, EVENT_ACTION } from '~/ci_variable_list/constants';
import createStore from '~/ci_variable_list/store';
import mockData from '../services/mock_data';
import ModalStub from '../stubs';
@@ -14,9 +15,12 @@ localVue.use(Vuex);
describe('Ci variable modal', () => {
let wrapper;
let store;
+ let trackingSpy;
+
+ const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$';
const createComponent = (method, options = {}) => {
- store = createStore({ isGroup: options.isGroup });
+ store = createStore({ maskableRegex, isGroup: options.isGroup });
wrapper = method(CiVariableModal, {
attachTo: document.body,
stubs: {
@@ -138,6 +142,7 @@ describe('Ci variable modal', () => {
};
createComponent(mount);
store.state.variable = invalidKeyVariable;
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
it(`${rendered ? 'renders' : 'does not render'} the variable reference warning`, () => {
@@ -226,6 +231,7 @@ describe('Ci variable modal', () => {
};
createComponent(mount);
store.state.variable = invalidMaskVariable;
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
it('disables the submit button', () => {
@@ -235,6 +241,50 @@ describe('Ci variable modal', () => {
it('shows the correct error text', () => {
expect(findModal().text()).toContain(maskError);
});
+
+ it('sends the correct tracking event', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, {
+ label: EVENT_LABEL,
+ property: ';',
+ });
+ });
+ });
+
+ describe.each`
+ value | secret | masked | eventSent | trackingErrorProperty
+ ${'value'} | ${'secretValue'} | ${false} | ${0} | ${null}
+ ${'shortMasked'} | ${'short'} | ${true} | ${0} | ${null}
+ ${'withDollar$Sign'} | ${'dollar$ign'} | ${false} | ${1} | ${'$'}
+ ${'withDollar$Sign'} | ${'dollar$ign'} | ${true} | ${1} | ${'$'}
+ ${'unsupported'} | ${'unsupported|char'} | ${true} | ${1} | ${'|'}
+ ${'unsupportedMasked'} | ${'unsupported|char'} | ${false} | ${0} | ${null}
+ `('Adding a new variable', ({ value, secret, masked, eventSent, trackingErrorProperty }) => {
+ beforeEach(() => {
+ const [variable] = mockData.mockVariables;
+ const invalidKeyVariable = {
+ ...variable,
+ key: 'key',
+ value,
+ secret_value: secret,
+ masked,
+ };
+ createComponent(mount);
+ store.state.variable = invalidKeyVariable;
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it(`${
+ eventSent > 0 ? 'sends the correct' : 'does not send the'
+ } variable validation tracking event`, () => {
+ expect(trackingSpy).toHaveBeenCalledTimes(eventSent);
+
+ if (eventSent > 0) {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, {
+ label: EVENT_LABEL,
+ property: trackingErrorProperty,
+ });
+ }
+ });
});
describe('when both states are valid', () => {
@@ -249,7 +299,6 @@ describe('Ci variable modal', () => {
};
createComponent(mount);
store.state.variable = validMaskandKeyVariable;
- store.state.maskableRegex = /^[a-zA-Z0-9_+=/@:.~-]{8,}$/;
});
it('does not disable the submit button', () => {
diff --git a/spec/frontend/clusters/agents/components/activity_events_list_spec.js b/spec/frontend/clusters/agents/components/activity_events_list_spec.js
new file mode 100644
index 00000000000..4abbd77dfb7
--- /dev/null
+++ b/spec/frontend/clusters/agents/components/activity_events_list_spec.js
@@ -0,0 +1,102 @@
+import { GlLoadingIcon, GlAlert, GlEmptyState } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { useFakeDate } from 'helpers/fake_date';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ActivityEvents from '~/clusters/agents/components/activity_events_list.vue';
+import ActivityHistoryItem from '~/clusters/agents/components/activity_history_item.vue';
+import getAgentActivityEventsQuery from '~/clusters/agents/graphql/queries/get_agent_activity_events.query.graphql';
+import { mockResponse, mockEmptyResponse } from '../../mock_data';
+
+const activityEmptyStateImage = '/path/to/image';
+const projectPath = 'path/to/project';
+const agentName = 'cluster-agent';
+
+Vue.use(VueApollo);
+
+describe('ActivityEvents', () => {
+ let wrapper;
+ useFakeDate([2021, 12, 3]);
+
+ const provideData = {
+ agentName,
+ projectPath,
+ activityEmptyStateImage,
+ };
+
+ const createWrapper = ({ queryResponse = null } = {}) => {
+ const agentEventsQueryResponse = queryResponse || jest.fn().mockResolvedValue(mockResponse);
+ const apolloProvider = createMockApollo([
+ [getAgentActivityEventsQuery, agentEventsQueryResponse],
+ ]);
+
+ wrapper = shallowMountExtended(ActivityEvents, {
+ apolloProvider,
+ provide: provideData,
+ });
+ };
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findAllActivityHistoryItems = () => wrapper.findAllComponents(ActivityHistoryItem);
+ const findSectionTitle = (at) => wrapper.findAllByTestId('activity-section-title').at(at);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('while the agentEvents query is loading', () => {
+ it('displays a loading icon', async () => {
+ createWrapper();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ await waitForPromises();
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('when the agentEvents query has errored', () => {
+ beforeEach(() => {
+ createWrapper({ queryResponse: jest.fn().mockRejectedValue() });
+ return waitForPromises();
+ });
+
+ it('displays an alert message', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+ });
+
+ describe('when there are no agentEvents', () => {
+ beforeEach(() => {
+ createWrapper({ queryResponse: jest.fn().mockResolvedValue(mockEmptyResponse) });
+ });
+
+ it('displays an empty state with the correct illustration', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEmptyState().props('svgPath')).toBe(activityEmptyStateImage);
+ });
+ });
+
+ describe('when the agentEvents are present', () => {
+ const length = mockResponse.data?.project?.clusterAgent?.activityEvents?.nodes?.length;
+
+ beforeEach(() => {
+ createWrapper();
+ });
+ it('renders an activity-history-item components for every event', () => {
+ expect(findAllActivityHistoryItems()).toHaveLength(length);
+ });
+
+ it.each`
+ recordedAt | date | lineNumber
+ ${'2021-12-03T01:06:56Z'} | ${'Today'} | ${0}
+ ${'2021-12-02T19:26:56Z'} | ${'Yesterday'} | ${1}
+ ${'2021-11-22T19:26:56Z'} | ${'2021-11-22'} | ${2}
+ `('renders correct titles for different days', ({ date, lineNumber }) => {
+ expect(findSectionTitle(lineNumber).text()).toBe(date);
+ });
+ });
+});
diff --git a/spec/frontend/clusters/agents/components/activity_history_item_spec.js b/spec/frontend/clusters/agents/components/activity_history_item_spec.js
new file mode 100644
index 00000000000..100a280d0cc
--- /dev/null
+++ b/spec/frontend/clusters/agents/components/activity_history_item_spec.js
@@ -0,0 +1,56 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { sprintf } from '~/locale';
+import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import ActivityHistoryItem from '~/clusters/agents/components/activity_history_item.vue';
+import { EVENT_DETAILS, DEFAULT_ICON } from '~/clusters/agents/constants';
+import { mockAgentHistoryActivityItems } from '../../mock_data';
+
+const agentName = 'cluster-agent';
+
+describe('ActivityHistoryItem', () => {
+ let wrapper;
+
+ const createWrapper = ({ event = {} }) => {
+ wrapper = shallowMount(ActivityHistoryItem, {
+ propsData: { event },
+ stubs: {
+ HistoryItem,
+ GlSprintf,
+ },
+ });
+ };
+
+ const findHistoryItem = () => wrapper.findComponent(HistoryItem);
+ const findTimeAgo = () => wrapper.find(TimeAgoTooltip);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe.each`
+ kind | icon | title | lineNumber
+ ${'token_created'} | ${EVENT_DETAILS.token_created.eventTypeIcon} | ${sprintf(EVENT_DETAILS.token_created.title, { tokenName: agentName })} | ${0}
+ ${'token_revoked'} | ${EVENT_DETAILS.token_revoked.eventTypeIcon} | ${sprintf(EVENT_DETAILS.token_revoked.title, { tokenName: agentName })} | ${1}
+ ${'agent_connected'} | ${EVENT_DETAILS.agent_connected.eventTypeIcon} | ${sprintf(EVENT_DETAILS.agent_connected.title, { titleIcon: '' })} | ${2}
+ ${'agent_disconnected'} | ${EVENT_DETAILS.agent_disconnected.eventTypeIcon} | ${sprintf(EVENT_DETAILS.agent_disconnected.title, { titleIcon: '' })} | ${3}
+ ${'agent_connected'} | ${EVENT_DETAILS.agent_connected.eventTypeIcon} | ${sprintf(EVENT_DETAILS.agent_connected.title, { titleIcon: '' })} | ${4}
+ ${'unknown_agent'} | ${DEFAULT_ICON} | ${'unknown_agent Event occurred'} | ${5}
+ `('when the event type is $kind event', ({ icon, title, lineNumber }) => {
+ beforeEach(() => {
+ const event = mockAgentHistoryActivityItems[lineNumber];
+ createWrapper({ event });
+ });
+ it('renders the correct icon', () => {
+ expect(findHistoryItem().props('icon')).toBe(icon);
+ });
+ it('renders the correct title', () => {
+ expect(findHistoryItem().text()).toContain(title);
+ });
+ it('renders the correct time-ago tooltip', () => {
+ const activityEvents = mockAgentHistoryActivityItems;
+ expect(findTimeAgo().props('time')).toBe(activityEvents[lineNumber].recordedAt);
+ });
+ });
+});
diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js
index c502e7d813e..d5a8117f48c 100644
--- a/spec/frontend/clusters/agents/components/show_spec.js
+++ b/spec/frontend/clusters/agents/components/show_spec.js
@@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ClusterAgentShow from '~/clusters/agents/components/show.vue';
import TokenTable from '~/clusters/agents/components/token_table.vue';
+import ActivityEvents from '~/clusters/agents/components/activity_events_list.vue';
import getAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -27,6 +28,7 @@ describe('ClusterAgentShow', () => {
id: '1',
createdAt: '2021-02-13T00:00:00Z',
createdByUser: {
+ id: 'user-1',
name: 'user-1',
},
name: 'token-1',
@@ -39,7 +41,8 @@ describe('ClusterAgentShow', () => {
const createWrapper = ({ clusterAgent, queryResponse = null }) => {
const agentQueryResponse =
- queryResponse || jest.fn().mockResolvedValue({ data: { project: { clusterAgent } } });
+ queryResponse ||
+ jest.fn().mockResolvedValue({ data: { project: { id: 'project-1', clusterAgent } } });
const apolloProvider = createMockApollo([[getAgentQuery, agentQueryResponse]]);
wrapper = extendedWrapper(
@@ -70,6 +73,7 @@ describe('ClusterAgentShow', () => {
const findPaginationButtons = () => wrapper.findComponent(GlKeysetPagination);
const findTokenCount = () => wrapper.findByTestId('cluster-agent-token-count').text();
const findEESecurityTabSlot = () => wrapper.findByTestId('ee-security-tab');
+ const findActivity = () => wrapper.findComponent(ActivityEvents);
afterEach(() => {
wrapper.destroy();
@@ -101,6 +105,10 @@ describe('ClusterAgentShow', () => {
it('should not render pagination buttons when there are no additional pages', () => {
expect(findPaginationButtons().exists()).toBe(false);
});
+
+ it('renders activity events list', () => {
+ expect(findActivity().exists()).toBe(true);
+ });
});
describe('when create user is unknown', () => {
diff --git a/spec/frontend/clusters/mock_data.js b/spec/frontend/clusters/mock_data.js
new file mode 100644
index 00000000000..75306ca0295
--- /dev/null
+++ b/spec/frontend/clusters/mock_data.js
@@ -0,0 +1,165 @@
+const user = {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://172.31.0.1:3000/root',
+};
+
+const agentToken = {
+ id: 1,
+ name: 'cluster-agent',
+};
+
+export const defaultActivityEvent = {
+ kind: 'unknown_agent',
+ level: 'info',
+ recordedAt: '2021-11-22T19:26:56Z',
+ agentToken,
+ user,
+};
+
+export const mockAgentActivityEvents = [
+ {
+ kind: 'token_created',
+ level: 'info',
+ recordedAt: '2021-12-03T01:06:56Z',
+ agentToken,
+ user,
+ },
+
+ {
+ kind: 'token_revoked',
+ level: 'info',
+ recordedAt: '2021-12-03T00:26:56Z',
+ agentToken,
+ user,
+ },
+
+ {
+ kind: 'agent_connected',
+ level: 'info',
+ recordedAt: '2021-12-02T19:26:56Z',
+ agentToken,
+ user,
+ },
+
+ {
+ kind: 'agent_disconnected',
+ level: 'info',
+ recordedAt: '2021-12-02T19:26:56Z',
+ agentToken,
+ user,
+ },
+
+ {
+ kind: 'agent_connected',
+ level: 'info',
+ recordedAt: '2021-11-22T19:26:56Z',
+ agentToken,
+ user,
+ },
+
+ {
+ kind: 'unknown_agent',
+ level: 'info',
+ recordedAt: '2021-11-22T19:26:56Z',
+ agentToken,
+ user,
+ },
+];
+
+export const mockResponse = {
+ data: {
+ project: {
+ id: 'project-1',
+ clusterAgent: {
+ id: 'cluster-agent',
+ activityEvents: {
+ nodes: mockAgentActivityEvents,
+ },
+ },
+ },
+ },
+};
+
+export const mockEmptyResponse = {
+ data: {
+ project: {
+ id: 'project-1',
+ clusterAgent: {
+ id: 'cluster-agent',
+ activityEvents: {
+ nodes: [],
+ },
+ },
+ },
+ },
+};
+
+export const mockAgentHistoryActivityItems = [
+ {
+ kind: 'token_created',
+ level: 'info',
+ recordedAt: '2021-12-03T01:06:56Z',
+ agentToken,
+ user,
+ eventTypeIcon: 'token',
+ title: 'cluster-agent created',
+ body: 'Token created by Administrator',
+ },
+
+ {
+ kind: 'token_revoked',
+ level: 'info',
+ recordedAt: '2021-12-03T00:26:56Z',
+ agentToken,
+ user,
+ eventTypeIcon: 'token',
+ title: 'cluster-agent revoked',
+ body: 'Token revoked by Administrator',
+ },
+
+ {
+ kind: 'agent_connected',
+ level: 'info',
+ recordedAt: '2021-12-02T19:26:56Z',
+ agentToken,
+ user,
+ eventTypeIcon: 'connected',
+ title: 'Connected',
+ body: 'Agent Connected',
+ },
+
+ {
+ kind: 'agent_disconnected',
+ level: 'info',
+ recordedAt: '2021-12-02T19:26:56Z',
+ agentToken,
+ user,
+ eventTypeIcon: 'connected',
+ title: 'Not connected',
+ body: 'Agent Not connected',
+ },
+
+ {
+ kind: 'agent_connected',
+ level: 'info',
+ recordedAt: '2021-11-22T19:26:56Z',
+ agentToken,
+ user,
+ eventTypeIcon: 'connected',
+ title: 'Connected',
+ body: 'Agent Connected',
+ },
+
+ {
+ kind: 'unknown_agent',
+ level: 'info',
+ recordedAt: '2021-11-22T19:26:56Z',
+ agentToken,
+ user,
+ eventTypeIcon: 'token',
+ title: 'unknown_agent',
+ body: 'Event occurred',
+ },
+];
diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js
index 38f0e0ba2c4..ed2a0d0b97b 100644
--- a/spec/frontend/clusters_list/components/agent_empty_state_spec.js
+++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js
@@ -1,34 +1,29 @@
-import { GlAlert, GlEmptyState, GlSprintf } from '@gitlab/ui';
+import { GlEmptyState, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue';
+import { INSTALL_AGENT_MODAL_ID } from '~/clusters_list/constants';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { helpPagePath } from '~/helpers/help_page_helper';
const emptyStateImage = '/path/to/image';
-const projectPath = 'path/to/project';
-const multipleClustersDocsUrl = helpPagePath('user/project/clusters/multiple_kubernetes_clusters');
-const installDocsUrl = helpPagePath('administration/clusters/kas');
+const installDocsUrl = helpPagePath('user/clusters/agent/index');
describe('AgentEmptyStateComponent', () => {
let wrapper;
-
- const propsData = {
- hasConfigurations: false,
- };
const provideData = {
emptyStateImage,
- projectPath,
};
- const findConfigurationsAlert = () => wrapper.findComponent(GlAlert);
- const findMultipleClustersDocsLink = () => wrapper.findByTestId('multiple-clusters-docs-link');
- const findInstallDocsLink = () => wrapper.findByTestId('install-docs-link');
- const findIntegrationButton = () => wrapper.findByTestId('integration-primary-button');
+ const findInstallDocsLink = () => wrapper.findComponent(GlLink);
+ const findIntegrationButton = () => wrapper.findComponent(GlButton);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
beforeEach(() => {
wrapper = shallowMountExtended(AgentEmptyState, {
- propsData,
provide: provideData,
+ directives: {
+ GlModalDirective: createMockDirective(),
+ },
stubs: { GlEmptyState, GlSprintf },
});
});
@@ -39,33 +34,21 @@ describe('AgentEmptyStateComponent', () => {
}
});
- it('renders correct href attributes for the links', () => {
- expect(findMultipleClustersDocsLink().attributes('href')).toBe(multipleClustersDocsUrl);
- expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl);
+ it('renders the empty state', () => {
+ expect(findEmptyState().exists()).toBe(true);
});
- describe('when there are no agent configurations in repository', () => {
- it('should render notification message box', () => {
- expect(findConfigurationsAlert().exists()).toBe(true);
- });
+ it('renders button for the agent registration', () => {
+ expect(findIntegrationButton().exists()).toBe(true);
+ });
- it('should disable integration button', () => {
- expect(findIntegrationButton().attributes('disabled')).toBe('true');
- });
+ it('renders correct href attributes for the docs link', () => {
+ expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl);
});
- describe('when there is a list of agent configurations', () => {
- beforeEach(() => {
- propsData.hasConfigurations = true;
- wrapper = shallowMountExtended(AgentEmptyState, {
- propsData,
- provide: provideData,
- });
- });
- it('should render content without notification message box', () => {
- expect(findEmptyState().exists()).toBe(true);
- expect(findConfigurationsAlert().exists()).toBe(false);
- expect(findIntegrationButton().attributes('disabled')).toBeUndefined();
- });
+ it('renders correct modal id for the agent registration modal', () => {
+ const binding = getBinding(findIntegrationButton().element, 'gl-modal-directive');
+
+ expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
});
});
diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js
index 2dec7cdc973..c9ca10f6bf7 100644
--- a/spec/frontend/clusters_list/components/agents_spec.js
+++ b/spec/frontend/clusters_list/components/agents_spec.js
@@ -19,7 +19,6 @@ describe('Agents', () => {
};
const provideData = {
projectPath: 'path/to/project',
- kasAddress: 'kas.example.com',
};
const createWrapper = ({ props = {}, agents = [], pageInfo = null, trees = [], count = 0 }) => {
@@ -27,6 +26,7 @@ describe('Agents', () => {
const apolloQueryResponse = {
data: {
project: {
+ id: '1',
clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] }, count },
repository: { tree: { trees: { nodes: trees, pageInfo } } },
},
@@ -76,6 +76,7 @@ describe('Agents', () => {
tokens: {
nodes: [
{
+ id: 'token-1',
lastUsedAt: testDate,
},
],
@@ -87,6 +88,7 @@ describe('Agents', () => {
const trees = [
{
+ id: 'tree-1',
name: 'agent-2',
path: '.gitlab/agents/agent-2',
webPath: '/project/path/.gitlab/agents/agent-2',
@@ -216,24 +218,6 @@ describe('Agents', () => {
});
});
- describe('when the agent configurations are present', () => {
- const trees = [
- {
- name: 'agent-1',
- path: '.gitlab/agents/agent-1',
- webPath: '/project/path/.gitlab/agents/agent-1',
- },
- ];
-
- beforeEach(() => {
- return createWrapper({ agents: [], trees });
- });
-
- it('should pass the correct hasConfigurations boolean value to empty state component', () => {
- expect(findEmptyState().props('hasConfigurations')).toEqual(true);
- });
- });
-
describe('when agents query has errored', () => {
beforeEach(() => {
return createWrapper({ agents: null });
diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
index 40c2c59e187..bcc1d4e8b9e 100644
--- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
+++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
@@ -1,14 +1,7 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { createLocalVue, mount } from '@vue/test-utils';
-import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue';
import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants';
-import agentConfigurationsQuery from '~/clusters_list/graphql/queries/agent_configurations.query.graphql';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import { agentConfigurationsResponse } from './mock_data';
-
-const localVue = createLocalVue();
-localVue.use(VueApollo);
describe('AvailableAgentsDropdown', () => {
let wrapper;
@@ -18,46 +11,19 @@ describe('AvailableAgentsDropdown', () => {
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findConfiguredAgentItem = () => findDropdownItems().at(0);
- const createWrapper = ({ propsData = {}, isLoading = false }) => {
- const provide = {
- projectPath: 'path/to/project',
- };
-
- wrapper = (() => {
- if (isLoading) {
- const mocks = {
- $apollo: {
- queries: {
- agents: {
- loading: true,
- },
- },
- },
- };
-
- return mount(AvailableAgentsDropdown, { mocks, provide, propsData });
- }
-
- const apolloProvider = createMockApollo([
- [agentConfigurationsQuery, jest.fn().mockResolvedValue(agentConfigurationsResponse)],
- ]);
-
- return mount(AvailableAgentsDropdown, {
- localVue,
- apolloProvider,
- provide,
- propsData,
- });
- })();
+ const createWrapper = ({ propsData }) => {
+ wrapper = shallowMount(AvailableAgentsDropdown, {
+ propsData,
+ });
};
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
describe('there are agents available', () => {
const propsData = {
+ availableAgents: ['configured-agent'],
isRegistering: false,
};
@@ -69,12 +35,6 @@ describe('AvailableAgentsDropdown', () => {
expect(findDropdown().props('text')).toBe(i18n.selectAgent);
});
- it('shows only agents that are not yet installed', () => {
- expect(findDropdownItems()).toHaveLength(1);
- expect(findConfiguredAgentItem().text()).toBe('configured-agent');
- expect(findConfiguredAgentItem().props('isChecked')).toBe(false);
- });
-
describe('click events', () => {
beforeEach(() => {
findConfiguredAgentItem().vm.$emit('click');
@@ -93,6 +53,7 @@ describe('AvailableAgentsDropdown', () => {
describe('registration in progress', () => {
const propsData = {
+ availableAgents: ['configured-agent'],
isRegistering: true,
};
@@ -108,22 +69,4 @@ describe('AvailableAgentsDropdown', () => {
expect(findDropdown().props('loading')).toBe(true);
});
});
-
- describe('agents query is loading', () => {
- const propsData = {
- isRegistering: false,
- };
-
- beforeEach(() => {
- createWrapper({ propsData, isLoading: true });
- });
-
- it('updates the text in the dropdown', () => {
- expect(findDropdown().text()).toBe(i18n.selectAgent);
- });
-
- it('displays a loading icon', () => {
- expect(findDropdown().props('loading')).toBe(true);
- });
- });
});
diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
index f7e1791d0f7..cf0f6881960 100644
--- a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
@@ -6,35 +6,33 @@ import ClusterStore from '~/clusters_list/store';
const clustersEmptyStateImage = 'path/to/svg';
const newClusterPath = '/path/to/connect/cluster';
const emptyStateHelpText = 'empty state text';
-const canAddCluster = true;
describe('ClustersEmptyStateComponent', () => {
let wrapper;
- const propsData = {
- isChildComponent: false,
- };
-
- const provideData = {
+ const defaultProvideData = {
clustersEmptyStateImage,
- emptyStateHelpText: null,
newClusterPath,
};
- const entryData = {
- canAddCluster,
- };
-
const findButton = () => wrapper.findComponent(GlButton);
const findEmptyStateText = () => wrapper.findByTestId('clusters-empty-state-text');
- beforeEach(() => {
+ const createWrapper = ({
+ provideData = { emptyStateHelpText: null },
+ isChildComponent = false,
+ canAddCluster = true,
+ } = {}) => {
wrapper = shallowMountExtended(ClustersEmptyState, {
- store: ClusterStore(entryData),
- propsData,
- provide: provideData,
+ store: ClusterStore({ canAddCluster }),
+ propsData: { isChildComponent },
+ provide: { ...defaultProvideData, ...provideData },
stubs: { GlEmptyState },
});
+ };
+
+ beforeEach(() => {
+ createWrapper();
});
afterEach(() => {
@@ -55,16 +53,7 @@ describe('ClustersEmptyStateComponent', () => {
describe('when the component is loaded as a child component', () => {
beforeEach(() => {
- propsData.isChildComponent = true;
- wrapper = shallowMountExtended(ClustersEmptyState, {
- store: ClusterStore(entryData),
- propsData,
- provide: provideData,
- });
- });
-
- afterEach(() => {
- propsData.isChildComponent = false;
+ createWrapper({ isChildComponent: true });
});
it('should not render the action button', () => {
@@ -74,12 +63,7 @@ describe('ClustersEmptyStateComponent', () => {
describe('when the help text is provided', () => {
beforeEach(() => {
- provideData.emptyStateHelpText = emptyStateHelpText;
- wrapper = shallowMountExtended(ClustersEmptyState, {
- store: ClusterStore(entryData),
- propsData,
- provide: provideData,
- });
+ createWrapper({ provideData: { emptyStateHelpText } });
});
it('should show the empty state text', () => {
@@ -88,14 +72,8 @@ describe('ClustersEmptyStateComponent', () => {
});
describe('when the user cannot add clusters', () => {
- entryData.canAddCluster = false;
beforeEach(() => {
- wrapper = shallowMountExtended(ClustersEmptyState, {
- store: ClusterStore(entryData),
- propsData,
- provide: provideData,
- stubs: { GlEmptyState },
- });
+ createWrapper({ canAddCluster: false });
});
it('should disable the button', () => {
expect(findButton().props('disabled')).toBe(true);
diff --git a/spec/frontend/clusters_list/components/clusters_main_view_spec.js b/spec/frontend/clusters_list/components/clusters_main_view_spec.js
index c2233e5d39c..37665bf7abd 100644
--- a/spec/frontend/clusters_list/components/clusters_main_view_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_main_view_spec.js
@@ -1,5 +1,6 @@
import { GlTabs, GlTab } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
import ClustersMainView from '~/clusters_list/components/clusters_main_view.vue';
import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue';
import {
@@ -8,12 +9,15 @@ import {
CLUSTERS_TABS,
MAX_CLUSTERS_LIST,
MAX_LIST_COUNT,
+ EVENT_LABEL_TABS,
+ EVENT_ACTIONS_CHANGE,
} from '~/clusters_list/constants';
const defaultBranchName = 'default-branch';
describe('ClustersMainViewComponent', () => {
let wrapper;
+ let trackingSpy;
const propsData = {
defaultBranchName,
@@ -23,6 +27,7 @@ describe('ClustersMainViewComponent', () => {
wrapper = shallowMountExtended(ClustersMainView, {
propsData,
});
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
afterEach(() => {
@@ -54,10 +59,10 @@ describe('ClustersMainViewComponent', () => {
describe('tabs', () => {
it.each`
- tabTitle | queryParamValue | lineNumber
- ${'All'} | ${'all'} | ${0}
- ${'Agent'} | ${AGENT} | ${1}
- ${'Certificate based'} | ${CERTIFICATE_BASED} | ${2}
+ tabTitle | queryParamValue | lineNumber
+ ${'All'} | ${'all'} | ${0}
+ ${'Agent'} | ${AGENT} | ${1}
+ ${'Certificate'} | ${CERTIFICATE_BASED} | ${2}
`(
'renders correct tab title and query param value',
({ tabTitle, queryParamValue, lineNumber }) => {
@@ -71,6 +76,7 @@ describe('ClustersMainViewComponent', () => {
beforeEach(() => {
findComponent().vm.$emit('changeTab', AGENT);
});
+
it('changes the tab', () => {
expect(findTabs().attributes('value')).toBe('1');
});
@@ -78,5 +84,13 @@ describe('ClustersMainViewComponent', () => {
it('passes correct max-agents param to the modal', () => {
expect(findModal().props('maxAgents')).toBe(MAX_LIST_COUNT);
});
+
+ it('sends the correct tracking event', () => {
+ findTabs().vm.$emit('input', 1);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_CHANGE, {
+ label: EVENT_LABEL_TABS,
+ property: AGENT,
+ });
+ });
});
});
diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js
index 6c2ea45b99b..4d1429c9e50 100644
--- a/spec/frontend/clusters_list/components/install_agent_modal_spec.js
+++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js
@@ -1,10 +1,21 @@
import { GlAlert, GlButton, GlFormInputGroup } from '@gitlab/ui';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue';
import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue';
-import { I18N_INSTALL_AGENT_MODAL, MAX_LIST_COUNT } from '~/clusters_list/constants';
+import {
+ I18N_AGENT_MODAL,
+ MAX_LIST_COUNT,
+ EVENT_LABEL_MODAL,
+ EVENT_ACTIONS_OPEN,
+ EVENT_ACTIONS_SELECT,
+ MODAL_TYPE_EMPTY,
+ MODAL_TYPE_REGISTER,
+} from '~/clusters_list/constants';
import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql';
+import getAgentConfigurations from '~/clusters_list/graphql/queries/agent_configurations.query.graphql';
import createAgentMutation from '~/clusters_list/graphql/mutations/create_agent.mutation.graphql';
import createAgentTokenMutation from '~/clusters_list/graphql/mutations/create_agent_token.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -23,14 +34,28 @@ const localVue = createLocalVue();
localVue.use(VueApollo);
const projectPath = 'path/to/project';
+const kasAddress = 'kas.example.com';
+const kasEnabled = true;
+const emptyStateImage = 'path/to/image';
const defaultBranchName = 'default';
const maxAgents = MAX_LIST_COUNT;
describe('InstallAgentModal', () => {
let wrapper;
let apolloProvider;
+ let trackingSpy;
+
+ const configurations = [{ agentName: 'agent-name' }];
+ const apolloQueryResponse = {
+ data: {
+ project: {
+ id: '1',
+ clusterAgents: { nodes: [] },
+ agentConfigurations: { nodes: configurations },
+ },
+ },
+ };
- const i18n = I18N_INSTALL_AGENT_MODAL;
const findModal = () => wrapper.findComponent(ModalStub);
const findAgentDropdown = () => findModal().findComponent(AvailableAgentsDropdown);
const findAlert = () => findModal().findComponent(GlAlert);
@@ -40,6 +65,8 @@ describe('InstallAgentModal', () => {
.wrappers.find((button) => button.props('variant') === variant);
const findActionButton = () => findButtonByVariant('confirm');
const findCancelButton = () => findButtonByVariant('default');
+ const findSecondaryButton = () => wrapper.findByTestId('agent-secondary-button');
+ const findImage = () => wrapper.findByRole('img', { alt: I18N_AGENT_MODAL.empty_state.altText });
const expectDisabledAttribute = (element, disabled) => {
if (disabled) {
@@ -52,7 +79,9 @@ describe('InstallAgentModal', () => {
const createWrapper = () => {
const provide = {
projectPath,
- kasAddress: 'kas.example.com',
+ kasAddress,
+ kasEnabled,
+ emptyStateImage,
};
const propsData = {
@@ -60,7 +89,7 @@ describe('InstallAgentModal', () => {
maxAgents,
};
- wrapper = shallowMount(InstallAgentModal, {
+ wrapper = shallowMountExtended(InstallAgentModal, {
attachTo: document.body,
stubs: {
GlModal: ModalStub,
@@ -85,10 +114,12 @@ describe('InstallAgentModal', () => {
});
};
- const mockSelectedAgentResponse = () => {
+ const mockSelectedAgentResponse = async () => {
createWrapper();
writeQuery();
+ await wrapper.vm.$nextTick();
+
wrapper.vm.setAgentName('agent-name');
findActionButton().vm.$emit('click');
@@ -96,120 +127,182 @@ describe('InstallAgentModal', () => {
};
beforeEach(() => {
+ apolloProvider = createMockApollo([
+ [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryResponse)],
+ ]);
createWrapper();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
afterEach(() => {
wrapper.destroy();
- wrapper = null;
apolloProvider = null;
});
- describe('initial state', () => {
- it('renders the dropdown for available agents', () => {
- expect(findAgentDropdown().isVisible()).toBe(true);
- expect(findModal().text()).not.toContain(i18n.basicInstallTitle);
- expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false);
- expect(findModal().findComponent(GlAlert).exists()).toBe(false);
- expect(findModal().findComponent(CodeBlock).exists()).toBe(false);
- });
+ describe('when agent configurations are present', () => {
+ const i18n = I18N_AGENT_MODAL.agent_registration;
- it('renders a cancel button', () => {
- expect(findCancelButton().isVisible()).toBe(true);
- expectDisabledAttribute(findCancelButton(), false);
- });
+ describe('initial state', () => {
+ it('renders the dropdown for available agents', () => {
+ expect(findAgentDropdown().isVisible()).toBe(true);
+ expect(findModal().text()).not.toContain(i18n.basicInstallTitle);
+ expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false);
+ expect(findModal().findComponent(GlAlert).exists()).toBe(false);
+ expect(findModal().findComponent(CodeBlock).exists()).toBe(false);
+ });
- it('renders a disabled next button', () => {
- expect(findActionButton().isVisible()).toBe(true);
- expect(findActionButton().text()).toBe(i18n.registerAgentButton);
- expectDisabledAttribute(findActionButton(), true);
- });
- });
+ it('renders a cancel button', () => {
+ expect(findCancelButton().isVisible()).toBe(true);
+ expectDisabledAttribute(findCancelButton(), false);
+ });
- describe('an agent is selected', () => {
- beforeEach(() => {
- findAgentDropdown().vm.$emit('agentSelected');
- });
+ it('renders a disabled next button', () => {
+ expect(findActionButton().isVisible()).toBe(true);
+ expect(findActionButton().text()).toBe(i18n.registerAgentButton);
+ expectDisabledAttribute(findActionButton(), true);
+ });
- it('enables the next button', () => {
- expect(findActionButton().isVisible()).toBe(true);
- expectDisabledAttribute(findActionButton(), false);
+ it('sends the event with the modalType', () => {
+ findModal().vm.$emit('show');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, {
+ label: EVENT_LABEL_MODAL,
+ property: MODAL_TYPE_REGISTER,
+ });
+ });
});
- });
- describe('registering an agent', () => {
- const createAgentHandler = jest.fn().mockResolvedValue(createAgentResponse);
- const createAgentTokenHandler = jest.fn().mockResolvedValue(createAgentTokenResponse);
+ describe('an agent is selected', () => {
+ beforeEach(() => {
+ findAgentDropdown().vm.$emit('agentSelected');
+ });
- beforeEach(() => {
- apolloProvider = createMockApollo([
- [createAgentMutation, createAgentHandler],
- [createAgentTokenMutation, createAgentTokenHandler],
- ]);
+ it('enables the next button', () => {
+ expect(findActionButton().isVisible()).toBe(true);
+ expectDisabledAttribute(findActionButton(), false);
+ });
- return mockSelectedAgentResponse(apolloProvider);
+ it('sends the correct tracking event', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_SELECT, {
+ label: EVENT_LABEL_MODAL,
+ });
+ });
});
- it('creates an agent and token', () => {
- expect(createAgentHandler).toHaveBeenCalledWith({
- input: { name: 'agent-name', projectPath },
- });
+ describe('registering an agent', () => {
+ const createAgentHandler = jest.fn().mockResolvedValue(createAgentResponse);
+ const createAgentTokenHandler = jest.fn().mockResolvedValue(createAgentTokenResponse);
- expect(createAgentTokenHandler).toHaveBeenCalledWith({
- input: { clusterAgentId: 'agent-id', name: 'agent-name' },
+ beforeEach(() => {
+ apolloProvider = createMockApollo([
+ [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryResponse)],
+ [createAgentMutation, createAgentHandler],
+ [createAgentTokenMutation, createAgentTokenHandler],
+ ]);
+
+ return mockSelectedAgentResponse();
});
- });
- it('renders a close button', () => {
- expect(findActionButton().isVisible()).toBe(true);
- expect(findActionButton().text()).toBe(i18n.close);
- expectDisabledAttribute(findActionButton(), false);
- });
+ it('creates an agent and token', () => {
+ expect(createAgentHandler).toHaveBeenCalledWith({
+ input: { name: 'agent-name', projectPath },
+ });
- it('shows agent instructions', () => {
- const modalText = findModal().text();
- expect(modalText).toContain(i18n.basicInstallTitle);
- expect(modalText).toContain(i18n.basicInstallBody);
+ expect(createAgentTokenHandler).toHaveBeenCalledWith({
+ input: { clusterAgentId: 'agent-id', name: 'agent-name' },
+ });
+ });
- const token = findModal().findComponent(GlFormInputGroup);
- expect(token.props('value')).toBe('mock-agent-token');
+ it('renders a close button', () => {
+ expect(findActionButton().isVisible()).toBe(true);
+ expect(findActionButton().text()).toBe(i18n.close);
+ expectDisabledAttribute(findActionButton(), false);
+ });
- const alert = findModal().findComponent(GlAlert);
- expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle);
+ it('shows agent instructions', () => {
+ const modalText = findModal().text();
+ expect(modalText).toContain(i18n.basicInstallTitle);
+ expect(modalText).toContain(i18n.basicInstallBody);
- const code = findModal().findComponent(CodeBlock).props('code');
- expect(code).toContain('--agent-token=mock-agent-token');
- expect(code).toContain('--kas-address=kas.example.com');
- });
+ const token = findModal().findComponent(GlFormInputGroup);
+ expect(token.props('value')).toBe('mock-agent-token');
- describe('error creating agent', () => {
- beforeEach(() => {
- apolloProvider = createMockApollo([
- [createAgentMutation, jest.fn().mockResolvedValue(createAgentErrorResponse)],
- ]);
+ const alert = findModal().findComponent(GlAlert);
+ expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle);
- return mockSelectedAgentResponse();
+ const code = findModal().findComponent(CodeBlock).props('code');
+ expect(code).toContain('--agent-token=mock-agent-token');
+ expect(code).toContain('--kas-address=kas.example.com');
+ });
+
+ describe('error creating agent', () => {
+ beforeEach(() => {
+ apolloProvider = createMockApollo([
+ [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryResponse)],
+ [createAgentMutation, jest.fn().mockResolvedValue(createAgentErrorResponse)],
+ ]);
+
+ return mockSelectedAgentResponse();
+ });
+
+ it('displays the error message', () => {
+ expect(findAlert().text()).toBe(
+ createAgentErrorResponse.data.createClusterAgent.errors[0],
+ );
+ });
});
- it('displays the error message', () => {
- expect(findAlert().text()).toBe(createAgentErrorResponse.data.createClusterAgent.errors[0]);
+ describe('error creating token', () => {
+ beforeEach(() => {
+ apolloProvider = createMockApollo([
+ [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryResponse)],
+ [createAgentMutation, jest.fn().mockResolvedValue(createAgentResponse)],
+ [createAgentTokenMutation, jest.fn().mockResolvedValue(createAgentTokenErrorResponse)],
+ ]);
+
+ return mockSelectedAgentResponse();
+ });
+
+ it('displays the error message', async () => {
+ expect(findAlert().text()).toBe(
+ createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
+ );
+ });
});
});
+ });
- describe('error creating token', () => {
- beforeEach(() => {
- apolloProvider = createMockApollo([
- [createAgentMutation, jest.fn().mockResolvedValue(createAgentResponse)],
- [createAgentTokenMutation, jest.fn().mockResolvedValue(createAgentTokenErrorResponse)],
- ]);
+ describe('when there are no agent configurations present', () => {
+ const i18n = I18N_AGENT_MODAL.empty_state;
+ const apolloQueryEmptyResponse = {
+ data: {
+ project: {
+ clusterAgents: { nodes: [] },
+ agentConfigurations: { nodes: [] },
+ },
+ },
+ };
- return mockSelectedAgentResponse();
- });
+ beforeEach(() => {
+ apolloProvider = createMockApollo([
+ [getAgentConfigurations, jest.fn().mockResolvedValue(apolloQueryEmptyResponse)],
+ ]);
+ createWrapper();
+ });
+
+ it('renders empty state image', () => {
+ expect(findImage().attributes('src')).toBe(emptyStateImage);
+ });
+
+ it('renders a secondary button', () => {
+ expect(findSecondaryButton().isVisible()).toBe(true);
+ expect(findSecondaryButton().text()).toBe(i18n.secondaryButton);
+ });
- it('displays the error message', () => {
- expect(findAlert().text()).toBe(
- createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0],
- );
+ it('sends the event with the modalType', () => {
+ findModal().vm.$emit('show');
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTIONS_OPEN, {
+ label: EVENT_LABEL_MODAL,
+ property: MODAL_TYPE_EMPTY,
});
});
});
diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js
index 1a7ef84a6d9..804f9834506 100644
--- a/spec/frontend/clusters_list/mocks/apollo.js
+++ b/spec/frontend/clusters_list/mocks/apollo.js
@@ -65,6 +65,7 @@ export const createAgentTokenErrorResponse = {
export const getAgentResponse = {
data: {
project: {
+ id: 'project-1',
clusterAgents: { nodes: [{ ...agent, tokens }], pageInfo, count },
repository: {
tree: {
diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
index 118d8ceceb9..97d9be110c8 100644
--- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
+++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
@@ -42,6 +42,8 @@ exports[`Code navigation popover component renders popover 1`] = `
<span>
main() {
</span>
+
+ <br />
</span>
<span
class="line"
@@ -50,6 +52,8 @@ exports[`Code navigation popover component renders popover 1`] = `
<span>
}
</span>
+
+ <br />
</span>
</pre>
</div>
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 178c7d749c8..7abd6b422ad 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
@@ -19,7 +19,7 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen
<div placeholder=\\"Link URL\\">
<div role=\\"group\\" class=\\"input-group\\">
<!---->
- <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"gl-form-input form-control\\">
+ <!----> <input type=\\"text\\" placeholder=\\"Link URL\\" class=\\"form-control gl-form-input\\">
<div class=\\"input-group-append\\"><button type=\\"button\\" class=\\"btn btn-confirm btn-md gl-button\\">
<!---->
<!----> <span class=\\"gl-button-text\\">Apply</span></button></div>
diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js
deleted file mode 100644
index da895970289..00000000000
--- a/spec/frontend/content_editor/markdown_processing_examples.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-import jsYaml from 'js-yaml';
-// eslint-disable-next-line import/no-deprecated
-import { getJSONFixture } from 'helpers/fixtures';
-
-export const loadMarkdownApiResult = (testName) => {
- const fixturePathPrefix = `api/markdown/${testName}.json`;
-
- // eslint-disable-next-line import/no-deprecated
- const fixture = getJSONFixture(fixturePathPrefix);
- return fixture.body || fixture.html;
-};
-
-export const loadMarkdownApiExamples = () => {
- const apiMarkdownYamlPath = path.join(__dirname, '..', 'fixtures', 'api_markdown.yml');
- const apiMarkdownYamlText = fs.readFileSync(apiMarkdownYamlPath);
- const apiMarkdownExampleObjects = jsYaml.safeLoad(apiMarkdownYamlText);
-
- return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]);
-};
-
-export const loadMarkdownApiExample = (testName) => {
- return loadMarkdownApiExamples().find(([name, context]) => {
- return (context ? `${context}_${name}` : name) === testName;
- })[2];
-};
diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js
index 71565768558..3930f47289a 100644
--- a/spec/frontend/content_editor/markdown_processing_spec.js
+++ b/spec/frontend/content_editor/markdown_processing_spec.js
@@ -1,20 +1,16 @@
-import { createContentEditor } from '~/content_editor';
-import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_processing_examples';
+import path from 'path';
+import { describeMarkdownProcessing } from 'jest/content_editor/markdown_processing_spec_helper';
jest.mock('~/emoji');
-describe('markdown processing', () => {
- // Ensure we generate same markdown that was provided to Markdown API.
- it.each(loadMarkdownApiExamples())(
- 'correctly handles %s (context: %s)',
- async (name, context, markdown) => {
- const testName = context ? `${context}_${name}` : name;
- const contentEditor = createContentEditor({
- renderMarkdown: () => loadMarkdownApiResult(testName),
- });
- await contentEditor.setSerializedContent(markdown);
+const markdownYamlPath = path.join(
+ __dirname,
+ '..',
+ '..',
+ 'fixtures',
+ 'markdown',
+ 'markdown_golden_master_examples.yml',
+);
- expect(contentEditor.getSerializedContent()).toBe(markdown);
- },
- );
-});
+// See spec/fixtures/markdown/markdown_golden_master_examples.yml for documentation on how this spec works.
+describeMarkdownProcessing('CE markdown processing in ContentEditor', markdownYamlPath);
diff --git a/spec/frontend/content_editor/markdown_processing_spec_helper.js b/spec/frontend/content_editor/markdown_processing_spec_helper.js
new file mode 100644
index 00000000000..bb7ec0030a2
--- /dev/null
+++ b/spec/frontend/content_editor/markdown_processing_spec_helper.js
@@ -0,0 +1,86 @@
+import fs from 'fs';
+import jsYaml from 'js-yaml';
+import { memoize } from 'lodash';
+import { createContentEditor } from '~/content_editor';
+import { setTestTimeoutOnce } from 'helpers/timeout';
+
+const getFocusedMarkdownExamples = memoize(
+ () => process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || [],
+);
+
+const includeExample = ({ name }) => {
+ const focusedMarkdownExamples = getFocusedMarkdownExamples();
+ if (!focusedMarkdownExamples.length) {
+ return true;
+ }
+ return focusedMarkdownExamples.includes(name);
+};
+
+const getPendingReason = (pendingStringOrObject) => {
+ if (!pendingStringOrObject) {
+ return null;
+ }
+ if (typeof pendingStringOrObject === 'string') {
+ return pendingStringOrObject;
+ }
+ if (pendingStringOrObject.frontend) {
+ return pendingStringOrObject.frontend;
+ }
+
+ return null;
+};
+
+const loadMarkdownApiExamples = (markdownYamlPath) => {
+ const apiMarkdownYamlText = fs.readFileSync(markdownYamlPath);
+ const apiMarkdownExampleObjects = jsYaml.safeLoad(apiMarkdownYamlText);
+
+ return apiMarkdownExampleObjects
+ .filter(includeExample)
+ .map(({ name, pending, markdown, html }) => [
+ name,
+ { pendingReason: getPendingReason(pending), markdown, html },
+ ]);
+};
+
+const testSerializesHtmlToMarkdownForElement = async ({ markdown, html }) => {
+ const contentEditor = createContentEditor({
+ // Overwrite renderMarkdown to always return this specific html
+ renderMarkdown: () => html,
+ });
+
+ await contentEditor.setSerializedContent(markdown);
+
+ // This serializes the ContentEditor document, which was based on the HTML, to markdown
+ const serializedContent = contentEditor.getSerializedContent();
+
+ // Assert that the markdown we ended up with after sending it through all the ContentEditor
+ // plumbing matches the original markdown from the YAML.
+ expect(serializedContent).toBe(markdown);
+};
+
+// describeMarkdownProcesssing
+//
+// This is used to dynamically generate examples (for both CE and EE) to ensure
+// we generate same markdown that was provided to Markdown API.
+//
+// eslint-disable-next-line jest/no-export
+export const describeMarkdownProcessing = (description, markdownYamlPath) => {
+ const examples = loadMarkdownApiExamples(markdownYamlPath);
+
+ describe(description, () => {
+ describe.each(examples)('%s', (name, { pendingReason, ...example }) => {
+ const exampleName = 'correctly serializes HTML to markdown';
+ if (pendingReason) {
+ it.todo(`${exampleName}: ${pendingReason}`);
+ return;
+ }
+
+ it(exampleName, async () => {
+ if (name === 'frontmatter_toml') {
+ setTestTimeoutOnce(2000);
+ }
+ await testSerializesHtmlToMarkdownForElement(example);
+ });
+ });
+ });
+};
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index cfd93c2df10..97f6d8f6334 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -11,6 +11,9 @@ import Division from '~/content_editor/extensions/division';
import Emoji from '~/content_editor/extensions/emoji';
import Figure from '~/content_editor/extensions/figure';
import FigureCaption from '~/content_editor/extensions/figure_caption';
+import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
+import FootnoteReference from '~/content_editor/extensions/footnote_reference';
+import FootnotesSection from '~/content_editor/extensions/footnotes_section';
import HardBreak from '~/content_editor/extensions/hard_break';
import Heading from '~/content_editor/extensions/heading';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
@@ -28,7 +31,6 @@ import TableHeader from '~/content_editor/extensions/table_header';
import TableRow from '~/content_editor/extensions/table_row';
import TaskItem from '~/content_editor/extensions/task_item';
import TaskList from '~/content_editor/extensions/task_list';
-import Text from '~/content_editor/extensions/text';
import markdownSerializer from '~/content_editor/services/markdown_serializer';
import { createTestEditor, createDocBuilder } from '../test_utils';
@@ -47,6 +49,9 @@ const tiptapEditor = createTestEditor({
DetailsContent,
Division,
Emoji,
+ FootnoteDefinition,
+ FootnoteReference,
+ FootnotesSection,
Figure,
FigureCaption,
HardBreak,
@@ -58,7 +63,6 @@ const tiptapEditor = createTestEditor({
Link,
ListItem,
OrderedList,
- Paragraph,
Strike,
Table,
TableCell,
@@ -66,7 +70,6 @@ const tiptapEditor = createTestEditor({
TableRow,
TaskItem,
TaskList,
- Text,
],
});
@@ -84,6 +87,9 @@ const {
descriptionItem,
descriptionList,
emoji,
+ footnoteDefinition,
+ footnoteReference,
+ footnotesSection,
figure,
figureCaption,
heading,
@@ -120,6 +126,9 @@ const {
emoji: { markType: Emoji.name },
figure: { nodeType: Figure.name },
figureCaption: { nodeType: FigureCaption.name },
+ footnoteDefinition: { nodeType: FootnoteDefinition.name },
+ footnoteReference: { nodeType: FootnoteReference.name },
+ footnotesSection: { nodeType: FootnotesSection.name },
hardBreak: { nodeType: HardBreak.name },
heading: { nodeType: Heading.name },
horizontalRule: { nodeType: HorizontalRule.name },
@@ -1108,4 +1117,22 @@ there
`.trim(),
);
});
+
+ it('correctly serializes footnotes', () => {
+ expect(
+ serialize(
+ paragraph(
+ 'Oranges are orange ',
+ footnoteReference({ footnoteId: '1', footnoteNumber: '1' }),
+ ),
+ footnotesSection(footnoteDefinition(paragraph('Oranges are fruits'))),
+ ),
+ ).toBe(
+ `
+Oranges are orange [^1]
+
+[^1]: Oranges are fruits
+ `.trim(),
+ );
+ });
});
diff --git a/spec/frontend/crm/contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js
new file mode 100644
index 00000000000..b2753ad8cf5
--- /dev/null
+++ b/spec/frontend/crm/contact_form_spec.js
@@ -0,0 +1,157 @@
+import { GlAlert } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ContactForm from '~/crm/components/contact_form.vue';
+import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql';
+import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql';
+import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
+import {
+ createContactMutationErrorResponse,
+ createContactMutationResponse,
+ getGroupContactsQueryResponse,
+ updateContactMutationErrorResponse,
+ updateContactMutationResponse,
+} from './mock_data';
+
+describe('Customer relations contact form component', () => {
+ Vue.use(VueApollo);
+ let wrapper;
+ let fakeApollo;
+ let mutation;
+ let queryHandler;
+
+ const findSaveContactButton = () => wrapper.findByTestId('save-contact-button');
+ const findCancelButton = () => wrapper.findByTestId('cancel-button');
+ const findForm = () => wrapper.find('form');
+ const findError = () => wrapper.findComponent(GlAlert);
+
+ const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => {
+ fakeApollo = createMockApollo([[mutation, queryHandler]]);
+ fakeApollo.clients.defaultClient.cache.writeQuery({
+ query: getGroupContactsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ data: getGroupContactsQueryResponse.data,
+ });
+ const propsData = { drawerOpen: true };
+ if (editForm)
+ propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' };
+ wrapper = mountFunction(ContactForm, {
+ provide: { groupId: 26, groupFullPath: 'flightjs' },
+ apolloProvider: fakeApollo,
+ propsData,
+ });
+ };
+
+ beforeEach(() => {
+ mutation = createContactMutation;
+ queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ describe('Save contact button', () => {
+ it('should be disabled when required fields are empty', () => {
+ mountComponent();
+
+ expect(findSaveContactButton().props('disabled')).toBe(true);
+ });
+
+ it('should not be disabled when required fields have values', async () => {
+ mountComponent();
+
+ wrapper.find('#contact-first-name').vm.$emit('input', 'A');
+ wrapper.find('#contact-last-name').vm.$emit('input', 'B');
+ wrapper.find('#contact-email').vm.$emit('input', 'C');
+ await waitForPromises();
+
+ expect(findSaveContactButton().props('disabled')).toBe(false);
+ });
+ });
+
+ it("should emit 'close' when cancel button is clicked", () => {
+ mountComponent();
+
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted().close).toBeTruthy();
+ });
+
+ describe('when create mutation is successful', () => {
+ it("should emit 'close'", async () => {
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(wrapper.emitted().close).toBeTruthy();
+ });
+ });
+
+ describe('when create mutation fails', () => {
+ it('should show error on reject', async () => {
+ queryHandler = jest.fn().mockRejectedValue('ERROR');
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(true);
+ });
+
+ it('should show error on error response', async () => {
+ queryHandler = jest.fn().mockResolvedValue(createContactMutationErrorResponse);
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(true);
+ expect(findError().text()).toBe('Phone is invalid.');
+ });
+ });
+
+ describe('when update mutation is successful', () => {
+ it("should emit 'close'", async () => {
+ mutation = updateContactMutation;
+ queryHandler = jest.fn().mockResolvedValue(updateContactMutationResponse);
+ mountComponent({ editForm: true });
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(wrapper.emitted().close).toBeTruthy();
+ });
+ });
+
+ describe('when update mutation fails', () => {
+ beforeEach(() => {
+ mutation = updateContactMutation;
+ });
+
+ it('should show error on reject', async () => {
+ queryHandler = jest.fn().mockRejectedValue('ERROR');
+ mountComponent({ editForm: true });
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(true);
+ });
+
+ it('should show error on error response', async () => {
+ queryHandler = jest.fn().mockResolvedValue(updateContactMutationErrorResponse);
+ mountComponent({ editForm: true });
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(true);
+ expect(findError().text()).toBe('Email is invalid.');
+ });
+ });
+});
diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js
index 79b85969eb4..b30349305a3 100644
--- a/spec/frontend/crm/contacts_root_spec.js
+++ b/spec/frontend/crm/contacts_root_spec.js
@@ -1,39 +1,65 @@
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import createFlash from '~/flash';
import ContactsRoot from '~/crm/components/contacts_root.vue';
+import ContactForm from '~/crm/components/contact_form.vue';
import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
+import { NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '~/crm/constants';
+import routes from '~/crm/routes';
import { getGroupContactsQueryResponse } from './mock_data';
-jest.mock('~/flash');
-
describe('Customer relations contacts root app', () => {
Vue.use(VueApollo);
+ Vue.use(VueRouter);
let wrapper;
let fakeApollo;
+ let router;
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
+ const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
+ const findNewContactButton = () => wrapper.findByTestId('new-contact-button');
+ const findEditContactButton = () => wrapper.findByTestId('edit-contact-button');
+ const findContactForm = () => wrapper.findComponent(ContactForm);
+ const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse);
+ const basePath = '/groups/flightjs/-/crm/contacts';
+
const mountComponent = ({
queryHandler = successQueryHandler,
mountFunction = shallowMountExtended,
+ canAdminCrmContact = true,
} = {}) => {
fakeApollo = createMockApollo([[getGroupContactsQuery, queryHandler]]);
wrapper = mountFunction(ContactsRoot, {
- provide: { groupFullPath: 'flightjs' },
+ router,
+ provide: {
+ groupFullPath: 'flightjs',
+ groupIssuesPath: '/issues',
+ groupId: 26,
+ canAdminCrmContact,
+ },
apolloProvider: fakeApollo,
});
};
+ beforeEach(() => {
+ router = new VueRouter({
+ base: basePath,
+ mode: 'history',
+ routes,
+ });
+ });
+
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
+ router = null;
});
it('should render loading spinner', () => {
@@ -42,19 +68,113 @@ describe('Customer relations contacts root app', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
- it('should render error message on reject', async () => {
- mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
- await waitForPromises();
+ describe('new contact button', () => {
+ it('should exist when user has permission', () => {
+ mountComponent();
+
+ expect(findNewContactButton().exists()).toBe(true);
+ });
+
+ it('should not exist when user has no permission', () => {
+ mountComponent({ canAdminCrmContact: false });
+
+ expect(findNewContactButton().exists()).toBe(false);
+ });
+ });
+
+ describe('contact form', () => {
+ it('should not exist by default', async () => {
+ mountComponent();
+ await waitForPromises();
+
+ expect(findContactForm().exists()).toBe(false);
+ });
+
+ it('should exist when user clicks new contact button', async () => {
+ mountComponent();
+
+ findNewContactButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(findContactForm().exists()).toBe(true);
+ });
+
+ it('should exist when user navigates directly to `new` route', async () => {
+ router.replace({ name: NEW_ROUTE_NAME });
+ mountComponent();
+ await waitForPromises();
+
+ expect(findContactForm().exists()).toBe(true);
+ });
+
+ it('should exist when user clicks edit contact button', async () => {
+ mountComponent({ mountFunction: mountExtended });
+ await waitForPromises();
+
+ findEditContactButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(findContactForm().exists()).toBe(true);
+ });
+
+ it('should exist when user navigates directly to `edit` route', async () => {
+ router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } });
+ mountComponent();
+ await waitForPromises();
+
+ expect(findContactForm().exists()).toBe(true);
+ });
+
+ it('should not exist when new form emits close', async () => {
+ router.replace({ name: NEW_ROUTE_NAME });
+ mountComponent();
+
+ findContactForm().vm.$emit('close');
+ await waitForPromises();
+
+ expect(findContactForm().exists()).toBe(false);
+ });
+
+ it('should not exist when edit form emits close', async () => {
+ router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } });
+ mountComponent();
+ await waitForPromises();
+
+ findContactForm().vm.$emit('close');
+ await waitForPromises();
+
+ expect(findContactForm().exists()).toBe(false);
+ });
+ });
+
+ describe('error', () => {
+ it('should exist on reject', async () => {
+ mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
+ await waitForPromises();
- expect(createFlash).toHaveBeenCalled();
+ expect(findError().exists()).toBe(true);
+ });
});
- it('renders correct results', async () => {
- mountComponent({ mountFunction: mountExtended });
- await waitForPromises();
+ describe('on successful load', () => {
+ it('should not render error', async () => {
+ mountComponent();
+ await waitForPromises();
- expect(findRowByName(/Marty/i)).toHaveLength(1);
- expect(findRowByName(/George/i)).toHaveLength(1);
- expect(findRowByName(/jd@gitlab.com/i)).toHaveLength(1);
+ expect(findError().exists()).toBe(false);
+ });
+
+ it('renders correct results', async () => {
+ mountComponent({ mountFunction: mountExtended });
+ await waitForPromises();
+
+ expect(findRowByName(/Marty/i)).toHaveLength(1);
+ expect(findRowByName(/George/i)).toHaveLength(1);
+ expect(findRowByName(/jd@gitlab.com/i)).toHaveLength(1);
+
+ const issueLink = findIssuesLinks().at(0);
+ expect(issueLink.exists()).toBe(true);
+ expect(issueLink.attributes('href')).toBe('/issues?scope=all&state=opened&crm_contact_id=16');
+ });
});
});
diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js
index 4197621aaa6..f7af2ccdb72 100644
--- a/spec/frontend/crm/mock_data.js
+++ b/spec/frontend/crm/mock_data.js
@@ -40,7 +40,6 @@ export const getGroupContactsQueryResponse = {
organization: null,
},
],
- __typename: 'CustomerRelationsContactConnection',
},
},
},
@@ -79,3 +78,84 @@ export const getGroupOrganizationsQueryResponse = {
},
},
};
+
+export const createContactMutationResponse = {
+ data: {
+ customerRelationsContactCreate: {
+ __typeName: 'CustomerRelationsContactCreatePayload',
+ contact: {
+ __typename: 'CustomerRelationsContact',
+ id: 'gid://gitlab/CustomerRelations::Contact/1',
+ firstName: 'A',
+ lastName: 'B',
+ email: 'C',
+ phone: null,
+ description: null,
+ organization: null,
+ },
+ errors: [],
+ },
+ },
+};
+
+export const createContactMutationErrorResponse = {
+ data: {
+ customerRelationsContactCreate: {
+ contact: null,
+ errors: ['Phone is invalid.'],
+ },
+ },
+};
+
+export const updateContactMutationResponse = {
+ data: {
+ customerRelationsContactUpdate: {
+ __typeName: 'CustomerRelationsContactCreatePayload',
+ contact: {
+ __typename: 'CustomerRelationsContact',
+ id: 'gid://gitlab/CustomerRelations::Contact/1',
+ firstName: 'First',
+ lastName: 'Last',
+ email: 'email@example.com',
+ phone: null,
+ description: null,
+ organization: null,
+ },
+ errors: [],
+ },
+ },
+};
+
+export const updateContactMutationErrorResponse = {
+ data: {
+ customerRelationsContactUpdate: {
+ contact: null,
+ errors: ['Email is invalid.'],
+ },
+ },
+};
+
+export const createOrganizationMutationResponse = {
+ data: {
+ customerRelationsOrganizationCreate: {
+ __typeName: 'CustomerRelationsOrganizationCreatePayload',
+ organization: {
+ __typename: 'CustomerRelationsOrganization',
+ id: 'gid://gitlab/CustomerRelations::Organization/2',
+ name: 'A',
+ defaultRate: null,
+ description: null,
+ },
+ errors: [],
+ },
+ },
+};
+
+export const createOrganizationMutationErrorResponse = {
+ data: {
+ customerRelationsOrganizationCreate: {
+ organization: null,
+ errors: ['Name cannot be blank.'],
+ },
+ },
+};
diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js
new file mode 100644
index 00000000000..976b626f35f
--- /dev/null
+++ b/spec/frontend/crm/new_organization_form_spec.js
@@ -0,0 +1,109 @@
+import { GlAlert } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
+import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql';
+import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
+import {
+ createOrganizationMutationErrorResponse,
+ createOrganizationMutationResponse,
+ getGroupOrganizationsQueryResponse,
+} from './mock_data';
+
+describe('Customer relations organizations root app', () => {
+ Vue.use(VueApollo);
+ let wrapper;
+ let fakeApollo;
+ let queryHandler;
+
+ const findCreateNewOrganizationButton = () =>
+ wrapper.findByTestId('create-new-organization-button');
+ const findCancelButton = () => wrapper.findByTestId('cancel-button');
+ const findForm = () => wrapper.find('form');
+ const findError = () => wrapper.findComponent(GlAlert);
+
+ const mountComponent = () => {
+ fakeApollo = createMockApollo([[createOrganizationMutation, queryHandler]]);
+ fakeApollo.clients.defaultClient.cache.writeQuery({
+ query: getGroupOrganizationsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ data: getGroupOrganizationsQueryResponse.data,
+ });
+ wrapper = shallowMountExtended(NewOrganizationForm, {
+ provide: { groupId: 26, groupFullPath: 'flightjs' },
+ apolloProvider: fakeApollo,
+ propsData: { drawerOpen: true },
+ });
+ };
+
+ beforeEach(() => {
+ queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationResponse);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ describe('Create new organization button', () => {
+ it('should be disabled by default', () => {
+ mountComponent();
+
+ expect(findCreateNewOrganizationButton().attributes('disabled')).toBeTruthy();
+ });
+
+ it('should not be disabled when first, last and email have values', async () => {
+ mountComponent();
+
+ wrapper.find('#organization-name').vm.$emit('input', 'A');
+ await waitForPromises();
+
+ expect(findCreateNewOrganizationButton().attributes('disabled')).toBeFalsy();
+ });
+ });
+
+ it("should emit 'close' when cancel button is clicked", () => {
+ mountComponent();
+
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted().close).toBeTruthy();
+ });
+
+ describe('when query is successful', () => {
+ it("should emit 'close'", async () => {
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(wrapper.emitted().close).toBeTruthy();
+ });
+ });
+
+ describe('when query fails', () => {
+ it('should show error on reject', async () => {
+ queryHandler = jest.fn().mockRejectedValue('ERROR');
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(true);
+ });
+
+ it('should show error on error response', async () => {
+ queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationErrorResponse);
+ mountComponent();
+
+ findForm().trigger('submit');
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(true);
+ expect(findError().text()).toBe('Name cannot be blank.');
+ });
+ });
+});
diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js
index a69a099e03d..aef417964f4 100644
--- a/spec/frontend/crm/organizations_root_spec.js
+++ b/spec/frontend/crm/organizations_root_spec.js
@@ -1,39 +1,59 @@
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import createFlash from '~/flash';
import OrganizationsRoot from '~/crm/components/organizations_root.vue';
+import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
+import { NEW_ROUTE_NAME } from '~/crm/constants';
+import routes from '~/crm/routes';
import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
import { getGroupOrganizationsQueryResponse } from './mock_data';
-jest.mock('~/flash');
-
describe('Customer relations organizations root app', () => {
Vue.use(VueApollo);
+ Vue.use(VueRouter);
let wrapper;
let fakeApollo;
+ let router;
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
+ const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
+ const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button');
+ const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm);
+ const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse);
+ const basePath = '/groups/flightjs/-/crm/organizations';
+
const mountComponent = ({
queryHandler = successQueryHandler,
mountFunction = shallowMountExtended,
+ canAdminCrmOrganization = true,
} = {}) => {
fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]);
wrapper = mountFunction(OrganizationsRoot, {
- provide: { groupFullPath: 'flightjs' },
+ router,
+ provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
apolloProvider: fakeApollo,
});
};
+ beforeEach(() => {
+ router = new VueRouter({
+ base: basePath,
+ mode: 'history',
+ routes,
+ });
+ });
+
afterEach(() => {
wrapper.destroy();
fakeApollo = null;
+ router = null;
});
it('should render loading spinner', () => {
@@ -42,19 +62,84 @@ describe('Customer relations organizations root app', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
+ describe('new organization button', () => {
+ it('should exist when user has permission', () => {
+ mountComponent();
+
+ expect(findNewOrganizationButton().exists()).toBe(true);
+ });
+
+ it('should not exist when user has no permission', () => {
+ mountComponent({ canAdminCrmOrganization: false });
+
+ expect(findNewOrganizationButton().exists()).toBe(false);
+ });
+ });
+
+ describe('new organization form', () => {
+ it('should not exist by default', async () => {
+ mountComponent();
+ await waitForPromises();
+
+ expect(findNewOrganizationForm().exists()).toBe(false);
+ });
+
+ it('should exist when user clicks new contact button', async () => {
+ mountComponent();
+
+ findNewOrganizationButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(findNewOrganizationForm().exists()).toBe(true);
+ });
+
+ it('should exist when user navigates directly to /new', async () => {
+ router.replace({ name: NEW_ROUTE_NAME });
+ mountComponent();
+ await waitForPromises();
+
+ expect(findNewOrganizationForm().exists()).toBe(true);
+ });
+
+ it('should not exist when form emits close', async () => {
+ router.replace({ name: NEW_ROUTE_NAME });
+ mountComponent();
+
+ findNewOrganizationForm().vm.$emit('close');
+ await waitForPromises();
+
+ expect(findNewOrganizationForm().exists()).toBe(false);
+ });
+ });
+
it('should render error message on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises();
- expect(createFlash).toHaveBeenCalled();
+ expect(findError().exists()).toBe(true);
});
- it('renders correct results', async () => {
- mountComponent({ mountFunction: mountExtended });
- await waitForPromises();
+ describe('on successful load', () => {
+ it('should not render error', async () => {
+ mountComponent();
+ await waitForPromises();
+
+ expect(findError().exists()).toBe(false);
+ });
+
+ it('renders correct results', async () => {
+ mountComponent({ mountFunction: mountExtended });
+ await waitForPromises();
- expect(findRowByName(/Test Inc/i)).toHaveLength(1);
- expect(findRowByName(/VIP/i)).toHaveLength(1);
- expect(findRowByName(/120/i)).toHaveLength(1);
+ expect(findRowByName(/Test Inc/i)).toHaveLength(1);
+ expect(findRowByName(/VIP/i)).toHaveLength(1);
+ expect(findRowByName(/120/i)).toHaveLength(1);
+
+ const issueLink = findIssuesLinks().at(0);
+ expect(issueLink.exists()).toBe(true);
+ expect(issueLink.attributes('href')).toBe(
+ '/issues?scope=all&state=opened&crm_organization_id=2',
+ );
+ });
});
});
diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap
deleted file mode 100644
index ed8ed3254ba..00000000000
--- a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap
+++ /dev/null
@@ -1,28 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design note pin component should match the snapshot of note with index 1`] = `
-<button
- aria-label="Comment '1' position"
- class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0! js-image-badge badge badge-pill"
- style="left: 10px; top: 10px;"
- type="button"
->
-
- 1
-
-</button>
-`;
-
-exports[`Design note pin component should match the snapshot of note without index 1`] = `
-<button
- aria-label="Comment form position"
- class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0! btn-transparent comment-indicator gl-p-0"
- style="left: 10px; top: 10px;"
- type="button"
->
- <gl-icon-stub
- name="image-comment-dark"
- size="24"
- />
-</button>
-`;
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index cdd07a16e90..2a43b5debee 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -5,6 +5,7 @@ export const designListQueryResponse = {
id: '1',
issue: {
__typename: 'Issue',
+ id: 'issue-1',
designCollection: {
__typename: 'DesignCollection',
copyState: 'READY',
@@ -97,6 +98,7 @@ export const permissionsQueryResponse = {
id: '1',
issue: {
__typename: 'Issue',
+ id: 'issue-1',
userPermissions: { __typename: 'UserPermissions', createDesign: true },
},
},
diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js
index c847a79435a..bd6f4cd2545 100644
--- a/spec/frontend/diffs/components/diff_discussions_spec.js
+++ b/spec/frontend/diffs/components/diff_discussions_spec.js
@@ -1,7 +1,6 @@
import { GlIcon } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import DiffDiscussions from '~/diffs/components/diff_discussions.vue';
-import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions';
import { createStore } from '~/mr_notes/stores';
import DiscussionNotes from '~/notes/components/discussion_notes.vue';
import NoteableDiscussion from '~/notes/components/noteable_discussion.vue';
@@ -20,9 +19,6 @@ describe('DiffDiscussions', () => {
store = createStore();
wrapper = mount(localVue.extend(DiffDiscussions), {
store,
- provide: {
- discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
- },
propsData: {
discussions: getDiscussionsMockData(),
...props,
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index feb7118744b..dc0ed621a64 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import Vuex from 'vuex';
-import DiffContentComponent from '~/diffs/components/diff_content.vue';
+import DiffContentComponent from 'jh_else_ce/diffs/components/diff_content.vue';
import DiffFileComponent from '~/diffs/components/diff_file.vue';
import DiffFileHeaderComponent from '~/diffs/components/diff_file_header.vue';
diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js
index c0c92908701..4c5ce429c9d 100644
--- a/spec/frontend/diffs/components/diff_row_spec.js
+++ b/spec/frontend/diffs/components/diff_row_spec.js
@@ -277,3 +277,36 @@ describe('DiffRow', () => {
});
});
});
+
+describe('coverage state memoization', () => {
+ it('updates when coverage is loaded', () => {
+ const lineWithoutCoverage = {};
+ const lineWithCoverage = {
+ text: 'Test coverage: 5 hits',
+ class: 'coverage',
+ };
+
+ const unchangedProps = {
+ inline: true,
+ filePath: 'file/path',
+ line: { left: { new_line: 3 } },
+ };
+
+ const noCoverageProps = {
+ fileLineCoverage: () => lineWithoutCoverage,
+ coverageLoaded: false,
+ ...unchangedProps,
+ };
+ const coverageProps = {
+ fileLineCoverage: () => lineWithCoverage,
+ coverageLoaded: true,
+ ...unchangedProps,
+ };
+
+ // this caches no coverage for the line
+ expect(DiffRow.coverageStateLeft(noCoverageProps)).toStrictEqual(lineWithoutCoverage);
+
+ // this retrieves coverage for the line because it has been recached
+ expect(DiffRow.coverageStateLeft(coverageProps)).toStrictEqual(lineWithCoverage);
+ });
+});
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index c104fcd5fb9..d8611b1ce1b 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -112,6 +112,7 @@ describe('DiffsStoreMutations', () => {
mutations[types.SET_COVERAGE_DATA](state, coverage);
expect(state.coverageFiles).toEqual(coverage);
+ expect(state.coverageLoaded).toEqual(true);
});
});
diff --git a/spec/frontend/diffs/utils/discussions_spec.js b/spec/frontend/diffs/utils/discussions_spec.js
deleted file mode 100644
index 9a3d442d943..00000000000
--- a/spec/frontend/diffs/utils/discussions_spec.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions';
-
-describe('Diff Discussions Utils', () => {
- describe('discussionIntersectionObserverHandlerFactory', () => {
- it('creates a handler function', () => {
- expect(discussionIntersectionObserverHandlerFactory()).toBeInstanceOf(Function);
- });
-
- describe('intersection observer handler', () => {
- const functions = {
- setCurrentDiscussionId: jest.fn(),
- getPreviousUnresolvedDiscussionId: jest.fn().mockImplementation((id) => {
- return Number(id) - 1;
- }),
- };
- const defaultProcessableWrapper = {
- entry: {
- time: 0,
- isIntersecting: true,
- rootBounds: {
- bottom: 0,
- },
- boundingClientRect: {
- top: 0,
- },
- },
- currentDiscussion: {
- id: 1,
- },
- isFirstUnresolved: false,
- isDiffsPage: true,
- };
- let handler;
- let getMock;
- let setMock;
-
- beforeEach(() => {
- functions.setCurrentDiscussionId.mockClear();
- functions.getPreviousUnresolvedDiscussionId.mockClear();
-
- defaultProcessableWrapper.functions = functions;
-
- setMock = functions.setCurrentDiscussionId.mock;
- getMock = functions.getPreviousUnresolvedDiscussionId.mock;
- handler = discussionIntersectionObserverHandlerFactory();
- });
-
- it('debounces multiple simultaneous requests into one queue', () => {
- handler(defaultProcessableWrapper);
- handler(defaultProcessableWrapper);
- handler(defaultProcessableWrapper);
- handler(defaultProcessableWrapper);
-
- expect(setTimeout).toHaveBeenCalledTimes(4);
- expect(clearTimeout).toHaveBeenCalledTimes(3);
-
- // By only advancing to one timer, we ensure it's all being batched into one queue
- jest.advanceTimersToNextTimer();
-
- expect(functions.setCurrentDiscussionId).toHaveBeenCalledTimes(4);
- });
-
- it('properly processes, sorts and executes the correct actions for a set of observed intersections', () => {
- handler(defaultProcessableWrapper);
- handler({
- // This observation is here to be filtered out because it's a scrollDown
- ...defaultProcessableWrapper,
- entry: {
- ...defaultProcessableWrapper.entry,
- isIntersecting: false,
- boundingClientRect: { top: 10 },
- rootBounds: { bottom: 100 },
- },
- });
- handler({
- ...defaultProcessableWrapper,
- entry: {
- ...defaultProcessableWrapper.entry,
- time: 101,
- isIntersecting: false,
- rootBounds: { bottom: -100 },
- },
- currentDiscussion: { id: 20 },
- });
- handler({
- ...defaultProcessableWrapper,
- entry: {
- ...defaultProcessableWrapper.entry,
- time: 100,
- isIntersecting: false,
- boundingClientRect: { top: 100 },
- },
- currentDiscussion: { id: 30 },
- isDiffsPage: false,
- });
- handler({
- ...defaultProcessableWrapper,
- isFirstUnresolved: true,
- entry: {
- ...defaultProcessableWrapper.entry,
- time: 100,
- isIntersecting: false,
- boundingClientRect: { top: 200 },
- },
- });
-
- jest.advanceTimersToNextTimer();
-
- expect(setMock.calls.length).toBe(4);
- expect(setMock.calls[0]).toEqual([1]);
- expect(setMock.calls[1]).toEqual([29]);
- expect(setMock.calls[2]).toEqual([null]);
- expect(setMock.calls[3]).toEqual([19]);
-
- expect(getMock.calls.length).toBe(2);
- expect(getMock.calls[0]).toEqual([30, false]);
- expect(getMock.calls[1]).toEqual([20, true]);
-
- [
- setMock.invocationCallOrder[0],
- getMock.invocationCallOrder[0],
- setMock.invocationCallOrder[1],
- setMock.invocationCallOrder[2],
- getMock.invocationCallOrder[1],
- setMock.invocationCallOrder[3],
- ].forEach((order, idx, list) => {
- // Compare each invocation sequence to the one before it (except the first one)
- expect(list[idx - 1] || -1).toBeLessThan(order);
- });
- });
- });
- });
-});
diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js
index 12e10f7c5f4..11414e8890d 100644
--- a/spec/frontend/dropzone_input_spec.js
+++ b/spec/frontend/dropzone_input_spec.js
@@ -32,6 +32,8 @@ describe('dropzone_input', () => {
});
describe('handlePaste', () => {
+ let form;
+
const triggerPasteEvent = (clipboardData = {}) => {
const event = $.Event('paste');
const origEvent = new Event('paste');
@@ -45,11 +47,15 @@ describe('dropzone_input', () => {
beforeEach(() => {
loadFixtures('issues/new-issue.html');
- const form = $('#new_issue');
+ form = $('#new_issue');
form.data('uploads-path', TEST_UPLOAD_PATH);
dropzoneInput(form);
});
+ afterEach(() => {
+ form = null;
+ });
+
it('pastes Markdown tables', () => {
jest.spyOn(PasteMarkdownTable.prototype, 'isTable');
jest.spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown');
@@ -86,6 +92,27 @@ describe('dropzone_input', () => {
expect(axiosMock.history.post[0].data.get('file').name).toHaveLength(246);
});
+ it('disables generated image file when clipboardData have both image and text', () => {
+ const TEST_PLAIN_TEXT = 'This wording is a plain text.';
+ triggerPasteEvent({
+ types: ['text/plain', 'Files'],
+ getData: () => TEST_PLAIN_TEXT,
+ items: [
+ {
+ kind: 'text',
+ type: 'text/plain',
+ },
+ {
+ kind: 'file',
+ type: 'image/png',
+ getAsFile: () => new Blob(),
+ },
+ ],
+ });
+
+ expect(form.find('.js-gfm-input')[0].value).toBe('');
+ });
+
it('display original file name in comment box', async () => {
const axiosMock = new MockAdapter(axios);
triggerPasteEvent({
diff --git a/spec/frontend/editor/helpers.js b/spec/frontend/editor/helpers.js
index 6f7cdf6efb3..252d783ad6d 100644
--- a/spec/frontend/editor/helpers.js
+++ b/spec/frontend/editor/helpers.js
@@ -1,4 +1,22 @@
-export class MyClassExtension {
+/* eslint-disable max-classes-per-file */
+
+// Helpers
+export const spyOnApi = (extension, spiesObj = {}) => {
+ const origApi = extension.api;
+ if (extension?.obj) {
+ jest.spyOn(extension.obj, 'provides').mockReturnValue({
+ ...origApi,
+ ...spiesObj,
+ });
+ }
+};
+
+// Dummy Extensions
+export class SEClassExtension {
+ static get extensionName() {
+ return 'SEClassExtension';
+ }
+
// eslint-disable-next-line class-methods-use-this
provides() {
return {
@@ -8,8 +26,9 @@ export class MyClassExtension {
}
}
-export function MyFnExtension() {
+export function SEFnExtension() {
return {
+ extensionName: 'SEFnExtension',
fnExtMethod: () => 'fn own method',
provides: () => {
return {
@@ -19,8 +38,9 @@ export function MyFnExtension() {
};
}
-export const MyConstExt = () => {
+export const SEConstExt = () => {
return {
+ extensionName: 'SEConstExt',
provides: () => {
return {
constExtMethod: () => 'const own method',
@@ -29,9 +49,39 @@ export const MyConstExt = () => {
};
};
+export class SEWithSetupExt {
+ static get extensionName() {
+ return 'SEWithSetupExt';
+ }
+ // eslint-disable-next-line class-methods-use-this
+ onSetup(instance, setupOptions = {}) {
+ if (setupOptions && !Array.isArray(setupOptions)) {
+ Object.entries(setupOptions).forEach(([key, value]) => {
+ Object.assign(instance, {
+ [key]: value,
+ });
+ });
+ }
+ }
+ provides() {
+ return {
+ returnInstanceAndProps: (instance, stringProp, objProp = {}) => {
+ return [stringProp, objProp, instance];
+ },
+ returnInstance: (instance) => {
+ return instance;
+ },
+ giveMeContext: () => {
+ return this;
+ },
+ };
+ }
+}
+
export const conflictingExtensions = {
WithInstanceExt: () => {
return {
+ extensionName: 'WithInstanceExt',
provides: () => {
return {
use: () => 'A conflict with instance',
@@ -42,6 +92,7 @@ export const conflictingExtensions = {
},
WithAnotherExt: () => {
return {
+ extensionName: 'WithAnotherExt',
provides: () => {
return {
shared: () => 'A conflict with extension',
diff --git a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
index 8a0d1ecf1af..5eaac9e9ef9 100644
--- a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
+++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js
@@ -23,7 +23,7 @@ describe('~/editor/editor_ci_config_ext', () => {
blobPath,
blobContent: '',
});
- instance.use(new CiSchemaExtension());
+ instance.use({ definition: CiSchemaExtension });
};
beforeAll(() => {
diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js
index a0fb1178b3b..6606557fd1f 100644
--- a/spec/frontend/editor/source_editor_extension_base_spec.js
+++ b/spec/frontend/editor/source_editor_extension_base_spec.js
@@ -2,40 +2,25 @@ 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,
EDITOR_TYPE_DIFF,
+ EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS,
+ EXTENSION_BASE_LINE_NUMBERS_CLASS,
} from '~/editor/constants';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
-
-jest.mock('~/helpers/startup_css_helper', () => {
- return {
- waitForCSSLoaded: jest.fn().mockImplementation((cb) => {
- // We have to artificially put the callback's execution
- // to the end of the current call stack to be able to
- // test that the callback is called after waitForCSSLoaded.
- // setTimeout with 0 delay does exactly that.
- // Otherwise we might end up with false positive results
- setTimeout(() => {
- cb.apply();
- }, 0);
- }),
- };
-});
+import EditorInstance from '~/editor/source_editor_instance';
describe('The basis for an Source Editor extension', () => {
const defaultLine = 3;
- let ext;
let event;
- const defaultOptions = { foo: 'bar' };
const findLine = (num) => {
- return document.querySelector(`.line-numbers:nth-child(${num})`);
+ return document.querySelector(`.${EXTENSION_BASE_LINE_NUMBERS_CLASS}:nth-child(${num})`);
};
const generateLines = () => {
let res = '';
for (let line = 1, lines = 5; line <= lines; line += 1) {
- res += `<div class="line-numbers">${line}</div>`;
+ res += `<div class="${EXTENSION_BASE_LINE_NUMBERS_CLASS}">${line}</div>`;
}
return res;
};
@@ -49,6 +34,9 @@ describe('The basis for an Source Editor extension', () => {
},
};
};
+ const createInstance = (baseInstance = {}) => {
+ return new EditorInstance(baseInstance);
+ };
beforeEach(() => {
setFixtures(generateLines());
@@ -59,95 +47,47 @@ describe('The basis for an Source Editor extension', () => {
jest.clearAllMocks();
});
- describe('constructor', () => {
- it('resets the layout in waitForCSSLoaded callback', async () => {
- const instance = {
- layout: jest.fn(),
- };
- ext = new SourceEditorExtension({ instance });
- expect(instance.layout).not.toHaveBeenCalled();
-
- // We're waiting for the waitForCSSLoaded mock to kick in
- await jest.runOnlyPendingTimers();
+ describe('onUse callback', () => {
+ it('initializes the line highlighting', () => {
+ const instance = createInstance();
+ const spy = jest.spyOn(SourceEditorExtension, 'highlightLines');
- expect(instance.layout).toHaveBeenCalled();
+ instance.use({ definition: SourceEditorExtension });
+ expect(spy).toHaveBeenCalled();
});
it.each`
- description | instance | options
- ${'accepts configuration options and instance'} | ${{}} | ${defaultOptions}
- ${'leaves instance intact if no options are passed'} | ${{}} | ${undefined}
- ${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined}
- ${'throws if only options are passed'} | ${undefined} | ${defaultOptions}
- `('$description', ({ instance, options } = {}) => {
- SourceEditorExtension.deferRerender = jest.fn();
- const originalInstance = { ...instance };
-
- if (instance) {
- if (options) {
- Object.entries(options).forEach((prop) => {
- expect(instance[prop]).toBeUndefined();
- });
- // Both instance and options are passed
- ext = new SourceEditorExtension({ instance, ...options });
- Object.entries(options).forEach(([prop, value]) => {
- expect(ext[prop]).toBeUndefined();
- expect(instance[prop]).toBe(value);
- });
+ description | instanceType | shouldBeCalled
+ ${'Sets up'} | ${EDITOR_TYPE_CODE} | ${true}
+ ${'Does not set up'} | ${EDITOR_TYPE_DIFF} | ${false}
+ `(
+ '$description the line linking for $instanceType instance',
+ ({ instanceType, shouldBeCalled }) => {
+ const instance = createInstance({
+ getEditorType: jest.fn().mockReturnValue(instanceType),
+ onMouseMove: jest.fn(),
+ onMouseDown: jest.fn(),
+ });
+ const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking');
+
+ instance.use({ definition: SourceEditorExtension });
+ if (shouldBeCalled) {
+ expect(spy).toHaveBeenCalledWith(instance);
} else {
- ext = new SourceEditorExtension({ instance });
- expect(instance).toEqual(originalInstance);
+ expect(spy).not.toHaveBeenCalled();
}
- } else if (options) {
- // Options are passed without instance
- expect(() => {
- ext = new SourceEditorExtension({ ...options });
- }).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
- } else {
- // Neither options nor instance are passed
- expect(() => {
- ext = new SourceEditorExtension();
- }).not.toThrow();
- }
- });
-
- it('initializes the line highlighting', () => {
- SourceEditorExtension.deferRerender = jest.fn();
- const spy = jest.spyOn(SourceEditorExtension, 'highlightLines');
- ext = new SourceEditorExtension({ instance: {} });
- expect(spy).toHaveBeenCalled();
- });
-
- it('sets up the line linking for code instance', () => {
- SourceEditorExtension.deferRerender = jest.fn();
- const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking');
- const instance = {
- getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_CODE),
- onMouseMove: jest.fn(),
- onMouseDown: jest.fn(),
- };
- ext = new SourceEditorExtension({ instance });
- expect(spy).toHaveBeenCalledWith(instance);
- });
-
- it('does not set up the line linking for diff instance', () => {
- SourceEditorExtension.deferRerender = jest.fn();
- const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking');
- const instance = {
- getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_DIFF),
- };
- ext = new SourceEditorExtension({ instance });
- expect(spy).not.toHaveBeenCalled();
- });
+ },
+ );
});
describe('highlightLines', () => {
const revealSpy = jest.fn();
const decorationsSpy = jest.fn();
- const instance = {
+ const instance = createInstance({
revealLineInCenter: revealSpy,
deltaDecorations: decorationsSpy,
- };
+ });
+ instance.use({ definition: SourceEditorExtension });
const defaultDecorationOptions = {
isWholeLine: true,
className: 'active-line-text',
@@ -175,7 +115,7 @@ describe('The basis for an Source Editor extension', () => {
${'uses bounds if both hash and bounds exist'} | ${'#L7-42'} | ${[3, 5]} | ${true} | ${[3, 1, 5, 1]}
`('$desc', ({ hash, bounds, shouldReveal, expectedRange } = {}) => {
window.location.hash = hash;
- SourceEditorExtension.highlightLines(instance, bounds);
+ instance.highlightLines(bounds);
if (!shouldReveal) {
expect(revealSpy).not.toHaveBeenCalled();
expect(decorationsSpy).not.toHaveBeenCalled();
@@ -193,11 +133,11 @@ describe('The basis for an Source Editor extension', () => {
}
});
- it('stores the line decorations on the instance', () => {
+ it('stores the line decorations on the instance', () => {
decorationsSpy.mockReturnValue('foo');
window.location.hash = '#L10';
expect(instance.lineDecorations).toBeUndefined();
- SourceEditorExtension.highlightLines(instance);
+ instance.highlightLines();
expect(instance.lineDecorations).toBe('foo');
});
@@ -215,7 +155,7 @@ describe('The basis for an Source Editor extension', () => {
},
];
instance.lineDecorations = oldLineDecorations;
- SourceEditorExtension.highlightLines(instance, [7, 10]);
+ instance.highlightLines([7, 10]);
expect(decorationsSpy).toHaveBeenCalledWith(oldLineDecorations, newLineDecorations);
});
});
@@ -228,13 +168,18 @@ describe('The basis for an Source Editor extension', () => {
options: { isWholeLine: true, className: 'active-line-text' },
},
];
- const instance = {
- deltaDecorations: decorationsSpy,
- lineDecorations,
- };
+ let instance;
+
+ beforeEach(() => {
+ instance = createInstance({
+ deltaDecorations: decorationsSpy,
+ lineDecorations,
+ });
+ instance.use({ definition: SourceEditorExtension });
+ });
it('removes all existing decorations', () => {
- SourceEditorExtension.removeHighlights(instance);
+ instance.removeHighlights();
expect(decorationsSpy).toHaveBeenCalledWith(lineDecorations, []);
});
});
@@ -261,9 +206,9 @@ describe('The basis for an Source Editor extension', () => {
});
it.each`
- desc | eventTrigger | shouldRemove
- ${'does not remove the line decorations if the event is triggered on a wrong node'} | ${null} | ${false}
- ${'removes existing line decorations when clicking a line number'} | ${'.link-anchor'} | ${true}
+ desc | eventTrigger | shouldRemove
+ ${'does not remove the line decorations if the event is triggered on a wrong node'} | ${null} | ${false}
+ ${'removes existing line decorations when clicking a line number'} | ${`.${EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS}`} | ${true}
`('$desc', ({ eventTrigger, shouldRemove } = {}) => {
event = generateEventMock({ el: eventTrigger ? document.querySelector(eventTrigger) : null });
instance.onMouseDown.mockImplementation((fn) => {
diff --git a/spec/frontend/editor/source_editor_extension_spec.js b/spec/frontend/editor/source_editor_extension_spec.js
index 6f2eb07a043..c5fa795f3b7 100644
--- a/spec/frontend/editor/source_editor_extension_spec.js
+++ b/spec/frontend/editor/source_editor_extension_spec.js
@@ -22,15 +22,15 @@ describe('Editor Extension', () => {
it.each`
definition | setupOptions | expectedName
- ${helpers.MyClassExtension} | ${undefined} | ${'MyClassExtension'}
- ${helpers.MyClassExtension} | ${{}} | ${'MyClassExtension'}
- ${helpers.MyClassExtension} | ${dummyObj} | ${'MyClassExtension'}
- ${helpers.MyFnExtension} | ${undefined} | ${'MyFnExtension'}
- ${helpers.MyFnExtension} | ${{}} | ${'MyFnExtension'}
- ${helpers.MyFnExtension} | ${dummyObj} | ${'MyFnExtension'}
- ${helpers.MyConstExt} | ${undefined} | ${'MyConstExt'}
- ${helpers.MyConstExt} | ${{}} | ${'MyConstExt'}
- ${helpers.MyConstExt} | ${dummyObj} | ${'MyConstExt'}
+ ${helpers.SEClassExtension} | ${undefined} | ${'SEClassExtension'}
+ ${helpers.SEClassExtension} | ${{}} | ${'SEClassExtension'}
+ ${helpers.SEClassExtension} | ${dummyObj} | ${'SEClassExtension'}
+ ${helpers.SEFnExtension} | ${undefined} | ${'SEFnExtension'}
+ ${helpers.SEFnExtension} | ${{}} | ${'SEFnExtension'}
+ ${helpers.SEFnExtension} | ${dummyObj} | ${'SEFnExtension'}
+ ${helpers.SEConstExt} | ${undefined} | ${'SEConstExt'}
+ ${helpers.SEConstExt} | ${{}} | ${'SEConstExt'}
+ ${helpers.SEConstExt} | ${dummyObj} | ${'SEConstExt'}
`(
'correctly creates extension for definition = $definition and setupOptions = $setupOptions',
({ definition, setupOptions, expectedName }) => {
@@ -40,7 +40,7 @@ describe('Editor Extension', () => {
expect(extension).toEqual(
expect.objectContaining({
- name: expectedName,
+ extensionName: expectedName,
setupOptions,
}),
);
@@ -51,9 +51,9 @@ describe('Editor Extension', () => {
describe('api', () => {
it.each`
definition | expectedKeys
- ${helpers.MyClassExtension} | ${['shared', 'classExtMethod']}
- ${helpers.MyFnExtension} | ${['fnExtMethod']}
- ${helpers.MyConstExt} | ${['constExtMethod']}
+ ${helpers.SEClassExtension} | ${['shared', 'classExtMethod']}
+ ${helpers.SEFnExtension} | ${['fnExtMethod']}
+ ${helpers.SEConstExt} | ${['constExtMethod']}
`('correctly returns API for $definition', ({ definition, expectedKeys }) => {
const extension = new EditorExtension({ definition });
const expectedApi = Object.fromEntries(
diff --git a/spec/frontend/editor/source_editor_instance_spec.js b/spec/frontend/editor/source_editor_instance_spec.js
index 87b20a4ba73..f9518743ef8 100644
--- a/spec/frontend/editor/source_editor_instance_spec.js
+++ b/spec/frontend/editor/source_editor_instance_spec.js
@@ -6,31 +6,43 @@ import {
EDITOR_EXTENSION_NOT_REGISTERED_ERROR,
EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR,
} from '~/editor/constants';
-import Instance from '~/editor/source_editor_instance';
+import SourceEditorInstance from '~/editor/source_editor_instance';
import { sprintf } from '~/locale';
-import { MyClassExtension, conflictingExtensions, MyFnExtension, MyConstExt } from './helpers';
+import {
+ SEClassExtension,
+ conflictingExtensions,
+ SEFnExtension,
+ SEConstExt,
+ SEWithSetupExt,
+} from './helpers';
describe('Source Editor Instance', () => {
let seInstance;
const defSetupOptions = { foo: 'bar' };
const fullExtensionsArray = [
- { definition: MyClassExtension },
- { definition: MyFnExtension },
- { definition: MyConstExt },
+ { definition: SEClassExtension },
+ { definition: SEFnExtension },
+ { definition: SEConstExt },
];
const fullExtensionsArrayWithOptions = [
- { definition: MyClassExtension, setupOptions: defSetupOptions },
- { definition: MyFnExtension, setupOptions: defSetupOptions },
- { definition: MyConstExt, setupOptions: defSetupOptions },
+ { definition: SEClassExtension, setupOptions: defSetupOptions },
+ { definition: SEFnExtension, setupOptions: defSetupOptions },
+ { definition: SEConstExt, setupOptions: defSetupOptions },
];
const fooFn = jest.fn();
+ const fooProp = 'foo';
class DummyExt {
// eslint-disable-next-line class-methods-use-this
+ get extensionName() {
+ return 'DummyExt';
+ }
+ // eslint-disable-next-line class-methods-use-this
provides() {
return {
fooFn,
+ fooProp,
};
}
}
@@ -40,26 +52,26 @@ describe('Source Editor Instance', () => {
});
it('sets up the registry for the methods coming from extensions', () => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
expect(seInstance.methods).toBeDefined();
- seInstance.use({ definition: MyClassExtension });
+ seInstance.use({ definition: SEClassExtension });
expect(seInstance.methods).toEqual({
- shared: 'MyClassExtension',
- classExtMethod: 'MyClassExtension',
+ shared: 'SEClassExtension',
+ classExtMethod: 'SEClassExtension',
});
- seInstance.use({ definition: MyFnExtension });
+ seInstance.use({ definition: SEFnExtension });
expect(seInstance.methods).toEqual({
- shared: 'MyClassExtension',
- classExtMethod: 'MyClassExtension',
- fnExtMethod: 'MyFnExtension',
+ shared: 'SEClassExtension',
+ classExtMethod: 'SEClassExtension',
+ fnExtMethod: 'SEFnExtension',
});
});
describe('proxy', () => {
- it('returns prop from an extension if extension provides it', () => {
- seInstance = new Instance();
+ it('returns a method from an extension if extension provides it', () => {
+ seInstance = new SourceEditorInstance();
seInstance.use({ definition: DummyExt });
expect(fooFn).not.toHaveBeenCalled();
@@ -67,20 +79,77 @@ describe('Source Editor Instance', () => {
expect(fooFn).toHaveBeenCalled();
});
+ it('returns a prop from an extension if extension provides it', () => {
+ seInstance = new SourceEditorInstance();
+ seInstance.use({ definition: DummyExt });
+
+ expect(seInstance.fooProp).toBe('foo');
+ });
+
+ it.each`
+ stringPropToPass | objPropToPass | setupOptions
+ ${undefined} | ${undefined} | ${undefined}
+ ${'prop'} | ${undefined} | ${undefined}
+ ${'prop'} | ${[]} | ${undefined}
+ ${'prop'} | ${{}} | ${undefined}
+ ${'prop'} | ${{ alpha: 'beta' }} | ${undefined}
+ ${'prop'} | ${{ alpha: 'beta' }} | ${defSetupOptions}
+ ${'prop'} | ${undefined} | ${defSetupOptions}
+ ${undefined} | ${undefined} | ${defSetupOptions}
+ ${''} | ${{}} | ${defSetupOptions}
+ `(
+ 'correctly passes arguments ("$stringPropToPass", "$objPropToPass") and instance (with "$setupOptions" setupOptions) to extension methods',
+ ({ stringPropToPass, objPropToPass, setupOptions }) => {
+ seInstance = new SourceEditorInstance();
+ seInstance.use({ definition: SEWithSetupExt, setupOptions });
+
+ const [stringProp, objProp, instance] = seInstance.returnInstanceAndProps(
+ stringPropToPass,
+ objPropToPass,
+ );
+ const expectedObjProps = objPropToPass || {};
+
+ expect(instance).toBe(seInstance);
+ expect(stringProp).toBe(stringPropToPass);
+ expect(objProp).toEqual(expectedObjProps);
+ if (setupOptions) {
+ Object.keys(setupOptions).forEach((key) => {
+ expect(instance[key]).toBe(setupOptions[key]);
+ });
+ }
+ },
+ );
+
+ it('correctly passes instance to the methods even if no additional props have been passed', () => {
+ seInstance = new SourceEditorInstance();
+ seInstance.use({ definition: SEWithSetupExt });
+
+ const instance = seInstance.returnInstance();
+
+ expect(instance).toBe(seInstance);
+ });
+
+ it("correctly sets the context of the 'this' keyword for the extension's methods", () => {
+ seInstance = new SourceEditorInstance();
+ const extension = seInstance.use({ definition: SEWithSetupExt });
+
+ expect(seInstance.giveMeContext()).toEqual(extension.obj);
+ });
+
it('returns props from SE instance itself if no extension provides the prop', () => {
- seInstance = new Instance({
+ seInstance = new SourceEditorInstance({
use: fooFn,
});
- jest.spyOn(seInstance, 'use').mockImplementation(() => {});
- expect(seInstance.use).not.toHaveBeenCalled();
+ const spy = jest.spyOn(seInstance.constructor.prototype, 'use').mockImplementation(() => {});
+ expect(spy).not.toHaveBeenCalled();
expect(fooFn).not.toHaveBeenCalled();
seInstance.use();
- expect(seInstance.use).toHaveBeenCalled();
+ expect(spy).toHaveBeenCalled();
expect(fooFn).not.toHaveBeenCalled();
});
it('returns props from Monaco instance when the prop does not exist on the SE instance', () => {
- seInstance = new Instance({
+ seInstance = new SourceEditorInstance({
fooFn,
});
@@ -92,13 +161,13 @@ describe('Source Editor Instance', () => {
describe('public API', () => {
it.each(['use', 'unuse'], 'provides "%s" as public method by default', (method) => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
expect(seInstance[method]).toBeDefined();
});
describe('use', () => {
it('extends the SE instance with methods provided by an extension', () => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
seInstance.use({ definition: DummyExt });
expect(fooFn).not.toHaveBeenCalled();
@@ -108,15 +177,15 @@ describe('Source Editor Instance', () => {
it.each`
extensions | expectedProps
- ${{ definition: MyClassExtension }} | ${['shared', 'classExtMethod']}
- ${{ definition: MyFnExtension }} | ${['fnExtMethod']}
- ${{ definition: MyConstExt }} | ${['constExtMethod']}
+ ${{ definition: SEClassExtension }} | ${['shared', 'classExtMethod']}
+ ${{ definition: SEFnExtension }} | ${['fnExtMethod']}
+ ${{ definition: SEConstExt }} | ${['constExtMethod']}
${fullExtensionsArray} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']}
${fullExtensionsArrayWithOptions} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']}
`(
'Should register $expectedProps when extension is "$extensions"',
({ extensions, expectedProps }) => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
expect(seInstance.extensionsAPI).toHaveLength(0);
seInstance.use(extensions);
@@ -127,15 +196,15 @@ describe('Source Editor Instance', () => {
it.each`
definition | preInstalledExtDefinition | expectedErrorProp
- ${conflictingExtensions.WithInstanceExt} | ${MyClassExtension} | ${'use'}
+ ${conflictingExtensions.WithInstanceExt} | ${SEClassExtension} | ${'use'}
${conflictingExtensions.WithInstanceExt} | ${null} | ${'use'}
${conflictingExtensions.WithAnotherExt} | ${null} | ${undefined}
- ${conflictingExtensions.WithAnotherExt} | ${MyClassExtension} | ${'shared'}
- ${MyClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'}
+ ${conflictingExtensions.WithAnotherExt} | ${SEClassExtension} | ${'shared'}
+ ${SEClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'}
`(
'logs the naming conflict error when registering $definition',
({ definition, preInstalledExtDefinition, expectedErrorProp }) => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
jest.spyOn(console, 'error').mockImplementation(() => {});
if (preInstalledExtDefinition) {
@@ -175,7 +244,7 @@ describe('Source Editor Instance', () => {
`(
'Should throw $thrownError when extension is "$extensions"',
({ extensions, thrownError }) => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
const useExtension = () => {
seInstance.use(extensions);
};
@@ -188,24 +257,24 @@ describe('Source Editor Instance', () => {
beforeEach(() => {
extensionStore = new Map();
- seInstance = new Instance({}, extensionStore);
+ seInstance = new SourceEditorInstance({}, extensionStore);
});
it('stores _instances_ of the used extensions in a global registry', () => {
- const extension = seInstance.use({ definition: MyClassExtension });
+ const extension = seInstance.use({ definition: SEClassExtension });
expect(extensionStore.size).toBe(1);
- expect(extensionStore.entries().next().value).toEqual(['MyClassExtension', extension]);
+ expect(extensionStore.entries().next().value).toEqual(['SEClassExtension', extension]);
});
it('does not duplicate entries in the registry', () => {
jest.spyOn(extensionStore, 'set');
- const extension1 = seInstance.use({ definition: MyClassExtension });
- seInstance.use({ definition: MyClassExtension });
+ const extension1 = seInstance.use({ definition: SEClassExtension });
+ seInstance.use({ definition: SEClassExtension });
expect(extensionStore.set).toHaveBeenCalledTimes(1);
- expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1);
+ expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension1);
});
it.each`
@@ -222,20 +291,20 @@ describe('Source Editor Instance', () => {
jest.spyOn(extensionStore, 'set');
const extension1 = seInstance.use({
- definition: MyClassExtension,
+ definition: SEClassExtension,
setupOptions: currentSetupOptions,
});
const extension2 = seInstance.use({
- definition: MyClassExtension,
+ definition: SEClassExtension,
setupOptions: newSetupOptions,
});
expect(extensionStore.size).toBe(1);
expect(extensionStore.set).toHaveBeenCalledTimes(expectedCallTimes);
if (expectedCallTimes > 1) {
- expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension2);
+ expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension2);
} else {
- expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1);
+ expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension1);
}
},
);
@@ -252,7 +321,7 @@ describe('Source Editor Instance', () => {
`(
`Should throw "${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}" when extension is "$unuseExtension"`,
({ unuseExtension, thrownError }) => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
const unuse = () => {
seInstance.unuse(unuseExtension);
};
@@ -262,16 +331,16 @@ describe('Source Editor Instance', () => {
it.each`
initExtensions | unuseExtensionIndex | remainingAPI
- ${{ definition: MyClassExtension }} | ${0} | ${[]}
- ${{ definition: MyFnExtension }} | ${0} | ${[]}
- ${{ definition: MyConstExt }} | ${0} | ${[]}
+ ${{ definition: SEClassExtension }} | ${0} | ${[]}
+ ${{ definition: SEFnExtension }} | ${0} | ${[]}
+ ${{ definition: SEConstExt }} | ${0} | ${[]}
${fullExtensionsArray} | ${0} | ${['fnExtMethod', 'constExtMethod']}
${fullExtensionsArray} | ${1} | ${['shared', 'classExtMethod', 'constExtMethod']}
${fullExtensionsArray} | ${2} | ${['shared', 'classExtMethod', 'fnExtMethod']}
`(
'un-registers properties introduced by single extension $unuseExtension',
({ initExtensions, unuseExtensionIndex, remainingAPI }) => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
const extensions = seInstance.use(initExtensions);
if (Array.isArray(initExtensions)) {
@@ -291,7 +360,7 @@ describe('Source Editor Instance', () => {
`(
'un-registers properties introduced by multiple extensions $unuseExtension',
({ unuseExtensionIndex, remainingAPI }) => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
const extensions = seInstance.use(fullExtensionsArray);
const extensionsToUnuse = extensions.filter((ext, index) =>
unuseExtensionIndex.includes(index),
@@ -304,11 +373,11 @@ describe('Source Editor Instance', () => {
it('it does not remove entry from the global registry to keep for potential future re-use', () => {
const extensionStore = new Map();
- seInstance = new Instance({}, extensionStore);
+ seInstance = new SourceEditorInstance({}, extensionStore);
const extensions = seInstance.use(fullExtensionsArray);
const verifyExpectations = () => {
const entries = extensionStore.entries();
- const mockExtensions = ['MyClassExtension', 'MyFnExtension', 'MyConstExt'];
+ const mockExtensions = ['SEClassExtension', 'SEFnExtension', 'SEConstExt'];
expect(extensionStore.size).toBe(mockExtensions.length);
mockExtensions.forEach((ext, index) => {
expect(entries.next().value).toEqual([ext, extensions[index]]);
@@ -326,7 +395,7 @@ describe('Source Editor Instance', () => {
beforeEach(() => {
instanceModel = monacoEditor.createModel('');
- seInstance = new Instance({
+ seInstance = new SourceEditorInstance({
getModel: () => instanceModel,
});
});
@@ -363,17 +432,17 @@ describe('Source Editor Instance', () => {
};
it('passes correct arguments to callback fns when using an extension', () => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
seInstance.use({
definition: MyFullExtWithCallbacks,
setupOptions: defSetupOptions,
});
- expect(onSetup).toHaveBeenCalledWith(defSetupOptions, seInstance);
+ expect(onSetup).toHaveBeenCalledWith(seInstance, defSetupOptions);
expect(onUse).toHaveBeenCalledWith(seInstance);
});
it('passes correct arguments to callback fns when un-using an extension', () => {
- seInstance = new Instance();
+ seInstance = new SourceEditorInstance();
const extension = seInstance.use({
definition: MyFullExtWithCallbacks,
setupOptions: defSetupOptions,
diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js
index 245c6c28d31..eecd23bff6e 100644
--- a/spec/frontend/editor/source_editor_markdown_ext_spec.js
+++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js
@@ -1,36 +1,19 @@
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 { Range, Position } from 'monaco-editor';
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 previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown';
const firstLine = 'This is a';
const secondLine = 'multiline';
const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
- 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);
@@ -42,11 +25,6 @@ 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>');
@@ -57,8 +35,7 @@ describe('Markdown Extension for Source Editor', () => {
blobPath: markdownPath,
blobContent: text,
});
- editor.use(new EditorMarkdownExtension({ instance, previewMarkdownPath }));
- panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
+ instance.use({ definition: EditorMarkdownExtension });
});
afterEach(() => {
@@ -67,345 +44,6 @@ describe('Markdown Extension for Source Editor', () => {
mockAxios.restore();
});
- it('sets up the instance', () => {
- expect(instance.preview).toEqual({
- el: undefined,
- action: expect.any(Object),
- shown: false,
- modelChangeListener: undefined,
- });
- expect(instance.previewMarkdownPath).toBe(previewMarkdownPath);
- });
-
- 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 fetchPreview = async () => {
- instance.fetchPreview();
- await waitForPromises();
- };
-
- let previewMarkdownSpy;
-
- beforeEach(() => {
- previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]);
- mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req));
- });
-
- it('correctly fetches preview based on previewMarkdownPath', async () => {
- await fetchPreview();
-
- expect(previewMarkdownSpy).toHaveBeenCalledWith(
- expect.objectContaining({ data: 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', () => {
it('does not fail if there is no selection and returns the empty string', () => {
jest.spyOn(instance, 'getSelection');
@@ -525,13 +163,11 @@ describe('Markdown Extension for Source Editor', () => {
});
it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => {
- jest.spyOn(instance, 'getSelectedText');
const toSelect = 'string';
selectSecondAndThirdLines();
instance.selectWithinSelection(toSelect);
- expect(instance.getSelectedText).toHaveBeenCalled();
expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`);
});
diff --git a/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
new file mode 100644
index 00000000000..c8d016e10ac
--- /dev/null
+++ b/spec/frontend/editor/source_editor_markdown_livepreview_ext_spec.js
@@ -0,0 +1,421 @@
+import MockAdapter from 'axios-mock-adapter';
+import { 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 { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
+import SourceEditor from '~/editor/source_editor';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import syntaxHighlight from '~/syntax_highlight';
+import { spyOnApi } from './helpers';
+
+jest.mock('~/syntax_highlight');
+jest.mock('~/flash');
+
+describe('Markdown Live Preview Extension for Source Editor', () => {
+ let editor;
+ let instance;
+ let editorEl;
+ let panelSpy;
+ let mockAxios;
+ let extension;
+ const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown';
+ const firstLine = 'This is a';
+ const secondLine = 'multiline';
+ const thirdLine = 'string with some **markup**';
+ const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
+ const plaintextPath = 'foo.txt';
+ const markdownPath = 'foo.md';
+ const responseData = '<div>FooBar</div>';
+
+ 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: markdownPath,
+ blobContent: text,
+ });
+ extension = instance.use({
+ definition: EditorMarkdownPreviewExtension,
+ setupOptions: { previewMarkdownPath },
+ });
+ panelSpy = jest.spyOn(extension.obj.constructor.prototype, 'togglePreviewPanel');
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ editorEl.remove();
+ mockAxios.restore();
+ });
+
+ it('sets up the preview on the instance', () => {
+ expect(instance.markdownPreview).toEqual({
+ el: undefined,
+ action: expect.any(Object),
+ shown: false,
+ modelChangeListener: undefined,
+ path: previewMarkdownPath,
+ });
+ });
+
+ describe('model language changes listener', () => {
+ let cleanupSpy;
+ let actionSpy;
+
+ beforeEach(async () => {
+ cleanupSpy = jest.fn();
+ actionSpy = jest.fn();
+ spyOnApi(extension, {
+ cleanup: cleanupSpy,
+ setupPreviewAction: actionSpy,
+ });
+ await togglePreview();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('cleans up when switching away from markdown', () => {
+ expect(cleanupSpy).not.toHaveBeenCalled();
+ expect(actionSpy).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.fn();
+ actionSpy = jest.fn();
+ spyOnApi(extension, {
+ cleanup: cleanupSpy,
+ setupPreviewAction: actionSpy,
+ });
+ 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.markdownPreview.modelChangeListener).toBeDefined();
+ const fetchPreviewSpy = jest.fn();
+ spyOnApi(extension, {
+ fetchPreview: fetchPreviewSpy,
+ });
+
+ instance.cleanup();
+ instance.setValue('Foo Bar');
+ jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY);
+
+ expect(fetchPreviewSpy).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.markdownPreview.shown).toBe(true);
+ instance.cleanup();
+ expect(instance.markdownPreview.shown).toBe(false);
+ });
+
+ it('toggles the panel only if the preview is visible', () => {
+ const { el: previewEl } = instance.markdownPreview;
+ 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.markdownPreview.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 fetchPreview = async () => {
+ instance.fetchPreview();
+ await waitForPromises();
+ };
+
+ let previewMarkdownSpy;
+
+ beforeEach(() => {
+ previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]);
+ mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req));
+ });
+
+ it('correctly fetches preview based on previewMarkdownPath', async () => {
+ await fetchPreview();
+
+ expect(previewMarkdownSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ data: JSON.stringify({ text }) }),
+ );
+ });
+
+ it('puts the fetched content into the preview DOM element', async () => {
+ instance.markdownPreview.el = editorEl.parentElement;
+ await fetchPreview();
+ expect(instance.markdownPreview.el.innerHTML).toEqual(responseData);
+ });
+
+ it('applies syntax highlighting to the preview content', async () => {
+ instance.markdownPreview.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', () => {
+ const togglePreviewSpy = jest.fn();
+ spyOnApi(extension, {
+ togglePreview: togglePreviewSpy,
+ });
+
+ expect(togglePreviewSpy).not.toHaveBeenCalled();
+
+ const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID);
+ action.run();
+
+ expect(togglePreviewSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('togglePreview', () => {
+ beforeEach(() => {
+ mockAxios.onPost().reply(200, { body: responseData });
+ });
+
+ it('toggles preview flag on instance', () => {
+ expect(instance.markdownPreview.shown).toBe(false);
+
+ instance.togglePreview();
+ expect(instance.markdownPreview.shown).toBe(true);
+
+ instance.togglePreview();
+ expect(instance.markdownPreview.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.markdownPreview.el).toBeUndefined();
+
+ instance.togglePreview();
+
+ expect(instance.markdownPreview.el).toBeDefined();
+ expect(
+ instance.markdownPreview.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.markdownPreview.el;
+ instance.togglePreview();
+
+ expect(instance.markdownPreview.el).toBe(origPreviewEl);
+ });
+
+ it('hides the preview DOM element by default', () => {
+ panelSpy.mockImplementation();
+ instance.togglePreview();
+ expect(instance.markdownPreview.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.markdownPreview.el.style.display).toBe('block');
+ await togglePreview();
+ expect(instance.markdownPreview.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.markdownPreview.modelChangeListener).toBeUndefined();
+ await togglePreview();
+ expect(instance.markdownPreview.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.markdownPreview.modelChangeListener = {
+ dispose: disposeSpy,
+ };
+ instance.togglePreview();
+ expect(disposeSpy).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js
index d87d373c952..bc53202c919 100644
--- a/spec/frontend/editor/source_editor_spec.js
+++ b/spec/frontend/editor/source_editor_spec.js
@@ -1,16 +1,28 @@
-/* eslint-disable max-classes-per-file */
import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
-import waitForPromises from 'helpers/wait_for_promises';
import {
SOURCE_EDITOR_INSTANCE_ERROR_NO_EL,
URI_PREFIX,
EDITOR_READY_EVENT,
} from '~/editor/constants';
-import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import SourceEditor from '~/editor/source_editor';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
import { joinPaths } from '~/lib/utils/url_utility';
+jest.mock('~/helpers/startup_css_helper', () => {
+ return {
+ waitForCSSLoaded: jest.fn().mockImplementation((cb) => {
+ // We have to artificially put the callback's execution
+ // to the end of the current call stack to be able to
+ // test that the callback is called after waitForCSSLoaded.
+ // setTimeout with 0 delay does exactly that.
+ // Otherwise we might end up with false positive results
+ setTimeout(() => {
+ cb.apply();
+ }, 0);
+ }),
+ };
+});
+
describe('Base editor', () => {
let editorEl;
let editor;
@@ -19,7 +31,6 @@ describe('Base editor', () => {
const blobContent = 'Foo Bar';
const blobPath = 'test.md';
const blobGlobalId = 'snippet_777';
- const fakeModel = { foo: 'bar', dispose: jest.fn() };
beforeEach(() => {
setFixtures('<div id="editor" data-editor-loading></div>');
@@ -52,16 +63,6 @@ describe('Base editor', () => {
describe('instance of the Source Editor', () => {
let modelSpy;
let instanceSpy;
- const setModel = jest.fn();
- const dispose = jest.fn();
- const mockModelReturn = (res = fakeModel) => {
- modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => res);
- };
- const mockDecorateInstance = (decorations = {}) => {
- jest.spyOn(SourceEditor, 'convertMonacoToELInstance').mockImplementation((inst) => {
- return Object.assign(inst, decorations);
- });
- };
beforeEach(() => {
modelSpy = jest.spyOn(monacoEditor, 'createModel');
@@ -73,46 +74,38 @@ describe('Base editor', () => {
});
it('throws an error if no dom element is supplied', () => {
- mockDecorateInstance();
- expect(() => {
+ const create = () => {
editor.createInstance();
- }).toThrow(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL);
+ };
+ expect(create).toThrow(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL);
expect(modelSpy).not.toHaveBeenCalled();
expect(instanceSpy).not.toHaveBeenCalled();
- expect(SourceEditor.convertMonacoToELInstance).not.toHaveBeenCalled();
});
- it('creates model to be supplied to Monaco editor', () => {
- mockModelReturn();
- mockDecorateInstance({
- setModel,
- });
- editor.createInstance(defaultArguments);
+ it('creates model and attaches it to the instance', () => {
+ jest.spyOn(monacoEditor, 'createModel');
+ const instance = editor.createInstance(defaultArguments);
- expect(modelSpy).toHaveBeenCalledWith(
+ expect(monacoEditor.createModel).toHaveBeenCalledWith(
blobContent,
undefined,
expect.objectContaining({
path: uriFilePath,
}),
);
- expect(setModel).toHaveBeenCalledWith(fakeModel);
+ expect(instance.getModel().getValue()).toEqual(defaultArguments.blobContent);
});
it('does not create a model automatically if model is passed as `null`', () => {
- mockDecorateInstance({
- setModel,
- });
- editor.createInstance({ ...defaultArguments, model: null });
- expect(modelSpy).not.toHaveBeenCalled();
- expect(setModel).not.toHaveBeenCalled();
+ const instance = editor.createInstance({ ...defaultArguments, model: null });
+ expect(instance.getModel()).toBeNull();
});
it('initializes the instance on a supplied DOM node', () => {
editor.createInstance({ el: editorEl });
- expect(editor.editorEl).not.toBe(null);
+ expect(editor.editorEl).not.toBeNull();
expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything());
});
@@ -143,32 +136,43 @@ describe('Base editor', () => {
});
it('disposes instance when the global editor is disposed', () => {
- mockDecorateInstance({
- dispose,
- });
- editor.createInstance(defaultArguments);
+ const instance = editor.createInstance(defaultArguments);
+ instance.dispose = jest.fn();
- expect(dispose).not.toHaveBeenCalled();
+ expect(instance.dispose).not.toHaveBeenCalled();
editor.dispose();
- expect(dispose).toHaveBeenCalled();
+ expect(instance.dispose).toHaveBeenCalled();
});
it("removes the disposed instance from the global editor's storage and disposes the associated model", () => {
- mockModelReturn();
- mockDecorateInstance({
- setModel,
- });
const instance = editor.createInstance(defaultArguments);
expect(editor.instances).toHaveLength(1);
- expect(fakeModel.dispose).not.toHaveBeenCalled();
+ expect(instance.getModel()).not.toBeNull();
instance.dispose();
expect(editor.instances).toHaveLength(0);
- expect(fakeModel.dispose).toHaveBeenCalled();
+ expect(instance.getModel()).toBeNull();
+ });
+
+ it('resets the layout in waitForCSSLoaded callback', async () => {
+ const layoutSpy = jest.fn();
+ jest.spyOn(monacoEditor, 'create').mockReturnValue({
+ layout: layoutSpy,
+ setModel: jest.fn(),
+ onDidDispose: jest.fn(),
+ dispose: jest.fn(),
+ });
+ editor.createInstance(defaultArguments);
+ expect(layoutSpy).not.toHaveBeenCalled();
+
+ // We're waiting for the waitForCSSLoaded mock to kick in
+ await jest.runOnlyPendingTimers();
+
+ expect(layoutSpy).toHaveBeenCalled();
});
});
@@ -214,26 +218,17 @@ describe('Base editor', () => {
});
it('correctly disposes the diff editor model', () => {
- const modifiedModel = fakeModel;
- const originalModel = { ...fakeModel };
- mockDecorateInstance({
- getModel: jest.fn().mockReturnValue({
- original: originalModel,
- modified: modifiedModel,
- }),
- });
-
const instance = editor.createDiffInstance({ ...defaultArguments, blobOriginalContent });
expect(editor.instances).toHaveLength(1);
- expect(originalModel.dispose).not.toHaveBeenCalled();
- expect(modifiedModel.dispose).not.toHaveBeenCalled();
+ expect(instance.getOriginalEditor().getModel()).not.toBeNull();
+ expect(instance.getModifiedEditor().getModel()).not.toBeNull();
instance.dispose();
expect(editor.instances).toHaveLength(0);
- expect(originalModel.dispose).toHaveBeenCalled();
- expect(modifiedModel.dispose).toHaveBeenCalled();
+ expect(instance.getOriginalEditor().getModel()).toBeNull();
+ expect(instance.getModifiedEditor().getModel()).toBeNull();
});
});
});
@@ -355,282 +350,19 @@ describe('Base editor', () => {
expect(instance.getValue()).toBe(blobContent);
});
- it('is capable of changing the language of the model', () => {
- // ignore warnings and errors Monaco posts during setup
- // (due to being called from Jest/Node.js environment)
- jest.spyOn(console, 'warn').mockImplementation(() => {});
- jest.spyOn(console, 'error').mockImplementation(() => {});
-
- const blobRenamedPath = 'test.js';
-
- expect(instance.getModel().getLanguageIdentifier().language).toBe('markdown');
- instance.updateModelLanguage(blobRenamedPath);
-
- expect(instance.getModel().getLanguageIdentifier().language).toBe('javascript');
- });
-
- it('falls back to plaintext if there is no language associated with an extension', () => {
- const blobRenamedPath = 'test.myext';
- const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
-
- instance.updateModelLanguage(blobRenamedPath);
-
- expect(spy).not.toHaveBeenCalled();
- expect(instance.getModel().getLanguageIdentifier().language).toBe('plaintext');
- });
- });
-
- describe('extensions', () => {
- let instance;
- const alphaRes = jest.fn();
- const betaRes = jest.fn();
- const fooRes = jest.fn();
- const barRes = jest.fn();
- class AlphaClass {
- constructor() {
- this.res = alphaRes;
- }
- alpha() {
- return this?.nonExistentProp || alphaRes;
- }
- }
- class BetaClass {
- beta() {
- return this?.nonExistentProp || betaRes;
- }
- }
- class WithStaticMethod {
- constructor({ instance: inst, ...options } = {}) {
- Object.assign(inst, options);
- }
- static computeBoo(a) {
- return a + 1;
- }
- boo() {
- return WithStaticMethod.computeBoo(this.base);
- }
- }
- class WithStaticMethodExtended extends SourceEditorExtension {
- static computeBoo(a) {
- return a + 1;
- }
- boo() {
- return WithStaticMethodExtended.computeBoo(this.base);
- }
- }
- const AlphaExt = new AlphaClass();
- const BetaExt = new BetaClass();
- const FooObjExt = {
- foo() {
- return fooRes;
- },
- };
- const BarObjExt = {
- bar() {
- return barRes;
- },
- };
-
- describe('basic functionality', () => {
- beforeEach(() => {
- instance = editor.createInstance({ el: editorEl, blobPath, blobContent });
- });
-
- it('does not fail if no extensions supplied', () => {
- const spy = jest.spyOn(global.console, 'error');
- instance.use();
-
- expect(spy).not.toHaveBeenCalled();
- });
-
- it("does not extend instance with extension's constructor", () => {
- expect(instance.constructor).toBeDefined();
- const { constructor } = instance;
-
- expect(AlphaExt.constructor).toBeDefined();
- expect(AlphaExt.constructor).not.toEqual(constructor);
-
- instance.use(AlphaExt);
- expect(instance.constructor).toBe(constructor);
- });
-
- it.each`
- type | extensions | methods | expectations
- ${'ES6 classes'} | ${AlphaExt} | ${['alpha']} | ${[alphaRes]}
- ${'multiple ES6 classes'} | ${[AlphaExt, BetaExt]} | ${['alpha', 'beta']} | ${[alphaRes, betaRes]}
- ${'simple objects'} | ${FooObjExt} | ${['foo']} | ${[fooRes]}
- ${'multiple simple objects'} | ${[FooObjExt, BarObjExt]} | ${['foo', 'bar']} | ${[fooRes, barRes]}
- ${'combination of ES6 classes and objects'} | ${[AlphaExt, BarObjExt]} | ${['alpha', 'bar']} | ${[alphaRes, barRes]}
- `('is extensible with $type', ({ extensions, methods, expectations } = {}) => {
- methods.forEach((method) => {
- expect(instance[method]).toBeUndefined();
- });
-
- instance.use(extensions);
-
- methods.forEach((method) => {
- expect(instance[method]).toBeDefined();
- });
-
- expectations.forEach((expectation, i) => {
- expect(instance[methods[i]].call()).toEqual(expectation);
- });
- });
-
- it('does not extend instance with private data of an extension', () => {
- const ext = new WithStaticMethod({ instance });
- ext.staticMethod = () => {
- return 'foo';
- };
- ext.staticProp = 'bar';
-
- expect(instance.boo).toBeUndefined();
- expect(instance.staticMethod).toBeUndefined();
- expect(instance.staticProp).toBeUndefined();
-
- instance.use(ext);
-
- expect(instance.boo).toBeDefined();
- expect(instance.staticMethod).toBeUndefined();
- expect(instance.staticProp).toBeUndefined();
- });
-
- it.each([WithStaticMethod, WithStaticMethodExtended])(
- 'properly resolves data for an extension with private data',
- (ExtClass) => {
- const base = 1;
- expect(instance.base).toBeUndefined();
- expect(instance.boo).toBeUndefined();
-
- const ext = new ExtClass({ instance, base });
-
- instance.use(ext);
- expect(instance.base).toBe(1);
- expect(instance.boo()).toBe(2);
- },
- );
-
- it('uses the last definition of a method in case of an overlap', () => {
- const FooObjExt2 = { foo: 'foo2' };
- instance.use([FooObjExt, BarObjExt, FooObjExt2]);
- expect(instance).toMatchObject({
- foo: 'foo2',
- ...BarObjExt,
- });
- });
-
- it('correctly resolves references withing extensions', () => {
- const FunctionExt = {
- inst() {
- return this;
- },
- mod() {
- return this.getModel();
- },
+ it('emits the EDITOR_READY_EVENT event after setting up the instance', () => {
+ jest.spyOn(monacoEditor, 'create').mockImplementation(() => {
+ return {
+ setModel: jest.fn(),
+ onDidDispose: jest.fn(),
+ layout: jest.fn(),
};
- instance.use(FunctionExt);
- expect(instance.inst()).toEqual(editor.instances[0]);
- });
- });
-
- describe('extensions as an instance parameter', () => {
- let editorExtensionSpy;
- const instanceConstructor = (extensions = []) => {
- return editor.createInstance({
- el: editorEl,
- blobPath,
- blobContent,
- extensions,
- });
- };
-
- beforeEach(() => {
- editorExtensionSpy = jest
- .spyOn(SourceEditor, 'pushToImportsArray')
- .mockImplementation((arr) => {
- arr.push(
- Promise.resolve({
- default: {},
- }),
- );
- });
- });
-
- it.each([undefined, [], [''], ''])(
- 'does not fail and makes no fetch if extensions is %s',
- () => {
- instance = instanceConstructor(null);
- expect(editorExtensionSpy).not.toHaveBeenCalled();
- },
- );
-
- it.each`
- type | value | callsCount
- ${'simple string'} | ${'foo'} | ${1}
- ${'combined string'} | ${'foo, bar'} | ${2}
- ${'array of strings'} | ${['foo', 'bar']} | ${2}
- `('accepts $type as an extension parameter', ({ value, callsCount }) => {
- instance = instanceConstructor(value);
- expect(editorExtensionSpy).toHaveBeenCalled();
- expect(editorExtensionSpy.mock.calls).toHaveLength(callsCount);
- });
-
- it.each`
- desc | path | expectation
- ${'~/editor'} | ${'foo'} | ${'~/editor/foo'}
- ${'~/CUSTOM_PATH with leading slash'} | ${'/my_custom_path/bar'} | ${'~/my_custom_path/bar'}
- ${'~/CUSTOM_PATH without leading slash'} | ${'my_custom_path/delta'} | ${'~/my_custom_path/delta'}
- `('fetches extensions from $desc path', ({ path, expectation }) => {
- instance = instanceConstructor(path);
- expect(editorExtensionSpy).toHaveBeenCalledWith(expect.any(Array), expectation);
- });
-
- it('emits EDITOR_READY_EVENT event after all extensions were applied', async () => {
- const calls = [];
- const eventSpy = jest.fn().mockImplementation(() => {
- calls.push('event');
- });
- const useSpy = jest.fn().mockImplementation(() => {
- calls.push('use');
- });
- jest.spyOn(SourceEditor, 'convertMonacoToELInstance').mockImplementation((inst) => {
- const decoratedInstance = inst;
- decoratedInstance.use = useSpy;
- return decoratedInstance;
- });
- editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy);
- instance = instanceConstructor('foo, bar');
- await waitForPromises();
- expect(useSpy.mock.calls).toHaveLength(2);
- expect(calls).toEqual(['use', 'use', 'event']);
- });
- });
-
- describe('multiple instances', () => {
- let inst1;
- let inst2;
- let editorEl1;
- let editorEl2;
-
- beforeEach(() => {
- setFixtures('<div id="editor1"></div><div id="editor2"></div>');
- editorEl1 = document.getElementById('editor1');
- editorEl2 = document.getElementById('editor2');
- inst1 = editor.createInstance({ el: editorEl1, blobPath: `foo-${blobPath}` });
- inst2 = editor.createInstance({ el: editorEl2, blobPath: `bar-${blobPath}` });
- });
-
- afterEach(() => {
- editor.dispose();
- editorEl1.remove();
- editorEl2.remove();
- });
-
- it('extends all instances if no specific instance is passed', () => {
- editor.use(AlphaExt);
- expect(inst1.alpha()).toEqual(alphaRes);
- expect(inst2.alpha()).toEqual(alphaRes);
});
+ const eventSpy = jest.fn();
+ editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy);
+ expect(eventSpy).not.toHaveBeenCalled();
+ editor.createInstance({ el: editorEl });
+ expect(eventSpy).toHaveBeenCalled();
});
});
diff --git a/spec/frontend/editor/source_editor_yaml_ext_spec.js b/spec/frontend/editor/source_editor_yaml_ext_spec.js
index 97d2b0b21d0..a861d9c7a45 100644
--- a/spec/frontend/editor/source_editor_yaml_ext_spec.js
+++ b/spec/frontend/editor/source_editor_yaml_ext_spec.js
@@ -2,6 +2,10 @@ import { Document } from 'yaml';
import SourceEditor from '~/editor/source_editor';
import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
+import { spyOnApi } from 'jest/editor/helpers';
+
+let baseExtension;
+let yamlExtension;
const getEditorInstance = (editorInstanceOptions = {}) => {
setFixtures('<div id="editor"></div>');
@@ -16,7 +20,10 @@ const getEditorInstance = (editorInstanceOptions = {}) => {
const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => {
setFixtures('<div id="editor"></div>');
const instance = getEditorInstance(editorInstanceOptions);
- instance.use(new YamlEditorExtension({ instance, ...extensionOptions }));
+ [baseExtension, yamlExtension] = instance.use([
+ { definition: SourceEditorExtension },
+ { definition: YamlEditorExtension, setupOptions: extensionOptions },
+ ]);
// Remove the below once
// https://gitlab.com/gitlab-org/gitlab/-/issues/325992 is resolved
@@ -29,19 +36,16 @@ const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOpt
describe('YamlCreatorExtension', () => {
describe('constructor', () => {
- it('saves constructor options', () => {
+ it('saves setupOptions options on the extension, but does not expose those to instance', () => {
+ const highlightPath = 'foo';
const instance = getEditorInstanceWithExtension({
- highlightPath: 'foo',
+ highlightPath,
enableComments: true,
});
- expect(instance).toEqual(
- expect.objectContaining({
- options: expect.objectContaining({
- highlightPath: 'foo',
- enableComments: true,
- }),
- }),
- );
+ expect(yamlExtension.obj.highlightPath).toBe(highlightPath);
+ expect(yamlExtension.obj.enableComments).toBe(true);
+ expect(instance.highlightPath).toBeUndefined();
+ expect(instance.enableComments).toBeUndefined();
});
it('dumps values loaded with the model constructor options', () => {
@@ -55,7 +59,7 @@ describe('YamlCreatorExtension', () => {
it('registers the onUpdate() function', () => {
const instance = getEditorInstance();
const onDidChangeModelContent = jest.spyOn(instance, 'onDidChangeModelContent');
- instance.use(new YamlEditorExtension({ instance }));
+ instance.use({ definition: YamlEditorExtension });
expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
});
@@ -82,21 +86,21 @@ describe('YamlCreatorExtension', () => {
it('should call transformComments if enableComments is true', () => {
const instance = getEditorInstanceWithExtension({ enableComments: true });
const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments');
- YamlEditorExtension.initFromModel(instance, model);
+ instance.initFromModel(model);
expect(transformComments).toHaveBeenCalled();
});
it('should not call transformComments if enableComments is false', () => {
const instance = getEditorInstanceWithExtension({ enableComments: false });
const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments');
- YamlEditorExtension.initFromModel(instance, model);
+ instance.initFromModel(model);
expect(transformComments).not.toHaveBeenCalled();
});
it('should call setValue with the stringified model', () => {
const instance = getEditorInstanceWithExtension();
const setValue = jest.spyOn(instance, 'setValue');
- YamlEditorExtension.initFromModel(instance, model);
+ instance.initFromModel(model);
expect(setValue).toHaveBeenCalledWith(doc.toString());
});
});
@@ -240,26 +244,35 @@ foo:
it("should call setValue with the stringified doc if the editor's value is empty", () => {
const instance = getEditorInstanceWithExtension();
const setValue = jest.spyOn(instance, 'setValue');
- const updateValue = jest.spyOn(instance, 'updateValue');
+ const updateValueSpy = jest.fn();
+ spyOnApi(yamlExtension, {
+ updateValue: updateValueSpy,
+ });
instance.setDoc(doc);
expect(setValue).toHaveBeenCalledWith(doc.toString());
- expect(updateValue).not.toHaveBeenCalled();
+ expect(updateValueSpy).not.toHaveBeenCalled();
});
it("should call updateValue with the stringified doc if the editor's value is not empty", () => {
const instance = getEditorInstanceWithExtension({}, { value: 'asjkdhkasjdh' });
const setValue = jest.spyOn(instance, 'setValue');
- const updateValue = jest.spyOn(instance, 'updateValue');
+ const updateValueSpy = jest.fn();
+ spyOnApi(yamlExtension, {
+ updateValue: updateValueSpy,
+ });
instance.setDoc(doc);
expect(setValue).not.toHaveBeenCalled();
- expect(updateValue).toHaveBeenCalledWith(doc.toString());
+ expect(updateValueSpy).toHaveBeenCalledWith(instance, doc.toString());
});
it('should trigger the onUpdate method', () => {
const instance = getEditorInstanceWithExtension();
- const onUpdate = jest.spyOn(instance, 'onUpdate');
+ const onUpdateSpy = jest.fn();
+ spyOnApi(yamlExtension, {
+ onUpdate: onUpdateSpy,
+ });
instance.setDoc(doc);
- expect(onUpdate).toHaveBeenCalled();
+ expect(onUpdateSpy).toHaveBeenCalled();
});
});
@@ -320,9 +333,12 @@ foo:
it('calls highlight', () => {
const highlightPath = 'foo';
const instance = getEditorInstanceWithExtension({ highlightPath });
- instance.highlight = jest.fn();
+ // Here we do not spy on the public API method of the extension, but rather
+ // the public method of the extension's instance.
+ // This is required based on how `onUpdate` works
+ const highlightSpy = jest.spyOn(yamlExtension.obj, 'highlight');
instance.onUpdate();
- expect(instance.highlight).toHaveBeenCalledWith(highlightPath);
+ expect(highlightSpy).toHaveBeenCalledWith(instance, highlightPath);
});
});
@@ -350,8 +366,12 @@ foo:
beforeEach(() => {
instance = getEditorInstanceWithExtension({ highlightPath: highlightPathOnSetup }, { value });
- highlightLinesSpy = jest.spyOn(SourceEditorExtension, 'highlightLines');
- removeHighlightsSpy = jest.spyOn(SourceEditorExtension, 'removeHighlights');
+ highlightLinesSpy = jest.fn();
+ removeHighlightsSpy = jest.fn();
+ spyOnApi(baseExtension, {
+ highlightLines: highlightLinesSpy,
+ removeHighlights: removeHighlightsSpy,
+ });
});
afterEach(() => {
@@ -361,7 +381,7 @@ foo:
it('saves the highlighted path in highlightPath', () => {
const path = 'foo.bar';
instance.highlight(path);
- expect(instance.options.highlightPath).toEqual(path);
+ expect(yamlExtension.obj.highlightPath).toEqual(path);
});
it('calls highlightLines with a number of lines', () => {
@@ -374,14 +394,14 @@ foo:
instance.highlight(null);
expect(removeHighlightsSpy).toHaveBeenCalledWith(instance);
expect(highlightLinesSpy).not.toHaveBeenCalled();
- expect(instance.options.highlightPath).toBeNull();
+ expect(yamlExtension.obj.highlightPath).toBeNull();
});
it('throws an error if path is invalid and does not change the highlighted path', () => {
expect(() => instance.highlight('invalidPath[0]')).toThrow(
'The node invalidPath[0] could not be found inside the document.',
);
- expect(instance.options.highlightPath).toEqual(highlightPathOnSetup);
+ expect(yamlExtension.obj.highlightPath).toEqual(highlightPathOnSetup);
expect(highlightLinesSpy).not.toHaveBeenCalled();
expect(removeHighlightsSpy).not.toHaveBeenCalled();
});
diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js
index 9652c513671..cc037586496 100644
--- a/spec/frontend/emoji/index_spec.js
+++ b/spec/frontend/emoji/index_spec.js
@@ -1,6 +1,21 @@
-import { emojiFixtureMap, mockEmojiData, initEmojiMock } from 'helpers/emoji';
+import {
+ emojiFixtureMap,
+ mockEmojiData,
+ initEmojiMock,
+ validEmoji,
+ invalidEmoji,
+ clearEmojiMock,
+} from 'helpers/emoji';
import { trimText } from 'helpers/text_helper';
-import { glEmojiTag, searchEmoji, getEmojiInfo, sortEmoji } from '~/emoji';
+import {
+ glEmojiTag,
+ searchEmoji,
+ getEmojiInfo,
+ sortEmoji,
+ initEmojiMap,
+ getAllEmoji,
+} from '~/emoji';
+
import isEmojiUnicodeSupported, {
isFlagEmoji,
isRainbowFlagEmoji,
@@ -9,7 +24,6 @@ import isEmojiUnicodeSupported, {
isHorceRacingSkinToneComboEmoji,
isPersonZwjEmoji,
} from '~/emoji/support/is_emoji_unicode_supported';
-import { sanitize } from '~/lib/dompurify';
const emptySupportMap = {
personZwj: false,
@@ -31,14 +45,55 @@ const emptySupportMap = {
};
describe('emoji', () => {
- let mock;
-
beforeEach(async () => {
- mock = await initEmojiMock();
+ await initEmojiMock();
});
afterEach(() => {
- mock.restore();
+ clearEmojiMock();
+ });
+
+ describe('initEmojiMap', () => {
+ it('should contain valid emoji', async () => {
+ await initEmojiMap();
+
+ const allEmoji = Object.keys(getAllEmoji());
+ Object.keys(validEmoji).forEach((key) => {
+ expect(allEmoji.includes(key)).toBe(true);
+ });
+ });
+
+ it('should not contain invalid emoji', async () => {
+ await initEmojiMap();
+
+ const allEmoji = Object.keys(getAllEmoji());
+ Object.keys(invalidEmoji).forEach((key) => {
+ expect(allEmoji.includes(key)).toBe(false);
+ });
+ });
+
+ it('fixes broken pride emoji', async () => {
+ clearEmojiMock();
+ await initEmojiMock({
+ gay_pride_flag: {
+ c: 'flags',
+ // Without a zero-width joiner
+ e: '🏳🌈',
+ name: 'gay_pride_flag',
+ u: '6.0',
+ },
+ });
+
+ expect(getAllEmoji()).toEqual({
+ gay_pride_flag: {
+ c: 'flags',
+ // With a zero-width joiner
+ e: '🏳️‍🌈',
+ name: 'gay_pride_flag',
+ u: '6.0',
+ },
+ });
+ });
});
describe('glEmojiTag', () => {
@@ -378,32 +433,14 @@ describe('emoji', () => {
});
describe('searchEmoji', () => {
- const emojiFixture = Object.keys(mockEmojiData).reduce((acc, k) => {
- const { name, e, u, d } = mockEmojiData[k];
- acc[k] = { name, e: sanitize(e), u, d };
-
- return acc;
- }, {});
-
it.each([undefined, null, ''])("should return all emoji when the input is '%s'", (input) => {
const search = searchEmoji(input);
- const expected = [
- 'atom',
- 'bomb',
- 'construction_worker_tone5',
- 'five',
- 'grey_question',
- 'black_heart',
- 'heart',
- 'custard',
- 'star',
- 'xss',
- ].map((name) => {
+ const expected = Object.keys(validEmoji).map((name) => {
return {
- emoji: emojiFixture[name],
+ emoji: mockEmojiData[name],
field: 'd',
- fieldValue: emojiFixture[name].d,
+ fieldValue: mockEmojiData[name].d,
score: 0,
};
});
@@ -453,7 +490,7 @@ describe('emoji', () => {
const { field, score, fieldValue, name } = item;
return {
- emoji: emojiFixture[name],
+ emoji: mockEmojiData[name],
field,
fieldValue,
score,
@@ -564,9 +601,9 @@ describe('emoji', () => {
const { field, score, name } = item;
return {
- emoji: emojiFixture[name],
+ emoji: mockEmojiData[name],
field,
- fieldValue: emojiFixture[name][field],
+ fieldValue: mockEmojiData[name][field],
score,
};
});
@@ -622,13 +659,4 @@ describe('emoji', () => {
expect(sortEmoji(scoredItems)).toEqual(expected);
});
});
-
- describe('sanitize emojis', () => {
- it('should return sanitized emoji', () => {
- expect(getEmojiInfo('xss')).toEqual({
- ...mockEmojiData.xss,
- e: '<img src="x">',
- });
- });
- });
});
diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js
index d62aaec4f69..b699f953945 100644
--- a/spec/frontend/environments/confirm_rollback_modal_spec.js
+++ b/spec/frontend/environments/confirm_rollback_modal_spec.js
@@ -1,6 +1,9 @@
import { GlModal, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
import eventHub from '~/environments/event_hub';
describe('Confirm Rollback Modal Component', () => {
@@ -17,6 +20,17 @@ describe('Confirm Rollback Modal Component', () => {
modalId: 'test',
};
+ const envWithLastDeploymentGraphql = {
+ name: 'test',
+ lastDeployment: {
+ commit: {
+ shortId: 'abc0123',
+ },
+ 'last?': true,
+ },
+ modalId: 'test',
+ };
+
const envWithoutLastDeployment = {
name: 'test',
modalId: 'test',
@@ -26,7 +40,7 @@ describe('Confirm Rollback Modal Component', () => {
const retryPath = 'test/-/jobs/123/retry';
- const createComponent = (props = {}) => {
+ const createComponent = (props = {}, options = {}) => {
component = shallowMount(ConfirmRollbackModal, {
propsData: {
...props,
@@ -34,6 +48,7 @@ describe('Confirm Rollback Modal Component', () => {
stubs: {
GlSprintf,
},
+ ...options,
});
};
@@ -101,4 +116,121 @@ describe('Confirm Rollback Modal Component', () => {
});
},
);
+
+ describe('graphql', () => {
+ describe.each`
+ hasMultipleCommits | environmentData | retryUrl | primaryPropsAttrs
+ ${true} | ${envWithLastDeploymentGraphql} | ${null} | ${[{ variant: 'danger' }]}
+ ${false} | ${envWithoutLastDeployment} | ${retryPath} | ${[{ variant: 'danger' }, { 'data-method': 'post' }, { href: retryPath }]}
+ `(
+ 'when hasMultipleCommits=$hasMultipleCommits',
+ ({ hasMultipleCommits, environmentData, retryUrl, primaryPropsAttrs }) => {
+ Vue.use(VueApollo);
+
+ let apolloProvider;
+ let rollbackResolver;
+
+ beforeEach(() => {
+ rollbackResolver = jest.fn();
+ apolloProvider = createMockApollo([], {
+ Mutation: { rollbackEnvironment: rollbackResolver },
+ });
+ environment = environmentData;
+ });
+
+ it('should set contain the commit hash and ask for confirmation', () => {
+ createComponent(
+ {
+ environment: {
+ ...environment,
+ lastDeployment: {
+ ...environment.lastDeployment,
+ 'last?': false,
+ },
+ },
+ hasMultipleCommits,
+ retryUrl,
+ graphql: true,
+ },
+ { apolloProvider },
+ );
+ const modal = component.find(GlModal);
+
+ expect(modal.text()).toContain('commit abc0123');
+ expect(modal.text()).toContain('Are you sure you want to continue?');
+ });
+
+ it('should show "Rollback" when isLastDeployment is false', () => {
+ createComponent(
+ {
+ environment: {
+ ...environment,
+ lastDeployment: {
+ ...environment.lastDeployment,
+ 'last?': false,
+ },
+ },
+ hasMultipleCommits,
+ retryUrl,
+ graphql: true,
+ },
+ { apolloProvider },
+ );
+ 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);
+ });
+
+ it('should show "Re-deploy" when isLastDeployment is true', () => {
+ createComponent(
+ {
+ environment: {
+ ...environment,
+ lastDeployment: {
+ ...environment.lastDeployment,
+ 'last?': true,
+ },
+ },
+ hasMultipleCommits,
+ graphql: true,
+ },
+ { apolloProvider },
+ );
+
+ 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');
+ });
+
+ it('should commit the "rollback" mutation when "ok" is clicked', async () => {
+ const env = { ...environmentData, isLastDeployment: true };
+
+ createComponent(
+ {
+ environment: env,
+ hasMultipleCommits,
+ graphql: true,
+ },
+ { apolloProvider },
+ );
+
+ const modal = component.find(GlModal);
+ modal.vm.$emit('ok');
+
+ await nextTick();
+ expect(rollbackResolver).toHaveBeenCalledWith(
+ expect.anything(),
+ { environment: env },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ },
+ );
+ });
});
diff --git a/spec/frontend/environments/delete_environment_modal_spec.js b/spec/frontend/environments/delete_environment_modal_spec.js
new file mode 100644
index 00000000000..50c4ca00009
--- /dev/null
+++ b/spec/frontend/environments/delete_environment_modal_spec.js
@@ -0,0 +1,64 @@
+import { GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { s__, sprintf } from '~/locale';
+import DeleteEnvironmentModal from '~/environments/components/delete_environment_modal.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { resolvedEnvironment } from './graphql/mock_data';
+
+Vue.use(VueApollo);
+
+describe('~/environments/components/delete_environment_modal.vue', () => {
+ let mockApollo;
+ let deleteResolver;
+ let wrapper;
+
+ const createComponent = ({ props = {}, apolloProvider } = {}) => {
+ wrapper = shallowMount(DeleteEnvironmentModal, {
+ propsData: {
+ graphql: true,
+ environment: resolvedEnvironment,
+ ...props,
+ },
+ apolloProvider,
+ });
+ };
+
+ beforeEach(() => {
+ deleteResolver = jest.fn();
+ mockApollo = createMockApollo([], {
+ Mutation: { deleteEnvironment: deleteResolver },
+ });
+ });
+
+ it('should confirm the environment to delete', () => {
+ createComponent({ apolloProvider: mockApollo });
+
+ expect(wrapper.text()).toBe(
+ sprintf(
+ s__(
+ `Environments|Deleting the '%{environmentName}' environment cannot be undone. Do you want to delete it anyway?`,
+ ),
+ {
+ environmentName: resolvedEnvironment.name,
+ },
+ ),
+ );
+ });
+
+ it('should send the delete mutation on primary', async () => {
+ createComponent({ apolloProvider: mockApollo });
+
+ wrapper.findComponent(GlModal).vm.$emit('primary');
+
+ await nextTick();
+
+ expect(deleteResolver).toHaveBeenCalledWith(
+ expect.anything(),
+ { environment: resolvedEnvironment },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+});
diff --git a/spec/frontend/environments/enable_review_app_modal_spec.js b/spec/frontend/environments/enable_review_app_modal_spec.js
index 9a3f13f19d5..17ae10a2884 100644
--- a/spec/frontend/environments/enable_review_app_modal_spec.js
+++ b/spec/frontend/environments/enable_review_app_modal_spec.js
@@ -1,10 +1,12 @@
import { shallowMount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import EnableReviewAppButton from '~/environments/components/enable_review_app_modal.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
describe('Enable Review App Button', () => {
let wrapper;
+ let modal;
afterEach(() => {
wrapper.destroy();
@@ -16,12 +18,15 @@ describe('Enable Review App Button', () => {
shallowMount(EnableReviewAppButton, {
propsData: {
modalId: 'fake-id',
+ visible: true,
},
provide: {
defaultBranchName: 'main',
},
}),
);
+
+ modal = wrapper.findComponent(GlModal);
});
it('renders the defaultBranchName copy', () => {
@@ -32,5 +37,15 @@ describe('Enable Review App Button', () => {
it('renders the copyToClipboard button', () => {
expect(wrapper.findComponent(ModalCopyButton).exists()).toBe(true);
});
+
+ it('emits change events from the modal up', () => {
+ modal.vm.$emit('change', false);
+
+ expect(wrapper.emitted('change')).toEqual([[false]]);
+ });
+
+ it('passes visible to the modal', () => {
+ expect(modal.props('visible')).toBe(true);
+ });
});
});
diff --git a/spec/frontend/environments/environment_delete_spec.js b/spec/frontend/environments/environment_delete_spec.js
index 2d8cff0c74a..057cb9858c4 100644
--- a/spec/frontend/environments/environment_delete_spec.js
+++ b/spec/frontend/environments/environment_delete_spec.js
@@ -1,37 +1,71 @@
import { GlDropdownItem } from '@gitlab/ui';
-
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import setEnvironmentToDelete from '~/environments/graphql/mutations/set_environment_to_delete.mutation.graphql';
import DeleteComponent from '~/environments/components/environment_delete.vue';
import eventHub from '~/environments/event_hub';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { resolvedEnvironment } from './graphql/mock_data';
describe('External URL Component', () => {
let wrapper;
- const createWrapper = () => {
+ const createWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(DeleteComponent, {
+ ...options,
propsData: {
- environment: {},
+ environment: resolvedEnvironment,
+ ...props,
},
});
};
const findDropdownItem = () => wrapper.find(GlDropdownItem);
- beforeEach(() => {
- jest.spyOn(window, 'confirm');
+ describe('event hub', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
- createWrapper();
- });
+ it('should render a dropdown item to delete the environment', () => {
+ expect(findDropdownItem().exists()).toBe(true);
+ expect(wrapper.text()).toEqual('Delete environment');
+ expect(findDropdownItem().attributes('variant')).toBe('danger');
+ });
- it('should render a dropdown item to delete the environment', () => {
- expect(findDropdownItem().exists()).toBe(true);
- expect(wrapper.text()).toEqual('Delete environment');
- expect(findDropdownItem().attributes('variant')).toBe('danger');
+ it('emits requestDeleteEnvironment in the event hub when button is clicked', () => {
+ jest.spyOn(eventHub, '$emit');
+ findDropdownItem().vm.$emit('click');
+ expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', resolvedEnvironment);
+ });
});
- it('emits requestDeleteEnvironment in the event hub when button is clicked', () => {
- jest.spyOn(eventHub, '$emit');
- findDropdownItem().vm.$emit('click');
- expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', wrapper.vm.environment);
+ describe('graphql', () => {
+ Vue.use(VueApollo);
+ let mockApollo;
+
+ beforeEach(() => {
+ mockApollo = createMockApollo();
+ createWrapper(
+ { graphql: true, environment: resolvedEnvironment },
+ { apolloProvider: mockApollo },
+ );
+ });
+
+ it('should render a dropdown item to delete the environment', () => {
+ expect(findDropdownItem().exists()).toBe(true);
+ expect(wrapper.text()).toEqual('Delete environment');
+ expect(findDropdownItem().attributes('variant')).toBe('danger');
+ });
+
+ it('emits requestDeleteEnvironment in the event hub when button is clicked', () => {
+ jest.spyOn(mockApollo.defaultClient, 'mutate');
+ findDropdownItem().vm.$emit('click');
+ expect(mockApollo.defaultClient.mutate).toHaveBeenCalledWith({
+ mutation: setEnvironmentToDelete,
+ variables: { environment: resolvedEnvironment },
+ });
+ });
});
});
diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js
index cde675cd9e7..7eff46baaf7 100644
--- a/spec/frontend/environments/environment_rollback_spec.js
+++ b/spec/frontend/environments/environment_rollback_spec.js
@@ -1,7 +1,11 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RollbackComponent from '~/environments/components/environment_rollback.vue';
import eventHub from '~/environments/event_hub';
+import setEnvironmentToRollback from '~/environments/graphql/mutations/set_environment_to_rollback.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
describe('Rollback Component', () => {
const retryUrl = 'https://gitlab.com/retry';
@@ -50,4 +54,29 @@ describe('Rollback Component', () => {
name: 'test',
});
});
+
+ it('should trigger a graphql mutation when graphql is enabled', () => {
+ Vue.use(VueApollo);
+
+ const apolloProvider = createMockApollo();
+ jest.spyOn(apolloProvider.defaultClient, 'mutate');
+ const environment = {
+ name: 'test',
+ };
+ const wrapper = shallowMount(RollbackComponent, {
+ propsData: {
+ retryUrl,
+ graphql: true,
+ environment,
+ },
+ apolloProvider,
+ });
+ const button = wrapper.find(GlDropdownItem);
+ button.vm.$emit('click');
+
+ expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({
+ mutation: setEnvironmentToRollback,
+ variables: { environment },
+ });
+ });
});
diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js
index e56b6448b7d..e75d3ac0321 100644
--- a/spec/frontend/environments/graphql/mock_data.js
+++ b/spec/frontend/environments/graphql/mock_data.js
@@ -469,6 +469,33 @@ export const folder = {
stopped_count: 0,
};
+export const resolvedEnvironment = {
+ id: 41,
+ globalId: 'gid://gitlab/Environment/41',
+ name: 'review/hello',
+ state: 'available',
+ externalUrl: 'https://example.org',
+ environmentType: 'review',
+ nameWithoutType: 'hello',
+ lastDeployment: null,
+ hasStopAction: false,
+ rolloutStatus: null,
+ environmentPath: '/h5bp/html5-boilerplate/-/environments/41',
+ stopPath: '/h5bp/html5-boilerplate/-/environments/41/stop',
+ cancelAutoStopPath: '/h5bp/html5-boilerplate/-/environments/41/cancel_auto_stop',
+ deletePath: '/api/v4/projects/8/environments/41',
+ folderPath: '/h5bp/html5-boilerplate/-/environments/folders/review',
+ createdAt: '2021-10-04T19:27:00.527Z',
+ updatedAt: '2021-10-04T19:27:00.527Z',
+ canStop: true,
+ logsPath: '/h5bp/html5-boilerplate/-/logs?environment_name=review%2Fhello',
+ logsApiPath: '/h5bp/html5-boilerplate/-/logs/k8s.json?environment_name=review%2Fhello',
+ enableAdvancedLogsQuerying: false,
+ canDelete: false,
+ hasOpenedAlert: false,
+ __typename: 'LocalEnvironment',
+};
+
export const resolvedFolder = {
availableCount: 2,
environments: [
diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers_spec.js
index 4d2a0818996..d8d26b74504 100644
--- a/spec/frontend/environments/graphql/resolvers_spec.js
+++ b/spec/frontend/environments/graphql/resolvers_spec.js
@@ -1,18 +1,33 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { resolvers } from '~/environments/graphql/resolvers';
+import environmentToRollback from '~/environments/graphql/queries/environment_to_rollback.query.graphql';
+import environmentToDelete from '~/environments/graphql/queries/environment_to_delete.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql';
+import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql';
import { TEST_HOST } from 'helpers/test_constants';
-import { environmentsApp, resolvedEnvironmentsApp, folder, resolvedFolder } from './mock_data';
+import {
+ environmentsApp,
+ resolvedEnvironmentsApp,
+ resolvedEnvironment,
+ folder,
+ resolvedFolder,
+} from './mock_data';
const ENDPOINT = `${TEST_HOST}/environments`;
describe('~/frontend/environments/graphql/resolvers', () => {
let mockResolvers;
let mock;
+ let mockApollo;
+ let localState;
beforeEach(() => {
mockResolvers = resolvers(ENDPOINT);
mock = new MockAdapter(axios);
+ mockApollo = createMockApollo();
+ localState = mockApollo.defaultClient.localState;
});
afterEach(() => {
@@ -21,10 +36,87 @@ describe('~/frontend/environments/graphql/resolvers', () => {
describe('environmentApp', () => {
it('should fetch environments and map them to frontend data', async () => {
- mock.onGet(ENDPOINT, { params: { nested: true } }).reply(200, environmentsApp);
+ const cache = { writeQuery: jest.fn() };
+ const scope = 'available';
+ mock
+ .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
+ .reply(200, environmentsApp, {});
- const app = await mockResolvers.Query.environmentApp();
+ const app = await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache });
expect(app).toEqual(resolvedEnvironmentsApp);
+ expect(cache.writeQuery).toHaveBeenCalledWith({
+ query: pollIntervalQuery,
+ data: { interval: undefined },
+ });
+ });
+ it('should set the poll interval when there is one', async () => {
+ const cache = { writeQuery: jest.fn() };
+ const scope = 'stopped';
+ const interval = 3000;
+ mock
+ .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
+ .reply(200, environmentsApp, {
+ 'poll-interval': interval,
+ });
+
+ await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache });
+ expect(cache.writeQuery).toHaveBeenCalledWith({
+ query: pollIntervalQuery,
+ data: { interval },
+ });
+ });
+ it('should set page info if there is any', async () => {
+ const cache = { writeQuery: jest.fn() };
+ const scope = 'stopped';
+ mock
+ .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
+ .reply(200, environmentsApp, {
+ 'x-next-page': '2',
+ 'x-page': '1',
+ 'X-Per-Page': '2',
+ 'X-Prev-Page': '',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '5',
+ });
+
+ await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache });
+ expect(cache.writeQuery).toHaveBeenCalledWith({
+ query: pageInfoQuery,
+ data: {
+ pageInfo: {
+ total: 37,
+ perPage: 2,
+ previousPage: NaN,
+ totalPages: 5,
+ nextPage: 2,
+ page: 1,
+ __typename: 'LocalPageInfo',
+ },
+ },
+ });
+ });
+ it('should not set page info if there is none', async () => {
+ const cache = { writeQuery: jest.fn() };
+ const scope = 'stopped';
+ mock
+ .onGet(ENDPOINT, { params: { nested: true, scope, page: 1 } })
+ .reply(200, environmentsApp, {});
+
+ await mockResolvers.Query.environmentApp(null, { scope, page: 1 }, { cache });
+ expect(cache.writeQuery).toHaveBeenCalledWith({
+ query: pageInfoQuery,
+ data: {
+ pageInfo: {
+ __typename: 'LocalPageInfo',
+ nextPage: NaN,
+ page: NaN,
+ perPage: NaN,
+ previousPage: NaN,
+ total: NaN,
+ totalPages: NaN,
+ },
+ },
+ });
});
});
describe('folder', () => {
@@ -42,7 +134,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should post to the stop environment path', async () => {
mock.onPost(ENDPOINT).reply(200);
- await mockResolvers.Mutations.stopEnvironment(null, { environment: { stopPath: ENDPOINT } });
+ await mockResolvers.Mutation.stopEnvironment(null, { environment: { stopPath: ENDPOINT } });
expect(mock.history.post).toContainEqual(
expect.objectContaining({ url: ENDPOINT, method: 'post' }),
@@ -53,7 +145,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should post to the retry environment path', async () => {
mock.onPost(ENDPOINT).reply(200);
- await mockResolvers.Mutations.rollbackEnvironment(null, {
+ await mockResolvers.Mutation.rollbackEnvironment(null, {
environment: { retryUrl: ENDPOINT },
});
@@ -66,7 +158,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should DELETE to the delete environment path', async () => {
mock.onDelete(ENDPOINT).reply(200);
- await mockResolvers.Mutations.deleteEnvironment(null, {
+ await mockResolvers.Mutation.deleteEnvironment(null, {
environment: { deletePath: ENDPOINT },
});
@@ -79,7 +171,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
it('should post to the auto stop path', async () => {
mock.onPost(ENDPOINT).reply(200);
- await mockResolvers.Mutations.cancelAutoStop(null, {
+ await mockResolvers.Mutation.cancelAutoStop(null, {
environment: { autoStopPath: ENDPOINT },
});
@@ -88,4 +180,34 @@ describe('~/frontend/environments/graphql/resolvers', () => {
);
});
});
+ describe('setEnvironmentToRollback', () => {
+ it('should write the given environment to the cache', () => {
+ localState.client.writeQuery = jest.fn();
+ mockResolvers.Mutation.setEnvironmentToRollback(
+ null,
+ { environment: resolvedEnvironment },
+ localState,
+ );
+
+ expect(localState.client.writeQuery).toHaveBeenCalledWith({
+ query: environmentToRollback,
+ data: { environmentToRollback: resolvedEnvironment },
+ });
+ });
+ });
+ describe('setEnvironmentToDelete', () => {
+ it('should write the given environment to the cache', () => {
+ localState.client.writeQuery = jest.fn();
+ mockResolvers.Mutation.setEnvironmentToDelete(
+ null,
+ { environment: resolvedEnvironment },
+ localState,
+ );
+
+ expect(localState.client.writeQuery).toHaveBeenCalledWith({
+ query: environmentToDelete,
+ data: { environmentToDelete: resolvedEnvironment },
+ });
+ });
+ });
});
diff --git a/spec/frontend/environments/new_environment_folder_spec.js b/spec/frontend/environments/new_environment_folder_spec.js
index 5696e187a86..27d27d5869a 100644
--- a/spec/frontend/environments/new_environment_folder_spec.js
+++ b/spec/frontend/environments/new_environment_folder_spec.js
@@ -3,8 +3,8 @@ import Vue from 'vue';
import { GlCollapse, GlIcon } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { __, s__ } from '~/locale';
import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue';
-import { s__ } from '~/locale';
import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data';
Vue.use(VueApollo);
@@ -14,6 +14,7 @@ describe('~/environments/components/new_environments_folder.vue', () => {
let environmentFolderMock;
let nestedEnvironment;
let folderName;
+ let button;
const findLink = () => wrapper.findByRole('link', { name: s__('Environments|Show all') });
@@ -32,6 +33,7 @@ describe('~/environments/components/new_environments_folder.vue', () => {
environmentFolderMock.mockReturnValue(resolvedFolder);
wrapper = createWrapper({ nestedEnvironment }, createApolloProvider());
folderName = wrapper.findByText(nestedEnvironment.name);
+ button = wrapper.findByRole('button', { name: __('Expand') });
});
afterEach(() => {
@@ -61,10 +63,11 @@ describe('~/environments/components/new_environments_folder.vue', () => {
});
it('opens on click', async () => {
- await folderName.trigger('click');
+ await button.trigger('click');
const link = findLink();
+ expect(button.attributes('aria-label')).toBe(__('Collapse'));
expect(collapse.attributes('visible')).toBe('true');
expect(icons.wrappers.map((i) => i.props('name'))).toEqual(['angle-down', 'folder-open']);
expect(folderName.classes('gl-font-weight-bold')).toBe(true);
diff --git a/spec/frontend/environments/new_environments_app_spec.js b/spec/frontend/environments/new_environments_app_spec.js
index 0ad8e8f442c..1e9bd4d64c9 100644
--- a/spec/frontend/environments/new_environments_app_spec.js
+++ b/spec/frontend/environments/new_environments_app_spec.js
@@ -1,8 +1,11 @@
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import { mount } from '@vue/test-utils';
+import { GlPagination } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { sprintf, __, s__ } from '~/locale';
import EnvironmentsApp from '~/environments/components/new_environments_app.vue';
import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue';
import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data';
@@ -13,20 +16,59 @@ describe('~/environments/components/new_environments_app.vue', () => {
let wrapper;
let environmentAppMock;
let environmentFolderMock;
+ let paginationMock;
const createApolloProvider = () => {
const mockResolvers = {
- Query: { environmentApp: environmentAppMock, folder: environmentFolderMock },
+ Query: {
+ environmentApp: environmentAppMock,
+ folder: environmentFolderMock,
+ pageInfo: paginationMock,
+ },
};
return createMockApollo([], mockResolvers);
};
- const createWrapper = (apolloProvider) => mount(EnvironmentsApp, { apolloProvider });
+ const createWrapper = ({ provide = {}, apolloProvider } = {}) =>
+ mountExtended(EnvironmentsApp, {
+ provide: {
+ newEnvironmentPath: '/environments/new',
+ canCreateEnvironment: true,
+ defaultBranchName: 'main',
+ ...provide,
+ },
+ apolloProvider,
+ });
+
+ const createWrapperWithMocked = async ({
+ provide = {},
+ environmentsApp,
+ folder,
+ pageInfo = {
+ total: 20,
+ perPage: 5,
+ nextPage: 3,
+ page: 2,
+ previousPage: 1,
+ __typename: 'LocalPageInfo',
+ },
+ }) => {
+ setWindowLocation('?scope=available&page=2');
+ environmentAppMock.mockReturnValue(environmentsApp);
+ environmentFolderMock.mockReturnValue(folder);
+ paginationMock.mockReturnValue(pageInfo);
+ const apolloProvider = createApolloProvider();
+ wrapper = createWrapper({ apolloProvider, provide });
+
+ await waitForPromises();
+ await nextTick();
+ };
beforeEach(() => {
environmentAppMock = jest.fn();
environmentFolderMock = jest.fn();
+ paginationMock = jest.fn();
});
afterEach(() => {
@@ -34,17 +76,196 @@ describe('~/environments/components/new_environments_app.vue', () => {
});
it('should show all the folders that are fetched', async () => {
- environmentAppMock.mockReturnValue(resolvedEnvironmentsApp);
- environmentFolderMock.mockReturnValue(resolvedFolder);
- const apolloProvider = createApolloProvider();
- wrapper = createWrapper(apolloProvider);
-
- await waitForPromises();
- await Vue.nextTick();
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
const text = wrapper.findAllComponents(EnvironmentsFolder).wrappers.map((w) => w.text());
expect(text).toContainEqual(expect.stringMatching('review'));
expect(text).not.toContainEqual(expect.stringMatching('production'));
});
+
+ it('should show a button to create a new environment', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+
+ const button = wrapper.findByRole('link', { name: s__('Environments|New environment') });
+ expect(button.attributes('href')).toBe('/environments/new');
+ });
+
+ it('should not show a button to create a new environment if the user has no permissions', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ provide: { canCreateEnvironment: false, newEnvironmentPath: '' },
+ });
+
+ const button = wrapper.findByRole('link', { name: s__('Environments|New environment') });
+ expect(button.exists()).toBe(false);
+ });
+
+ it('should show a button to open the review app modal', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+
+ const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
+ button.trigger('click');
+
+ await nextTick();
+
+ expect(wrapper.findByText(s__('ReviewApp|Enable Review App')).exists()).toBe(true);
+ });
+
+ it('should not show a button to open the review app modal if review apps are configured', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: {
+ ...resolvedEnvironmentsApp,
+ reviewApp: { canSetupReviewApp: false },
+ },
+ folder: resolvedFolder,
+ });
+ await waitForPromises();
+ await nextTick();
+
+ const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
+ expect(button.exists()).toBe(false);
+ });
+
+ describe('tabs', () => {
+ it('should show tabs for available and stopped environmets', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+
+ const [available, stopped] = wrapper.findAllByRole('tab').wrappers;
+
+ expect(available.text()).toContain(__('Available'));
+ expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount);
+ expect(stopped.text()).toContain(__('Stopped'));
+ expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount);
+ });
+
+ it('should change the requested scope on tab change', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+ const stopped = wrapper.findByRole('tab', {
+ name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
+ });
+
+ stopped.trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ scope: 'stopped' }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('pagination', () => {
+ it('should sync page from query params on load', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+
+ expect(wrapper.findComponent(GlPagination).props('value')).toBe(2);
+ });
+
+ it('should change the requested page on next page click', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+ const next = wrapper.findByRole('link', {
+ name: __('Go to next page'),
+ });
+
+ next.trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ page: 3 }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('should change the requested page on previous page click', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+ const prev = wrapper.findByRole('link', {
+ name: __('Go to previous page'),
+ });
+
+ prev.trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ page: 1 }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('should change the requested page on specific page click', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+
+ const page = 1;
+ const pageButton = wrapper.findByRole('link', {
+ name: sprintf(__('Go to page %{page}'), { page }),
+ });
+
+ pageButton.trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ expect(environmentAppMock).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ page }),
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('should sync the query params to the new page', async () => {
+ await createWrapperWithMocked({
+ environmentsApp: resolvedEnvironmentsApp,
+ folder: resolvedFolder,
+ });
+ const next = wrapper.findByRole('link', {
+ name: __('Go to next page'),
+ });
+
+ next.trigger('click');
+
+ await nextTick();
+ expect(window.location.search).toBe('?scope=available&page=3');
+ });
+ });
});
diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js
index 923795ca3f3..0d663fd055e 100644
--- a/spec/frontend/experimentation/utils_spec.js
+++ b/spec/frontend/experimentation/utils_spec.js
@@ -51,6 +51,29 @@ describe('experiment Utilities', () => {
expect(experimentUtils.getExperimentData(...input)).toEqual(output);
});
});
+
+ it('only collects the data properties which are supported by the schema', () => {
+ origGl = window.gl;
+ window.gl.experiments = {
+ my_experiment: {
+ experiment: 'my_experiment',
+ variant: 'control',
+ key: 'randomization-unit-key',
+ migration_keys: 'migration_keys object',
+ excluded: false,
+ other: 'foobar',
+ },
+ };
+
+ expect(experimentUtils.getExperimentData('my_experiment')).toEqual({
+ experiment: 'my_experiment',
+ variant: 'control',
+ key: 'randomization-unit-key',
+ migration_keys: 'migration_keys object',
+ });
+
+ window.gl = origGl;
+ });
});
describe('getAllExperimentContexts', () => {
@@ -72,29 +95,17 @@ describe('experiment Utilities', () => {
it('returns an empty array if there are no experiments', () => {
expect(experimentUtils.getAllExperimentContexts()).toEqual([]);
});
-
- it('only collects the data properties which are supported by the schema', () => {
- origGl = window.gl;
- window.gl.experiments = {
- my_experiment: { experiment: 'my_experiment', variant: 'control', excluded: false },
- };
-
- expect(experimentUtils.getAllExperimentContexts()).toEqual([
- { schema, data: { experiment: 'my_experiment', variant: 'control' } },
- ]);
-
- window.gl = origGl;
- });
});
describe('isExperimentVariant', () => {
describe.each`
- experiment | variant | input | output
- ${ABC_KEY} | ${DEFAULT_VARIANT} | ${[ABC_KEY, DEFAULT_VARIANT]} | ${true}
- ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_variant_name']} | ${true}
- ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_bogus_name']} | ${false}
- ${ABC_KEY} | ${'_variant_name'} | ${['boguskey', '_variant_name']} | ${false}
- ${undefined} | ${undefined} | ${[ABC_KEY, '_variant_name']} | ${false}
+ experiment | variant | input | output
+ ${ABC_KEY} | ${CANDIDATE_VARIANT} | ${[ABC_KEY]} | ${true}
+ ${ABC_KEY} | ${DEFAULT_VARIANT} | ${[ABC_KEY, DEFAULT_VARIANT]} | ${true}
+ ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_variant_name']} | ${true}
+ ${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_bogus_name']} | ${false}
+ ${ABC_KEY} | ${'_variant_name'} | ${['boguskey', '_variant_name']} | ${false}
+ ${undefined} | ${undefined} | ${[ABC_KEY, '_variant_name']} | ${false}
`(
'with input=$input, experiment=$experiment, variant=$variant',
({ experiment, variant, input, output }) => {
diff --git a/spec/frontend/fixtures/api_deploy_keys.rb b/spec/frontend/fixtures/api_deploy_keys.rb
new file mode 100644
index 00000000000..7027b8c975b
--- /dev/null
+++ b/spec/frontend/fixtures/api_deploy_keys.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::DeployKeys, '(JavaScript fixtures)', type: :request do
+ include ApiHelpers
+ include JavaScriptFixturesHelpers
+
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:project2) { create(:project) }
+ let_it_be(:deploy_key) { create(:deploy_key, public: true) }
+ let_it_be(:deploy_key2) { create(:deploy_key, public: true) }
+ let_it_be(:deploy_keys_project) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key) }
+ let_it_be(:deploy_keys_project2) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key) }
+ let_it_be(:deploy_keys_project3) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key2) }
+ let_it_be(:deploy_keys_project4) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key2) }
+
+ it 'api/deploy_keys/index.json' do
+ get api("/deploy_keys", admin)
+
+ expect(response).to be_successful
+ end
+end
diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb
deleted file mode 100644
index 89f012a5110..00000000000
--- a/spec/frontend/fixtures/api_markdown.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do
- include ApiHelpers
- include WikiHelpers
- include JavaScriptFixturesHelpers
-
- 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(: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) }
-
- before(:all) do
- group.add_owner(user)
- project.add_maintainer(user)
- end
-
- before do
- sign_in(user)
- end
-
- markdown_examples = begin
- yaml_file_path = File.expand_path('api_markdown.yml', __dir__)
- yaml = File.read(yaml_file_path)
- YAML.safe_load(yaml, symbolize_names: true)
- end
-
- markdown_examples.each do |markdown_example|
- context = markdown_example.fetch(:context, '')
- name = markdown_example.fetch(:name)
-
- context "for #{name}#{!context.empty? ? " (context: #{context})" : ''}" do
- let(:markdown) { markdown_example.fetch(:markdown) }
-
- name = "#{context}_#{name}" unless context.empty?
-
- it "api/markdown/#{name}.json" do
- api_url = case context
- when 'project'
- "/#{project.full_path}/preview_markdown"
- when 'group'
- "/groups/#{group.full_path}/preview_markdown"
- when 'project_wiki'
- "/#{project.full_path}/-/wikis/#{project_wiki_page.slug}/preview_markdown"
- else
- api "/markdown"
- end
-
- post api_url, params: { text: markdown, gfm: true }
- expect(response).to be_successful
- end
- end
- end
-end
diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml
deleted file mode 100644
index 8fd6a5531db..00000000000
--- a/spec/frontend/fixtures/api_markdown.yml
+++ /dev/null
@@ -1,289 +0,0 @@
-# This data file drives the specs in
-# spec/frontend/fixtures/api_markdown.rb and
-# spec/frontend/content_editor/extensions/markdown_processing_spec.js
----
-- name: attachment_image
- context: group
- markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)'
-- name: attachment_image
- context: project
- markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)'
-- name: attachment_image
- context: project_wiki
- markdown: '![test-file](test-file.png)'
-- name: attachment_link
- context: group
- markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)'
-- name: attachment_link
- context: project
- markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)'
-- name: attachment_link
- context: project_wiki
- markdown: '[test-file](test-file.zip)'
-- name: audio
- markdown: '![Sample Audio](https://gitlab.com/gitlab.mp3)'
-- name: audio_and_video_in_lists
- markdown: |-
- * ![Sample Audio](https://gitlab.com/1.mp3)
- * ![Sample Video](https://gitlab.com/2.mp4)
-
- 1. ![Sample Video](https://gitlab.com/1.mp4)
- 2. ![Sample Audio](https://gitlab.com/2.mp3)
-
- * [x] ![Sample Audio](https://gitlab.com/1.mp3)
- * [x] ![Sample Audio](https://gitlab.com/2.mp3)
- * [x] ![Sample Video](https://gitlab.com/3.mp4)
-- name: blockquote
- markdown: |-
- > This is a blockquote
- >
- > This is another one
-- name: bold
- markdown: '**bold**'
-- name: bullet_list_style_1
- markdown: |-
- * list item 1
- * list item 2
- * embedded list item 3
-- name: bullet_list_style_2
- markdown: |-
- - list item 1
- - list item 2
- * embedded list item 3
-- name: bullet_list_style_3
- markdown: |-
- + list item 1
- + list item 2
- - embedded list item 3
-- name: code_block
- markdown: |-
- ```javascript
- console.log('hello world')
- ```
-- name: color_chips
- markdown: |-
- - `#F00`
- - `#F00A`
- - `#FF0000`
- - `#FF0000AA`
- - `RGB(0,255,0)`
- - `RGB(0%,100%,0%)`
- - `RGBA(0,255,0,0.3)`
- - `HSL(540,70%,50%)`
- - `HSLA(540,70%,50%,0.3)`
-- name: description_list
- markdown: |-
- <dl>
- <dt>Frog</dt>
- <dd>Wet green thing</dd>
- <dt>Rabbit</dt>
- <dd>Warm fluffy thing</dd>
- <dt>Punt</dt>
- <dd>Kick a ball</dd>
- <dd>Take a bet</dd>
- <dt>Color</dt>
- <dt>Colour</dt>
- <dd>
-
- Any hue except _white_ or **black**
-
- </dd>
- </dl>
-- name: details
- markdown: |-
- <details>
- <summary>Apply this patch</summary>
-
- ```diff
- diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml
- index 8433efaf00c..69b12c59d46 100644
- --- a/spec/frontend/fixtures/api_markdown.yml
- +++ b/spec/frontend/fixtures/api_markdown.yml
- @@ -33,6 +33,13 @@
- * <ruby>漢<rt>ㄏㄢˋ</rt></ruby>
- * C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O
- * The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>.The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>
- +- name: details
- + markdown: |-
- + <details>
- + <summary>Apply this patch</summary>
- +
- + 🐶 much meta, 🐶 many patch
- + 🐶 such diff, 🐶 very meme
- + 🐶 wow!
- + </details>
- - name: link
- markdown: '[GitLab](https://gitlab.com)'
- - name: attachment_link
- ```
-
- </details>
-- name: div
- markdown: |-
- <div>plain text</div>
- <div>
-
- just a plain ol' div, not much to _expect_!
-
- </div>
-- name: emoji
- markdown: ':sparkles: :heart: :100:'
-- name: emphasis
- markdown: '_emphasized text_'
-- name: figure
- markdown: |-
- <figure>
-
- ![Elephant at sunset](elephant-sunset.jpg)
-
- <figcaption>An elephant at sunset</figcaption>
- </figure>
- <figure>
-
- ![A crocodile wearing crocs](croc-crocs.jpg)
-
- <figcaption>
-
- A crocodile wearing _crocs_!
-
- </figcaption>
- </figure>
-- name: frontmatter_json
- markdown: |-
- ;;;
- {
- "title": "Page title"
- }
- ;;;
-- name: frontmatter_toml
- markdown: |-
- +++
- title = "Page title"
- +++
-- name: frontmatter_yaml
- markdown: |-
- ---
- title: Page title
- ---
-- name: hard_break
- markdown: |-
- This is a line after a\
- hard break
-- name: headings
- markdown: |-
- # Heading 1
-
- ## Heading 2
-
- ### Heading 3
-
- #### Heading 4
-
- ##### Heading 5
-
- ###### Heading 6
-- name: horizontal_rule
- markdown: '---'
-- name: html_marks
- markdown: |-
- * Content editor is ~~great~~<ins>amazing</ins>.
- * If the changes <abbr title="Looks good to merge">LGTM</abbr>, please <abbr title="Merge when pipeline succeeds">MWPS</abbr>.
- * The English song <q>Oh I do like to be beside the seaside</q> looks like this in Hebrew: <span dir="rtl">אה, אני אוהב להיות ליד חוף הים</span>. In the computer's memory, this is stored as <bdo dir="ltr">אה, אני אוהב להיות ליד חוף הים</bdo>.
- * <cite>The Scream</cite> by Edvard Munch. Painted in 1893.
- * <dfn>HTML</dfn> is the standard markup language for creating web pages.
- * Do not forget to buy <mark>milk</mark> today.
- * This is a paragraph and <small>smaller text goes here</small>.
- * The concert starts at <time datetime="20:00">20:00</time> and you'll be able to enjoy the band for at least <time datetime="PT2H30M">2h 30m</time>.
- * Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to copy text (Windows).
- * WWF's goal is to: <q>Build a future where people live in harmony with nature.</q> We hope they succeed.
- * The error occured was: <samp>Keyboard not found. Press F1 to continue.</samp>
- * The area of a triangle is: 1/2 x <var>b</var> x <var>h</var>, where <var>b</var> is the base, and <var>h</var> is the vertical height.
- * <ruby>漢<rt>ㄏㄢˋ</rt></ruby>
- * C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O
- * The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>
-- name: image
- markdown: '![alt text](https://gitlab.com/logo.png)'
-- name: inline_code
- markdown: '`code`'
-- name: inline_diff
- markdown: |-
- * {-deleted-}
- * {+added+}
-- name: link
- markdown: '[GitLab](https://gitlab.com)'
-- name: math
- markdown: |-
- This math is inline $`a^2+b^2=c^2`$.
-
- This is on a separate line:
-
- ```math
- a^2+b^2=c^2
- ```
-- name: ordered_list
- markdown: |-
- 1. list item 1
- 2. list item 2
- 3. list item 3
-- name: ordered_list_with_start_order
- markdown: |-
- 134. list item 1
- 135. list item 2
- 136. list item 3
-- name: ordered_task_list
- markdown: |-
- 1. [x] hello
- 2. [x] world
- 3. [ ] example
- 1. [ ] of nested
- 1. [x] task list
- 2. [ ] items
-- name: ordered_task_list_with_order
- markdown: |-
- 4893. [x] hello
- 4894. [x] world
- 4895. [ ] example
-- 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
-- name: strike
- markdown: '~~del~~'
-- name: table
- markdown: |-
- | header | header |
- |--------|--------|
- | `code` | cell with **bold** |
- | ~~strike~~ | cell with _italic_ |
-
- # content after table
-- name: table_of_contents
- markdown: |-
- [[_TOC_]]
-
- # Lorem
-
- Well, that's just like... your opinion.. man.
-
- ## Ipsum
-
- ### Dolar
-
- # Sit amit
-
- ### I don't know
-- name: task_list
- markdown: |-
- * [x] hello
- * [x] world
- * [ ] example
- * [ ] of nested
- * [x] task list
- * [ ] items
-- name: thematic_break
- markdown: |-
- ---
-- name: video
- markdown: '![Sample Video](https://gitlab.com/gitlab.mp4)'
-- name: word_break
- markdown: Fernstraßen<wbr>bau<wbr>privat<wbr>finanzierungs<wbr>gesetz
diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb
index f90e3662e98..bfdeee0881b 100644
--- a/spec/frontend/fixtures/blob.rb
+++ b/spec/frontend/fixtures/blob.rb
@@ -34,7 +34,7 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control
get(:show, params: {
namespace_id: project.namespace,
project_id: project,
- id: 'master/README.md'
+ id: "#{project.default_branch}/README.md"
})
expect(response).to be_successful
diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb
index 23c18c97df2..3c8964d398a 100644
--- a/spec/frontend/fixtures/projects.rb
+++ b/spec/frontend/fixtures/projects.rb
@@ -65,31 +65,5 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
expect_graphql_errors_to_be_empty
end
end
-
- context 'project storage count query' do
- before do
- project.statistics.update!(
- repository_size: 3900000,
- lfs_objects_size: 4800000,
- build_artifacts_size: 400000,
- pipeline_artifacts_size: 400000,
- wiki_size: 300000,
- packages_size: 3800000,
- uploads_size: 900000
- )
- end
-
- base_input_path = 'projects/storage_counter/queries/'
- base_output_path = 'graphql/projects/storage_counter/'
- query_name = 'project_storage.query.graphql'
-
- it "#{base_output_path}#{query_name}.json" do
- query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
-
- post_graphql(query, current_user: user, variables: { fullPath: project.full_path })
-
- expect_graphql_errors_to_be_empty
- end
- end
end
end
diff --git a/spec/frontend/fixtures/raw.rb b/spec/frontend/fixtures/raw.rb
index 211c4e7c048..b117cfea5fa 100644
--- a/spec/frontend/fixtures/raw.rb
+++ b/spec/frontend/fixtures/raw.rb
@@ -7,41 +7,45 @@ RSpec.describe 'Raw files', '(JavaScript fixtures)' do
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, :repository, namespace: namespace, path: 'raw-project') }
- let(:response) { @blob.data.force_encoding('UTF-8') }
+ let(:response) { @response }
+
+ def blob_at(commit, path)
+ @response = project.repository.blob_at(commit, path).data.force_encoding('UTF-8')
+ end
after do
remove_repository(project)
end
it 'blob/notebook/basic.json' do
- @blob = project.repository.blob_at('6d85bb69', 'files/ipython/basic.ipynb')
+ blob_at('6d85bb69', 'files/ipython/basic.ipynb')
end
it 'blob/notebook/markdown-table.json' do
- @blob = project.repository.blob_at('f6b7a707', 'files/ipython/markdown-table.ipynb')
+ blob_at('f6b7a707', 'files/ipython/markdown-table.ipynb')
end
it 'blob/notebook/worksheets.json' do
- @blob = project.repository.blob_at('6d85bb69', 'files/ipython/worksheets.ipynb')
+ blob_at('6d85bb69', 'files/ipython/worksheets.ipynb')
end
it 'blob/notebook/math.json' do
- @blob = project.repository.blob_at('93ee732', 'files/ipython/math.ipynb')
+ blob_at('93ee732', 'files/ipython/math.ipynb')
end
it 'blob/pdf/test.pdf' do
- @blob = project.repository.blob_at('e774ebd33', 'files/pdf/test.pdf')
+ blob_at('e774ebd33', 'files/pdf/test.pdf')
end
it 'blob/text/README.md' do
- @blob = project.repository.blob_at('e774ebd33', 'README.md')
+ blob_at('e774ebd33', 'README.md')
end
it 'blob/images/logo-white.png' do
- @blob = project.repository.blob_at('e774ebd33', 'files/images/logo-white.png')
+ blob_at('e774ebd33', 'files/images/logo-white.png')
end
it 'blob/binary/Gemfile.zip' do
- @blob = project.repository.blob_at('e774ebd33', 'Gemfile.zip')
+ blob_at('e774ebd33', 'Gemfile.zip')
end
end
diff --git a/spec/frontend/fixtures/tabs.rb b/spec/frontend/fixtures/tabs.rb
new file mode 100644
index 00000000000..697ff1c7c20
--- /dev/null
+++ b/spec/frontend/fixtures/tabs.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'GlTabsBehavior', '(JavaScript fixtures)', type: :helper do
+ include JavaScriptFixturesHelpers
+ include TabHelper
+
+ let(:response) { @tabs }
+
+ it 'tabs/tabs.html' do
+ tabs = gl_tabs_nav({ data: { testid: 'tabs' } }) do
+ gl_tab_link_to('Foo', '#foo', item_active: true, data: { testid: 'foo-tab' }) +
+ gl_tab_link_to('Bar', '#bar', item_active: false, data: { testid: 'bar-tab' }) +
+ gl_tab_link_to('Qux', '#qux', item_active: false, data: { testid: 'qux-tab' })
+ end
+
+ panels = content_tag(:div, class: 'tab-content') do
+ content_tag(:div, 'Foo', { id: 'foo', class: 'tab-pane active', data: { testid: 'foo-panel' } }) +
+ content_tag(:div, 'Bar', { id: 'bar', class: 'tab-pane', data: { testid: 'bar-panel' } }) +
+ content_tag(:div, 'Qux', { id: 'qux', class: 'tab-pane', data: { testid: 'qux-panel' } })
+ end
+
+ @tabs = tabs + panels
+ end
+end
diff --git a/spec/frontend/fixtures/timezones.rb b/spec/frontend/fixtures/timezones.rb
index 157f47855ea..2393f4e797d 100644
--- a/spec/frontend/fixtures/timezones.rb
+++ b/spec/frontend/fixtures/timezones.rb
@@ -8,11 +8,9 @@ RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do
let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json }
- it 'timezones/short.json' do
- @timezones = timezone_data(format: :short)
- end
-
- it 'timezones/full.json' do
- @timezones = timezone_data(format: :full)
+ %I[short abbr full].each do |format|
+ it "timezones/#{format}.json" do
+ @timezones = timezone_data(format: format)
+ end
end
end
diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js
index f7bde8d2f16..fc736f2d155 100644
--- a/spec/frontend/flash_spec.js
+++ b/spec/frontend/flash_spec.js
@@ -1,38 +1,14 @@
+import * as Sentry from '@sentry/browser';
import createFlash, {
- createFlashEl,
- createAction,
hideFlash,
- removeFlashClickListener,
+ addDismissFlashClickListener,
+ FLASH_TYPES,
FLASH_CLOSED_EVENT,
} from '~/flash';
-describe('Flash', () => {
- describe('createFlashEl', () => {
- let el;
-
- beforeEach(() => {
- el = document.createElement('div');
- });
-
- afterEach(() => {
- el.innerHTML = '';
- });
-
- it('creates flash element with type', () => {
- el.innerHTML = createFlashEl('testing', 'alert');
-
- expect(el.querySelector('.flash-alert')).not.toBeNull();
- });
-
- it('escapes text', () => {
- el.innerHTML = createFlashEl('<script>alert("a");</script>', 'alert');
-
- expect(el.querySelector('.flash-text').textContent.trim()).toBe(
- '<script>alert("a");</script>',
- );
- });
- });
+jest.mock('@sentry/browser');
+describe('Flash', () => {
describe('hideFlash', () => {
let el;
@@ -92,59 +68,12 @@ describe('Flash', () => {
});
});
- describe('createAction', () => {
- let el;
-
- beforeEach(() => {
- el = document.createElement('div');
- });
-
- it('creates link with href', () => {
- el.innerHTML = createAction({
- href: 'testing',
- title: 'test',
- });
-
- expect(el.querySelector('.flash-action').href).toContain('testing');
- });
-
- it('uses hash as href when no href is present', () => {
- el.innerHTML = createAction({
- title: 'test',
- });
-
- expect(el.querySelector('.flash-action').href).toContain('#');
- });
-
- it('adds role when no href is present', () => {
- el.innerHTML = createAction({
- title: 'test',
- });
-
- expect(el.querySelector('.flash-action').getAttribute('role')).toBe('button');
- });
-
- it('escapes the title text', () => {
- el.innerHTML = createAction({
- title: '<script>alert("a")</script>',
- });
-
- expect(el.querySelector('.flash-action').textContent.trim()).toBe(
- '<script>alert("a")</script>',
- );
- });
- });
-
describe('createFlash', () => {
const message = 'test';
- const type = 'alert';
- const parent = document;
const fadeTransition = false;
const addBodyClass = true;
const defaultParams = {
message,
- type,
- parent,
actionConfig: null,
fadeTransition,
addBodyClass,
@@ -171,14 +100,28 @@ describe('Flash', () => {
document.querySelector('.js-content-wrapper').remove();
});
- it('adds flash element into container', () => {
+ it('adds flash alert element into the document by default', () => {
createFlash({ ...defaultParams });
- expect(document.querySelector('.flash-alert')).not.toBeNull();
+ expect(document.querySelector('.flash-container .flash-alert')).not.toBeNull();
+ expect(document.body.className).toContain('flash-shown');
+ });
+
+ it('adds flash of a warning type', () => {
+ createFlash({ ...defaultParams, type: FLASH_TYPES.WARNING });
+ expect(document.querySelector('.flash-container .flash-warning')).not.toBeNull();
expect(document.body.className).toContain('flash-shown');
});
+ it('escapes text', () => {
+ createFlash({ ...defaultParams, message: '<script>alert("a");</script>' });
+
+ expect(document.querySelector('.flash-text').textContent.trim()).toBe(
+ '<script>alert("a");</script>',
+ );
+ });
+
it('adds flash into specified parent', () => {
createFlash({ ...defaultParams, parent: document.querySelector('.content-wrapper') });
@@ -210,7 +153,26 @@ describe('Flash', () => {
expect(document.body.className).not.toContain('flash-shown');
});
+ it('does not capture error using Sentry', () => {
+ createFlash({ ...defaultParams, captureError: false, error: new Error('Error!') });
+
+ expect(Sentry.captureException).not.toHaveBeenCalled();
+ });
+
+ it('captures error using Sentry', () => {
+ createFlash({ ...defaultParams, captureError: true, error: new Error('Error!') });
+
+ expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Error!',
+ }),
+ );
+ });
+
describe('with actionConfig', () => {
+ const findFlashAction = () => document.querySelector('.flash-container .flash-action');
+
it('adds action link', () => {
createFlash({
...defaultParams,
@@ -219,20 +181,69 @@ describe('Flash', () => {
},
});
- expect(document.querySelector('.flash-action')).not.toBeNull();
+ expect(findFlashAction()).not.toBeNull();
+ });
+
+ it('creates link with href', () => {
+ createFlash({
+ ...defaultParams,
+ actionConfig: {
+ href: 'testing',
+ title: 'test',
+ },
+ });
+
+ expect(findFlashAction().href).toBe(`${window.location}testing`);
+ expect(findFlashAction().textContent.trim()).toBe('test');
+ });
+
+ it('uses hash as href when no href is present', () => {
+ createFlash({
+ ...defaultParams,
+ actionConfig: {
+ title: 'test',
+ },
+ });
+
+ expect(findFlashAction().href).toBe(`${window.location}#`);
+ });
+
+ it('adds role when no href is present', () => {
+ createFlash({
+ ...defaultParams,
+ actionConfig: {
+ title: 'test',
+ },
+ });
+
+ expect(findFlashAction().getAttribute('role')).toBe('button');
+ });
+
+ it('escapes the title text', () => {
+ createFlash({
+ ...defaultParams,
+ actionConfig: {
+ title: '<script>alert("a")</script>',
+ },
+ });
+
+ expect(findFlashAction().textContent.trim()).toBe('<script>alert("a")</script>');
});
it('calls actionConfig clickHandler on click', () => {
- const actionConfig = {
- title: 'test',
- clickHandler: jest.fn(),
- };
+ const clickHandler = jest.fn();
- createFlash({ ...defaultParams, actionConfig });
+ createFlash({
+ ...defaultParams,
+ actionConfig: {
+ title: 'test',
+ clickHandler,
+ },
+ });
- document.querySelector('.flash-action').click();
+ findFlashAction().click();
- expect(actionConfig.clickHandler).toHaveBeenCalled();
+ expect(clickHandler).toHaveBeenCalled();
});
});
@@ -252,7 +263,7 @@ describe('Flash', () => {
});
});
- describe('removeFlashClickListener', () => {
+ describe('addDismissFlashClickListener', () => {
let el;
describe('with close icon', () => {
@@ -268,7 +279,7 @@ describe('Flash', () => {
});
it('removes global flash on click', (done) => {
- removeFlashClickListener(el, false);
+ addDismissFlashClickListener(el, false);
el.querySelector('.js-close-icon').click();
@@ -292,7 +303,7 @@ describe('Flash', () => {
});
it('does not throw', () => {
- expect(() => removeFlashClickListener(el, false)).not.toThrow();
+ expect(() => addDismissFlashClickListener(el, false)).not.toThrow();
});
});
});
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 631e3307f7f..1ab3286fe4c 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json';
import GfmAutoComplete, { membersBeforeSave, highlighter } from 'ee_else_ce/gfm_auto_complete';
-import { initEmojiMock } from 'helpers/emoji';
+import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import '~/lib/utils/jquery_at_who';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
@@ -803,8 +803,6 @@ describe('GfmAutoComplete', () => {
});
describe('emoji', () => {
- let mock;
-
const mockItem = {
'atwho-at': ':',
emoji: {
@@ -818,14 +816,14 @@ describe('GfmAutoComplete', () => {
};
beforeEach(async () => {
- mock = await initEmojiMock();
+ await initEmojiMock();
await new GfmAutoComplete({}).loadEmojiData({ atwho() {}, trigger() {} }, ':');
if (!GfmAutoComplete.glEmojiTag) throw new Error('emoji not loaded');
});
afterEach(() => {
- mock.restore();
+ clearEmojiMock();
});
describe('Emoji.templateFunction', () => {
diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js
index bb86eb5c22e..570ac1e6ed1 100644
--- a/spec/frontend/google_cloud/components/app_spec.js
+++ b/spec/frontend/google_cloud/components/app_spec.js
@@ -1,65 +1,71 @@
import { shallowMount } from '@vue/test-utils';
-import { GlTab, GlTabs } from '@gitlab/ui';
+import { mapValues } from 'lodash';
import App from '~/google_cloud/components/app.vue';
+import Home from '~/google_cloud/components/home.vue';
import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
-import ServiceAccounts from '~/google_cloud/components/service_accounts.vue';
+import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue';
+import GcpError from '~/google_cloud/components/errors/gcp_error.vue';
+import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue';
+
+const BASE_FEEDBACK_URL =
+ 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new';
+const SCREEN_COMPONENTS = {
+ Home,
+ ServiceAccountsForm,
+ GcpError,
+ NoGcpProjects,
+};
+const SERVICE_ACCOUNTS_FORM_PROPS = {
+ gcpProjects: [1, 2, 3],
+ environments: [4, 5, 6],
+ cancelPath: '',
+};
+const HOME_PROPS = {
+ serviceAccounts: [{}, {}],
+ createServiceAccountUrl: '#url-create-service-account',
+ emptyIllustrationUrl: '#url-empty-illustration',
+};
describe('google_cloud App component', () => {
let wrapper;
const findIncubationBanner = () => wrapper.findComponent(IncubationBanner);
- const findTabs = () => wrapper.findComponent(GlTabs);
- const findTabItems = () => findTabs().findAllComponents(GlTab);
- const findConfigurationTab = () => findTabItems().at(0);
- const findDeploymentTab = () => findTabItems().at(1);
- const findServicesTab = () => findTabItems().at(2);
- const findServiceAccounts = () => findConfigurationTab().findComponent(ServiceAccounts);
-
- beforeEach(() => {
- const propsData = {
- serviceAccounts: [{}, {}],
- createServiceAccountUrl: '#url-create-service-account',
- emptyIllustrationUrl: '#url-empty-illustration',
- };
- wrapper = shallowMount(App, { propsData });
- });
afterEach(() => {
wrapper.destroy();
});
- it('should contain incubation banner', () => {
- expect(findIncubationBanner().exists()).toBe(true);
- });
-
- describe('google_cloud App tabs', () => {
- it('should contain tabs', () => {
- expect(findTabs().exists()).toBe(true);
- });
+ describe.each`
+ screen | extraProps | componentName
+ ${'gcp_error'} | ${{ error: 'mock_gcp_client_error' }} | ${'GcpError'}
+ ${'no_gcp_projects'} | ${{}} | ${'NoGcpProjects'}
+ ${'service_accounts_form'} | ${SERVICE_ACCOUNTS_FORM_PROPS} | ${'ServiceAccountsForm'}
+ ${'home'} | ${HOME_PROPS} | ${'Home'}
+ `('for screen=$screen', ({ screen, extraProps, componentName }) => {
+ const component = SCREEN_COMPONENTS[componentName];
- it('should contain three tab items', () => {
- expect(findTabItems().length).toBe(3);
+ beforeEach(() => {
+ wrapper = shallowMount(App, { propsData: { screen, ...extraProps } });
});
- describe('configuration tab', () => {
- it('should exist', () => {
- expect(findConfigurationTab().exists()).toBe(true);
- });
+ it(`renders only ${componentName}`, () => {
+ const existences = mapValues(SCREEN_COMPONENTS, (x) => wrapper.findComponent(x).exists());
- it('should contain service accounts component', () => {
- expect(findServiceAccounts().exists()).toBe(true);
+ expect(existences).toEqual({
+ ...mapValues(SCREEN_COMPONENTS, () => false),
+ [componentName]: true,
});
});
- describe('deployments tab', () => {
- it('should exist', () => {
- expect(findDeploymentTab().exists()).toBe(true);
- });
+ it(`renders the ${componentName} with props`, () => {
+ expect(wrapper.findComponent(component).props()).toEqual(extraProps);
});
- describe('services tab', () => {
- it('should exist', () => {
- expect(findServicesTab().exists()).toBe(true);
+ it('renders incubation banner', () => {
+ expect(findIncubationBanner().props()).toEqual({
+ shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`,
+ reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`,
+ featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`,
});
});
});
diff --git a/spec/frontend/google_cloud/components/errors/gcp_error_spec.js b/spec/frontend/google_cloud/components/errors/gcp_error_spec.js
new file mode 100644
index 00000000000..4062a8b902a
--- /dev/null
+++ b/spec/frontend/google_cloud/components/errors/gcp_error_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import GcpError from '~/google_cloud/components/errors/gcp_error.vue';
+
+describe('GcpError component', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findBlockquote = () => wrapper.find('blockquote');
+
+ const propsData = { error: 'IAM and CloudResourceManager API disabled' };
+
+ beforeEach(() => {
+ wrapper = shallowMount(GcpError, { propsData });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('contains relevant text', () => {
+ const alertText = findAlert().text();
+ expect(findAlert().props('title')).toBe(GcpError.i18n.title);
+ expect(alertText).toContain(GcpError.i18n.description);
+ });
+
+ it('contains error stacktrace', () => {
+ expect(findBlockquote().text()).toBe(propsData.error);
+ });
+});
diff --git a/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js b/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js
new file mode 100644
index 00000000000..e1e20377880
--- /dev/null
+++ b/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js
@@ -0,0 +1,33 @@
+import { mount } from '@vue/test-utils';
+import { GlAlert, GlButton } from '@gitlab/ui';
+import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue';
+
+describe('NoGcpProjects component', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ beforeEach(() => {
+ wrapper = mount(NoGcpProjects);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('contains relevant text', () => {
+ expect(findAlert().props('title')).toBe(NoGcpProjects.i18n.title);
+ expect(findAlert().text()).toContain(NoGcpProjects.i18n.description);
+ });
+
+ it('contains create gcp project button', () => {
+ const button = findButton();
+ expect(button.text()).toBe(NoGcpProjects.i18n.createLabel);
+ expect(button.attributes('href')).toBe('https://console.cloud.google.com/projectcreate');
+ });
+});
diff --git a/spec/frontend/google_cloud/components/home_spec.js b/spec/frontend/google_cloud/components/home_spec.js
new file mode 100644
index 00000000000..9b4c3a79f11
--- /dev/null
+++ b/spec/frontend/google_cloud/components/home_spec.js
@@ -0,0 +1,61 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlTab, GlTabs } from '@gitlab/ui';
+import Home from '~/google_cloud/components/home.vue';
+import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue';
+
+describe('google_cloud Home component', () => {
+ let wrapper;
+
+ const findTabs = () => wrapper.findComponent(GlTabs);
+ const findTabItems = () => findTabs().findAllComponents(GlTab);
+ const findTabItemsModel = () =>
+ findTabs()
+ .findAllComponents(GlTab)
+ .wrappers.map((x) => ({
+ title: x.attributes('title'),
+ disabled: x.attributes('disabled'),
+ }));
+
+ const TEST_HOME_PROPS = {
+ serviceAccounts: [{}, {}],
+ createServiceAccountUrl: '#url-create-service-account',
+ emptyIllustrationUrl: '#url-empty-illustration',
+ };
+
+ beforeEach(() => {
+ const propsData = {
+ screen: 'home',
+ ...TEST_HOME_PROPS,
+ };
+ wrapper = shallowMount(Home, { propsData });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('google_cloud App tabs', () => {
+ it('should contain tabs', () => {
+ expect(findTabs().exists()).toBe(true);
+ });
+
+ it('should contain three tab items', () => {
+ expect(findTabItemsModel()).toEqual([
+ { title: 'Configuration', disabled: undefined },
+ { title: 'Deployments', disabled: '' },
+ { title: 'Services', disabled: '' },
+ ]);
+ });
+
+ describe('configuration tab', () => {
+ it('should contain service accounts component', () => {
+ const serviceAccounts = findTabItems().at(0).findComponent(ServiceAccountsList);
+ expect(serviceAccounts.props()).toEqual({
+ list: TEST_HOME_PROPS.serviceAccounts,
+ createUrl: TEST_HOME_PROPS.createServiceAccountUrl,
+ emptyIllustrationUrl: TEST_HOME_PROPS.emptyIllustrationUrl,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/google_cloud/components/service_accounts_form_spec.js b/spec/frontend/google_cloud/components/service_accounts_form_spec.js
new file mode 100644
index 00000000000..5394d0cdaef
--- /dev/null
+++ b/spec/frontend/google_cloud/components/service_accounts_form_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue';
+
+describe('ServiceAccountsForm component', () => {
+ let wrapper;
+
+ const findHeader = () => wrapper.find('header');
+ const findAllFormGroups = () => wrapper.findAllComponents(GlFormGroup);
+ const findAllFormSelects = () => wrapper.findAllComponents(GlFormSelect);
+ const findAllButtons = () => wrapper.findAllComponents(GlButton);
+
+ const propsData = { gcpProjects: [], environments: [], cancelPath: '#cancel-url' };
+
+ beforeEach(() => {
+ wrapper = shallowMount(ServiceAccountsForm, { propsData });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains header', () => {
+ expect(findHeader().exists()).toBe(true);
+ });
+
+ it('contains GCP project form group', () => {
+ const formGroup = findAllFormGroups().at(0);
+ expect(formGroup.exists()).toBe(true);
+ });
+
+ it('contains GCP project dropdown', () => {
+ const select = findAllFormSelects().at(0);
+ expect(select.exists()).toBe(true);
+ });
+
+ it('contains Environments form group', () => {
+ const formGorup = findAllFormGroups().at(1);
+ expect(formGorup.exists()).toBe(true);
+ });
+
+ it('contains Environments dropdown', () => {
+ const select = findAllFormSelects().at(1);
+ expect(select.exists()).toBe(true);
+ });
+
+ it('contains Submit button', () => {
+ const button = findAllButtons().at(0);
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe(ServiceAccountsForm.i18n.submitLabel);
+ });
+
+ it('contains Cancel button', () => {
+ const button = findAllButtons().at(1);
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe(ServiceAccountsForm.i18n.cancelLabel);
+ expect(button.attributes('href')).toBe('#cancel-url');
+ });
+});
diff --git a/spec/frontend/google_cloud/components/service_accounts_spec.js b/spec/frontend/google_cloud/components/service_accounts_list_spec.js
index 3d097078f03..cdb3f74051c 100644
--- a/spec/frontend/google_cloud/components/service_accounts_spec.js
+++ b/spec/frontend/google_cloud/components/service_accounts_list_spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
-import ServiceAccounts from '~/google_cloud/components/service_accounts.vue';
+import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue';
describe('ServiceAccounts component', () => {
describe('when the project does not have any service accounts', () => {
@@ -15,7 +15,7 @@ describe('ServiceAccounts component', () => {
createUrl: '#create-url',
emptyIllustrationUrl: '#empty-illustration-url',
};
- wrapper = mount(ServiceAccounts, { propsData });
+ wrapper = mount(ServiceAccountsList, { propsData });
});
afterEach(() => {
@@ -48,7 +48,7 @@ describe('ServiceAccounts component', () => {
createUrl: '#create-url',
emptyIllustrationUrl: '#empty-illustration-url',
};
- wrapper = mount(ServiceAccounts, { propsData });
+ wrapper = mount(ServiceAccountsList, { propsData });
});
it('shows the title', () => {
diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
index 33e2c0db5e5..9447e7daba8 100644
--- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
+++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap
@@ -47,6 +47,7 @@ exports[`grafana integration component default state to match the default snapsh
label="Enable authentication"
label-for="grafana-integration-enabled"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-checkbox-stub
id="grafana-integration-enabled"
@@ -62,6 +63,7 @@ exports[`grafana integration component default state to match the default snapsh
label="Grafana URL"
label-for="grafana-url"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-input-stub
id="grafana-url"
@@ -74,6 +76,7 @@ exports[`grafana integration component default state to match the default snapsh
label="API token"
label-for="grafana-token"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-input-stub
id="grafana-token"
diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js
index 2ea2693a978..3200c6614f1 100644
--- a/spec/frontend/header_search/components/app_spec.js
+++ b/spec/frontend/header_search/components/app_spec.js
@@ -6,9 +6,17 @@ import HeaderSearchApp from '~/header_search/components/app.vue';
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
-import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys';
+import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION } from '~/header_search/constants';
+import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
+import { ENTER_KEY } from '~/lib/utils/keys';
import { visitUrl } from '~/lib/utils/url_utility';
-import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME } from '../mock_data';
+import {
+ MOCK_SEARCH,
+ MOCK_SEARCH_QUERY,
+ MOCK_USERNAME,
+ MOCK_DEFAULT_SEARCH_OPTIONS,
+ MOCK_SCOPED_SEARCH_OPTIONS,
+} from '../mock_data';
Vue.use(Vuex);
@@ -22,9 +30,10 @@ describe('HeaderSearchApp', () => {
const actionSpies = {
setSearch: jest.fn(),
fetchAutocompleteOptions: jest.fn(),
+ clearAutocomplete: jest.fn(),
};
- const createComponent = (initialState) => {
+ const createComponent = (initialState, mockGetters) => {
const store = new Vuex.Store({
state: {
...initialState,
@@ -32,6 +41,8 @@ describe('HeaderSearchApp', () => {
actions: actionSpies,
getters: {
searchQuery: () => MOCK_SEARCH_QUERY,
+ searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
+ ...mockGetters,
},
});
@@ -50,11 +61,27 @@ describe('HeaderSearchApp', () => {
const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
const findHeaderSearchAutocompleteItems = () =>
wrapper.findComponent(HeaderSearchAutocompleteItems);
+ const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation);
+ const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`);
+ const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION);
describe('template', () => {
- it('always renders Header Search Input', () => {
- createComponent();
- expect(findHeaderSearchInput().exists()).toBe(true);
+ describe('always renders', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('Header Search Input', () => {
+ expect(findHeaderSearchInput().exists()).toBe(true);
+ });
+
+ it('Search Input Description', () => {
+ expect(findSearchInputDescription().exists()).toBe(true);
+ });
+
+ it('Search Results Description', () => {
+ expect(findSearchResultsDescription().exists()).toBe(true);
+ });
});
describe.each`
@@ -66,9 +93,9 @@ describe('HeaderSearchApp', () => {
`('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => {
describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => {
beforeEach(() => {
- createComponent();
window.gon.current_username = username;
- wrapper.setData({ showDropdown });
+ createComponent();
+ findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
});
it(`should${showSearchDropdown ? '' : ' not'} render`, () => {
@@ -78,31 +105,89 @@ describe('HeaderSearchApp', () => {
});
describe.each`
- search | showDefault | showScoped | showAutocomplete
- ${null} | ${true} | ${false} | ${false}
- ${''} | ${true} | ${false} | ${false}
- ${MOCK_SEARCH} | ${false} | ${true} | ${true}
- `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => {
- describe(`when search is ${search}`, () => {
- beforeEach(() => {
- createComponent({ search });
- window.gon.current_username = MOCK_USERNAME;
- wrapper.setData({ showDropdown: true });
- });
-
- it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
- expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
+ search | showDefault | showScoped | showAutocomplete | showDropdownNavigation
+ ${null} | ${true} | ${false} | ${false} | ${true}
+ ${''} | ${true} | ${false} | ${false} | ${true}
+ ${MOCK_SEARCH} | ${false} | ${true} | ${true} | ${true}
+ `(
+ 'Header Search Dropdown Items',
+ ({ search, showDefault, showScoped, showAutocomplete, showDropdownNavigation }) => {
+ describe(`when search is ${search}`, () => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent({ search });
+ findHeaderSearchInput().vm.$emit('click');
+ });
+
+ it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
+ expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
+ });
+
+ it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
+ expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
+ });
+
+ it(`should${
+ showAutocomplete ? '' : ' not'
+ } render the Autocomplete Dropdown Items`, () => {
+ expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
+ });
+
+ it(`should${
+ showDropdownNavigation ? '' : ' not'
+ } render the Dropdown Navigation Component`, () => {
+ expect(findDropdownKeyboardNavigation().exists()).toBe(showDropdownNavigation);
+ });
});
+ },
+ );
- it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
- expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
+ describe.each`
+ username | showDropdown | expectedDesc
+ ${null} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown}
+ ${null} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown}
+ ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown}
+ ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown}
+ `('Search Input Description', ({ username, showDropdown, expectedDesc }) => {
+ describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => {
+ beforeEach(() => {
+ window.gon.current_username = username;
+ createComponent();
+ findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
});
- it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => {
- expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
+ it(`sets description to ${expectedDesc}`, () => {
+ expect(findSearchInputDescription().text()).toBe(expectedDesc);
});
});
});
+
+ describe.each`
+ username | showDropdown | search | loading | searchOptions | expectedDesc
+ ${null} | ${true} | ${''} | ${false} | ${[]} | ${''}
+ ${MOCK_USERNAME} | ${false} | ${''} | ${false} | ${[]} | ${''}
+ ${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
+ ${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
+ ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`}
+ ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.searchResultsLoading}
+ `(
+ 'Search Results Description',
+ ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => {
+ describe(`search is ${search}, loading is ${loading}, and showSearchDropdown is ${
+ Boolean(username) && showDropdown
+ }`, () => {
+ beforeEach(() => {
+ window.gon.current_username = username;
+ createComponent({ search, loading }, { searchOptions: () => searchOptions });
+ findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
+ });
+
+ it(`sets description to ${expectedDesc}`, () => {
+ expect(findSearchResultsDescription().text()).toBe(expectedDesc);
+ });
+ });
+ },
+ );
});
describe('events', () => {
@@ -132,36 +217,86 @@ describe('HeaderSearchApp', () => {
});
});
- describe('when dropdown is opened', () => {
- beforeEach(() => {
- wrapper.setData({ showDropdown: true });
+ describe('onInput', () => {
+ describe('when search has text', () => {
+ beforeEach(() => {
+ findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH);
+ });
+
+ it('calls setSearch with search term', () => {
+ expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH);
+ });
+
+ it('calls fetchAutocompleteOptions', () => {
+ expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled();
+ });
+
+ it('does not call clearAutocomplete', () => {
+ expect(actionSpies.clearAutocomplete).not.toHaveBeenCalled();
+ });
});
- it('onKey-Escape closes dropdown', async () => {
- expect(findHeaderSearchDropdown().exists()).toBe(true);
- findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ESC_KEY }));
+ describe('when search is emptied', () => {
+ beforeEach(() => {
+ findHeaderSearchInput().vm.$emit('input', '');
+ });
- await wrapper.vm.$nextTick();
+ it('calls setSearch with empty term', () => {
+ expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), '');
+ });
- expect(findHeaderSearchDropdown().exists()).toBe(false);
+ it('does not call fetchAutocompleteOptions', () => {
+ expect(actionSpies.fetchAutocompleteOptions).not.toHaveBeenCalled();
+ });
+
+ it('calls clearAutocomplete', () => {
+ expect(actionSpies.clearAutocomplete).toHaveBeenCalled();
+ });
});
});
+ });
- describe('onInput', () => {
- beforeEach(() => {
- findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH);
- });
+ describe('Dropdown Keyboard Navigation', () => {
+ beforeEach(() => {
+ findHeaderSearchInput().vm.$emit('click');
+ });
- it('calls setSearch with search term', () => {
- expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH);
- });
+ it('closes dropdown when @tab is emitted', async () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(true);
+ findDropdownKeyboardNavigation().vm.$emit('tab');
- it('calls fetchAutocompleteOptions', () => {
- expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled();
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findHeaderSearchDropdown().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('computed', () => {
+ describe('currentFocusedOption', () => {
+ const MOCK_INDEX = 1;
+
+ beforeEach(() => {
+ createComponent();
+ window.gon.current_username = MOCK_USERNAME;
+ findHeaderSearchInput().vm.$emit('click');
+ });
+
+ it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, async () => {
+ findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]);
});
+ });
+ });
- it('submits a search onKey-Enter', async () => {
+ describe('Submitting a search', () => {
+ describe('with no currentFocusedOption', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('onKey-enter submits a search', async () => {
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
await wrapper.vm.$nextTick();
@@ -169,5 +304,22 @@ describe('HeaderSearchApp', () => {
expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
});
});
+
+ describe('with currentFocusedOption', () => {
+ const MOCK_INDEX = 1;
+
+ beforeEach(() => {
+ createComponent();
+ window.gon.current_username = MOCK_USERNAME;
+ findHeaderSearchInput().vm.$emit('click');
+ });
+
+ it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => {
+ findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
+ await wrapper.vm.$nextTick();
+ findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+ expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url);
+ });
+ });
});
});
diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
index 6b84e63989d..bec0cbc8a5c 100644
--- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
@@ -9,14 +9,14 @@ import {
PROJECTS_CATEGORY,
SMALL_AVATAR_PX,
} from '~/header_search/constants';
-import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
+import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS } from '../mock_data';
Vue.use(Vuex);
describe('HeaderSearchAutocompleteItems', () => {
let wrapper;
- const createComponent = (initialState, mockGetters) => {
+ const createComponent = (initialState, mockGetters, props) => {
const store = new Vuex.Store({
state: {
loading: false,
@@ -30,6 +30,9 @@ describe('HeaderSearchAutocompleteItems', () => {
wrapper = shallowMount(HeaderSearchAutocompleteItems, {
store,
+ propsData: {
+ ...props,
+ },
});
};
@@ -38,6 +41,7 @@ describe('HeaderSearchAutocompleteItems', () => {
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
@@ -69,16 +73,16 @@ describe('HeaderSearchAutocompleteItems', () => {
describe('Dropdown items', () => {
it('renders item for each option in autocomplete option', () => {
- expect(findDropdownItems()).toHaveLength(MOCK_AUTOCOMPLETE_OPTIONS.length);
+ expect(findDropdownItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length);
});
it('renders titles correctly', () => {
- const expectedTitles = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.label);
+ const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.label);
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
});
it('renders links correctly', () => {
- const expectedLinks = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.url);
+ const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url);
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
});
});
@@ -104,5 +108,46 @@ describe('HeaderSearchAutocompleteItems', () => {
});
});
});
+
+ describe.each`
+ currentFocusedOption | isFocused | ariaSelected
+ ${null} | ${false} | ${undefined}
+ ${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
+ ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true} | ${'true'}
+ `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
+ describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
+ beforeEach(() => {
+ createComponent({}, {}, { currentFocusedOption });
+ });
+
+ it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
+ expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
+ });
+
+ it(`sets "aria-selected to ${ariaSelected}`, () => {
+ expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
+ });
+ });
+ });
+ });
+
+ describe('watchers', () => {
+ describe('currentFocusedOption', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('when focused changes to existing element calls scroll into view on the newly focused element', async () => {
+ const focusedElement = findFirstDropdownItem().element;
+ const scrollSpy = jest.spyOn(focusedElement, 'scrollIntoView');
+
+ wrapper.setProps({ currentFocusedOption: MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0] });
+
+ await wrapper.vm.$nextTick();
+
+ expect(scrollSpy).toHaveBeenCalledWith(false);
+ scrollSpy.mockRestore();
+ });
+ });
});
});
diff --git a/spec/frontend/header_search/components/header_search_default_items_spec.js b/spec/frontend/header_search/components/header_search_default_items_spec.js
index ce083d0df72..abcacc487df 100644
--- a/spec/frontend/header_search/components/header_search_default_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_default_items_spec.js
@@ -10,7 +10,7 @@ Vue.use(Vuex);
describe('HeaderSearchDefaultItems', () => {
let wrapper;
- const createComponent = (initialState) => {
+ const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
searchContext: MOCK_SEARCH_CONTEXT,
@@ -23,6 +23,9 @@ describe('HeaderSearchDefaultItems', () => {
wrapper = shallowMount(HeaderSearchDefaultItems, {
store,
+ propsData: {
+ ...props,
+ },
});
};
@@ -32,6 +35,7 @@ describe('HeaderSearchDefaultItems', () => {
const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
@@ -77,5 +81,26 @@ describe('HeaderSearchDefaultItems', () => {
});
});
});
+
+ describe.each`
+ currentFocusedOption | isFocused | ariaSelected
+ ${null} | ${false} | ${undefined}
+ ${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
+ ${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true} | ${'true'}
+ `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
+ describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
+ beforeEach(() => {
+ createComponent({}, { currentFocusedOption });
+ });
+
+ it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
+ expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
+ });
+
+ it(`sets "aria-selected to ${ariaSelected}`, () => {
+ expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
index f0e5e182ec4..a65b4d8b813 100644
--- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
@@ -11,7 +11,7 @@ Vue.use(Vuex);
describe('HeaderSearchScopedItems', () => {
let wrapper;
- const createComponent = (initialState) => {
+ const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
search: MOCK_SEARCH,
@@ -24,6 +24,9 @@ describe('HeaderSearchScopedItems', () => {
wrapper = shallowMount(HeaderSearchScopedItems, {
store,
+ propsData: {
+ ...props,
+ },
});
};
@@ -32,7 +35,10 @@ describe('HeaderSearchScopedItems', () => {
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
+ const findDropdownItemAriaLabels = () =>
+ findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label')));
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
describe('template', () => {
@@ -52,10 +58,38 @@ describe('HeaderSearchScopedItems', () => {
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
});
+ it('renders aria-labels correctly', () => {
+ const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
+ trimText(`${MOCK_SEARCH} ${o.description} ${o.scope || ''}`),
+ );
+ expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels);
+ });
+
it('renders links correctly', () => {
const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url);
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
});
});
+
+ describe.each`
+ currentFocusedOption | isFocused | ariaSelected
+ ${null} | ${false} | ${undefined}
+ ${{ html_id: 'not-a-match' }} | ${false} | ${undefined}
+ ${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true} | ${'true'}
+ `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
+ describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
+ beforeEach(() => {
+ createComponent({}, { currentFocusedOption });
+ });
+
+ it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
+ expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
+ });
+
+ it(`sets "aria-selected to ${ariaSelected}`, () => {
+ expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected);
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js
index 915b3a4a678..1d980679547 100644
--- a/spec/frontend/header_search/mock_data.js
+++ b/spec/frontend/header_search/mock_data.js
@@ -46,22 +46,27 @@ export const MOCK_SEARCH_CONTEXT = {
export const MOCK_DEFAULT_SEARCH_OPTIONS = [
{
+ html_id: 'default-issues-assigned',
title: MSG_ISSUES_ASSIGNED_TO_ME,
url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`,
},
{
+ html_id: 'default-issues-created',
title: MSG_ISSUES_IVE_CREATED,
url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`,
},
{
+ html_id: 'default-mrs-assigned',
title: MSG_MR_ASSIGNED_TO_ME,
url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`,
},
{
+ html_id: 'default-mrs-reviewer',
title: MSG_MR_IM_REVIEWER,
url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`,
},
{
+ html_id: 'default-mrs-created',
title: MSG_MR_IVE_CREATED,
url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`,
},
@@ -69,22 +74,25 @@ export const MOCK_DEFAULT_SEARCH_OPTIONS = [
export const MOCK_SCOPED_SEARCH_OPTIONS = [
{
+ html_id: 'scoped-in-project',
scope: MOCK_PROJECT.name,
description: MSG_IN_PROJECT,
url: MOCK_PROJECT.path,
},
{
+ html_id: 'scoped-in-group',
scope: MOCK_GROUP.name,
description: MSG_IN_GROUP,
url: MOCK_GROUP.path,
},
{
+ html_id: 'scoped-in-all',
description: MSG_IN_ALL_GITLAB,
url: MOCK_ALL_PATH,
},
];
-export const MOCK_AUTOCOMPLETE_OPTIONS = [
+export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [
{
category: 'Projects',
id: 1,
@@ -92,19 +100,49 @@ export const MOCK_AUTOCOMPLETE_OPTIONS = [
url: 'project/1',
},
{
+ category: 'Groups',
+ id: 1,
+ label: 'MockGroup1',
+ url: 'group/1',
+ },
+ {
category: 'Projects',
id: 2,
label: 'MockProject2',
url: 'project/2',
},
{
+ category: 'Help',
+ label: 'GitLab Help',
+ url: 'help/gitlab',
+ },
+];
+
+export const MOCK_AUTOCOMPLETE_OPTIONS = [
+ {
+ category: 'Projects',
+ html_id: 'autocomplete-Projects-0',
+ id: 1,
+ label: 'MockProject1',
+ url: 'project/1',
+ },
+ {
category: 'Groups',
+ html_id: 'autocomplete-Groups-1',
id: 1,
label: 'MockGroup1',
url: 'group/1',
},
{
+ category: 'Projects',
+ html_id: 'autocomplete-Projects-2',
+ id: 2,
+ label: 'MockProject2',
+ url: 'project/2',
+ },
+ {
category: 'Help',
+ html_id: 'autocomplete-Help-3',
label: 'GitLab Help',
url: 'help/gitlab',
},
@@ -116,12 +154,16 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
data: [
{
category: 'Projects',
+ html_id: 'autocomplete-Projects-0',
+
id: 1,
label: 'MockProject1',
url: 'project/1',
},
{
category: 'Projects',
+ html_id: 'autocomplete-Projects-2',
+
id: 2,
label: 'MockProject2',
url: 'project/2',
@@ -133,6 +175,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
data: [
{
category: 'Groups',
+ html_id: 'autocomplete-Groups-1',
+
id: 1,
label: 'MockGroup1',
url: 'group/1',
@@ -144,9 +188,41 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
data: [
{
category: 'Help',
+ html_id: 'autocomplete-Help-3',
+
label: 'GitLab Help',
url: 'help/gitlab',
},
],
},
];
+
+export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [
+ {
+ category: 'Projects',
+ html_id: 'autocomplete-Projects-0',
+ id: 1,
+ label: 'MockProject1',
+ url: 'project/1',
+ },
+ {
+ category: 'Projects',
+ html_id: 'autocomplete-Projects-2',
+ id: 2,
+ label: 'MockProject2',
+ url: 'project/2',
+ },
+ {
+ category: 'Groups',
+ html_id: 'autocomplete-Groups-1',
+ id: 1,
+ label: 'MockGroup1',
+ url: 'group/1',
+ },
+ {
+ category: 'Help',
+ html_id: 'autocomplete-Help-3',
+ label: 'GitLab Help',
+ url: 'help/gitlab',
+ },
+];
diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js
index ee2c72df77b..6599115f017 100644
--- a/spec/frontend/header_search/store/actions_spec.js
+++ b/spec/frontend/header_search/store/actions_spec.js
@@ -5,7 +5,7 @@ import * as actions from '~/header_search/store/actions';
import * as types from '~/header_search/store/mutation_types';
import createState from '~/header_search/store/state';
import axios from '~/lib/utils/axios_utils';
-import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
+import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS_RES } from '../mock_data';
jest.mock('~/flash');
@@ -29,9 +29,9 @@ describe('Header Search Store Actions', () => {
});
describe.each`
- axiosMock | type | expectedMutations | flashCallCount
- ${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS }]} | ${0}
- ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} | ${1}
+ axiosMock | type | expectedMutations | flashCallCount
+ ${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS_RES }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }]} | ${0}
+ ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} | ${1}
`('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations, flashCallCount }) => {
describe(`on ${type}`, () => {
beforeEach(() => {
@@ -47,6 +47,16 @@ describe('Header Search Store Actions', () => {
});
});
+ describe('clearAutocomplete', () => {
+ it('calls the CLEAR_AUTOCOMPLETE mutation', () => {
+ return testAction({
+ action: actions.clearAutocomplete,
+ state,
+ expectedMutations: [{ type: types.CLEAR_AUTOCOMPLETE }],
+ });
+ });
+ });
+
describe('setSearch', () => {
it('calls the SET_SEARCH mutation', () => {
return testAction({
diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js
index d55db07188e..35d1bf350d7 100644
--- a/spec/frontend/header_search/store/getters_spec.js
+++ b/spec/frontend/header_search/store/getters_spec.js
@@ -15,6 +15,7 @@ import {
MOCK_SEARCH,
MOCK_AUTOCOMPLETE_OPTIONS,
MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+ MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
} from '../mock_data';
describe('Header Search Store Getters', () => {
@@ -36,18 +37,20 @@ describe('Header Search Store Getters', () => {
});
describe.each`
- group | project | expectedPath
- ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=undefined&scope=issues`}
- ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=${MOCK_GROUP.id}&scope=issues`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
- `('searchQuery', ({ group, project, expectedPath }) => {
- describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ group | project | scope | expectedPath
+ ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
+ `('searchQuery', ({ group, project, scope, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
- scope: 'issues',
+ scope,
},
});
state.search = MOCK_SEARCH;
@@ -61,8 +64,9 @@ describe('Header Search Store Getters', () => {
describe.each`
project | ref | expectedPath
- ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=undefined&project_ref=null`}
- ${MOCK_PROJECT} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=null`}
+ ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}`}
+ ${MOCK_PROJECT} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}`}
+ ${null} | ${MOCK_PROJECT.id} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_ref=${MOCK_PROJECT.id}`}
${MOCK_PROJECT} | ${MOCK_PROJECT.id} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=${MOCK_PROJECT.id}`}
`('autocompleteQuery', ({ project, ref, expectedPath }) => {
describe(`when project is ${project?.name} and project ref is ${ref}`, () => {
@@ -131,18 +135,20 @@ describe('Header Search Store Getters', () => {
});
describe.each`
- group | project | expectedPath
- ${null} | ${null} | ${null}
- ${MOCK_GROUP} | ${null} | ${null}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
- `('projectUrl', ({ group, project, expectedPath }) => {
- describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ group | project | scope | expectedPath
+ ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`}
+ `('projectUrl', ({ group, project, scope, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
- scope: 'issues',
+ scope,
},
});
state.search = MOCK_SEARCH;
@@ -155,18 +161,20 @@ describe('Header Search Store Getters', () => {
});
describe.each`
- group | project | expectedPath
- ${null} | ${null} | ${null}
- ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
- ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
- `('groupUrl', ({ group, project, expectedPath }) => {
- describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ group | project | scope | expectedPath
+ ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`}
+ `('groupUrl', ({ group, project, scope, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => {
beforeEach(() => {
createState({
searchContext: {
group,
project,
- scope: 'issues',
+ scope,
},
});
state.search = MOCK_SEARCH;
@@ -178,20 +186,29 @@ describe('Header Search Store Getters', () => {
});
});
- describe('allUrl', () => {
- const expectedPath = `${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`;
-
- beforeEach(() => {
- createState({
- searchContext: {
- scope: 'issues',
- },
+ describe.each`
+ group | project | scope | expectedPath
+ ${null} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${null} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar`}
+ ${MOCK_GROUP} | ${MOCK_PROJECT} | ${'issues'} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`}
+ `('allUrl', ({ group, project, scope, expectedPath }) => {
+ describe(`when group is ${group?.name}, project is ${project?.name}, and scope is ${scope}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ project,
+ scope,
+ },
+ });
+ state.search = MOCK_SEARCH;
});
- state.search = MOCK_SEARCH;
- });
- it(`should return ${expectedPath}`, () => {
- expect(getters.allUrl(state)).toBe(expectedPath);
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.allUrl(state)).toBe(expectedPath);
+ });
});
});
@@ -248,4 +265,44 @@ describe('Header Search Store Getters', () => {
);
});
});
+
+ describe.each`
+ search | defaultSearchOptions | scopedSearchOptions | autocompleteGroupedSearchOptions | expectedArray
+ ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS}
+ ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
+ ${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
+ `(
+ 'searchOptions',
+ ({
+ search,
+ defaultSearchOptions,
+ scopedSearchOptions,
+ autocompleteGroupedSearchOptions,
+ expectedArray,
+ }) => {
+ describe(`when search is ${search} and the defaultSearchOptions${
+ defaultSearchOptions.length ? '' : ' do not'
+ } exist, scopedSearchOptions${
+ scopedSearchOptions.length ? '' : ' do not'
+ } exist, and autocompleteGroupedSearchOptions${
+ autocompleteGroupedSearchOptions.length ? '' : ' do not'
+ } exist`, () => {
+ const mockGetters = {
+ defaultSearchOptions,
+ scopedSearchOptions,
+ autocompleteGroupedSearchOptions,
+ };
+
+ beforeEach(() => {
+ createState();
+ state.search = search;
+ });
+
+ it(`should return the correct combined array`, () => {
+ expect(getters.searchOptions(state, mockGetters)).toStrictEqual(expectedArray);
+ });
+ });
+ },
+ );
});
diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js
index 7f9b7631a7e..7bcf8e49118 100644
--- a/spec/frontend/header_search/store/mutations_spec.js
+++ b/spec/frontend/header_search/store/mutations_spec.js
@@ -1,7 +1,11 @@
import * as types from '~/header_search/store/mutation_types';
import mutations from '~/header_search/store/mutations';
import createState from '~/header_search/store/state';
-import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
+import {
+ MOCK_SEARCH,
+ MOCK_AUTOCOMPLETE_OPTIONS_RES,
+ MOCK_AUTOCOMPLETE_OPTIONS,
+} from '../mock_data';
describe('Header Search Store Mutations', () => {
let state;
@@ -20,8 +24,8 @@ describe('Header Search Store Mutations', () => {
});
describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => {
- it('sets loading to false and sets autocompleteOptions array', () => {
- mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS);
+ it('sets loading to false and then formats and sets the autocompleteOptions array', () => {
+ mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES);
expect(state.loading).toBe(false);
expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS);
@@ -37,6 +41,14 @@ describe('Header Search Store Mutations', () => {
});
});
+ describe('CLEAR_AUTOCOMPLETE', () => {
+ it('empties autocompleteOptions array', () => {
+ mutations[types.CLEAR_AUTOCOMPLETE](state);
+
+ expect(state.autocompleteOptions).toStrictEqual([]);
+ });
+ });
+
describe('SET_SEARCH', () => {
it('sets search to value', () => {
mutations[types.SET_SEARCH](state, MOCK_SEARCH);
diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js
index 85d9feb0c09..ace51204374 100644
--- a/spec/frontend/ide/components/ide_tree_list_spec.js
+++ b/spec/frontend/ide/components/ide_tree_list_spec.js
@@ -38,9 +38,16 @@ describe('IDE tree list', () => {
beforeEach(() => {
bootstrapWithTree();
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+
vm.$mount();
});
+ it('emits tree-ready event', () => {
+ expect(vm.$emit).toHaveBeenCalledTimes(1);
+ expect(vm.$emit).toHaveBeenCalledWith('tree-ready');
+ });
+
it('renders loading indicator', (done) => {
store.state.trees['abcproject/main'].loading = true;
@@ -61,9 +68,15 @@ describe('IDE tree list', () => {
beforeEach(() => {
bootstrapWithTree(emptyBranchTree);
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+
vm.$mount();
});
+ it('still emits tree-ready event', () => {
+ expect(vm.$emit).toHaveBeenCalledWith('tree-ready');
+ });
+
it('does not load files if the branch is empty', () => {
expect(vm.$el.textContent).not.toContain('fileName');
expect(vm.$el.textContent).toContain('No files');
diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap
index 47e3a56e83d..069b6927bac 100644
--- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap
+++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap
@@ -6,10 +6,10 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli
>
<!---->
- <empty-state-stub
- cansetci="true"
- class="gl-p-5"
- emptystatesvgpath="http://test.host"
- />
+ <div
+ class="gl-h-full gl-display-flex gl-flex-direction-column gl-justify-content-center"
+ >
+ <empty-state-stub />
+ </div>
</div>
`;
diff --git a/spec/frontend/ide/components/pipelines/empty_state_spec.js b/spec/frontend/ide/components/pipelines/empty_state_spec.js
new file mode 100644
index 00000000000..f7409fc36be
--- /dev/null
+++ b/spec/frontend/ide/components/pipelines/empty_state_spec.js
@@ -0,0 +1,44 @@
+import { GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import EmptyState from '~/ide/components/pipelines/empty_state.vue';
+import { createStore } from '~/ide/stores';
+
+const TEST_PIPELINES_EMPTY_STATE_SVG_PATH = 'illustrations/test/pipelines.svg';
+
+describe('~/ide/components/pipelines/empty_state.vue', () => {
+ let store;
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(EmptyState, {
+ store,
+ });
+ };
+
+ beforeEach(() => {
+ store = createStore();
+ store.dispatch('setEmptyStateSvgs', {
+ pipelinesEmptyStateSvgPath: TEST_PIPELINES_EMPTY_STATE_SVG_PATH,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty state', () => {
+ expect(wrapper.find(GlEmptyState).props()).toMatchObject({
+ title: EmptyState.i18n.title,
+ description: EmptyState.i18n.description,
+ primaryButtonText: EmptyState.i18n.primaryButtonText,
+ primaryButtonLink: '/help/ci/quick_start/index.md',
+ svgPath: TEST_PIPELINES_EMPTY_STATE_SVG_PATH,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
index a917f4c0230..8a3606e27eb 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -2,10 +2,10 @@ import { GlLoadingIcon, GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
-import { TEST_HOST } from 'helpers/test_constants';
import { pipelines } from 'jest/ide/mock_data';
import JobsList from '~/ide/components/jobs/list.vue';
import List from '~/ide/components/pipelines/list.vue';
+import EmptyState from '~/ide/components/pipelines/empty_state.vue';
import IDEServices from '~/ide/services';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -18,9 +18,6 @@ jest.mock('~/ide/services', () => ({
describe('IDE pipelines list', () => {
let wrapper;
- const defaultState = {
- pipelinesEmptyStateSvgPath: TEST_HOST,
- };
const defaultPipelinesState = {
stages: [],
failedStages: [],
@@ -38,7 +35,6 @@ describe('IDE pipelines list', () => {
currentProject: () => ({ web_url: 'some/url ', path_with_namespace: fakeProjectPath }),
},
state: {
- ...defaultState,
...rootState,
},
modules: {
@@ -131,6 +127,8 @@ describe('IDE pipelines list', () => {
it('renders empty state when no latestPipeline', () => {
createComponent({}, { ...defaultPipelinesLoadedState, latestPipeline: null });
+
+ expect(wrapper.find(EmptyState).exists()).toBe(true);
expect(wrapper.element).toMatchSnapshot();
});
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index c2212eea849..c957c64aa10 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -9,7 +9,7 @@ import waitUsingRealTimer from 'helpers/wait_using_real_timer';
import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data';
import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
-import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
+import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext';
import SourceEditor from '~/editor/source_editor';
import RepoEditor from '~/ide/components/repo_editor.vue';
import {
@@ -23,6 +23,8 @@ import service from '~/ide/services';
import { createStoreOptions } from '~/ide/stores';
import axios from '~/lib/utils/axios_utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
+import SourceEditorInstance from '~/editor/source_editor_instance';
+import { spyOnApi } from 'jest/editor/helpers';
import { file } from '../helpers';
const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown';
@@ -101,6 +103,7 @@ describe('RepoEditor', () => {
let createDiffInstanceSpy;
let createModelSpy;
let applyExtensionSpy;
+ let extensionsStore;
const waitForEditorSetup = () =>
new Promise((resolve) => {
@@ -120,6 +123,7 @@ describe('RepoEditor', () => {
});
await waitForPromises();
vm = wrapper.vm;
+ extensionsStore = wrapper.vm.globalEditor.extensionsStore;
jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
};
@@ -127,28 +131,12 @@ describe('RepoEditor', () => {
const findEditor = () => wrapper.find('[data-testid="editor-container"]');
const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li');
const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]');
- const expectEditorMarkdownExtension = (shouldHaveExtension) => {
- if (shouldHaveExtension) {
- expect(applyExtensionSpy).toHaveBeenCalledWith(
- wrapper.vm.editor,
- expect.any(EditorMarkdownExtension),
- );
- // TODO: spying on extensions causes Jest to blow up, so we have to assert on
- // the public property the extension adds, as opposed to the args passed to the ctor
- expect(wrapper.vm.editor.previewMarkdownPath).toBe(PREVIEW_MARKDOWN_PATH);
- } else {
- expect(applyExtensionSpy).not.toHaveBeenCalledWith(
- wrapper.vm.editor,
- expect.any(EditorMarkdownExtension),
- );
- }
- };
beforeEach(() => {
createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN);
createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN);
createModelSpy = jest.spyOn(monacoEditor, 'createModel');
- applyExtensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension');
+ applyExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'use');
jest.spyOn(service, 'getFileData').mockResolvedValue();
jest.spyOn(service, 'getRawFileData').mockResolvedValue();
});
@@ -275,14 +263,13 @@ describe('RepoEditor', () => {
);
it('installs the WebIDE extension', async () => {
- const extensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension');
await createComponent();
- expect(extensionSpy).toHaveBeenCalled();
- Reflect.ownKeys(EditorWebIdeExtension.prototype)
- .filter((fn) => fn !== 'constructor')
- .forEach((fn) => {
- expect(vm.editor[fn]).toBe(EditorWebIdeExtension.prototype[fn]);
- });
+ expect(applyExtensionSpy).toHaveBeenCalled();
+ const ideExtensionApi = extensionsStore.get('EditorWebIde').api;
+ Reflect.ownKeys(ideExtensionApi).forEach((fn) => {
+ expect(vm.editor[fn]).toBeDefined();
+ expect(vm.editor.methods[fn]).toBe('EditorWebIde');
+ });
});
it.each`
@@ -301,7 +288,20 @@ describe('RepoEditor', () => {
async ({ activeFile, viewer, shouldHaveMarkdownExtension } = {}) => {
await createComponent({ state: { viewer }, activeFile });
- expectEditorMarkdownExtension(shouldHaveMarkdownExtension);
+ if (shouldHaveMarkdownExtension) {
+ expect(applyExtensionSpy).toHaveBeenCalledWith({
+ definition: EditorMarkdownPreviewExtension,
+ setupOptions: { previewMarkdownPath: PREVIEW_MARKDOWN_PATH },
+ });
+ // TODO: spying on extensions causes Jest to blow up, so we have to assert on
+ // the public property the extension adds, as opposed to the args passed to the ctor
+ expect(wrapper.vm.editor.markdownPreview.path).toBe(PREVIEW_MARKDOWN_PATH);
+ } else {
+ expect(applyExtensionSpy).not.toHaveBeenCalledWith(
+ wrapper.vm.editor,
+ expect.any(EditorMarkdownExtension),
+ );
+ }
},
);
});
@@ -329,18 +329,6 @@ describe('RepoEditor', () => {
expect(vm.model).toBe(existingModel);
});
- it('adds callback methods', () => {
- jest.spyOn(vm.editor, 'onPositionChange');
- jest.spyOn(vm.model, 'onChange');
- jest.spyOn(vm.model, 'updateOptions');
-
- vm.setupEditor();
-
- expect(vm.editor.onPositionChange).toHaveBeenCalledTimes(1);
- expect(vm.model.onChange).toHaveBeenCalledTimes(1);
- expect(vm.model.updateOptions).toHaveBeenCalledWith(vm.rules);
- });
-
it('updates state with the value of the model', () => {
const newContent = 'As Gregor Samsa\n awoke one morning\n';
vm.model.setValue(newContent);
@@ -366,53 +354,48 @@ describe('RepoEditor', () => {
describe('editor updateDimensions', () => {
let updateDimensionsSpy;
- let updateDiffViewSpy;
beforeEach(async () => {
await createComponent();
- updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions');
- updateDiffViewSpy = jest.spyOn(vm.editor, 'updateDiffView').mockImplementation();
+ const ext = extensionsStore.get('EditorWebIde');
+ updateDimensionsSpy = jest.fn();
+ spyOnApi(ext, {
+ updateDimensions: updateDimensionsSpy,
+ });
});
it('calls updateDimensions only when panelResizing is false', async () => {
expect(updateDimensionsSpy).not.toHaveBeenCalled();
- expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.$store.state.panelResizing).toBe(false); // default value
vm.$store.state.panelResizing = true;
await vm.$nextTick();
expect(updateDimensionsSpy).not.toHaveBeenCalled();
- expect(updateDiffViewSpy).not.toHaveBeenCalled();
vm.$store.state.panelResizing = false;
await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
- expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
vm.$store.state.panelResizing = true;
await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
- expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
});
it('calls updateDimensions when rightPane is toggled', async () => {
expect(updateDimensionsSpy).not.toHaveBeenCalled();
- expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value
vm.$store.state.rightPane.isOpen = true;
await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
- expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
vm.$store.state.rightPane.isOpen = false;
await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(2);
- expect(updateDiffViewSpy).toHaveBeenCalledTimes(2);
});
});
@@ -447,7 +430,11 @@ describe('RepoEditor', () => {
activeFile: dummyFile.markdown,
});
- updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions');
+ const ext = extensionsStore.get('EditorWebIde');
+ updateDimensionsSpy = jest.fn();
+ spyOnApi(ext, {
+ updateDimensions: updateDimensionsSpy,
+ });
changeViewMode(FILE_VIEW_MODE_PREVIEW);
await vm.$nextTick();
diff --git a/spec/frontend/ide/ide_router_spec.js b/spec/frontend/ide/ide_router_spec.js
index 3fb7781b176..cd10812f8ea 100644
--- a/spec/frontend/ide/ide_router_spec.js
+++ b/spec/frontend/ide/ide_router_spec.js
@@ -6,6 +6,7 @@ describe('IDE router', () => {
const PROJECT_NAMESPACE = 'my-group/sub-group';
const PROJECT_NAME = 'my-project';
const TEST_PATH = `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/merge_requests/2`;
+ const DEFAULT_BRANCH = 'default-main';
let store;
let router;
@@ -13,34 +14,46 @@ describe('IDE router', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/');
store = createStore();
- router = createRouter(store);
+ router = createRouter(store, DEFAULT_BRANCH);
jest.spyOn(store, 'dispatch').mockReturnValue(new Promise(() => {}));
});
- [
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob/`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob/-/src/blob`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/tree/`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/weird:branch/name-123/-/src/tree/`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/blob`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/edit`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/merge_requests/2`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/blob/-/src/blob`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit/blob/-/src/blob`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/merge_requests/2`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit`,
- `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}`,
- ].forEach((route) => {
- it(`finds project path when route is "${route}"`, () => {
- router.push(route);
-
- expect(store.dispatch).toHaveBeenCalledWith('getProjectData', {
- namespace: PROJECT_NAMESPACE,
- projectId: PROJECT_NAME,
- });
+ it.each`
+ route | expectedBranchId | expectedBasePath
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob/`} | ${'main'} | ${'src/blob/'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/blob`} | ${'main'} | ${'src/blob'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob/-/src/blob`} | ${'blob'} | ${'src/blob'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/main/-/src/tree/`} | ${'main'} | ${'src/tree/'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/weird:branch/name-123/-/src/tree/`} | ${'weird:branch/name-123'} | ${'src/tree/'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/blob`} | ${'main'} | ${'src/blob'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/edit`} | ${'main'} | ${'src/edit'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/main/-/src/merge_requests/2`} | ${'main'} | ${'src/merge_requests/2'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/blob/blob/-/src/blob`} | ${'blob'} | ${'src/blob'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit/blob/-/src/blob`} | ${'blob'} | ${'src/blob'}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/tree/blob`} | ${'blob'} | ${''}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/edit`} | ${DEFAULT_BRANCH} | ${''}
+ ${`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}`} | ${DEFAULT_BRANCH} | ${''}
+ `('correctly opens Web IDE for $route', ({ route, expectedBranchId, expectedBasePath } = {}) => {
+ router.push(route);
+
+ expect(store.dispatch).toHaveBeenCalledWith('openBranch', {
+ projectId: `${PROJECT_NAMESPACE}/${PROJECT_NAME}`,
+ branchId: expectedBranchId,
+ basePath: expectedBasePath,
+ });
+ });
+
+ it('correctly opens an MR', () => {
+ const expectedId = '2';
+
+ router.push(`/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/merge_requests/${expectedId}`);
+
+ expect(store.dispatch).toHaveBeenCalledWith('openMergeRequest', {
+ projectId: `${PROJECT_NAMESPACE}/${PROJECT_NAME}`,
+ mergeRequestId: expectedId,
+ targetProjectId: undefined,
});
+ expect(store.dispatch).not.toHaveBeenCalledWith('openBranch');
});
it('keeps router in sync when store changes', async () => {
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index eacf1244d55..0fab828dfb3 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -6,7 +6,7 @@ import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.
import services from '~/ide/services';
import { query, mutate } from '~/ide/services/gql';
import { escapeFileUrl } from '~/lib/utils/url_utility';
-import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
+import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.query.graphql';
import { projectData } from '../mock_data';
jest.mock('~/api');
@@ -216,29 +216,6 @@ describe('IDE services', () => {
);
});
- describe('getProjectData', () => {
- it('combines gql and API requests', () => {
- const gqlProjectData = {
- userPermissions: {
- bogus: true,
- },
- };
- Api.project.mockReturnValue(Promise.resolve({ data: { ...projectData } }));
- query.mockReturnValue(Promise.resolve({ data: { project: gqlProjectData } }));
-
- return services.getProjectData(TEST_NAMESPACE, TEST_PROJECT).then((response) => {
- expect(response).toEqual({ data: { ...projectData, ...gqlProjectData } });
- expect(Api.project).toHaveBeenCalledWith(TEST_PROJECT_ID);
- expect(query).toHaveBeenCalledWith({
- query: getIdeProject,
- variables: {
- projectPath: TEST_PROJECT_ID,
- },
- });
- });
- });
- });
-
describe('getFiles', () => {
let mock;
let relativeUrlRoot;
@@ -330,4 +307,38 @@ describe('IDE services', () => {
});
});
});
+
+ describe('getProjectPermissionsData', () => {
+ const TEST_PROJECT_PATH = 'foo/bar';
+
+ it('queries for the project permissions', () => {
+ const result = { data: { project: projectData } };
+ query.mockResolvedValue(result);
+
+ return services.getProjectPermissionsData(TEST_PROJECT_PATH).then((data) => {
+ expect(data).toEqual(result.data.project);
+ expect(query).toHaveBeenCalledWith(
+ expect.objectContaining({
+ query: getIdeProject,
+ variables: { projectPath: TEST_PROJECT_PATH },
+ }),
+ );
+ });
+ });
+
+ it('converts the returned GraphQL id to the regular ID number', () => {
+ const projectId = 2;
+ const gqlProjectData = {
+ id: `gid://gitlab/Project/${projectId}`,
+ userPermissions: {
+ bogus: true,
+ },
+ };
+
+ query.mockResolvedValue({ data: { project: gqlProjectData } });
+ return services.getProjectPermissionsData(TEST_PROJECT_PATH).then((data) => {
+ expect(data.id).toBe(projectId);
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js
index ca6f7169059..e07dcf22860 100644
--- a/spec/frontend/ide/stores/actions/project_spec.js
+++ b/spec/frontend/ide/stores/actions/project_spec.js
@@ -2,9 +2,12 @@ import MockAdapter from 'axios-mock-adapter';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import testAction from 'helpers/vuex_action_helper';
import api from '~/api';
+import createFlash from '~/flash';
import service from '~/ide/services';
import { createStore } from '~/ide/stores';
import {
+ setProject,
+ fetchProjectPermissions,
refreshLastCommitData,
showBranchNotFoundError,
createNewBranchFromDefault,
@@ -13,8 +16,12 @@ import {
loadFile,
loadBranch,
} from '~/ide/stores/actions';
+import { logError } from '~/lib/logger';
import axios from '~/lib/utils/axios_utils';
+jest.mock('~/flash');
+jest.mock('~/lib/logger');
+
const TEST_PROJECT_ID = 'abc/def';
describe('IDE store project actions', () => {
@@ -34,6 +41,92 @@ describe('IDE store project actions', () => {
mock.restore();
});
+ describe('setProject', () => {
+ const project = { id: 'foo', path_with_namespace: TEST_PROJECT_ID };
+ const baseMutations = [
+ {
+ type: 'SET_PROJECT',
+ payload: {
+ projectPath: TEST_PROJECT_ID,
+ project,
+ },
+ },
+ {
+ type: 'SET_CURRENT_PROJECT',
+ payload: TEST_PROJECT_ID,
+ },
+ ];
+
+ it.each`
+ desc | payload | expectedMutations
+ ${'does not commit any action if project is not passed'} | ${undefined} | ${[]}
+ ${'commits correct actions in the correct order by default'} | ${{ project }} | ${[...baseMutations]}
+ `('$desc', async ({ payload, expectedMutations } = {}) => {
+ await testAction({
+ action: setProject,
+ payload,
+ state: store.state,
+ expectedMutations,
+ expectedActions: [],
+ });
+ });
+ });
+
+ describe('fetchProjectPermissions', () => {
+ const permissionsData = {
+ userPermissions: {
+ bogus: true,
+ },
+ };
+ const permissionsMutations = [
+ {
+ type: 'UPDATE_PROJECT',
+ payload: {
+ projectPath: TEST_PROJECT_ID,
+ props: {
+ ...permissionsData,
+ },
+ },
+ },
+ ];
+
+ let spy;
+
+ beforeEach(() => {
+ spy = jest.spyOn(service, 'getProjectPermissionsData');
+ });
+
+ afterEach(() => {
+ createFlash.mockRestore();
+ });
+
+ it.each`
+ desc | projectPath | responseSuccess | expectedMutations
+ ${'does not fetch permissions if project does not exist'} | ${undefined} | ${true} | ${[]}
+ ${'fetches permission when project is specified'} | ${TEST_PROJECT_ID} | ${true} | ${[...permissionsMutations]}
+ ${'flashes an error if the request fails'} | ${TEST_PROJECT_ID} | ${false} | ${[]}
+ `('$desc', async ({ projectPath, expectedMutations, responseSuccess } = {}) => {
+ store.state.currentProjectId = projectPath;
+ if (responseSuccess) {
+ spy.mockResolvedValue(permissionsData);
+ } else {
+ spy.mockRejectedValue();
+ }
+
+ await testAction({
+ action: fetchProjectPermissions,
+ state: store.state,
+ expectedMutations,
+ expectedActions: [],
+ });
+
+ if (!responseSuccess) {
+ expect(logError).toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalled();
+ }
+ });
+ });
+
describe('refreshLastCommitData', () => {
beforeEach(() => {
store.state.currentProjectId = 'abc/def';
diff --git a/spec/frontend/ide/stores/mutations/project_spec.js b/spec/frontend/ide/stores/mutations/project_spec.js
index b3ce39c33d2..0fdd7798f00 100644
--- a/spec/frontend/ide/stores/mutations/project_spec.js
+++ b/spec/frontend/ide/stores/mutations/project_spec.js
@@ -3,21 +3,48 @@ import state from '~/ide/stores/state';
describe('Multi-file store branch mutations', () => {
let localState;
+ const nonExistentProj = 'nonexistent';
+ const existingProj = 'abcproject';
beforeEach(() => {
localState = state();
- localState.projects = { abcproject: { empty_repo: true } };
+ localState.projects = { [existingProj]: { empty_repo: true } };
});
describe('TOGGLE_EMPTY_STATE', () => {
it('sets empty_repo for project to passed value', () => {
- mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: 'abcproject', value: false });
+ mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: existingProj, value: false });
- expect(localState.projects.abcproject.empty_repo).toBe(false);
+ expect(localState.projects[existingProj].empty_repo).toBe(false);
- mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: 'abcproject', value: true });
+ mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: existingProj, value: true });
- expect(localState.projects.abcproject.empty_repo).toBe(true);
+ expect(localState.projects[existingProj].empty_repo).toBe(true);
+ });
+ });
+
+ describe('UPDATE_PROJECT', () => {
+ it.each`
+ desc | projectPath | props | expectedProps
+ ${'extends existing project with the passed props'} | ${existingProj} | ${{ foo1: 'bar' }} | ${{ foo1: 'bar' }}
+ ${'overrides existing props on the exsiting project'} | ${existingProj} | ${{ empty_repo: false }} | ${{ empty_repo: false }}
+ ${'does nothing if the project does not exist'} | ${nonExistentProj} | ${{ foo2: 'bar' }} | ${undefined}
+ ${'does nothing if project is not passed'} | ${undefined} | ${{ foo3: 'bar' }} | ${undefined}
+ ${'does nothing if the props are not passed'} | ${existingProj} | ${undefined} | ${{}}
+ ${'does nothing if the props are empty'} | ${existingProj} | ${{}} | ${{}}
+ `('$desc', ({ projectPath, props, expectedProps } = {}) => {
+ const origProject = localState.projects[projectPath];
+
+ mutations.UPDATE_PROJECT(localState, { projectPath, props });
+
+ if (!expectedProps) {
+ expect(localState.projects[projectPath]).toBeUndefined();
+ } else {
+ expect(localState.projects[projectPath]).toEqual({
+ ...origProject,
+ ...expectedProps,
+ });
+ }
});
});
});
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 6e3df21e30a..b17ff2e0f52 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
@@ -1,4 +1,4 @@
-import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@@ -33,13 +33,23 @@ describe('import table', () => {
generateFakeEntry({ id: 2, status: STATUSES.FINISHED }),
];
const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 };
+ const FAKE_VERSION_VALIDATION = {
+ features: {
+ projectMigration: { available: false, minVersion: '14.8.0' },
+ sourceInstanceVersion: '14.6.0',
+ },
+ };
const findImportSelectedButton = () =>
wrapper.findAll('button').wrappers.find((w) => w.text() === 'Import selected');
const findImportButtons = () =>
wrapper.findAll('button').wrappers.filter((w) => w.text() === 'Import');
- const findPaginationDropdown = () => wrapper.find('[aria-label="Page size"]');
+ const findPaginationDropdown = () => wrapper.find('[data-testid="page-size"]');
const findPaginationDropdownText = () => findPaginationDropdown().find('button').text();
+ const findSelectionCount = () => wrapper.find('[data-test-id="selection-count"]');
+
+ const triggerSelectAllCheckbox = () =>
+ wrapper.find('thead input[type=checkbox]').trigger('click');
const selectRow = (idx) =>
wrapper.findAll('tbody td input[type=checkbox]').at(idx).trigger('click');
@@ -104,6 +114,7 @@ describe('import table', () => {
bulkImportSourceGroups: () => ({
nodes: [],
pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
@@ -117,6 +128,7 @@ describe('import table', () => {
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
@@ -129,6 +141,7 @@ describe('import table', () => {
bulkImportSourceGroups: jest.fn().mockResolvedValue({
nodes: [],
pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
@@ -138,7 +151,11 @@ describe('import table', () => {
it('invokes importGroups mutation when row button is clicked', async () => {
createComponent({
- bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }),
+ bulkImportSourceGroups: () => ({
+ nodes: [FAKE_GROUP],
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
});
jest.spyOn(apolloProvider.defaultClient, 'mutate');
@@ -162,7 +179,11 @@ describe('import table', () => {
it('displays error if importing group fails', async () => {
createComponent({
- bulkImportSourceGroups: () => ({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO }),
+ bulkImportSourceGroups: () => ({
+ nodes: [FAKE_GROUP],
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
importGroups: () => {
throw new Error();
},
@@ -182,9 +203,11 @@ describe('import table', () => {
});
describe('pagination', () => {
- const bulkImportSourceGroupsQueryMock = jest
- .fn()
- .mockResolvedValue({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO });
+ const bulkImportSourceGroupsQueryMock = jest.fn().mockResolvedValue({
+ nodes: [FAKE_GROUP],
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ });
beforeEach(() => {
createComponent({
@@ -205,7 +228,13 @@ describe('import table', () => {
const otherOption = findPaginationDropdown().findAll('li p').at(1);
expect(otherOption.text()).toMatchInterpolatedText('50 items per page');
+ bulkImportSourceGroupsQueryMock.mockResolvedValue({
+ nodes: [FAKE_GROUP],
+ pageInfo: { ...FAKE_PAGE_INFO, perPage: 50 },
+ versionValidation: FAKE_VERSION_VALIDATION,
+ });
await otherOption.trigger('click');
+
await waitForPromises();
expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page');
@@ -234,6 +263,7 @@ describe('import table', () => {
perPage: 20,
totalPages: 2,
},
+ versionValidation: FAKE_VERSION_VALIDATION,
});
wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE);
await waitForPromises();
@@ -243,9 +273,11 @@ describe('import table', () => {
});
describe('filters', () => {
- const bulkImportSourceGroupsQueryMock = jest
- .fn()
- .mockResolvedValue({ nodes: [FAKE_GROUP], pageInfo: FAKE_PAGE_INFO });
+ const bulkImportSourceGroupsQueryMock = jest.fn().mockResolvedValue({
+ nodes: [FAKE_GROUP],
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ });
beforeEach(() => {
createComponent({
@@ -313,11 +345,28 @@ describe('import table', () => {
});
describe('bulk operations', () => {
+ it('import all button correctly selects/deselects all groups', async () => {
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: FAKE_GROUPS,
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
+ });
+ await waitForPromises();
+ expect(findSelectionCount().text()).toMatchInterpolatedText('0 selected');
+ await triggerSelectAllCheckbox();
+ expect(findSelectionCount().text()).toMatchInterpolatedText('2 selected');
+ await triggerSelectAllCheckbox();
+ expect(findSelectionCount().text()).toMatchInterpolatedText('0 selected');
+ });
+
it('import selected button is disabled when no groups selected', async () => {
createComponent({
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
@@ -330,6 +379,7 @@ describe('import table', () => {
bulkImportSourceGroups: () => ({
nodes: FAKE_GROUPS,
pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
@@ -346,6 +396,7 @@ describe('import table', () => {
bulkImportSourceGroups: () => ({
nodes: NEW_GROUPS,
pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
@@ -368,6 +419,7 @@ describe('import table', () => {
bulkImportSourceGroups: () => ({
nodes: NEW_GROUPS,
pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
}),
});
await waitForPromises();
@@ -391,6 +443,7 @@ describe('import table', () => {
bulkImportSourceGroups: () => ({
nodes: NEW_GROUPS,
pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
}),
});
jest.spyOn(apolloProvider.defaultClient, 'mutate');
@@ -421,4 +474,38 @@ describe('import table', () => {
});
});
});
+
+ describe('unavailable features warning', () => {
+ it('renders alert when there are unavailable features', async () => {
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: FAKE_GROUPS,
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
+ });
+ await waitForPromises();
+
+ expect(wrapper.find(GlAlert).exists()).toBe(true);
+ expect(wrapper.find(GlAlert).text()).toContain('projects (require v14.8.0)');
+ });
+
+ it('does not renders alert when there are no unavailable features', async () => {
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: FAKE_GROUPS,
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: {
+ features: {
+ projectMigration: { available: true, minVersion: '14.8.0' },
+ sourceInstanceVersion: '14.6.0',
+ },
+ },
+ }),
+ });
+ await waitForPromises();
+
+ expect(wrapper.find(GlAlert).exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js
index 3c2367e22f5..d3f86672f33 100644
--- a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js
+++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js
@@ -123,13 +123,22 @@ describe('import target cell', () => {
});
describe('when entity is available for import', () => {
+ const FAKE_PROGRESS_MESSAGE = 'progress message';
beforeEach(() => {
- group = generateFakeTableEntry({ id: 1, flags: { isAvailableForImport: true } });
+ group = generateFakeTableEntry({
+ id: 1,
+ flags: { isAvailableForImport: true },
+ progress: { message: FAKE_PROGRESS_MESSAGE },
+ });
createComponent({ group });
});
it('renders namespace dropdown as enabled', () => {
expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined);
});
+
+ it('renders progress message as error if it exists', () => {
+ expect(wrapper.find('[role=alert]').text()).toBe(FAKE_PROGRESS_MESSAGE);
+ });
});
});
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 f3447494578..c6ddce17fe4 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
@@ -163,12 +163,34 @@ describe('Bulk import resolvers', () => {
});
describe('mutations', () => {
- beforeEach(() => {
- axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
- });
+ beforeEach(() => {});
describe('importGroup', () => {
- it('sets import status to CREATED when request completes', async () => {
+ it('sets import status to CREATED for successful groups when request completes', async () => {
+ axiosMockAdapter
+ .onPost(FAKE_ENDPOINTS.createBulkImport)
+ .reply(httpStatus.OK, [{ success: true, id: 1 }]);
+
+ await client.mutate({
+ mutation: importGroupsMutation,
+ variables: {
+ importRequests: [
+ {
+ sourceGroupId: statusEndpointFixture.importable_data[0].id,
+ newName: 'test',
+ targetNamespace: 'root',
+ },
+ ],
+ },
+ });
+
+ await axios.waitForAll();
+ expect(results[0].progress.status).toBe(STATUSES.CREATED);
+ });
+
+ it('sets import status to CREATED for successful groups when request completes with legacy response', async () => {
+ axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
+
await client.mutate({
mutation: importGroupsMutation,
variables: {
@@ -185,9 +207,37 @@ describe('Bulk import resolvers', () => {
await axios.waitForAll();
expect(results[0].progress.status).toBe(STATUSES.CREATED);
});
+
+ it('sets import status to FAILED and sets progress message for failed groups when request completes', async () => {
+ const FAKE_ERROR_MESSAGE = 'foo';
+ axiosMockAdapter
+ .onPost(FAKE_ENDPOINTS.createBulkImport)
+ .reply(httpStatus.OK, [{ success: false, id: 1, message: FAKE_ERROR_MESSAGE }]);
+
+ await client.mutate({
+ mutation: importGroupsMutation,
+ variables: {
+ importRequests: [
+ {
+ sourceGroupId: statusEndpointFixture.importable_data[0].id,
+ newName: 'test',
+ targetNamespace: 'root',
+ },
+ ],
+ },
+ });
+
+ await axios.waitForAll();
+ expect(results[0].progress.status).toBe(STATUSES.FAILED);
+ expect(results[0].progress.message).toBe(FAKE_ERROR_MESSAGE);
+ });
});
it('updateImportStatus updates status', async () => {
+ axiosMockAdapter
+ .onPost(FAKE_ENDPOINTS.createBulkImport)
+ .reply(httpStatus.OK, [{ success: true, id: 1 }]);
+
const NEW_STATUS = 'dummy';
await client.mutate({
mutation: importGroupsMutation,
@@ -216,6 +266,7 @@ describe('Bulk import resolvers', () => {
expect(statusInResponse).toStrictEqual({
__typename: clientTypenames.BulkImportProgress,
id,
+ message: null,
status: NEW_STATUS,
});
});
diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js
index 5f6f9987a8f..ed4e343f331 100644
--- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js
+++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js
@@ -1,7 +1,7 @@
import { STATUSES } from '~/import_entities/constants';
import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory';
-export const generateFakeEntry = ({ id, status, ...rest }) => ({
+export const generateFakeEntry = ({ id, status, message, ...rest }) => ({
__typename: clientTypenames.BulkImportSourceGroup,
webUrl: `https://fake.host/${id}`,
fullPath: `fake_group_${id}`,
@@ -18,6 +18,7 @@ export const generateFakeEntry = ({ id, status, ...rest }) => ({
: {
id,
status,
+ message: message || '',
},
...rest,
});
@@ -49,6 +50,12 @@ export const statusEndpointFixture = {
web_url: 'https://gitlab.com/groups/gitlab-examples',
},
],
+ version_validation: {
+ features: {
+ project_migration: { available: false, min_version: '14.8.0' },
+ source_instance_version: '14.6.0',
+ },
+ },
};
export const availableNamespacesFixture = Object.freeze([
diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
index 2a976c04319..feee14c9c40 100644
--- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
+++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap
@@ -14,6 +14,7 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
<gl-form-group-stub
class="col-8 col-md-9 gl-p-0"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-toggle-stub
id="active"
@@ -28,10 +29,12 @@ exports[`Alert integration settings form should match the default snapshot 1`] =
label="Webhook URL"
label-for="url"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-input-group-stub
data-testid="webhook-url"
id="url"
+ inputclass=""
predefinedoptions="[object Object]"
readonly=""
value="pagerduty.webhook.com"
diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
index df7ffd19747..0dc31616166 100644
--- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js
+++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
@@ -34,16 +34,22 @@ describe('ActiveCheckbox', () => {
});
});
- describe('initialActivated is false', () => {
- it('renders GlFormCheckbox as unchecked', () => {
+ describe('initialActivated is `false`', () => {
+ beforeEach(() => {
createComponent({
initialActivated: false,
});
+ });
+ it('renders GlFormCheckbox as unchecked', () => {
expect(findGlFormCheckbox().exists()).toBe(true);
expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false);
expect(findInputInCheckbox().attributes('disabled')).toBeUndefined();
});
+
+ it('emits `toggle-integration-active` event with `false` on mount', () => {
+ expect(wrapper.emitted('toggle-integration-active')[0]).toEqual([false]);
+ });
});
describe('initialActivated is true', () => {
@@ -63,10 +69,21 @@ describe('ActiveCheckbox', () => {
findInputInCheckbox().trigger('click');
await wrapper.vm.$nextTick();
-
expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false);
});
});
+
+ it('emits `toggle-integration-active` event with `true` on mount', () => {
+ expect(wrapper.emitted('toggle-integration-active')[0]).toEqual([true]);
+ });
+
+ describe('on checkbox `change` event', () => {
+ it('emits `toggle-integration-active` event', () => {
+ findGlFormCheckbox().vm.$emit('change', false);
+
+ expect(wrapper.emitted('toggle-integration-active')[1]).toEqual([false]);
+ });
+ });
});
});
});
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index 0a9cbadb249..4c1394f3a87 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -1,6 +1,8 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import * as Sentry from '@sentry/browser';
import { setHTMLFixture } from 'helpers/fixtures';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-
import { mockIntegrationProps } from 'jest/integrations/edit/mock_data';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
@@ -11,11 +13,27 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
-import { integrationLevels } from '~/integrations/constants';
+import waitForPromises from 'helpers/wait_for_promises';
+import {
+ integrationLevels,
+ I18N_SUCCESSFUL_CONNECTION_MESSAGE,
+ VALIDATE_INTEGRATION_FORM_EVENT,
+ I18N_DEFAULT_ERROR_MESSAGE,
+} from '~/integrations/constants';
import { createStore } from '~/integrations/edit/store';
+import eventHub from '~/integrations/edit/event_hub';
+import httpStatus from '~/lib/utils/http_status';
+
+jest.mock('~/integrations/edit/event_hub');
+jest.mock('@sentry/browser');
describe('IntegrationForm', () => {
+ const mockToastShow = jest.fn();
+
let wrapper;
+ let dispatch;
+ let mockAxios;
+ let mockForm;
const createComponent = ({
customStateProps = {},
@@ -23,12 +41,18 @@ describe('IntegrationForm', () => {
initialState = {},
props = {},
} = {}) => {
+ const store = createStore({
+ customState: { ...mockIntegrationProps, ...customStateProps },
+ ...initialState,
+ });
+ dispatch = jest.spyOn(store, 'dispatch').mockImplementation();
+
wrapper = shallowMountExtended(IntegrationForm, {
- propsData: { ...props },
- store: createStore({
- customState: { ...mockIntegrationProps, ...customStateProps },
- ...initialState,
- }),
+ propsData: { ...props, formSelector: '.test' },
+ provide: {
+ glFeatures: featureFlags,
+ },
+ store,
stubs: {
OverrideDropdown,
ActiveCheckbox,
@@ -36,46 +60,42 @@ describe('IntegrationForm', () => {
JiraTriggerFields,
TriggerFields,
},
- provide: {
- glFeatures: featureFlags,
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
},
});
};
- afterEach(() => {
- wrapper.destroy();
- });
+ const createForm = ({ isValid = true } = {}) => {
+ mockForm = document.createElement('form');
+ jest.spyOn(document, 'querySelector').mockReturnValue(mockForm);
+ jest.spyOn(mockForm, 'checkValidity').mockReturnValue(isValid);
+ jest.spyOn(mockForm, 'submit');
+ };
const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown);
const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox);
const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal);
const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal);
const findResetButton = () => wrapper.findByTestId('reset-button');
+ const findSaveButton = () => wrapper.findByTestId('save-button');
+ const findTestButton = () => wrapper.findByTestId('test-button');
const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields);
const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields);
const findTriggerFields = () => wrapper.findComponent(TriggerFields);
- describe('template', () => {
- describe('showActive is true', () => {
- it('renders ActiveCheckbox', () => {
- createComponent();
-
- expect(findActiveCheckbox().exists()).toBe(true);
- });
- });
-
- describe('showActive is false', () => {
- it('does not render ActiveCheckbox', () => {
- createComponent({
- customStateProps: {
- showActive: false,
- },
- });
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+ });
- expect(findActiveCheckbox().exists()).toBe(false);
- });
- });
+ afterEach(() => {
+ wrapper.destroy();
+ mockAxios.restore();
+ });
+ describe('template', () => {
describe('integrationLevel is instance', () => {
it('renders ConfirmationModal', () => {
createComponent({
@@ -195,13 +215,29 @@ describe('IntegrationForm', () => {
});
describe('type is "jira"', () => {
- it('renders JiraTriggerFields', () => {
+ beforeEach(() => {
+ jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form'));
+
createComponent({
- customStateProps: { type: 'jira' },
+ customStateProps: { type: 'jira', testPath: '/test' },
});
+ });
+ it('renders JiraTriggerFields', () => {
expect(findJiraTriggerFields().exists()).toBe(true);
});
+
+ it('renders JiraIssuesFields', () => {
+ expect(findJiraIssuesFields().exists()).toBe(true);
+ });
+
+ describe('when JiraIssueFields emits `request-jira-issue-types` event', () => {
+ it('dispatches `requestJiraIssueTypes` action', () => {
+ findJiraIssuesFields().vm.$emit('request-jira-issue-types');
+
+ expect(dispatch).toHaveBeenCalledWith('requestJiraIssueTypes', expect.any(FormData));
+ });
+ });
});
describe('triggerEvents is present', () => {
@@ -303,4 +339,210 @@ describe('IntegrationForm', () => {
});
});
});
+
+ describe('ActiveCheckbox', () => {
+ describe.each`
+ showActive
+ ${true}
+ ${false}
+ `('when `showActive` is $showActive', ({ showActive }) => {
+ it(`${showActive ? 'renders' : 'does not render'} ActiveCheckbox`, () => {
+ createComponent({
+ customStateProps: {
+ showActive,
+ },
+ });
+
+ expect(findActiveCheckbox().exists()).toBe(showActive);
+ });
+ });
+
+ describe.each`
+ formActive | novalidate
+ ${true} | ${null}
+ ${false} | ${'true'}
+ `(
+ 'when `toggle-integration-active` is emitted with $formActive',
+ ({ formActive, novalidate }) => {
+ beforeEach(async () => {
+ createForm();
+ createComponent({
+ customStateProps: {
+ showActive: true,
+ initialActivated: false,
+ },
+ });
+
+ await findActiveCheckbox().vm.$emit('toggle-integration-active', formActive);
+ });
+
+ it(`sets noValidate to ${novalidate}`, () => {
+ expect(mockForm.getAttribute('novalidate')).toBe(novalidate);
+ });
+ },
+ );
+ });
+
+ describe('when `save` button is clicked', () => {
+ describe('buttons', () => {
+ beforeEach(async () => {
+ createForm();
+ createComponent({
+ customStateProps: {
+ showActive: true,
+ canTest: true,
+ initialActivated: true,
+ },
+ });
+
+ await findSaveButton().vm.$emit('click', new Event('click'));
+ });
+
+ it('sets save button `loading` prop to `true`', () => {
+ expect(findSaveButton().props('loading')).toBe(true);
+ });
+
+ it('sets test button `disabled` prop to `true`', () => {
+ expect(findTestButton().props('disabled')).toBe(true);
+ });
+ });
+
+ describe.each`
+ checkValidityReturn | integrationActive
+ ${true} | ${false}
+ ${true} | ${true}
+ ${false} | ${false}
+ `(
+ 'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)',
+ ({ integrationActive, checkValidityReturn }) => {
+ beforeEach(async () => {
+ createForm({ isValid: checkValidityReturn });
+ createComponent({
+ customStateProps: {
+ showActive: true,
+ canTest: true,
+ initialActivated: integrationActive,
+ },
+ });
+
+ await findSaveButton().vm.$emit('click', new Event('click'));
+ });
+
+ it('submit form', () => {
+ expect(mockForm.submit).toHaveBeenCalledTimes(1);
+ });
+ },
+ );
+
+ describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => {
+ beforeEach(async () => {
+ createForm({ isValid: false });
+ createComponent({
+ customStateProps: {
+ showActive: true,
+ canTest: true,
+ initialActivated: true,
+ },
+ });
+
+ await findSaveButton().vm.$emit('click', new Event('click'));
+ });
+
+ it('does not submit form', () => {
+ expect(mockForm.submit).not.toHaveBeenCalled();
+ });
+
+ it('sets save button `loading` prop to `false`', () => {
+ expect(findSaveButton().props('loading')).toBe(false);
+ });
+
+ it('sets test button `disabled` prop to `false`', () => {
+ expect(findTestButton().props('disabled')).toBe(false);
+ });
+
+ it('emits `VALIDATE_INTEGRATION_FORM_EVENT`', () => {
+ expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT);
+ });
+ });
+ });
+
+ describe('when `test` button is clicked', () => {
+ describe('when form is invalid', () => {
+ it('emits `VALIDATE_INTEGRATION_FORM_EVENT` event to the event hub', () => {
+ createForm({ isValid: false });
+ createComponent({
+ customStateProps: {
+ showActive: true,
+ canTest: true,
+ },
+ });
+
+ findTestButton().vm.$emit('click', new Event('click'));
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(VALIDATE_INTEGRATION_FORM_EVENT);
+ });
+ });
+
+ describe('when form is valid', () => {
+ const mockTestPath = '/test';
+
+ beforeEach(() => {
+ createForm({ isValid: true });
+ createComponent({
+ customStateProps: {
+ showActive: true,
+ canTest: true,
+ testPath: mockTestPath,
+ },
+ });
+ });
+
+ describe('buttons', () => {
+ beforeEach(async () => {
+ await findTestButton().vm.$emit('click', new Event('click'));
+ });
+
+ it('sets test button `loading` prop to `true`', () => {
+ expect(findTestButton().props('loading')).toBe(true);
+ });
+
+ it('sets save button `disabled` prop to `true`', () => {
+ expect(findSaveButton().props('disabled')).toBe(true);
+ });
+ });
+
+ describe.each`
+ scenario | replyStatus | errorMessage | expectToast | expectSentry
+ ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true}
+ ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${'an error'} | ${false}
+ ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false}
+ `('$scenario', ({ replyStatus, errorMessage, expectToast, expectSentry }) => {
+ beforeEach(async () => {
+ mockAxios.onPut(mockTestPath).replyOnce(replyStatus, {
+ error: Boolean(errorMessage),
+ message: errorMessage,
+ });
+
+ await findTestButton().vm.$emit('click', new Event('click'));
+ await waitForPromises();
+ });
+
+ it(`calls toast with '${expectToast}'`, () => {
+ expect(mockToastShow).toHaveBeenCalledWith(expectToast);
+ });
+
+ it('sets `loading` prop of test button to `false`', () => {
+ expect(findTestButton().props('loading')).toBe(false);
+ });
+
+ it('sets save button `disabled` prop to `false`', () => {
+ expect(findSaveButton().props('disabled')).toBe(false);
+ });
+
+ it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => {
+ expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0);
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
index 3a664b652ac..b5a8eed3598 100644
--- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
@@ -1,10 +1,7 @@
import { GlFormCheckbox, GlFormInput } from '@gitlab/ui';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import {
- GET_JIRA_ISSUE_TYPES_EVENT,
- VALIDATE_INTEGRATION_FORM_EVENT,
-} from '~/integrations/constants';
+import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import eventHub from '~/integrations/edit/event_hub';
import { createStore } from '~/integrations/edit/store';
@@ -216,13 +213,11 @@ describe('JiraIssuesFields', () => {
);
});
- it('emits "getJiraIssueTypes" to the eventHub when the jira-vulnerabilities component requests to fetch issue types', async () => {
- const eventHubEmitSpy = jest.spyOn(eventHub, '$emit');
-
+ it('emits "request-jira-issue-types` when the jira-vulnerabilities component requests to fetch issue types', async () => {
await setEnableCheckbox(true);
- await findJiraForVulnerabilities().vm.$emit('request-get-issue-types');
+ await findJiraForVulnerabilities().vm.$emit('request-jira-issue-types');
- expect(eventHubEmitSpy).toHaveBeenCalledWith(GET_JIRA_ISSUE_TYPES_EVENT);
+ expect(wrapper.emitted('request-jira-issue-types')).toHaveLength(1);
});
});
diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js
index 27ba0768331..3c45ed0fb1b 100644
--- a/spec/frontend/integrations/edit/mock_data.js
+++ b/spec/frontend/integrations/edit/mock_data.js
@@ -14,3 +14,9 @@ export const mockIntegrationProps = {
type: '',
inheritFromId: 25,
};
+
+export const mockJiraIssueTypes = [
+ { id: '1', name: 'issue', description: 'issue' },
+ { id: '2', name: 'bug', description: 'bug' },
+ { id: '3', name: 'epic', description: 'epic' },
+];
diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js
index e2f4c138ece..b413de2b286 100644
--- a/spec/frontend/integrations/edit/store/actions_spec.js
+++ b/spec/frontend/integrations/edit/store/actions_spec.js
@@ -1,8 +1,9 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
+import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE } from '~/integrations/constants';
import {
setOverride,
- setIsSaving,
- setIsTesting,
setIsResetting,
requestResetIntegration,
receiveResetIntegrationSuccess,
@@ -14,14 +15,21 @@ import {
import * as types from '~/integrations/edit/store/mutation_types';
import createState from '~/integrations/edit/store/state';
import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import { mockJiraIssueTypes } from '../mock_data';
jest.mock('~/lib/utils/url_utility');
describe('Integration form store actions', () => {
let state;
+ let mockAxios;
beforeEach(() => {
state = createState();
+ mockAxios = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
});
describe('setOverride', () => {
@@ -30,18 +38,6 @@ describe('Integration form store actions', () => {
});
});
- describe('setIsSaving', () => {
- it('should commit isSaving mutation', () => {
- return testAction(setIsSaving, true, state, [{ type: types.SET_IS_SAVING, payload: true }]);
- });
- });
-
- describe('setIsTesting', () => {
- it('should commit isTesting mutation', () => {
- return testAction(setIsTesting, true, state, [{ type: types.SET_IS_TESTING, payload: true }]);
- });
- });
-
describe('setIsResetting', () => {
it('should commit isResetting mutation', () => {
return testAction(setIsResetting, true, state, [
@@ -75,11 +71,28 @@ describe('Integration form store actions', () => {
});
describe('requestJiraIssueTypes', () => {
- it('should commit SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE and SET_IS_LOADING_JIRA_ISSUE_TYPES mutations', () => {
- return testAction(requestJiraIssueTypes, null, state, [
- { type: types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, payload: '' },
- { type: types.SET_IS_LOADING_JIRA_ISSUE_TYPES, payload: true },
- ]);
+ describe.each`
+ scenario | responseCode | response | action
+ ${'when successful'} | ${200} | ${{ issuetypes: mockJiraIssueTypes }} | ${{ type: 'receiveJiraIssueTypesSuccess', payload: mockJiraIssueTypes }}
+ ${'when response has no issue types'} | ${200} | ${{ issuetypes: [] }} | ${{ type: 'receiveJiraIssueTypesError', payload: I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }}
+ ${'when response includes error'} | ${200} | ${{ error: new Error() }} | ${{ type: 'receiveJiraIssueTypesError', payload: I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE }}
+ ${'when error occurs'} | ${500} | ${{}} | ${{ type: 'receiveJiraIssueTypesError', payload: expect.any(String) }}
+ `('$scenario', ({ responseCode, response, action }) => {
+ it(`should commit SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE and SET_IS_LOADING_JIRA_ISSUE_TYPES mutations, and dispatch ${action.type}`, () => {
+ mockAxios.onPut('/test').replyOnce(responseCode, response);
+
+ return testAction(
+ requestJiraIssueTypes,
+ new FormData(),
+ { propsSource: { testPath: '/test' } },
+ [
+ // should clear the error messages and set the loading state
+ { type: types.SET_JIRA_ISSUE_TYPES_ERROR_MESSAGE, payload: '' },
+ { type: types.SET_IS_LOADING_JIRA_ISSUE_TYPES, payload: true },
+ ],
+ [action],
+ );
+ });
});
});
diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js
index ad7a887dff2..3353e0c84cc 100644
--- a/spec/frontend/integrations/edit/store/getters_spec.js
+++ b/spec/frontend/integrations/edit/store/getters_spec.js
@@ -1,11 +1,4 @@
-import {
- currentKey,
- isInheriting,
- isDisabled,
- propsSource,
-} from '~/integrations/edit/store/getters';
-import * as types from '~/integrations/edit/store/mutation_types';
-import mutations from '~/integrations/edit/store/mutations';
+import { currentKey, isInheriting, propsSource } from '~/integrations/edit/store/getters';
import createState from '~/integrations/edit/store/state';
import { mockIntegrationProps } from '../mock_data';
@@ -52,29 +45,6 @@ describe('Integration form store getters', () => {
});
});
- describe('isDisabled', () => {
- it.each`
- isSaving | isTesting | isResetting | expected
- ${false} | ${false} | ${false} | ${false}
- ${true} | ${false} | ${false} | ${true}
- ${false} | ${true} | ${false} | ${true}
- ${false} | ${false} | ${true} | ${true}
- ${false} | ${true} | ${true} | ${true}
- ${true} | ${false} | ${true} | ${true}
- ${true} | ${true} | ${false} | ${true}
- ${true} | ${true} | ${true} | ${true}
- `(
- 'when isSaving = $isSaving, isTesting = $isTesting, isResetting = $isResetting then isDisabled = $expected',
- ({ isSaving, isTesting, isResetting, expected }) => {
- mutations[types.SET_IS_SAVING](state, isSaving);
- mutations[types.SET_IS_TESTING](state, isTesting);
- mutations[types.SET_IS_RESETTING](state, isResetting);
-
- expect(isDisabled(state)).toBe(expected);
- },
- );
- });
-
describe('propsSource', () => {
beforeEach(() => {
state.defaultState = defaultState;
diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js
index 18faa2f6bba..641547550d1 100644
--- a/spec/frontend/integrations/edit/store/mutations_spec.js
+++ b/spec/frontend/integrations/edit/store/mutations_spec.js
@@ -17,22 +17,6 @@ describe('Integration form store mutations', () => {
});
});
- describe(`${types.SET_IS_SAVING}`, () => {
- it('sets isSaving', () => {
- mutations[types.SET_IS_SAVING](state, true);
-
- expect(state.isSaving).toBe(true);
- });
- });
-
- describe(`${types.SET_IS_TESTING}`, () => {
- it('sets isTesting', () => {
- mutations[types.SET_IS_TESTING](state, true);
-
- expect(state.isTesting).toBe(true);
- });
- });
-
describe(`${types.SET_IS_RESETTING}`, () => {
it('sets isResetting', () => {
mutations[types.SET_IS_RESETTING](state, true);
diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js
index 6cd84836395..5582be7fd3c 100644
--- a/spec/frontend/integrations/edit/store/state_spec.js
+++ b/spec/frontend/integrations/edit/store/state_spec.js
@@ -6,7 +6,6 @@ describe('Integration form state factory', () => {
defaultState: null,
customState: {},
isSaving: false,
- isTesting: false,
isResetting: false,
override: false,
isLoadingJiraIssueTypes: false,
diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js
deleted file mode 100644
index c35d178e518..00000000000
--- a/spec/frontend/integrations/integration_settings_form_spec.js
+++ /dev/null
@@ -1,248 +0,0 @@
-import MockAdaptor from 'axios-mock-adapter';
-import IntegrationSettingsForm from '~/integrations/integration_settings_form';
-import eventHub from '~/integrations/edit/event_hub';
-import axios from '~/lib/utils/axios_utils';
-import toast from '~/vue_shared/plugins/global_toast';
-import {
- I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE,
- I18N_SUCCESSFUL_CONNECTION_MESSAGE,
- I18N_DEFAULT_ERROR_MESSAGE,
- GET_JIRA_ISSUE_TYPES_EVENT,
- TOGGLE_INTEGRATION_EVENT,
- TEST_INTEGRATION_EVENT,
- SAVE_INTEGRATION_EVENT,
-} from '~/integrations/constants';
-import waitForPromises from 'helpers/wait_for_promises';
-
-jest.mock('~/vue_shared/plugins/global_toast');
-jest.mock('lodash/delay', () => (callback) => callback());
-
-const FIXTURE = 'services/edit_service.html';
-
-describe('IntegrationSettingsForm', () => {
- let integrationSettingsForm;
-
- const mockStoreDispatch = () => jest.spyOn(integrationSettingsForm.vue.$store, 'dispatch');
-
- beforeEach(() => {
- loadFixtures(FIXTURE);
-
- integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- integrationSettingsForm.init();
- });
-
- describe('constructor', () => {
- it('should initialize form element refs on class object', () => {
- expect(integrationSettingsForm.$form).toBeDefined();
- expect(integrationSettingsForm.$form.nodeName).toBe('FORM');
- expect(integrationSettingsForm.formActive).toBeDefined();
- });
-
- it('should initialize form metadata on class object', () => {
- expect(integrationSettingsForm.testEndPoint).toBeDefined();
- });
- });
-
- describe('event handling', () => {
- let mockAxios;
-
- beforeEach(() => {
- mockAxios = new MockAdaptor(axios);
- jest.spyOn(axios, 'put');
- });
-
- afterEach(() => {
- mockAxios.restore();
- eventHub.dispose(); // clear event hub handlers
- });
-
- describe('when event hub receives `TOGGLE_INTEGRATION_EVENT`', () => {
- it('should remove `novalidate` attribute to form when called with `true`', () => {
- eventHub.$emit(TOGGLE_INTEGRATION_EVENT, true);
-
- expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null);
- });
-
- it('should set `novalidate` attribute to form when called with `false`', () => {
- eventHub.$emit(TOGGLE_INTEGRATION_EVENT, false);
-
- expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe('novalidate');
- });
- });
-
- describe('when event hub receives `TEST_INTEGRATION_EVENT`', () => {
- describe('when form is valid', () => {
- beforeEach(() => {
- jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true);
- });
-
- it('should make an ajax request with provided `formData`', async () => {
- eventHub.$emit(TEST_INTEGRATION_EVENT);
- await waitForPromises();
-
- expect(axios.put).toHaveBeenCalledWith(
- integrationSettingsForm.testEndPoint,
- new FormData(integrationSettingsForm.$form),
- );
- });
-
- it('should show success message if test is successful', async () => {
- jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {});
-
- mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: false,
- });
-
- eventHub.$emit(TEST_INTEGRATION_EVENT);
- await waitForPromises();
-
- expect(toast).toHaveBeenCalledWith(I18N_SUCCESSFUL_CONNECTION_MESSAGE);
- });
-
- it('should show error message if ajax request responds with test error', async () => {
- const errorMessage = 'Test failed.';
- const serviceResponse = 'some error';
-
- mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: true,
- message: errorMessage,
- service_response: serviceResponse,
- test_failed: false,
- });
-
- eventHub.$emit(TEST_INTEGRATION_EVENT);
- await waitForPromises();
-
- expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`);
- });
-
- it('should show error message if ajax request failed', async () => {
- mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError();
-
- eventHub.$emit(TEST_INTEGRATION_EVENT);
- await waitForPromises();
-
- expect(toast).toHaveBeenCalledWith(I18N_DEFAULT_ERROR_MESSAGE);
- });
-
- it('should always dispatch `setIsTesting` with `false` once request is completed', async () => {
- const dispatchSpy = mockStoreDispatch();
- mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError();
-
- eventHub.$emit(TEST_INTEGRATION_EVENT);
- await waitForPromises();
-
- expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false);
- });
- });
-
- describe('when form is invalid', () => {
- beforeEach(() => {
- jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false);
- jest.spyOn(integrationSettingsForm, 'testSettings');
- });
-
- it('should dispatch `setIsTesting` with `false` and not call `testSettings`', async () => {
- const dispatchSpy = mockStoreDispatch();
-
- eventHub.$emit(TEST_INTEGRATION_EVENT);
- await waitForPromises();
-
- expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false);
- expect(integrationSettingsForm.testSettings).not.toHaveBeenCalled();
- });
- });
- });
-
- describe('when event hub receives `GET_JIRA_ISSUE_TYPES_EVENT`', () => {
- it('should always dispatch `requestJiraIssueTypes`', () => {
- const dispatchSpy = mockStoreDispatch();
- mockAxios.onPut(integrationSettingsForm.testEndPoint).networkError();
-
- eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
-
- expect(dispatchSpy).toHaveBeenCalledWith('requestJiraIssueTypes');
- });
-
- it('should make an ajax request with provided `formData`', () => {
- eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
-
- expect(axios.put).toHaveBeenCalledWith(
- integrationSettingsForm.testEndPoint,
- new FormData(integrationSettingsForm.$form),
- );
- });
-
- it('should dispatch `receiveJiraIssueTypesSuccess` with the correct payload if ajax request is successful', async () => {
- const dispatchSpy = mockStoreDispatch();
- const mockData = ['ISSUE', 'EPIC'];
- mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: false,
- issuetypes: mockData,
- });
-
- eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
- await waitForPromises();
-
- expect(dispatchSpy).toHaveBeenCalledWith('receiveJiraIssueTypesSuccess', mockData);
- });
-
- it.each(['Custom error message here', undefined])(
- 'should dispatch "receiveJiraIssueTypesError" with a message if the backend responds with error',
- async (responseErrorMessage) => {
- const dispatchSpy = mockStoreDispatch();
-
- const expectedErrorMessage =
- responseErrorMessage || I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE;
- mockAxios.onPut(integrationSettingsForm.testEndPoint).reply(200, {
- error: true,
- message: responseErrorMessage,
- });
-
- eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
- await waitForPromises();
-
- expect(dispatchSpy).toHaveBeenCalledWith(
- 'receiveJiraIssueTypesError',
- expectedErrorMessage,
- );
- },
- );
- });
-
- describe('when event hub receives `SAVE_INTEGRATION_EVENT`', () => {
- describe('when form is valid', () => {
- beforeEach(() => {
- jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(true);
- jest.spyOn(integrationSettingsForm.$form, 'submit');
- });
-
- it('should submit the form', async () => {
- eventHub.$emit(SAVE_INTEGRATION_EVENT);
- await waitForPromises();
-
- expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
- expect(integrationSettingsForm.$form.submit).toHaveBeenCalledTimes(1);
- });
- });
-
- describe('when form is invalid', () => {
- beforeEach(() => {
- jest.spyOn(integrationSettingsForm.$form, 'checkValidity').mockReturnValue(false);
- jest.spyOn(integrationSettingsForm.$form, 'submit');
- });
-
- it('should dispatch `setIsSaving` with `false` and not submit form', async () => {
- const dispatchSpy = mockStoreDispatch();
-
- eventHub.$emit(SAVE_INTEGRATION_EVENT);
-
- await waitForPromises();
-
- expect(dispatchSpy).toHaveBeenCalledWith('setIsSaving', false);
- expect(integrationSettingsForm.$form.submit).not.toHaveBeenCalled();
- });
- });
- });
- });
-});
diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js
index ae89d05cead..8abd83887f7 100644
--- a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js
+++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js
@@ -8,6 +8,7 @@ import IntegrationOverrides from '~/integrations/overrides/components/integratio
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
const mockOverrides = Array(DEFAULT_PER_PAGE * 3)
.fill(1)
@@ -26,9 +27,10 @@ describe('IntegrationOverrides', () => {
overridesPath: 'mock/overrides',
};
- const createComponent = ({ mountFn = shallowMount } = {}) => {
+ const createComponent = ({ mountFn = shallowMount, stubs } = {}) => {
wrapper = mountFn(IntegrationOverrides, {
propsData: defaultProps,
+ stubs,
});
};
@@ -127,27 +129,58 @@ describe('IntegrationOverrides', () => {
});
describe('pagination', () => {
- it('triggers fetch when `input` event is emitted', async () => {
- createComponent();
- jest.spyOn(axios, 'get');
- await waitForPromises();
+ describe('when total items does not exceed the page limit', () => {
+ it('does not render', async () => {
+ mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], {
+ 'X-TOTAL': DEFAULT_PER_PAGE - 1,
+ 'X-PAGE': 1,
+ });
+
+ createComponent();
+
+ // wait for initial load
+ await waitForPromises();
- await findPagination().vm.$emit('input', 2);
- expect(axios.get).toHaveBeenCalledWith(defaultProps.overridesPath, {
- params: { page: 2, per_page: DEFAULT_PER_PAGE },
+ expect(findPagination().exists()).toBe(false);
});
});
- it('does not render with <=1 page', async () => {
- mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], {
- 'X-TOTAL': 1,
- 'X-PAGE': 1,
+ describe('when total items exceeds the page limit', () => {
+ const mockPage = 2;
+
+ beforeEach(async () => {
+ createComponent({ stubs: { UrlSync } });
+ mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.OK, [mockOverrides[0]], {
+ 'X-TOTAL': DEFAULT_PER_PAGE * 2,
+ 'X-PAGE': mockPage,
+ });
+
+ // wait for initial load
+ await waitForPromises();
});
- createComponent();
- await waitForPromises();
+ it('renders', () => {
+ expect(findPagination().exists()).toBe(true);
+ });
- expect(findPagination().exists()).toBe(false);
+ describe('when navigating to a page', () => {
+ beforeEach(async () => {
+ jest.spyOn(axios, 'get');
+
+ // trigger a page change
+ await findPagination().vm.$emit('input', mockPage);
+ });
+
+ it('performs GET request with correct params', () => {
+ expect(axios.get).toHaveBeenCalledWith(defaultProps.overridesPath, {
+ params: { page: mockPage, per_page: DEFAULT_PER_PAGE },
+ });
+ });
+
+ it('updates `page` URL parameter', () => {
+ expect(window.location.search).toBe(`?page=${mockPage}`);
+ });
+ });
});
});
});
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 5be79004640..e190ddf243e 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -6,7 +6,6 @@ import {
GlSprintf,
GlLink,
GlModal,
- GlFormCheckboxGroup,
} from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { stubComponent } from 'helpers/stub_component';
@@ -18,8 +17,6 @@ import InviteMembersModal from '~/invite_members/components/invite_members_modal
import ModalConfetti from '~/invite_members/components/confetti.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import {
- INVITE_MEMBERS_IN_COMMENT,
- MEMBER_AREAS_OF_FOCUS,
INVITE_MEMBERS_FOR_TASK,
CANCEL_BUTTON_TEXT,
INVITE_BUTTON_TEXT,
@@ -28,6 +25,7 @@ import {
MEMBERS_MODAL_DEFAULT_TITLE,
MEMBERS_PLACEHOLDER,
MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT,
+ LEARN_GITLAB,
} from '~/invite_members/constants';
import eventHub from '~/invite_members/event_hub';
import axios from '~/lib/utils/axios_utils';
@@ -51,12 +49,7 @@ 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 tasksToBeDoneOptions = [
{ text: 'First task', value: 'first' },
{ text: 'Second task', value: 'second' },
@@ -95,9 +88,7 @@ const createComponent = (data = {}, props = {}) => {
isProject,
inviteeType,
accessLevels,
- areasOfFocusOptions,
defaultAccessLevel,
- noSelectionAreasOfFocus,
tasksToBeDoneOptions,
projects,
helpLink,
@@ -163,7 +154,6 @@ describe('InviteMembersModal', () => {
const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback');
const membersFormGroupDescription = () => findMembersFormGroup().props('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
- const findAreaofFocusCheckBoxGroup = () => wrapper.findComponent(GlFormCheckboxGroup);
const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done');
const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks');
const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select');
@@ -214,21 +204,6 @@ describe('InviteMembersModal', () => {
});
});
- 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('rendering the tasks to be done', () => {
const setupComponent = (
extraData = {},
@@ -268,6 +243,14 @@ describe('InviteMembersModal', () => {
expect(findTasksToBeDone().exists()).toBe(false);
});
+
+ describe('when opened from the Learn GitLab page', () => {
+ it('does render the tasks to be done', () => {
+ setupComponent({ source: LEARN_GITLAB }, {}, []);
+
+ expect(findTasksToBeDone().exists()).toBe(true);
+ });
+ });
});
describe('rendering the tasks', () => {
@@ -433,20 +416,6 @@ describe('InviteMembersModal', () => {
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.";
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',
@@ -454,7 +423,6 @@ describe('InviteMembersModal', () => {
expires_at: undefined,
invite_source: inviteSource,
format: 'json',
- areas_of_focus: noSelectionAreasOfFocus,
tasks_to_be_done: [],
tasks_project_id: '',
};
@@ -465,17 +433,6 @@ 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);
});
describe('when triggered from regular mounting', () => {
@@ -492,7 +449,23 @@ describe('InviteMembersModal', () => {
});
it('displays the successful toastMessage', () => {
- expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
+ onComplete: expect.any(Function),
+ });
+ });
+ });
+
+ describe('when opened from a Learn GitLab page', () => {
+ it('emits the `showSuccessfulInvitationsAlert` event', async () => {
+ eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB });
+
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+
+ clickInviteButton();
+
+ await waitForPromises();
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('showSuccessfulInvitationsAlert');
});
});
});
@@ -637,7 +610,6 @@ describe('InviteMembersModal', () => {
expires_at: undefined,
email: 'email@example.com',
invite_source: inviteSource,
- areas_of_focus: noSelectionAreasOfFocus,
tasks_to_be_done: [],
tasks_project_id: '',
format: 'json',
@@ -649,17 +621,6 @@ 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();
-
- expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, updatedPostData);
});
describe('when triggered from regular mounting', () => {
@@ -672,7 +633,9 @@ describe('InviteMembersModal', () => {
});
it('displays the successful toastMessage', () => {
- expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
+ onComplete: expect.any(Function),
+ });
});
});
});
@@ -711,13 +674,14 @@ describe('InviteMembersModal', () => {
it('displays the successful toast message when email has already been invited', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
clickInviteButton();
await waitForPromises();
- expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
+ onComplete: expect.any(Function),
+ });
expect(findMembersSelect().props('validationState')).toBe(null);
});
@@ -766,7 +730,6 @@ describe('InviteMembersModal', () => {
access_level: defaultAccessLevel,
expires_at: undefined,
invite_source: inviteSource,
- areas_of_focus: noSelectionAreasOfFocus,
format: 'json',
tasks_to_be_done: [],
tasks_project_id: '',
@@ -782,8 +745,6 @@ describe('InviteMembersModal', () => {
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
- jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
- jest.spyOn(wrapper.vm, 'trackInvite');
});
describe('when triggered from regular mounting', () => {
@@ -800,7 +761,9 @@ describe('InviteMembersModal', () => {
});
it('displays the successful toastMessage', () => {
- expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
+ onComplete: expect.any(Function),
+ });
});
});
@@ -855,7 +818,6 @@ describe('InviteMembersModal', () => {
wrapper.setData({ inviteeType: 'group' });
wrapper.vm.$toast = { show: jest.fn() };
jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData });
- jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
clickInviteButton();
});
@@ -865,7 +827,9 @@ describe('InviteMembersModal', () => {
});
it('displays the successful toastMessage', () => {
- expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled();
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added', {
+ onComplete: expect.any(Function),
+ });
});
});
@@ -898,47 +862,11 @@ describe('InviteMembersModal', () => {
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({});
});
- it('tracks the invite', () => {
- eventHub.$emit('openModal', { inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT });
-
- clickInviteButton();
-
- expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT);
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_success');
- });
-
- it('does not track invite for unknown source', () => {
- eventHub.$emit('openModal', { inviteeType: 'members', source: 'unknown' });
-
- clickInviteButton();
-
- expect(ExperimentTracking).not.toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT);
- });
-
- it('does not track invite undefined source', () => {
- 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' });
+ it('tracks the view for learn_gitlab source', () => {
+ eventHub.$emit('openModal', { inviteeType: 'members', source: LEARN_GITLAB });
- 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).toHaveBeenCalledWith(MEMBER_AREAS_OF_FOCUS.name);
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(
- MEMBER_AREAS_OF_FOCUS.submit,
- );
+ expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_FOR_TASK.name);
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith(LEARN_GITLAB);
});
});
});
diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
index 3fce23f854c..429b6fad24a 100644
--- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -1,6 +1,5 @@
import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
import eventHub from '~/invite_members/event_hub';
import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '~/invite_members/constants';
@@ -79,19 +78,6 @@ describe.each(triggerItems)('with triggerElement as %s', (triggerItem) => {
});
describe('tracking', () => {
- it('tracks on mounting', () => {
- createComponent({ trackExperiment: '_track_experiment_' });
-
- expect(ExperimentTracking).toHaveBeenCalledWith('_track_experiment_');
- expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_shown');
- });
-
- it('does not track on mounting', () => {
- createComponent();
-
- expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_');
- });
-
it('does not add tracking attributes', () => {
createComponent();
diff --git a/spec/frontend/issuable_bulk_update_sidebar/components/status_select_spec.js b/spec/frontend/issuable/bulk_update_sidebar/components/status_select_spec.js
index 09dcb963154..8ecbf41ce56 100644
--- a/spec/frontend/issuable_bulk_update_sidebar/components/status_select_spec.js
+++ b/spec/frontend/issuable/bulk_update_sidebar/components/status_select_spec.js
@@ -1,7 +1,7 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import StatusSelect from '~/issuable_bulk_update_sidebar/components/status_select.vue';
-import { ISSUE_STATUS_SELECT_OPTIONS } from '~/issuable_bulk_update_sidebar/constants';
+import StatusSelect from '~/issuable/bulk_update_sidebar/components/status_select.vue';
+import { ISSUE_STATUS_SELECT_OPTIONS } from '~/issuable/bulk_update_sidebar/constants';
describe('StatusSelect', () => {
let wrapper;
diff --git a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js
index ad8331afcff..c8380e42787 100644
--- a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js
+++ b/spec/frontend/issuable/components/issuable_header_warnings_spec.js
@@ -1,16 +1,15 @@
-import { createLocalVue } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createStore as createMrStore } from '~/mr_notes/stores';
import createIssueStore from '~/notes/stores';
-import IssuableHeaderWarnings from '~/vue_shared/components/issuable/issuable_header_warnings.vue';
+import IssuableHeaderWarnings from '~/issuable/components/issuable_header_warnings.vue';
const ISSUABLE_TYPE_ISSUE = 'issue';
const ISSUABLE_TYPE_MR = 'merge request';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
describe('IssuableHeaderWarnings', () => {
let wrapper;
@@ -24,7 +23,6 @@ describe('IssuableHeaderWarnings', () => {
const createComponent = ({ store, provide }) => {
wrapper = shallowMountExtended(IssuableHeaderWarnings, {
store,
- localVue,
provide,
directives: {
GlTooltip: createMockDirective(),
diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/issuable/components/issue_assignees_spec.js
index f74b9b37197..713c8b1dfdd 100644
--- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js
+++ b/spec/frontend/issuable/components/issue_assignees_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { mockAssigneesList } from 'jest/boards/mock_data';
-import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import IssueAssignees from '~/issuable/components/issue_assignees.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
const TEST_CSS_CLASSES = 'test-classes';
diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/issuable/components/issue_milestone_spec.js
index 9a121050225..44416676180 100644
--- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js
+++ b/spec/frontend/issuable/components/issue_milestone_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import { mockMilestone } from 'jest/boards/mock_data';
-import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
+import IssueMilestone from '~/issuable/components/issue_milestone.vue';
const createComponent = (milestone = mockMilestone) => {
const Component = Vue.extend(IssueMilestone);
diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/issuable/components/related_issuable_item_spec.js
index 6ab828efebe..6ac4c9e8546 100644
--- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
+++ b/spec/frontend/issuable/components/related_issuable_item_spec.js
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { formatDate } from '~/lib/utils/datetime_utility';
-import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data';
describe('RelatedIssuableItem', () => {
diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js b/spec/frontend/issuable/components/related_issuable_mock_data.js
index 6cdb945ec20..6cdb945ec20 100644
--- a/spec/frontend/vue_shared/components/issue/related_issuable_mock_data.js
+++ b/spec/frontend/issuable/components/related_issuable_mock_data.js
diff --git a/spec/frontend/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js
index c77fde4261e..321c61ead1e 100644
--- a/spec/frontend/issuable_form_spec.js
+++ b/spec/frontend/issuable/issuable_form_spec.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
-import IssuableForm from '~/issuable_form';
+import IssuableForm from '~/issuable/issuable_form';
function createIssuable() {
const instance = new IssuableForm($(document.createElement('form')));
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
index a450f912c4e..608fec45bbd 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -4,7 +4,7 @@ import {
issuable1,
issuable2,
issuable3,
-} from 'jest/vue_shared/components/issue/related_issuable_mock_data';
+} from 'jest/issuable/components/related_issuable_mock_data';
import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue';
import {
linkedIssueTypesMap,
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
index ffd9683cd6b..c7df3755e88 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
@@ -5,7 +5,7 @@ import {
issuable3,
issuable4,
issuable5,
-} from 'jest/vue_shared/components/issue/related_issuable_mock_data';
+} from 'jest/issuable/components/related_issuable_mock_data';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import RelatedIssuesList from '~/related_issues/components/related_issues_list.vue';
import { PathIdSeparator } from '~/related_issues/constants';
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
index 3099e0b639b..01de4da7900 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
@@ -5,7 +5,7 @@ import {
defaultProps,
issuable1,
issuable2,
-} from 'jest/vue_shared/components/issue/related_issuable_mock_data';
+} from 'jest/issuable/components/related_issuable_mock_data';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
diff --git a/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js b/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js
index ada1c44560f..4a6bd832fba 100644
--- a/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js
+++ b/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js
@@ -4,7 +4,7 @@ import {
issuable3,
issuable4,
issuable5,
-} from 'jest/vue_shared/components/issue/related_issuable_mock_data';
+} from 'jest/issuable/components/related_issuable_mock_data';
import RelatedIssuesStore from '~/related_issues/stores/related_issues_store';
describe('RelatedIssuesStore', () => {
diff --git a/spec/frontend/issuable_spec.js b/spec/frontend/issuable_spec.js
deleted file mode 100644
index e0bd7b802c9..00000000000
--- a/spec/frontend/issuable_spec.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
-import IssuableIndex from '~/issuable_index';
-
-describe('Issuable', () => {
- describe('initBulkUpdate', () => {
- it('should not set bulkUpdateSidebar', () => {
- new IssuableIndex('issue_'); // eslint-disable-line no-new
-
- expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeNull();
- });
-
- it('should set bulkUpdateSidebar', () => {
- const element = document.createElement('div');
- element.classList.add('issues-bulk-update');
- document.body.appendChild(element);
-
- new IssuableIndex('issue_'); // eslint-disable-line no-new
-
- expect(issuableInitBulkUpdateSidebar.bulkUpdateSidebar).toBeDefined();
- });
- });
-});
diff --git a/spec/frontend/issue_spec.js b/spec/frontend/issues/issue_spec.js
index 952ef54d286..8a089b372ff 100644
--- a/spec/frontend/issue_spec.js
+++ b/spec/frontend/issues/issue_spec.js
@@ -1,7 +1,7 @@
import { getByText } from '@testing-library/dom';
import MockAdapter from 'axios-mock-adapter';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import Issue from '~/issue';
+import Issue from '~/issues/issue';
import axios from '~/lib/utils/axios_utils';
describe('Issue', () => {
diff --git a/spec/frontend/issuable_type_selector/components/__snapshots__/info_popover_spec.js.snap b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap
index 196fbb8a643..881dcda126f 100644
--- a/spec/frontend/issuable_type_selector/components/__snapshots__/info_popover_spec.js.snap
+++ b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap
@@ -1,12 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Issuable type info popover renders 1`] = `
+exports[`Issue type info popover renders 1`] = `
<span
id="popovercontainer"
>
<gl-icon-stub
class="gl-ml-5 gl-text-gray-500"
- id="issuable-type-info"
+ id="issue-type-info"
name="question-o"
size="16"
/>
@@ -14,7 +14,7 @@ exports[`Issuable type info popover renders 1`] = `
<gl-popover-stub
container="popovercontainer"
cssclasses=""
- target="issuable-type-info"
+ target="issue-type-info"
title="Issue types"
triggers="focus hover"
>
diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issues/new/components/title_suggestions_item_spec.js
index 45f96103e3e..5eb30b52de5 100644
--- a/spec/frontend/issuable_suggestions/components/item_spec.js
+++ b/spec/frontend/issues/new/components/title_suggestions_item_spec.js
@@ -1,15 +1,15 @@
import { GlTooltip, GlLink, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
-import Suggestion from '~/issuable_suggestions/components/item.vue';
+import TitleSuggestionsItem from '~/issues/new/components/title_suggestions_item.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import mockData from '../mock_data';
-describe('Issuable suggestions suggestion component', () => {
+describe('Issue title suggestions item component', () => {
let wrapper;
function createComponent(suggestion = {}) {
- wrapper = shallowMount(Suggestion, {
+ wrapper = shallowMount(TitleSuggestionsItem, {
propsData: {
suggestion: {
...mockData(),
diff --git a/spec/frontend/issuable_suggestions/components/app_spec.js b/spec/frontend/issues/new/components/title_suggestions_spec.js
index fb8ef00567c..984d0c9d25b 100644
--- a/spec/frontend/issuable_suggestions/components/app_spec.js
+++ b/spec/frontend/issues/new/components/title_suggestions_spec.js
@@ -1,12 +1,12 @@
import { shallowMount } from '@vue/test-utils';
-import App from '~/issuable_suggestions/components/app.vue';
-import Suggestion from '~/issuable_suggestions/components/item.vue';
+import TitleSuggestions from '~/issues/new/components/title_suggestions.vue';
+import TitleSuggestionsItem from '~/issues/new/components/title_suggestions_item.vue';
-describe('Issuable suggestions app component', () => {
+describe('Issue title suggestions component', () => {
let wrapper;
function createComponent(search = 'search') {
- wrapper = shallowMount(App, {
+ wrapper = shallowMount(TitleSuggestions, {
propsData: {
search,
projectPath: 'project',
@@ -77,7 +77,7 @@ describe('Issuable suggestions app component', () => {
wrapper.setData(data);
return wrapper.vm.$nextTick(() => {
- expect(wrapper.findAll(Suggestion).length).toBe(2);
+ expect(wrapper.findAll(TitleSuggestionsItem).length).toBe(2);
});
});
diff --git a/spec/frontend/issuable_type_selector/components/info_popover_spec.js b/spec/frontend/issues/new/components/type_popover_spec.js
index 975977ffeb3..fe3d5207516 100644
--- a/spec/frontend/issuable_type_selector/components/info_popover_spec.js
+++ b/spec/frontend/issues/new/components/type_popover_spec.js
@@ -1,11 +1,11 @@
import { shallowMount } from '@vue/test-utils';
-import InfoPopover from '~/issuable_type_selector/components/info_popover.vue';
+import TypePopover from '~/issues/new/components/type_popover.vue';
-describe('Issuable type info popover', () => {
+describe('Issue type info popover', () => {
let wrapper;
function createComponent() {
- wrapper = shallowMount(InfoPopover);
+ wrapper = shallowMount(TypePopover);
}
afterEach(() => {
diff --git a/spec/frontend/issuable_suggestions/mock_data.js b/spec/frontend/issues/new/mock_data.js
index 74b569d9833..74b569d9833 100644
--- a/spec/frontend/issuable_suggestions/mock_data.js
+++ b/spec/frontend/issues/new/mock_data.js
diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js
index 486fb699275..4d780a674be 100644
--- a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js
+++ b/spec/frontend/issues/related_merge_requests/components/related_merge_requests_spec.js
@@ -2,9 +2,9 @@ import { mount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import mockData from 'test_fixtures/issues/related_merge_requests.json';
import axios from '~/lib/utils/axios_utils';
-import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue';
-import createStore from '~/related_merge_requests/store/index';
-import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import RelatedMergeRequests from '~/issues/related_merge_requests/components/related_merge_requests.vue';
+import createStore from '~/issues/related_merge_requests/store/index';
+import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests';
const localVue = createLocalVue();
diff --git a/spec/frontend/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js
index 3bd07c34b6f..5f232fee09b 100644
--- a/spec/frontend/related_merge_requests/store/actions_spec.js
+++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js
@@ -2,8 +2,8 @@ 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 '~/related_merge_requests/store/actions';
-import * as types from '~/related_merge_requests/store/mutation_types';
+import * as actions from '~/issues/related_merge_requests/store/actions';
+import * as types from '~/issues/related_merge_requests/store/mutation_types';
jest.mock('~/flash');
diff --git a/spec/frontend/related_merge_requests/store/mutations_spec.js b/spec/frontend/issues/related_merge_requests/store/mutations_spec.js
index 436c7dca6ce..0e3d26b3879 100644
--- a/spec/frontend/related_merge_requests/store/mutations_spec.js
+++ b/spec/frontend/issues/related_merge_requests/store/mutations_spec.js
@@ -1,5 +1,5 @@
-import * as types from '~/related_merge_requests/store/mutation_types';
-import mutations from '~/related_merge_requests/store/mutations';
+import * as types from '~/issues/related_merge_requests/store/mutation_types';
+import mutations from '~/issues/related_merge_requests/store/mutations';
describe('RelatedMergeRequests Store Mutations', () => {
describe('SET_INITIAL_STATE', () => {
diff --git a/spec/frontend/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js b/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js
index 772d6903052..5a51ae3cfe0 100644
--- a/spec/frontend/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js
+++ b/spec/frontend/issues/sentry_error_stack_trace/components/sentry_error_stack_trace_spec.js
@@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import Stacktrace from '~/error_tracking/components/stacktrace.vue';
-import SentryErrorStackTrace from '~/sentry_error_stack_trace/components/sentry_error_stack_trace.vue';
+import SentryErrorStackTrace from '~/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js
index e32215b4aa6..02db82b84dc 100644
--- a/spec/frontend/issue_show/components/app_spec.js
+++ b/spec/frontend/issues/show/components/app_spec.js
@@ -4,12 +4,13 @@ import { nextTick } from 'vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import '~/behaviors/markdown/render_gfm';
-import IssuableApp from '~/issue_show/components/app.vue';
-import DescriptionComponent from '~/issue_show/components/description.vue';
-import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
-import PinnedLinks from '~/issue_show/components/pinned_links.vue';
-import { IssuableStatus, IssuableStatusText, POLLING_DELAY } from '~/issue_show/constants';
-import eventHub from '~/issue_show/event_hub';
+import { IssuableStatus, IssuableStatusText } from '~/issues/constants';
+import IssuableApp from '~/issues/show/components/app.vue';
+import DescriptionComponent from '~/issues/show/components/description.vue';
+import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue';
+import PinnedLinks from '~/issues/show/components/pinned_links.vue';
+import { POLLING_DELAY } from '~/issues/show/constants';
+import eventHub from '~/issues/show/event_hub';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import {
@@ -25,7 +26,7 @@ function formatText(text) {
}
jest.mock('~/lib/utils/url_utility');
-jest.mock('~/issue_show/event_hub');
+jest.mock('~/issues/show/event_hub');
const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
@@ -325,44 +326,6 @@ describe('Issuable output', () => {
});
});
- describe('deleteIssuable', () => {
- it('changes URL when deleted', () => {
- jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockResolvedValue({
- data: {
- web_url: '/test',
- },
- });
-
- return wrapper.vm.deleteIssuable().then(() => {
- expect(visitUrl).toHaveBeenCalledWith('/test');
- });
- });
-
- it('stops polling when deleting', () => {
- const spy = jest.spyOn(wrapper.vm.poll, 'stop');
- jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockResolvedValue({
- data: {
- web_url: '/test',
- },
- });
-
- return wrapper.vm.deleteIssuable().then(() => {
- expect(spy).toHaveBeenCalledWith();
- });
- });
-
- it('closes form on error', () => {
- jest.spyOn(wrapper.vm.service, 'deleteIssuable').mockRejectedValue();
-
- return wrapper.vm.deleteIssuable().then(() => {
- expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form');
- expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
- 'Error deleting issue',
- );
- });
- });
- });
-
describe('updateAndShowForm', () => {
it('shows locked warning if form is open & data is different', () => {
return wrapper.vm
diff --git a/spec/frontend/issues/show/components/delete_issue_modal_spec.js b/spec/frontend/issues/show/components/delete_issue_modal_spec.js
new file mode 100644
index 00000000000..97a091a1748
--- /dev/null
+++ b/spec/frontend/issues/show/components/delete_issue_modal_spec.js
@@ -0,0 +1,108 @@
+import { GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+describe('DeleteIssueModal component', () => {
+ let wrapper;
+
+ const defaultProps = {
+ issuePath: 'gitlab-org/gitlab-test/-/issues/1',
+ issueType: 'issue',
+ modalId: 'modal-id',
+ title: 'Delete issue',
+ };
+
+ const findForm = () => wrapper.find('form');
+ const findModal = () => wrapper.findComponent(GlModal);
+
+ const mountComponent = (props = {}) =>
+ shallowMount(DeleteIssueModal, { propsData: { ...defaultProps, ...props } });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('modal', () => {
+ it('renders', () => {
+ wrapper = mountComponent();
+
+ expect(findModal().props()).toMatchObject({
+ actionCancel: DeleteIssueModal.actionCancel,
+ actionPrimary: {
+ attributes: { variant: 'danger' },
+ text: defaultProps.title,
+ },
+ modalId: defaultProps.modalId,
+ size: 'sm',
+ title: defaultProps.title,
+ });
+ });
+
+ describe('when "primary" event is emitted', () => {
+ let formSubmitSpy;
+
+ beforeEach(() => {
+ wrapper = mountComponent();
+ formSubmitSpy = jest.spyOn(wrapper.vm.$refs.form, 'submit');
+ findModal().vm.$emit('primary');
+ });
+
+ it('"delete" event is emitted by DeleteIssueModal', () => {
+ expect(wrapper.emitted('delete')).toEqual([[]]);
+ });
+
+ it('submits the form', () => {
+ expect(formSubmitSpy).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('form', () => {
+ beforeEach(() => {
+ wrapper = mountComponent();
+ });
+
+ it('renders with action and method', () => {
+ expect(findForm().attributes()).toEqual({
+ action: defaultProps.issuePath,
+ method: 'post',
+ });
+ });
+
+ it('contains form data', () => {
+ const formData = wrapper.findAll('input').wrappers.reduce(
+ (acc, input) => ({
+ ...acc,
+ [input.element.name]: input.element.value,
+ }),
+ {},
+ );
+
+ expect(formData).toEqual({
+ _method: 'delete',
+ authenticity_token: 'mock-csrf-token',
+ destroy_confirm: 'true',
+ });
+ });
+ });
+
+ describe('body text', () => {
+ describe('when issue type is not epic', () => {
+ it('renders', () => {
+ wrapper = mountComponent();
+
+ expect(findForm().text()).toBe('Issue will be removed! Are you sure?');
+ });
+ });
+
+ describe('when issue type is epic', () => {
+ it('renders', () => {
+ wrapper = mountComponent({ issueType: 'epic' });
+
+ expect(findForm().text()).toBe('Delete this epic and all descendants?');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index bdcc82cab81..d39e00b9c9e 100644
--- a/spec/frontend/issue_show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import '~/behaviors/markdown/render_gfm';
import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
-import Description from '~/issue_show/components/description.vue';
+import Description from '~/issues/show/components/description.vue';
import TaskList from '~/task_list';
import { descriptionProps as props } from '../mock_data/mock_data';
diff --git a/spec/frontend/issue_show/components/edit_actions_spec.js b/spec/frontend/issues/show/components/edit_actions_spec.js
index 50c27cb5bda..79368023d76 100644
--- a/spec/frontend/issue_show/components/edit_actions_spec.js
+++ b/spec/frontend/issues/show/components/edit_actions_spec.js
@@ -1,25 +1,25 @@
-import { GlButton, GlModal } from '@gitlab/ui';
-import { createLocalVue } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import IssuableEditActions from '~/issue_show/components/edit_actions.vue';
-import eventHub from '~/issue_show/event_hub';
-
+import IssuableEditActions from '~/issues/show/components/edit_actions.vue';
+import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue';
+import eventHub from '~/issues/show/event_hub';
import {
getIssueStateQueryResponse,
updateIssueStateQueryResponse,
} from '../mock_data/apollo_mock';
-const localVue = createLocalVue();
-localVue.use(VueApollo);
-
describe('Edit Actions component', () => {
let wrapper;
let fakeApollo;
let mockIssueStateData;
+ Vue.use(VueApollo);
+
const mockResolvers = {
Query: {
issueState() {
@@ -43,6 +43,7 @@ describe('Edit Actions component', () => {
title: 'GitLab Issue',
},
canDestroy: true,
+ endpoint: 'gitlab-org/gitlab-test/-/issues/1',
issuableType: 'issue',
...props,
},
@@ -56,11 +57,7 @@ describe('Edit Actions component', () => {
});
};
- async function deleteIssuable(localWrapper) {
- localWrapper.findComponent(GlModal).vm.$emit('primary');
- }
-
- const findModal = () => wrapper.findComponent(GlModal);
+ const findModal = () => wrapper.findComponent(DeleteIssueModal);
const findEditButtons = () => wrapper.findAllComponents(GlButton);
const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button');
const findSaveButton = () => wrapper.findByTestId('issuable-save-button');
@@ -123,9 +120,30 @@ describe('Edit Actions component', () => {
});
});
- describe('renders create modal with the correct information', () => {
- it('renders correct modal id', () => {
- expect(findModal().attributes('modalid')).toBe(modalId);
+ describe('delete issue button', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it('tracks clicking on button', () => {
+ findDeleteButton().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'delete_issue',
+ });
+ });
+ });
+
+ describe('delete issue modal', () => {
+ it('renders', () => {
+ expect(findModal().props()).toEqual({
+ issuePath: 'gitlab-org/gitlab-test/-/issues/1',
+ issueType: 'Issue',
+ modalId,
+ title: 'Delete issue',
+ });
});
});
@@ -141,8 +159,8 @@ describe('Edit Actions component', () => {
it('sends the `delete.issuable` event when clicking the delete confirm button', async () => {
expect(eventHub.$emit).toHaveBeenCalledTimes(0);
- await deleteIssuable(wrapper);
- expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true });
+ findModal().vm.$emit('delete');
+ expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable');
expect(eventHub.$emit).toHaveBeenCalledTimes(1);
});
});
diff --git a/spec/frontend/issue_show/components/edited_spec.js b/spec/frontend/issues/show/components/edited_spec.js
index a1683f060c0..8a8fe23230a 100644
--- a/spec/frontend/issue_show/components/edited_spec.js
+++ b/spec/frontend/issues/show/components/edited_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import edited from '~/issue_show/components/edited.vue';
+import edited from '~/issues/show/components/edited.vue';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
diff --git a/spec/frontend/issue_show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js
index a50be30cf4c..3043c4c3673 100644
--- a/spec/frontend/issue_show/components/fields/description_spec.js
+++ b/spec/frontend/issues/show/components/fields/description_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import DescriptionField from '~/issue_show/components/fields/description.vue';
-import eventHub from '~/issue_show/event_hub';
+import DescriptionField from '~/issues/show/components/fields/description.vue';
+import eventHub from '~/issues/show/event_hub';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
describe('Description field component', () => {
diff --git a/spec/frontend/issue_show/components/fields/description_template_spec.js b/spec/frontend/issues/show/components/fields/description_template_spec.js
index dc126c53f5e..abe2805e5b2 100644
--- a/spec/frontend/issue_show/components/fields/description_template_spec.js
+++ b/spec/frontend/issues/show/components/fields/description_template_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import descriptionTemplate from '~/issue_show/components/fields/description_template.vue';
+import descriptionTemplate from '~/issues/show/components/fields/description_template.vue';
describe('Issue description template component with templates as hash', () => {
let vm;
diff --git a/spec/frontend/issue_show/components/fields/title_spec.js b/spec/frontend/issues/show/components/fields/title_spec.js
index 783ce9eb76c..efd0b6fbd30 100644
--- a/spec/frontend/issue_show/components/fields/title_spec.js
+++ b/spec/frontend/issues/show/components/fields/title_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import TitleField from '~/issue_show/components/fields/title.vue';
-import eventHub from '~/issue_show/event_hub';
+import TitleField from '~/issues/show/components/fields/title.vue';
+import eventHub from '~/issues/show/event_hub';
describe('Title field component', () => {
let wrapper;
diff --git a/spec/frontend/issue_show/components/fields/type_spec.js b/spec/frontend/issues/show/components/fields/type_spec.js
index 95ae6f37877..3ece10e70db 100644
--- a/spec/frontend/issue_show/components/fields/type_spec.js
+++ b/spec/frontend/issues/show/components/fields/type_spec.js
@@ -3,8 +3,8 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import IssueTypeField, { i18n } from '~/issue_show/components/fields/type.vue';
-import { IssuableTypes } from '~/issue_show/constants';
+import IssueTypeField, { i18n } from '~/issues/show/components/fields/type.vue';
+import { IssuableTypes } from '~/issues/show/constants';
import {
getIssueStateQueryResponse,
updateIssueStateQueryResponse,
diff --git a/spec/frontend/issue_show/components/form_spec.js b/spec/frontend/issues/show/components/form_spec.js
index 28498cb90ec..db49d2635ba 100644
--- a/spec/frontend/issue_show/components/form_spec.js
+++ b/spec/frontend/issues/show/components/form_spec.js
@@ -1,11 +1,11 @@
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Autosave from '~/autosave';
-import DescriptionTemplate from '~/issue_show/components/fields/description_template.vue';
-import IssueTypeField from '~/issue_show/components/fields/type.vue';
-import formComponent from '~/issue_show/components/form.vue';
-import LockedWarning from '~/issue_show/components/locked_warning.vue';
-import eventHub from '~/issue_show/event_hub';
+import DescriptionTemplate from '~/issues/show/components/fields/description_template.vue';
+import IssueTypeField from '~/issues/show/components/fields/type.vue';
+import formComponent from '~/issues/show/components/form.vue';
+import LockedWarning from '~/issues/show/components/locked_warning.vue';
+import eventHub from '~/issues/show/event_hub';
jest.mock('~/autosave');
@@ -13,6 +13,7 @@ describe('Inline edit form component', () => {
let wrapper;
const defaultProps = {
canDestroy: true,
+ endpoint: 'gitlab-org/gitlab-test/-/issues/1',
formState: {
title: 'b',
description: 'a',
diff --git a/spec/frontend/issue_show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js
index 4df62ec8717..2a16c699c4d 100644
--- a/spec/frontend/issue_show/components/header_actions_spec.js
+++ b/spec/frontend/issues/show/components/header_actions_spec.js
@@ -1,11 +1,15 @@
import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
+import { mockTracking } from 'helpers/tracking_helper';
import createFlash, { FLASH_TYPES } from '~/flash';
-import { IssuableType } from '~/issuable_show/constants';
-import HeaderActions from '~/issue_show/components/header_actions.vue';
-import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
-import promoteToEpicMutation from '~/issue_show/queries/promote_to_epic.mutation.graphql';
+import { IssuableType } from '~/vue_shared/issuable/show/constants';
+import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue';
+import HeaderActions from '~/issues/show/components/header_actions.vue';
+import { IssuableStatus } from '~/issues/constants';
+import { IssueStateEvent } from '~/issues/show/constants';
+import promoteToEpicMutation from '~/issues/show/queries/promote_to_epic.mutation.graphql';
import * as urlUtility from '~/lib/utils/url_utility';
import eventHub from '~/notes/event_hub';
import createStore from '~/notes/stores';
@@ -18,18 +22,20 @@ describe('HeaderActions component', () => {
let wrapper;
let visitUrlSpy;
- const localVue = createLocalVue();
- localVue.use(Vuex);
+ Vue.use(Vuex);
+
const store = createStore();
const defaultProps = {
canCreateIssue: true,
+ canDestroyIssue: true,
canPromoteToEpic: true,
canReopenIssue: true,
canReportSpam: true,
canUpdateIssue: true,
iid: '32',
isIssueAuthor: true,
+ issuePath: 'gitlab-org/gitlab-test/-/issues/1',
issueType: IssuableType.Issue,
newIssuePath: 'gitlab-org/gitlab-test/-/issues/new',
projectPath: 'gitlab-org/gitlab-test',
@@ -60,17 +66,12 @@ describe('HeaderActions component', () => {
},
};
- const findToggleIssueStateButton = () => wrapper.find(GlButton);
-
- const findDropdownAt = (index) => wrapper.findAll(GlDropdown).at(index);
-
- const findMobileDropdownItems = () => findDropdownAt(0).findAll(GlDropdownItem);
-
- const findDesktopDropdownItems = () => findDropdownAt(1).findAll(GlDropdownItem);
-
- const findModal = () => wrapper.find(GlModal);
-
- const findModalLinkAt = (index) => findModal().findAll(GlLink).at(index);
+ const findToggleIssueStateButton = () => wrapper.findComponent(GlButton);
+ const findDropdownAt = (index) => wrapper.findAllComponents(GlDropdown).at(index);
+ const findMobileDropdownItems = () => findDropdownAt(0).findAllComponents(GlDropdownItem);
+ const findDesktopDropdownItems = () => findDropdownAt(1).findAllComponents(GlDropdownItem);
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findModalLinkAt = (index) => findModal().findAllComponents(GlLink).at(index);
const mountComponent = ({
props = {},
@@ -86,7 +87,6 @@ describe('HeaderActions component', () => {
});
return shallowMount(HeaderActions, {
- localVue,
store,
provide: {
...defaultProps,
@@ -167,17 +167,19 @@ describe('HeaderActions component', () => {
${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems}
`('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => {
describe.each`
- description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic
- ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true}
- ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true}
- ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false}
- ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
- ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
- ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true}
+ description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue
+ ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true}
+ ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true}
+ ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true}
+ ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
+ ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false}
`(
'$description',
({
@@ -188,6 +190,7 @@ describe('HeaderActions component', () => {
isIssueAuthor,
canReportSpam,
canPromoteToEpic,
+ canDestroyIssue,
}) => {
beforeEach(() => {
wrapper = mountComponent({
@@ -198,6 +201,7 @@ describe('HeaderActions component', () => {
issueType,
canReportSpam,
canPromoteToEpic,
+ canDestroyIssue,
},
});
});
@@ -214,6 +218,23 @@ describe('HeaderActions component', () => {
});
});
+ describe('delete issue button', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ wrapper = mountComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it('tracks clicking on button', () => {
+ findDesktopDropdownItems().at(3).vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_dropdown', {
+ label: 'delete_issue',
+ });
+ });
+ });
+
describe('when "Promote to epic" button is clicked', () => {
describe('when response is successful', () => {
beforeEach(() => {
@@ -267,7 +288,7 @@ describe('HeaderActions component', () => {
it('shows an error message', () => {
expect(createFlash).toHaveBeenCalledWith({
- message: promoteToEpicMutationErrorResponse.data.promoteToEpic.errors.join('; '),
+ message: HeaderActions.i18n.promoteErrorMessage,
});
});
});
@@ -293,7 +314,7 @@ describe('HeaderActions component', () => {
});
});
- describe('modal', () => {
+ describe('blocked by issues modal', () => {
const blockedByIssues = [
{ iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' },
{ iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' },
@@ -345,4 +366,17 @@ describe('HeaderActions component', () => {
});
});
});
+
+ describe('delete issue modal', () => {
+ it('renders', () => {
+ wrapper = mountComponent();
+
+ expect(wrapper.findComponent(DeleteIssueModal).props()).toEqual({
+ issuePath: defaultProps.issuePath,
+ issueType: defaultProps.issueType,
+ modalId: HeaderActions.deleteModalId,
+ title: 'Delete issue',
+ });
+ });
+ });
});
diff --git a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js
index 6758e6192b8..a4910d63bb5 100644
--- a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js
+++ b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js
@@ -1,7 +1,7 @@
import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import merge from 'lodash/merge';
-import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue';
+import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue';
import { formatDate } from '~/lib/utils/datetime_utility';
jest.mock('~/lib/utils/datetime_utility');
diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
index 6b9f5b17e99..9bf0e106194 100644
--- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js
+++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
@@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils';
import merge from 'lodash/merge';
import waitForPromises from 'helpers/wait_for_promises';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
-import DescriptionComponent from '~/issue_show/components/description.vue';
-import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue';
-import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
+import DescriptionComponent from '~/issues/show/components/description.vue';
+import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue';
+import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue';
import INVALID_URL from '~/lib/utils/invalid_url';
import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
diff --git a/spec/frontend/issue_show/components/pinned_links_spec.js b/spec/frontend/issues/show/components/pinned_links_spec.js
index 3fe1f9fd6d9..aac720df6e9 100644
--- a/spec/frontend/issue_show/components/pinned_links_spec.js
+++ b/spec/frontend/issues/show/components/pinned_links_spec.js
@@ -1,7 +1,7 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import PinnedLinks from '~/issue_show/components/pinned_links.vue';
-import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '~/issue_show/constants';
+import PinnedLinks from '~/issues/show/components/pinned_links.vue';
+import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '~/issues/show/constants';
const plainZoomUrl = 'https://zoom.us/j/123456789';
const plainStatusUrl = 'https://status.com';
diff --git a/spec/frontend/issue_show/components/title_spec.js b/spec/frontend/issues/show/components/title_spec.js
index 78880a7f540..f9026557be2 100644
--- a/spec/frontend/issue_show/components/title_spec.js
+++ b/spec/frontend/issues/show/components/title_spec.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
-import titleComponent from '~/issue_show/components/title.vue';
-import eventHub from '~/issue_show/event_hub';
-import Store from '~/issue_show/stores';
+import titleComponent from '~/issues/show/components/title.vue';
+import eventHub from '~/issues/show/event_hub';
+import Store from '~/issues/show/stores';
describe('Title component', () => {
let vm;
diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issues/show/issue_spec.js
index 76989413edb..6d7a31a6c8c 100644
--- a/spec/frontend/issue_show/issue_spec.js
+++ b/spec/frontend/issues/show/issue_spec.js
@@ -1,7 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
-import { initIssuableApp } from '~/issue_show/issue';
-import * as parseData from '~/issue_show/utils/parse_data';
+import { initIssuableApp } from '~/issues/show/issue';
+import * as parseData from '~/issues/show/utils/parse_data';
import axios from '~/lib/utils/axios_utils';
import createStore from '~/notes/stores';
import { appProps } from './mock_data/mock_data';
@@ -17,7 +17,7 @@ const setupHTML = (initialData) => {
};
describe('Issue show index', () => {
- describe('initIssueableApp', () => {
+ describe('initIssuableApp', () => {
it('should initialize app with no potential XSS attack', async () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData');
diff --git a/spec/frontend/issue_show/mock_data/apollo_mock.js b/spec/frontend/issues/show/mock_data/apollo_mock.js
index bfd31e74393..bfd31e74393 100644
--- a/spec/frontend/issue_show/mock_data/apollo_mock.js
+++ b/spec/frontend/issues/show/mock_data/apollo_mock.js
diff --git a/spec/frontend/issue_show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js
index a73826954c3..a73826954c3 100644
--- a/spec/frontend/issue_show/mock_data/mock_data.js
+++ b/spec/frontend/issues/show/mock_data/mock_data.js
diff --git a/spec/frontend/issue_show/store_spec.js b/spec/frontend/issues/show/store_spec.js
index b7fd70bf00e..20d3a6cdaae 100644
--- a/spec/frontend/issue_show/store_spec.js
+++ b/spec/frontend/issues/show/store_spec.js
@@ -1,7 +1,7 @@
-import Store from '~/issue_show/stores';
-import updateDescription from '~/issue_show/utils/update_description';
+import Store from '~/issues/show/stores';
+import updateDescription from '~/issues/show/utils/update_description';
-jest.mock('~/issue_show/utils/update_description');
+jest.mock('~/issues/show/utils/update_description');
describe('Store', () => {
let store;
diff --git a/spec/frontend/issue_show/utils/update_description_spec.js b/spec/frontend/issues/show/utils/update_description_spec.js
index b2c6bd3c302..f4afef8af12 100644
--- a/spec/frontend/issue_show/utils/update_description_spec.js
+++ b/spec/frontend/issues/show/utils/update_description_spec.js
@@ -1,4 +1,4 @@
-import updateDescription from '~/issue_show/utils/update_description';
+import updateDescription from '~/issues/show/utils/update_description';
describe('updateDescription', () => {
it('returns the correct value to be set as descriptionHtml', () => {
diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js
index 97d841c861d..f3c2ae1f9dc 100644
--- a/spec/frontend/issues_list/components/issuable_spec.js
+++ b/spec/frontend/issues_list/components/issuable_spec.js
@@ -7,7 +7,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
-import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import IssueAssignees from '~/issuable/components/issue_assignees.vue';
import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data';
jest.mock('~/user_popovers');
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 5ef2a2e0525..11854db534e 100644
--- a/spec/frontend/issues_list/components/issuables_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js
@@ -13,7 +13,7 @@ import createFlash from '~/flash';
import Issuable from '~/issues_list/components/issuable.vue';
import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue';
import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants';
-import issueablesEventBus from '~/issues_list/eventhub';
+import issuablesEventBus from '~/issues_list/eventhub';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
jest.mock('~/flash');
@@ -185,8 +185,8 @@ describe('Issuables list component', () => {
describe('with bulk editing enabled', () => {
beforeEach(() => {
- issueablesEventBus.$on.mockReset();
- issueablesEventBus.$emit.mockReset();
+ issuablesEventBus.$on.mockReset();
+ issuablesEventBus.$emit.mockReset();
setupApiMock(() => [200, MOCK_ISSUES.slice(0)]);
factory({ canBulkEdit: true });
@@ -239,19 +239,19 @@ describe('Issuables list component', () => {
});
it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => {
- issueablesEventBus.$emit.mockReset();
+ issuablesEventBus.$emit.mockReset();
const i1 = wrapper.vm.issuables[1];
wrapper.vm.onSelectIssuable({ issuable: i1, selected: true });
return wrapper.vm.$nextTick().then(() => {
- expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(1);
- expect(issueablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
+ expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(1);
+ expect(issuablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => {
- issueablesEventBus.$emit.mockReset();
+ issuablesEventBus.$emit.mockReset();
return wrapper.vm
.$nextTick()
@@ -263,19 +263,19 @@ describe('Issuables list component', () => {
})
.then(wrapper.vm.$nextTick)
.then(() => {
- expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(0);
+ expect(issuablesEventBus.$emit).toHaveBeenCalledTimes(0);
});
});
it('listens to a message to toggle bulk editing', () => {
expect(wrapper.vm.isBulkEditing).toBe(false);
- expect(issueablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit');
- issueablesEventBus.$on.mock.calls[0][1](true); // Call the message handler
+ expect(issuablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit');
+ issuablesEventBus.$on.mock.calls[0][1](true); // Call the message handler
return waitForPromises()
.then(() => {
expect(wrapper.vm.isBulkEditing).toBe(true);
- issueablesEventBus.$on.mock.calls[0][1](false);
+ issuablesEventBus.$on.mock.calls[0][1](false);
})
.then(() => {
expect(wrapper.vm.isBulkEditing).toBe(false);
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 d195c159cbb..7c5faeb8dc1 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
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
import IssueCardTimeInfo from '~/issues_list/components/issue_card_time_info.vue';
-describe('IssuesListApp component', () => {
+describe('CE IssueCardTimeInfo component', () => {
useFakeDate(2020, 11, 11);
let wrapper;
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 3f52c7b4afe..f24c090fa92 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -1,8 +1,9 @@
import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
-import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
+import * as Sentry from '@sentry/browser';
+import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { cloneDeep } from 'lodash';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
@@ -17,29 +18,28 @@ import {
locationSearch,
urlParams,
} from 'jest/issues_list/mock_data';
-import createFlash from '~/flash';
+import createFlash, { FLASH_TYPES } from '~/flash';
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';
-import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
+import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
+import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue';
import {
CREATED_DESC,
DUE_DATE_OVERDUE,
PARAM_DUE_DATE,
+ RELATIVE_POSITION,
+ RELATIVE_POSITION_ASC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
- TOKEN_TYPE_EPIC,
- TOKEN_TYPE_ITERATION,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
- TOKEN_TYPE_WEIGHT,
urlSortParams,
} from '~/issues_list/constants';
import eventHub from '~/issues_list/eventhub';
@@ -48,17 +48,17 @@ import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { joinPaths } from '~/lib/utils/url_utility';
+jest.mock('@sentry/browser');
jest.mock('~/flash');
jest.mock('~/lib/utils/scroll_utils', () => ({
scrollUp: jest.fn().mockName('scrollUpMock'),
}));
-describe('IssuesListApp component', () => {
+describe('CE IssuesListApp component', () => {
let axiosMock;
let wrapper;
- const localVue = createLocalVue();
- localVue.use(VueApollo);
+ Vue.use(VueApollo);
const defaultProvide = {
calendarPath: 'calendar/path',
@@ -69,6 +69,7 @@ describe('IssuesListApp component', () => {
hasAnyIssues: true,
hasAnyProjects: true,
hasBlockedIssuesFeature: true,
+ hasIssuableHealthStatusFeature: true,
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
isProject: true,
@@ -111,7 +112,6 @@ describe('IssuesListApp component', () => {
const apolloProvider = createMockApollo(requestHandlers);
return mountFn(IssuesListApp, {
- localVue,
apolloProvider,
provide: {
...defaultProvide,
@@ -314,6 +314,29 @@ describe('IssuesListApp component', () => {
},
});
});
+
+ describe('when issue repositioning is disabled and the sort is manual', () => {
+ beforeEach(() => {
+ setWindowLocation(`?sort=${RELATIVE_POSITION}`);
+ wrapper = mountComponent({ provide: { isIssueRepositioningDisabled: true } });
+ });
+
+ it('changes the sort to the default of created descending', () => {
+ expect(findIssuableList().props()).toMatchObject({
+ initialSortBy: CREATED_DESC,
+ urlParams: {
+ sort: urlSortParams[CREATED_DESC],
+ },
+ });
+ });
+
+ it('shows an alert to tell the user that manual reordering is disabled', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: IssuesListApp.i18n.issueRepositioningMessage,
+ type: FLASH_TYPES.NOTICE,
+ });
+ });
+ });
});
describe('state', () => {
@@ -336,6 +359,27 @@ describe('IssuesListApp component', () => {
expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens);
});
+
+ describe('when anonymous searching is performed', () => {
+ beforeEach(() => {
+ setWindowLocation(locationSearch);
+
+ wrapper = mountComponent({
+ provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
+ });
+ });
+
+ it('is not set from url params', () => {
+ expect(findIssuableList().props('initialFilterValue')).toEqual([]);
+ });
+
+ it('shows an alert to tell the user they must be signed in to search', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: IssuesListApp.i18n.anonymousSearchingMessage,
+ type: FLASH_TYPES.NOTICE,
+ });
+ });
+ });
});
});
@@ -484,11 +528,7 @@ describe('IssuesListApp component', () => {
describe('when user is signed out', () => {
beforeEach(() => {
- wrapper = mountComponent({
- provide: {
- isSignedIn: false,
- },
- });
+ wrapper = mountComponent({ provide: { isSignedIn: false } });
});
it('does not render My-Reaction or Confidential tokens', () => {
@@ -501,54 +541,6 @@ describe('IssuesListApp component', () => {
});
});
- describe('when iterations are not available', () => {
- beforeEach(() => {
- wrapper = mountComponent({
- provide: {
- projectIterationsPath: '',
- },
- });
- });
-
- it('does not render Iteration token', () => {
- expect(findIssuableList().props('searchTokens')).not.toMatchObject([
- { type: TOKEN_TYPE_ITERATION },
- ]);
- });
- });
-
- describe('when epics are not available', () => {
- beforeEach(() => {
- wrapper = mountComponent({
- provide: {
- groupPath: '',
- },
- });
- });
-
- it('does not render Epic token', () => {
- expect(findIssuableList().props('searchTokens')).not.toMatchObject([
- { type: TOKEN_TYPE_EPIC },
- ]);
- });
- });
-
- describe('when weights are not available', () => {
- beforeEach(() => {
- wrapper = mountComponent({
- provide: {
- groupPath: '',
- },
- });
- });
-
- it('does not render Weight token', () => {
- expect(findIssuableList().props('searchTokens')).not.toMatchObject([
- { type: TOKEN_TYPE_WEIGHT },
- ]);
- });
- });
-
describe('when all tokens are available', () => {
const originalGon = window.gon;
@@ -561,33 +553,27 @@ describe('IssuesListApp component', () => {
current_user_avatar_url: mockCurrentUser.avatar_url,
};
- wrapper = mountComponent({
- provide: {
- isSignedIn: true,
- projectIterationsPath: 'project/iterations/path',
- groupPath: 'group/path',
- hasIssueWeightsFeature: true,
- },
- });
+ wrapper = mountComponent({ provide: { isSignedIn: true } });
});
- it('renders all tokens', () => {
+ afterEach(() => {
+ window.gon = originalGon;
+ });
+
+ it('renders all tokens alphabetically', () => {
const preloadedAuthors = [
{ ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) },
];
expect(findIssuableList().props('searchTokens')).toMatchObject([
- { type: TOKEN_TYPE_AUTHOR, preloadedAuthors },
{ type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors },
- { type: TOKEN_TYPE_MILESTONE },
+ { type: TOKEN_TYPE_AUTHOR, preloadedAuthors },
+ { type: TOKEN_TYPE_CONFIDENTIAL },
{ type: TOKEN_TYPE_LABEL },
- { type: TOKEN_TYPE_TYPE },
- { type: TOKEN_TYPE_RELEASE },
+ { type: TOKEN_TYPE_MILESTONE },
{ type: TOKEN_TYPE_MY_REACTION },
- { type: TOKEN_TYPE_CONFIDENTIAL },
- { type: TOKEN_TYPE_ITERATION },
- { type: TOKEN_TYPE_EPIC },
- { type: TOKEN_TYPE_WEIGHT },
+ { type: TOKEN_TYPE_RELEASE },
+ { type: TOKEN_TYPE_TYPE },
]);
});
});
@@ -607,13 +593,18 @@ describe('IssuesListApp component', () => {
});
it('shows an error message', () => {
- expect(createFlash).toHaveBeenCalledWith({
- captureError: true,
- error: new Error('Network error: ERROR'),
- message,
- });
+ expect(findIssuableList().props('error')).toBe(message);
+ expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Network error: ERROR'));
});
});
+
+ it('clears error message when "dismiss-alert" event is emitted from IssuableList', () => {
+ wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockRejectedValue(new Error()) });
+
+ findIssuableList().vm.$emit('dismiss-alert');
+
+ expect(findIssuableList().props('error')).toBeNull();
+ });
});
describe('events', () => {
@@ -676,6 +667,7 @@ describe('IssuesListApp component', () => {
const response = (isProject = true) => ({
data: {
[isProject ? 'project' : 'group']: {
+ id: '1',
issues: {
...defaultQueryResponse.data.project.issues,
nodes: [issueOne, issueTwo, issueThree, issueFour],
@@ -737,11 +729,10 @@ describe('IssuesListApp component', () => {
await waitForPromises();
- expect(createFlash).toHaveBeenCalledWith({
- message: IssuesListApp.i18n.reorderError,
- captureError: true,
- error: new Error('Request failed with status code 500'),
- });
+ expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError);
+ expect(Sentry.captureException).toHaveBeenCalledWith(
+ new Error('Request failed with status code 500'),
+ );
});
});
});
@@ -762,6 +753,30 @@ describe('IssuesListApp component', () => {
});
},
);
+
+ describe('when issue repositioning is disabled', () => {
+ const initialSort = CREATED_DESC;
+
+ beforeEach(() => {
+ setWindowLocation(`?sort=${initialSort}`);
+ wrapper = mountComponent({ provide: { isIssueRepositioningDisabled: true } });
+
+ findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC);
+ });
+
+ it('does not update the sort to manual', () => {
+ expect(findIssuableList().props('urlParams')).toMatchObject({
+ sort: urlSortParams[initialSort],
+ });
+ });
+
+ it('shows an alert to tell the user that manual reordering is disabled', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: IssuesListApp.i18n.issueRepositioningMessage,
+ type: FLASH_TYPES.NOTICE,
+ });
+ });
+ });
});
describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => {
@@ -778,15 +793,37 @@ describe('IssuesListApp component', () => {
});
describe('when "filter" event is emitted by IssuableList', () => {
- beforeEach(() => {
+ it('updates IssuableList with url params', async () => {
wrapper = mountComponent();
findIssuableList().vm.$emit('filter', filteredTokens);
- });
+ await nextTick();
- it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
});
+
+ describe('when anonymous searching is performed', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
+ });
+
+ findIssuableList().vm.$emit('filter', filteredTokens);
+ });
+
+ it('does not update IssuableList with url params ', async () => {
+ const defaultParams = { sort: 'created_date', state: 'opened' };
+
+ expect(findIssuableList().props('urlParams')).toEqual(defaultParams);
+ });
+
+ it('shows an alert to tell the user they must be signed in to search', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: IssuesListApp.i18n.anonymousSearchingMessage,
+ type: FLASH_TYPES.NOTICE,
+ });
+ });
+ });
});
});
});
diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js
index 19a8af4d9c2..948699876ce 100644
--- a/spec/frontend/issues_list/mock_data.js
+++ b/spec/frontend/issues_list/mock_data.js
@@ -6,6 +6,7 @@ import {
export const getIssuesQueryResponse = {
data: {
project: {
+ id: '1',
issues: {
pageInfo: {
hasNextPage: true,
@@ -22,6 +23,7 @@ export const getIssuesQueryResponse = {
createdAt: '2021-05-22T04:08:01Z',
downvotes: 2,
dueDate: '2021-05-29',
+ hidden: false,
humanTimeEstimate: null,
mergeRequestsCount: false,
moved: false,
@@ -74,6 +76,7 @@ export const getIssuesQueryResponse = {
export const getIssuesCountsQueryResponse = {
data: {
project: {
+ id: '1',
openedIssues: {
count: 1,
},
@@ -287,6 +290,7 @@ export const project3 = {
export const searchProjectsQueryResponse = {
data: {
group: {
+ id: '1',
projects: {
nodes: [project1, project2, project3],
},
@@ -297,6 +301,7 @@ export const searchProjectsQueryResponse = {
export const emptySearchProjectsQueryResponse = {
data: {
group: {
+ id: '1',
projects: {
nodes: [],
},
diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
index 8e464968453..47fe96262ee 100644
--- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
@@ -5,6 +5,7 @@ import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue';
import AddNamespaceButton from '~/jira_connect/subscriptions/components/add_namespace_button.vue';
import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue';
import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
+import UserLink from '~/jira_connect/subscriptions/components/user_link.vue';
import createStore from '~/jira_connect/subscriptions/store';
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { __ } from '~/locale';
@@ -12,6 +13,7 @@ import { mockSubscription } from '../mock_data';
jest.mock('~/jira_connect/subscriptions/utils', () => ({
retrieveAlert: jest.fn().mockReturnValue({ message: 'error message' }),
+ getGitlabSignInURL: jest.fn(),
}));
describe('JiraConnectApp', () => {
@@ -83,6 +85,22 @@ describe('JiraConnectApp', () => {
});
},
);
+
+ it('renders UserLink component', () => {
+ createComponent({
+ provide: {
+ usersPath: '/user',
+ subscriptions: [],
+ },
+ });
+
+ const userLink = wrapper.findComponent(UserLink);
+ expect(userLink.exists()).toBe(true);
+ expect(userLink.props()).toEqual({
+ hasSubscriptions: false,
+ userSignedIn: false,
+ });
+ });
});
describe('alert', () => {
diff --git a/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js
new file mode 100644
index 00000000000..b98a36269a3
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/components/user_link_spec.js
@@ -0,0 +1,91 @@
+import { GlSprintf } from '@gitlab/ui';
+import UserLink from '~/jira_connect/subscriptions/components/user_link.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/jira_connect/subscriptions/utils', () => ({
+ getGitlabSignInURL: jest.fn().mockImplementation((path) => Promise.resolve(path)),
+}));
+
+describe('SubscriptionsList', () => {
+ let wrapper;
+
+ const createComponent = (propsData = {}, { provide } = {}) => {
+ wrapper = shallowMountExtended(UserLink, {
+ propsData,
+ provide,
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ const findSignInLink = () => wrapper.findByTestId('sign-in-link');
+ const findGitlabUserLink = () => wrapper.findByTestId('gitlab-user-link');
+ const findSprintf = () => wrapper.findComponent(GlSprintf);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe.each`
+ userSignedIn | hasSubscriptions | expectGlSprintf | expectGlLink
+ ${true} | ${false} | ${true} | ${false}
+ ${false} | ${true} | ${false} | ${true}
+ ${true} | ${true} | ${true} | ${false}
+ ${false} | ${false} | ${false} | ${false}
+ `(
+ 'when `userSignedIn` is $userSignedIn and `hasSubscriptions` is $hasSubscriptions',
+ ({ userSignedIn, hasSubscriptions, expectGlSprintf, expectGlLink }) => {
+ it('renders template correctly', () => {
+ createComponent({
+ userSignedIn,
+ hasSubscriptions,
+ });
+
+ expect(findSprintf().exists()).toBe(expectGlSprintf);
+ expect(findSignInLink().exists()).toBe(expectGlLink);
+ });
+ },
+ );
+
+ describe('sign in link', () => {
+ it('renders with correct href', async () => {
+ const mockUsersPath = '/user';
+ createComponent(
+ {
+ userSignedIn: false,
+ hasSubscriptions: true,
+ },
+ { provide: { usersPath: mockUsersPath } },
+ );
+
+ await waitForPromises();
+
+ expect(findSignInLink().exists()).toBe(true);
+ expect(findSignInLink().attributes('href')).toBe(mockUsersPath);
+ });
+ });
+
+ describe('gitlab user link', () => {
+ window.gon = { current_username: 'root' };
+
+ beforeEach(() => {
+ createComponent(
+ {
+ userSignedIn: true,
+ hasSubscriptions: true,
+ },
+ { provide: { gitlabUserPath: '/root' } },
+ );
+ });
+
+ it('renders with correct href', () => {
+ expect(findGitlabUserLink().attributes('href')).toBe('/root');
+ });
+
+ it('contains GitLab user handle', () => {
+ expect(findGitlabUserLink().text()).toBe('@root');
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/index_spec.js b/spec/frontend/jira_connect/subscriptions/index_spec.js
deleted file mode 100644
index b97918a198e..00000000000
--- a/spec/frontend/jira_connect/subscriptions/index_spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { initJiraConnect } from '~/jira_connect/subscriptions';
-import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
-
-jest.mock('~/jira_connect/subscriptions/utils');
-
-describe('initJiraConnect', () => {
- const mockInitialHref = 'https://gitlab.com';
-
- beforeEach(() => {
- setFixtures(`
- <a class="js-jira-connect-sign-in" href="${mockInitialHref}">Sign In</a>
- <a class="js-jira-connect-sign-in" href="${mockInitialHref}">Another Sign In</a>
- `);
- });
-
- const assertSignInLinks = (expectedLink) => {
- Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => {
- expect(el.getAttribute('href')).toBe(expectedLink);
- });
- };
-
- describe('Sign in links', () => {
- it('are updated on initialization', async () => {
- const mockSignInLink = `https://gitlab.com?return_to=${encodeURIComponent('/test/location')}`;
- getGitlabSignInURL.mockResolvedValue(mockSignInLink);
-
- // assert the initial state
- assertSignInLinks(mockInitialHref);
-
- await initJiraConnect();
-
- // assert the update has occurred
- assertSignInLinks(mockSignInLink);
- });
- });
-});
diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
index 9f5b772a5c7..a72528ae36b 100644
--- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
+++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
@@ -152,7 +152,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
aria-label="Search"
class="gl-form-input gl-search-box-by-type-input form-control"
placeholder="Search"
- type="text"
+ type="search"
/>
<div
@@ -283,7 +283,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
aria-label="Search"
class="gl-form-input gl-search-box-by-type-input form-control"
placeholder="Search"
- type="text"
+ type="search"
/>
<div
diff --git a/spec/frontend/jobs/bridge/app_spec.js b/spec/frontend/jobs/bridge/app_spec.js
new file mode 100644
index 00000000000..0e232ab240d
--- /dev/null
+++ b/spec/frontend/jobs/bridge/app_spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+import BridgeApp from '~/jobs/bridge/app.vue';
+import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue';
+import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue';
+
+describe('Bridge Show Page', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(BridgeApp, {});
+ };
+
+ const findEmptyState = () => wrapper.findComponent(BridgeEmptyState);
+ const findSidebar = () => wrapper.findComponent(BridgeSidebar);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty state', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ });
+
+ it('renders sidebar', () => {
+ expect(findSidebar().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/bridge/components/empty_state_spec.js b/spec/frontend/jobs/bridge/components/empty_state_spec.js
new file mode 100644
index 00000000000..83642450118
--- /dev/null
+++ b/spec/frontend/jobs/bridge/components/empty_state_spec.js
@@ -0,0 +1,59 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue';
+import { MOCK_EMPTY_ILLUSTRATION_PATH, MOCK_PATH_TO_DOWNSTREAM } from '../mock_data';
+
+describe('Bridge Empty State', () => {
+ let wrapper;
+
+ const createComponent = (props) => {
+ wrapper = shallowMount(BridgeEmptyState, {
+ provide: {
+ emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH,
+ },
+ propsData: {
+ downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM,
+ ...props,
+ },
+ });
+ };
+
+ const findSvg = () => wrapper.find('img');
+ const findTitle = () => wrapper.find('h1');
+ const findLinkBtn = () => wrapper.findComponent(GlButton);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders illustration', () => {
+ expect(findSvg().exists()).toBe(true);
+ });
+
+ it('renders title', () => {
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title);
+ });
+
+ it('renders CTA button', () => {
+ expect(findLinkBtn().exists()).toBe(true);
+ expect(findLinkBtn().text()).toBe(wrapper.vm.$options.i18n.linkBtnText);
+ expect(findLinkBtn().attributes('href')).toBe(MOCK_PATH_TO_DOWNSTREAM);
+ });
+ });
+
+ describe('without downstream pipeline', () => {
+ beforeEach(() => {
+ createComponent({ downstreamPipelinePath: undefined });
+ });
+
+ it('does not render CTA button', () => {
+ expect(findLinkBtn().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/bridge/components/sidebar_spec.js b/spec/frontend/jobs/bridge/components/sidebar_spec.js
new file mode 100644
index 00000000000..ba4018753af
--- /dev/null
+++ b/spec/frontend/jobs/bridge/components/sidebar_spec.js
@@ -0,0 +1,76 @@
+import { GlButton, GlDropdown } from '@gitlab/ui';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue';
+import { BUILD_NAME } from '../mock_data';
+
+describe('Bridge Sidebar', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(BridgeSidebar, {
+ provide: {
+ buildName: BUILD_NAME,
+ },
+ });
+ };
+
+ const findSidebar = () => wrapper.find('aside');
+ const findRetryDropdown = () => wrapper.find(GlDropdown);
+ const findToggle = () => wrapper.find(GlButton);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders retry dropdown', () => {
+ expect(findRetryDropdown().exists()).toBe(true);
+ });
+ });
+
+ describe('sidebar expansion', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('toggles expansion on button click', async () => {
+ expect(findSidebar().classes()).not.toContain('gl-display-none');
+
+ findToggle().vm.$emit('click');
+ await nextTick();
+
+ expect(findSidebar().classes()).toContain('gl-display-none');
+ });
+
+ describe('on resize', () => {
+ it.each`
+ breakpoint | isSidebarExpanded
+ ${'xs'} | ${false}
+ ${'sm'} | ${false}
+ ${'md'} | ${true}
+ ${'lg'} | ${true}
+ ${'xl'} | ${true}
+ `(
+ 'sets isSidebarExpanded to `$isSidebarExpanded` when the breakpoint is "$breakpoint"',
+ async ({ breakpoint, isSidebarExpanded }) => {
+ jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockReturnValue(breakpoint);
+
+ window.dispatchEvent(new Event('resize'));
+ await nextTick();
+
+ if (isSidebarExpanded) {
+ expect(findSidebar().classes()).not.toContain('gl-display-none');
+ } else {
+ expect(findSidebar().classes()).toContain('gl-display-none');
+ }
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/jobs/bridge/mock_data.js b/spec/frontend/jobs/bridge/mock_data.js
new file mode 100644
index 00000000000..146d1a062ac
--- /dev/null
+++ b/spec/frontend/jobs/bridge/mock_data.js
@@ -0,0 +1,3 @@
+export const MOCK_EMPTY_ILLUSTRATION_PATH = '/path/to/svg';
+export const MOCK_PATH_TO_DOWNSTREAM = '/path/to/downstream/pipeline';
+export const BUILD_NAME = 'Child Pipeline Trigger';
diff --git a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js
index ad0368555fa..cc9a5e4ee25 100644
--- a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js
+++ b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js
@@ -56,7 +56,7 @@ describe('Job Sidebar Details Container', () => {
beforeEach(createWrapper);
it.each([
- ['duration', 'Duration: 6 seconds'],
+ ['duration', 'Elapsed time: 6 seconds'],
['erased_at', 'Erased: 3 weeks ago'],
['finished_at', 'Finished: 3 weeks ago'],
['queued', 'Queued: 9 seconds'],
@@ -86,6 +86,15 @@ describe('Job Sidebar Details Container', () => {
expect(findAllDetailsRow()).toHaveLength(7);
});
+
+ describe('duration row', () => {
+ it('renders all the details components', async () => {
+ createWrapper();
+ await store.dispatch('receiveJobSuccess', job);
+
+ expect(findAllDetailsRow().at(0).text()).toBe('Duration: 6 seconds');
+ });
+ });
});
describe('timeout', () => {
diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
index 1b1e2d4df8f..6caf36e1461 100644
--- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
@@ -5,7 +5,14 @@ import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue';
import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql';
import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql';
import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql';
-import { playableJob, retryableJob, scheduledJob } from '../../../mock_data';
+import {
+ playableJob,
+ retryableJob,
+ scheduledJob,
+ cannotRetryJob,
+ cannotPlayJob,
+ cannotPlayScheduledJob,
+} from '../../../mock_data';
describe('Job actions cell', () => {
let wrapper;
@@ -51,6 +58,14 @@ describe('Job actions cell', () => {
wrapper.destroy();
});
+ it('displays the artifacts download button with correct link', () => {
+ createComponent(playableJob);
+
+ expect(findDownloadArtifactsButton().attributes('href')).toBe(
+ playableJob.artifacts.nodes[0].downloadPath,
+ );
+ });
+
it('does not display an artifacts download button', () => {
createComponent(retryableJob);
@@ -58,6 +73,17 @@ describe('Job actions cell', () => {
});
it.each`
+ button | action | jobType
+ ${findPlayButton} | ${'play'} | ${cannotPlayJob}
+ ${findRetryButton} | ${'retry'} | ${cannotRetryJob}
+ ${findPlayScheduledJobButton} | ${'play scheduled'} | ${cannotPlayScheduledJob}
+ `('does not display the $action button if user cannot update build', ({ button, jobType }) => {
+ createComponent(jobType);
+
+ expect(button().exists()).toBe(false);
+ });
+
+ it.each`
button | action | jobType
${findPlayButton} | ${'play'} | ${playableJob}
${findRetryButton} | ${'retry'} | ${retryableJob}
diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js
index 43755b46bc9..45d297ba364 100644
--- a/spec/frontend/jobs/mock_data.js
+++ b/spec/frontend/jobs/mock_data.js
@@ -1474,6 +1474,7 @@ export const mockJobsInTable = [
export const mockJobsQueryResponse = {
data: {
project: {
+ id: '1',
jobs: {
pageInfo: {
endCursor: 'eyJpZCI6IjIzMTcifQ',
@@ -1488,15 +1489,18 @@ export const mockJobsQueryResponse = {
nodes: [
{
downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/root/ci-project/-/jobs/2336/artifacts/download?file_type=metadata',
+ fileType: 'METADATA',
__typename: 'CiJobArtifact',
},
{
downloadPath: '/root/ci-project/-/jobs/2336/artifacts/download?file_type=archive',
+ fileType: 'ARCHIVE',
__typename: 'CiJobArtifact',
},
],
@@ -1509,6 +1513,7 @@ export const mockJobsQueryResponse = {
triggered: null,
createdByTag: false,
detailedStatus: {
+ id: 'status-1',
detailsPath: '/root/ci-project/-/jobs/2336',
group: 'success',
icon: 'status_success',
@@ -1516,6 +1521,7 @@ export const mockJobsQueryResponse = {
text: 'passed',
tooltip: 'passed',
action: {
+ id: 'action-1',
buttonTitle: 'Retry this job',
icon: 'retry',
method: 'post',
@@ -1535,6 +1541,7 @@ export const mockJobsQueryResponse = {
id: 'gid://gitlab/Ci::Pipeline/473',
path: '/root/ci-project/-/pipelines/473',
user: {
+ id: 'user-1',
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
@@ -1543,6 +1550,7 @@ export const mockJobsQueryResponse = {
__typename: 'Pipeline',
},
stage: {
+ id: 'stage-1',
name: 'deploy',
__typename: 'CiStage',
},
@@ -1558,6 +1566,7 @@ export const mockJobsQueryResponse = {
userPermissions: {
readBuild: true,
readJobArtifacts: true,
+ updateBuild: true,
__typename: 'JobPermissions',
},
__typename: 'CiJob',
@@ -1573,13 +1582,23 @@ export const mockJobsQueryResponse = {
export const mockJobsQueryEmptyResponse = {
data: {
project: {
+ id: '1',
jobs: [],
},
},
};
export const retryableJob = {
- artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' },
+ artifacts: {
+ nodes: [
+ {
+ downloadPath: '/root/ci-project/-/jobs/847/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
allowFailure: false,
status: 'SUCCESS',
scheduledAt: null,
@@ -1630,15 +1649,31 @@ export const retryableJob = {
cancelable: false,
active: false,
stuck: false,
- userPermissions: { readBuild: true, __typename: 'JobPermissions' },
+ userPermissions: { readBuild: true, updateBuild: true, __typename: 'JobPermissions' },
__typename: 'CiJob',
};
+export const cannotRetryJob = {
+ ...retryableJob,
+ userPermissions: { readBuild: true, updateBuild: false, __typename: 'JobPermissions' },
+};
+
export const playableJob = {
artifacts: {
nodes: [
{
- downloadPath: '/root/test-job-artifacts/-/jobs/1982/artifacts/download?file_type=trace',
+ downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=archive',
+ fileType: 'ARCHIVE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=metadata',
+ fileType: 'METADATA',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath: '/root/ci-project/-/jobs/621/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
],
@@ -1694,10 +1729,25 @@ export const playableJob = {
cancelable: false,
active: false,
stuck: false,
- userPermissions: { readBuild: true, readJobArtifacts: true, __typename: 'JobPermissions' },
+ userPermissions: {
+ readBuild: true,
+ readJobArtifacts: true,
+ updateBuild: true,
+ __typename: 'JobPermissions',
+ },
__typename: 'CiJob',
};
+export const cannotPlayJob = {
+ ...playableJob,
+ userPermissions: {
+ readBuild: true,
+ readJobArtifacts: true,
+ updateBuild: false,
+ __typename: 'JobPermissions',
+ },
+};
+
export const scheduledJob = {
artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' },
allowFailure: false,
@@ -1750,6 +1800,16 @@ export const scheduledJob = {
cancelable: false,
active: false,
stuck: false,
- userPermissions: { readBuild: true, __typename: 'JobPermissions' },
+ userPermissions: { readBuild: true, updateBuild: true, __typename: 'JobPermissions' },
__typename: 'CiJob',
};
+
+export const cannotPlayScheduledJob = {
+ ...scheduledJob,
+ userPermissions: {
+ readBuild: true,
+ readJobArtifacts: true,
+ updateBuild: false,
+ __typename: 'JobPermissions',
+ },
+};
diff --git a/spec/frontend/vue_shared/components/delete_label_modal_spec.js b/spec/frontend/labels/components/delete_label_modal_spec.js
index 3905690dab4..6204138f885 100644
--- a/spec/frontend/vue_shared/components/delete_label_modal_spec.js
+++ b/spec/frontend/labels/components/delete_label_modal_spec.js
@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue';
+import DeleteLabelModal from '~/labels/components/delete_label_modal.vue';
const MOCK_MODAL_DATA = {
labelName: 'label 1',
@@ -11,7 +11,7 @@ const MOCK_MODAL_DATA = {
destroyPath: `${TEST_HOST}/1`,
};
-describe('vue_shared/components/delete_label_modal', () => {
+describe('~/labels/components/delete_label_modal', () => {
let wrapper;
const createComponent = () => {
diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/labels/components/promote_label_modal_spec.js
index 4d5d1f98b59..d2fbdfc9a8d 100644
--- a/spec/frontend/pages/labels/components/promote_label_modal_spec.js
+++ b/spec/frontend/labels/components/promote_label_modal_spec.js
@@ -2,8 +2,8 @@ import Vue from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
-import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue';
-import eventHub from '~/pages/projects/labels/event_hub';
+import promoteLabelModal from '~/labels/components/promote_label_modal.vue';
+import eventHub from '~/labels/event_hub';
describe('Promote label modal', () => {
let vm;
diff --git a/spec/frontend/delete_label_modal_spec.js b/spec/frontend/labels/delete_label_modal_spec.js
index 0b3e6fe652a..c1e6ce87990 100644
--- a/spec/frontend/delete_label_modal_spec.js
+++ b/spec/frontend/labels/delete_label_modal_spec.js
@@ -1,5 +1,5 @@
import { TEST_HOST } from 'helpers/test_constants';
-import initDeleteLabelModal from '~/delete_label_modal';
+import { initDeleteLabelModal } from '~/labels';
describe('DeleteLabelModal', () => {
const buttons = [
diff --git a/spec/frontend/labels_select_spec.js b/spec/frontend/labels/labels_select_spec.js
index cbc9a923f8b..f6e280564cc 100644
--- a/spec/frontend/labels_select_spec.js
+++ b/spec/frontend/labels/labels_select_spec.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import LabelsSelect from '~/labels_select';
+import LabelsSelect from '~/labels/labels_select';
const mockUrl = '/foo/bar/url';
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index de1be5bc337..3e2ba918d9b 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -1040,4 +1040,15 @@ describe('common_utils', () => {
expect(result).toEqual(['hello', 'helloWorld']);
});
});
+
+ describe('convertArrayOfObjectsToCamelCase', () => {
+ it('returns a new array with snake_case object property names converted camelCase', () => {
+ const result = commonUtils.convertArrayOfObjectsToCamelCase([
+ { hello: '' },
+ { hello_world: '' },
+ ]);
+
+ expect(result).toEqual([{ hello: '' }, { helloWorld: '' }]);
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js
index cb8b1c7ca9a..2f240f25d2a 100644
--- a/spec/frontend/lib/utils/dom_utils_spec.js
+++ b/spec/frontend/lib/utils/dom_utils_spec.js
@@ -6,6 +6,7 @@ import {
isElementVisible,
isElementHidden,
getParents,
+ setAttributes,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
@@ -208,4 +209,15 @@ describe('DOM Utils', () => {
]);
});
});
+
+ describe('setAttributes', () => {
+ it('sets multiple attribues on element', () => {
+ const div = document.createElement('div');
+
+ setAttributes(div, { class: 'test', title: 'another test' });
+
+ expect(div.getAttribute('class')).toBe('test');
+ expect(div.getAttribute('title')).toBe('another test');
+ });
+ });
});
diff --git a/spec/frontend/lib/utils/intersection_observer_spec.js b/spec/frontend/lib/utils/intersection_observer_spec.js
new file mode 100644
index 00000000000..71b1daffe0d
--- /dev/null
+++ b/spec/frontend/lib/utils/intersection_observer_spec.js
@@ -0,0 +1,86 @@
+import { create } from '~/lib/utils/intersection_observer';
+
+describe('IntersectionObserver Utility', () => {
+ beforeAll(() => {
+ global.IntersectionObserver = class MockIntersectionObserver {
+ constructor(callback) {
+ this.callback = callback;
+
+ this.entries = [];
+ }
+
+ addEntry(entry) {
+ this.entries.push(entry);
+ }
+
+ trigger() {
+ this.callback(this.entries);
+ }
+ };
+ });
+ describe('create', () => {
+ describe('memoization', () => {
+ const options = { rootMargin: '1px 1px 1px 1px' };
+ let expectedOutput;
+
+ beforeEach(() => {
+ create.cache.clear();
+ expectedOutput = create(options);
+ });
+
+ it('returns the same Observer for the same options input', () => {
+ expect(expectedOutput.id).toBe(create(options).id);
+ });
+
+ it('creates a new Observer for unique input options', () => {
+ expect(expectedOutput.id).not.toBe(create({ rootMargin: '1px 2px 3px 4px' }));
+ });
+
+ it('creates a new Observer for the same input options in different object references', () => {
+ expect(expectedOutput.id).not.toBe(create({ rootMargin: '1px 1px 1px 1px' }));
+ });
+ });
+ });
+
+ describe('Observer behavior', () => {
+ let observer = null;
+ let id = null;
+
+ beforeEach(() => {
+ create.cache.clear();
+ ({ observer, id } = create());
+ });
+
+ it.each`
+ isIntersecting | event
+ ${false} | ${'IntersectionDisappear'}
+ ${true} | ${'IntersectionAppear'}
+ `(
+ 'should emit the correct event on the entry target based on the computed Intersection',
+ async ({ isIntersecting, event }) => {
+ const target = document.createElement('div');
+ observer.addEntry({ target, isIntersecting });
+
+ target.addEventListener(event, (e) => {
+ expect(e.detail.observer).toBe(id);
+ });
+
+ observer.trigger();
+ },
+ );
+
+ it('should always emit an Update event with the entry and the observer', () => {
+ const target = document.createElement('div');
+ const entry = { target };
+
+ observer.addEntry(entry);
+
+ target.addEventListener('IntersectionUpdate', (e) => {
+ expect(e.detail.observer).toBe(id);
+ expect(e.detail.entry).toStrictEqual(entry);
+ });
+
+ observer.trigger();
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js
index 88172f38894..6a880a0f354 100644
--- a/spec/frontend/lib/utils/navigation_utility_spec.js
+++ b/spec/frontend/lib/utils/navigation_utility_spec.js
@@ -1,4 +1,5 @@
import findAndFollowLink from '~/lib/utils/navigation_utility';
+import * as navigationUtils from '~/lib/utils/navigation_utility';
import { visitUrl } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
@@ -21,3 +22,91 @@ describe('findAndFollowLink', () => {
expect(visitUrl).not.toHaveBeenCalled();
});
});
+
+describe('prefetchDocument', () => {
+ it('creates a prefetch link tag', () => {
+ const linkElement = document.createElement('link');
+
+ jest.spyOn(document, 'createElement').mockImplementation(() => linkElement);
+ jest.spyOn(document.head, 'appendChild');
+
+ navigationUtils.prefetchDocument('index.htm');
+
+ expect(document.head.appendChild).toHaveBeenCalledWith(linkElement);
+ expect(linkElement.href).toEqual('http://test.host/index.htm');
+ expect(linkElement.rel).toEqual('prefetch');
+ expect(linkElement.getAttribute('as')).toEqual('document');
+ });
+});
+
+describe('initPrefetchLinks', () => {
+ let newLink;
+
+ beforeEach(() => {
+ newLink = document.createElement('a');
+ newLink.href = 'index_prefetch.htm';
+ newLink.classList.add('js-test-prefetch-link');
+ document.body.appendChild(newLink);
+ });
+
+ it('adds to all links mouse out handlers when hovered', () => {
+ const mouseOverEvent = new Event('mouseover');
+
+ jest.spyOn(newLink, 'addEventListener');
+
+ navigationUtils.initPrefetchLinks('.js-test-prefetch-link');
+ newLink.dispatchEvent(mouseOverEvent);
+
+ expect(newLink.addEventListener).toHaveBeenCalled();
+ });
+
+ it('it is not fired when less then 100ms over link', () => {
+ const mouseOverEvent = new Event('mouseover');
+ const mouseOutEvent = new Event('mouseout');
+
+ jest.spyOn(newLink, 'addEventListener');
+ jest.spyOn(navigationUtils, 'prefetchDocument').mockImplementation(() => true);
+
+ navigationUtils.initPrefetchLinks('.js-test-prefetch-link');
+ newLink.dispatchEvent(mouseOverEvent);
+ newLink.dispatchEvent(mouseOutEvent);
+
+ expect(navigationUtils.prefetchDocument).not.toHaveBeenCalled();
+ });
+
+ describe('executes correctly when hovering long enough', () => {
+ const mouseOverEvent = new Event('mouseover');
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+
+ jest.spyOn(global, 'setTimeout');
+ jest.spyOn(newLink, 'removeEventListener');
+ });
+
+ it('calls prefetchDocument which adds to document', () => {
+ jest.spyOn(document.head, 'appendChild');
+
+ navigationUtils.initPrefetchLinks('.js-test-prefetch-link');
+ newLink.dispatchEvent(mouseOverEvent);
+
+ jest.runAllTimers();
+
+ expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 100);
+ expect(document.head.appendChild).toHaveBeenCalled();
+ });
+
+ it('removes Event Listener when fired so only done once', () => {
+ navigationUtils.initPrefetchLinks('.js-test-prefetch-link');
+ newLink.dispatchEvent(mouseOverEvent);
+
+ jest.runAllTimers();
+
+ expect(newLink.removeEventListener).toHaveBeenCalledWith(
+ 'mouseover',
+ expect.any(Function),
+ true,
+ );
+ });
+ });
+});
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 7eb0ea37fe6..1a031cc56d6 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
@@ -54,6 +54,8 @@ describe('RemoveMemberButton', () => {
});
};
+ const findButton = () => wrapper.findComponent(GlButton);
+
beforeEach(() => {
createComponent();
});
@@ -66,7 +68,6 @@ describe('RemoveMemberButton', () => {
expect(wrapper.attributes()).toMatchObject({
'aria-label': 'Remove member',
title: 'Remove member',
- icon: 'remove',
});
});
@@ -75,8 +76,22 @@ describe('RemoveMemberButton', () => {
});
it('calls Vuex action to show `remove member` modal when clicked', () => {
- wrapper.findComponent(GlButton).vm.$emit('click');
+ findButton().vm.$emit('click');
expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), modalData);
});
+
+ describe('button optional properties', () => {
+ it('has default value for category and text', () => {
+ createComponent();
+ expect(findButton().props('category')).toBe('secondary');
+ expect(findButton().text()).toBe('');
+ });
+
+ it('allow changing value of button category and text', () => {
+ createComponent({ buttonCategory: 'primary', buttonText: 'Decline request' });
+ expect(findButton().props('category')).toBe('primary');
+ expect(findButton().text()).toBe('Decline request');
+ });
+ });
});
diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js
index 10e451376c8..356df7e7b11 100644
--- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js
+++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js
@@ -13,6 +13,7 @@ describe('UserActionButtons', () => {
propsData: {
member,
isCurrentUser: false,
+ isInvitedUser: false,
...propsData,
},
});
@@ -45,7 +46,9 @@ describe('UserActionButtons', () => {
title: 'Remove member',
isAccessRequest: false,
isInvite: false,
- icon: 'remove',
+ icon: '',
+ buttonCategory: 'secondary',
+ buttonText: 'Remove user',
userDeletionObstacles: {
name: member.user.name,
obstacles: parseUserDeletionObstacles(member.user),
@@ -129,4 +132,30 @@ describe('UserActionButtons', () => {
expect(findRemoveMemberButton().props().memberType).toBe('ProjectMember');
});
});
+
+ describe('isInvitedUser', () => {
+ it.each`
+ isInvitedUser | icon | buttonText | buttonCategory
+ ${true} | ${'remove'} | ${null} | ${'primary'}
+ ${false} | ${''} | ${'Remove user'} | ${'secondary'}
+ `(
+ 'passes the correct props to remove-member-button when isInvitedUser is $isInvitedUser',
+ ({ isInvitedUser, icon, buttonText, buttonCategory }) => {
+ createComponent({
+ isInvitedUser,
+ permissions: {
+ canRemove: true,
+ },
+ });
+
+ expect(findRemoveMemberButton().props()).toEqual(
+ expect.objectContaining({
+ icon,
+ buttonText,
+ buttonCategory,
+ }),
+ );
+ },
+ );
+ });
});
diff --git a/spec/frontend/members/components/table/member_action_buttons_spec.js b/spec/frontend/members/components/table/member_action_buttons_spec.js
index 546d09732d6..1379b2d26ce 100644
--- a/spec/frontend/members/components/table/member_action_buttons_spec.js
+++ b/spec/frontend/members/components/table/member_action_buttons_spec.js
@@ -14,6 +14,7 @@ describe('MemberActionButtons', () => {
wrapper = shallowMount(MemberActionButtons, {
propsData: {
isCurrentUser: false,
+ isInvitedUser: false,
permissions: {
canRemove: true,
},
diff --git a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/frontend/milestones/components/delete_milestone_modal_spec.js
index 1fbec0d996d..8978de0e0e0 100644
--- a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js
+++ b/spec/frontend/milestones/components/delete_milestone_modal_spec.js
@@ -3,8 +3,8 @@ import { TEST_HOST } from 'helpers/test_constants';
import mountComponent from 'helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
-import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue';
-import eventHub from '~/pages/milestones/shared/event_hub';
+import deleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue';
+import eventHub from '~/milestones/event_hub';
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
diff --git a/spec/frontend/milestones/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js
index 4d1a0a0a440..1af39aff30c 100644
--- a/spec/frontend/milestones/milestone_combobox_spec.js
+++ b/spec/frontend/milestones/components/milestone_combobox_spec.js
@@ -7,7 +7,7 @@ import Vuex from 'vuex';
import { ENTER_KEY } from '~/lib/utils/keys';
import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import createStore from '~/milestones/stores/';
-import { projectMilestones, groupMilestones } from './mock_data';
+import { projectMilestones, groupMilestones } from '../mock_data';
const extraLinks = [
{ text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },
diff --git a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/frontend/milestones/components/promote_milestone_modal_spec.js
index 4280a78c202..11eaa92f2b0 100644
--- a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js
+++ b/spec/frontend/milestones/components/promote_milestone_modal_spec.js
@@ -6,7 +6,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import * as urlUtils from '~/lib/utils/url_utility';
-import PromoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue';
+import PromoteMilestoneModal from '~/milestones/components/promote_milestone_modal.vue';
jest.mock('~/lib/utils/url_utility');
jest.mock('~/flash');
diff --git a/spec/frontend/milestones/milestone_utils_spec.js b/spec/frontend/milestones/utils_spec.js
index f863f31e5a9..82e31c98398 100644
--- a/spec/frontend/milestones/milestone_utils_spec.js
+++ b/spec/frontend/milestones/utils_spec.js
@@ -1,5 +1,5 @@
import { useFakeDate } from 'helpers/fake_date';
-import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
+import { sortMilestonesByDueDate } from '~/milestones/utils';
describe('sortMilestonesByDueDate', () => {
useFakeDate(2021, 6, 22);
diff --git a/spec/frontend/mocks/mocks_helper.js b/spec/frontend/mocks/mocks_helper.js
deleted file mode 100644
index 295483cd64c..00000000000
--- a/spec/frontend/mocks/mocks_helper.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @module
- *
- * This module implements auto-injected manual mocks that are cleaner than Jest's approach.
- *
- * See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html
- */
-
-import fs from 'fs';
-import path from 'path';
-
-import readdir from 'readdir-enhanced';
-
-const MAX_DEPTH = 20;
-const prefixMap = [
- // E.g. the mock ce/foo/bar maps to require path ~/foo/bar
- { mocksRoot: 'ce', requirePrefix: '~' },
- // { mocksRoot: 'ee', requirePrefix: 'ee' }, // We'll deal with EE-specific mocks later
- // { mocksRoot: 'virtual', requirePrefix: '' }, // We'll deal with virtual mocks later
-];
-
-const mockFileFilter = (stats) => stats.isFile() && stats.path.endsWith('.js');
-
-const getMockFiles = (root) => readdir.sync(root, { deep: MAX_DEPTH, filter: mockFileFilter });
-
-// Function that performs setting a mock. This has to be overridden by the unit test, because
-// jest.setMock can't be overwritten across files.
-// Use require() because jest.setMock expects the CommonJS exports object
-const defaultSetMock = (srcPath, mockPath) =>
- jest.mock(srcPath, () => jest.requireActual(mockPath));
-
-export const setupManualMocks = function setupManualMocks(setMock = defaultSetMock) {
- prefixMap.forEach(({ mocksRoot, requirePrefix }) => {
- const mocksRootAbsolute = path.join(__dirname, mocksRoot);
- if (!fs.existsSync(mocksRootAbsolute)) {
- return;
- }
-
- getMockFiles(path.join(__dirname, mocksRoot)).forEach((mockPath) => {
- const mockPathNoExt = mockPath.substring(0, mockPath.length - path.extname(mockPath).length);
- const sourcePath = path.join(requirePrefix, mockPathNoExt);
- const mockPathRelative = `./${path.join(mocksRoot, mockPathNoExt)}`;
-
- try {
- setMock(sourcePath, mockPathRelative);
- } catch (e) {
- if (e.message.includes('Could not locate module')) {
- // The corresponding mocked module doesn't exist. Raise a better error.
- // Eventualy, we may support virtual mocks (mocks whose path doesn't directly correspond
- // to a module, like with the `ee_else_ce` prefix).
- throw new Error(
- `A manual mock was defined for module ${sourcePath}, but the module doesn't exist!`,
- );
- }
- }
- });
- });
-};
diff --git a/spec/frontend/mocks/mocks_helper_spec.js b/spec/frontend/mocks/mocks_helper_spec.js
deleted file mode 100644
index 0abe5c6b949..00000000000
--- a/spec/frontend/mocks/mocks_helper_spec.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/* eslint-disable global-require */
-
-import path from 'path';
-
-import axios from '~/lib/utils/axios_utils';
-
-const absPath = path.join.bind(null, __dirname);
-
-jest.mock('fs');
-jest.mock('readdir-enhanced');
-
-describe('mocks_helper.js', () => {
- let setupManualMocks;
- const setMock = jest.fn().mockName('setMock');
- let fs;
- let readdir;
-
- beforeAll(() => {
- jest.resetModules();
- jest.setMock = jest.fn().mockName('jest.setMock');
- fs = require('fs');
- readdir = require('readdir-enhanced');
-
- // We need to provide setupManualMocks with a mock function that pretends to do the setup of
- // the mock. This is because we can't mock jest.setMock across files.
- setupManualMocks = () => require('./mocks_helper').setupManualMocks(setMock);
- });
-
- afterEach(() => {
- fs.existsSync.mockReset();
- readdir.sync.mockReset();
- setMock.mockReset();
- });
-
- it('enumerates through mock file roots', () => {
- setupManualMocks();
- expect(fs.existsSync).toHaveBeenCalledTimes(1);
- expect(fs.existsSync).toHaveBeenNthCalledWith(1, absPath('ce'));
-
- expect(readdir.sync).toHaveBeenCalledTimes(0);
- });
-
- it("doesn't traverse the directory tree infinitely", () => {
- fs.existsSync.mockReturnValue(true);
- readdir.sync.mockReturnValue([]);
- setupManualMocks();
-
- const readdirSpy = readdir.sync;
- expect(readdirSpy).toHaveBeenCalled();
- readdirSpy.mock.calls.forEach((call) => {
- expect(call[1].deep).toBeLessThan(100);
- });
- });
-
- it('sets up mocks for CE (the ~/ prefix)', () => {
- fs.existsSync.mockImplementation((root) => root.endsWith('ce'));
- readdir.sync.mockReturnValue(['root.js', 'lib/utils/util.js']);
- setupManualMocks();
-
- expect(readdir.sync).toHaveBeenCalledTimes(1);
- expect(readdir.sync.mock.calls[0][0]).toBe(absPath('ce'));
-
- expect(setMock).toHaveBeenCalledTimes(2);
- expect(setMock).toHaveBeenNthCalledWith(1, '~/root', './ce/root');
- expect(setMock).toHaveBeenNthCalledWith(2, '~/lib/utils/util', './ce/lib/utils/util');
- });
-
- it('sets up mocks for all roots', () => {
- const files = {
- [absPath('ce')]: ['root', 'lib/utils/util'],
- [absPath('node')]: ['jquery', '@babel/core'],
- };
-
- fs.existsSync.mockReturnValue(true);
- readdir.sync.mockImplementation((root) => files[root]);
- setupManualMocks();
-
- expect(readdir.sync).toHaveBeenCalledTimes(1);
- expect(readdir.sync.mock.calls[0][0]).toBe(absPath('ce'));
-
- expect(setMock).toHaveBeenCalledTimes(2);
- expect(setMock).toHaveBeenNthCalledWith(1, '~/root', './ce/root');
- expect(setMock).toHaveBeenNthCalledWith(2, '~/lib/utils/util', './ce/lib/utils/util');
- });
-
- it('fails when given a virtual mock', () => {
- fs.existsSync.mockImplementation((p) => p.endsWith('ce'));
- readdir.sync.mockReturnValue(['virtual', 'shouldntBeImported']);
- setMock.mockImplementation(() => {
- throw new Error('Could not locate module');
- });
-
- expect(setupManualMocks).toThrow(
- new Error("A manual mock was defined for module ~/virtual, but the module doesn't exist!"),
- );
-
- expect(readdir.sync).toHaveBeenCalledTimes(1);
- expect(readdir.sync.mock.calls[0][0]).toBe(absPath('ce'));
- });
-
- describe('auto-injection', () => {
- it('handles ambiguous paths', () => {
- jest.isolateModules(() => {
- const axios2 = require('../../../app/assets/javascripts/lib/utils/axios_utils').default;
- expect(axios2.isMock).toBe(true);
- });
- });
-
- it('survives jest.isolateModules()', (done) => {
- jest.isolateModules(() => {
- const axios2 = require('~/lib/utils/axios_utils').default;
- expect(axios2.isMock).toBe(true);
- done();
- });
- });
-
- it('can be unmocked and remocked', () => {
- jest.dontMock('~/lib/utils/axios_utils');
- jest.resetModules();
- const axios2 = require('~/lib/utils/axios_utils').default;
- expect(axios2).not.toBe(axios);
- expect(axios2.isMock).toBeUndefined();
-
- jest.doMock('~/lib/utils/axios_utils');
- jest.resetModules();
- const axios3 = require('~/lib/utils/axios_utils').default;
- expect(axios3).not.toBe(axios2);
- expect(axios3.isMock).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap
index 3229492506a..5d84b4660c9 100644
--- a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap
+++ b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap
@@ -26,7 +26,7 @@ exports[`MR Popover loaded state matches the snapshot 1`] = `
</div>
<span
- class="text-secondary"
+ class="gl-text-secondary"
>
Opened
<time>
@@ -45,11 +45,11 @@ exports[`MR Popover loaded state matches the snapshot 1`] = `
<h5
class="my-2"
>
- MR Title
+ Updated Title
</h5>
<div
- class="text-secondary"
+ class="gl-text-secondary"
>
foo/bar!1
@@ -77,14 +77,10 @@ exports[`MR Popover shows skeleton-loader while apollo is loading 1`] = `
/>
</div>
- <h5
- class="my-2"
- >
- MR Title
- </h5>
+ <!---->
<div
- class="text-secondary"
+ class="gl-text-secondary"
>
foo/bar!1
diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js
index 094d1a6472c..0c6e4211b10 100644
--- a/spec/frontend/mr_popover/mr_popover_spec.js
+++ b/spec/frontend/mr_popover/mr_popover_spec.js
@@ -15,14 +15,18 @@ describe('MR Popover', () => {
},
mocks: {
$apollo: {
- loading: false,
+ queries: {
+ mergeRequest: {
+ loading: false,
+ },
+ },
},
},
});
});
it('shows skeleton-loader while apollo is loading', () => {
- wrapper.vm.$apollo.loading = true;
+ wrapper.vm.$apollo.queries.mergeRequest.loading = true;
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
@@ -33,6 +37,7 @@ describe('MR Popover', () => {
it('matches the snapshot', () => {
wrapper.setData({
mergeRequest: {
+ title: 'Updated Title',
state: 'opened',
createdAt: new Date(),
headPipeline: {
@@ -64,5 +69,11 @@ describe('MR Popover', () => {
expect(wrapper.find(CiIcon).exists()).toBe(false);
});
});
+
+ it('falls back to cached MR title when request fails', () => {
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.text()).toContain('MR Title');
+ });
+ });
});
});
diff --git a/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap b/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap
new file mode 100644
index 00000000000..5f4b3e04a79
--- /dev/null
+++ b/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap
@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`note_app when sort direction is asc shows skeleton notes after the loaded discussions 1`] = `
+"<ul id=\\"notes-list\\" class=\\"notes main-notes-list timeline\\">
+ <noteable-discussion-stub discussion=\\"[object Object]\\" renderdifffile=\\"true\\" helppagepath=\\"\\" isoverviewtab=\\"true\\"></noteable-discussion-stub>
+ <skeleton-loading-container-stub></skeleton-loading-container-stub>
+ <discussion-filter-note-stub style=\\"display: none;\\"></discussion-filter-note-stub>
+</ul>"
+`;
+
+exports[`note_app when sort direction is desc shows skeleton notes before the loaded discussions 1`] = `
+"<ul id=\\"notes-list\\" class=\\"notes main-notes-list timeline\\">
+ <skeleton-loading-container-stub></skeleton-loading-container-stub>
+ <noteable-discussion-stub discussion=\\"[object Object]\\" renderdifffile=\\"true\\" helppagepath=\\"\\" isoverviewtab=\\"true\\"></noteable-discussion-stub>
+ <discussion-filter-note-stub style=\\"display: none;\\"></discussion-filter-note-stub>
+</ul>"
+`;
diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js
index 6f62b8ba528..17998dfc9d5 100644
--- a/spec/frontend/notes/components/discussion_filter_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_spec.js
@@ -1,3 +1,4 @@
+import { GlDropdown } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
@@ -88,6 +89,12 @@ describe('DiscussionFilter component', () => {
);
});
+ it('disables the dropdown when discussions are loading', () => {
+ store.state.isLoading = true;
+
+ expect(wrapper.findComponent(GlDropdown).props('disabled')).toBe(true);
+ });
+
it('updates to the selected item', () => {
const filterItem = findFilter(DISCUSSION_FILTER_TYPES.ALL);
diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js
index ff840a55535..59ac75f00e6 100644
--- a/spec/frontend/notes/components/discussion_notes_spec.js
+++ b/spec/frontend/notes/components/discussion_notes_spec.js
@@ -1,7 +1,6 @@
import { getByRole } from '@testing-library/dom';
import { shallowMount, mount } from '@vue/test-utils';
import '~/behaviors/markdown/render_gfm';
-import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions';
import DiscussionNotes from '~/notes/components/discussion_notes.vue';
import NoteableNote from '~/notes/components/noteable_note.vue';
import { SYSTEM_NOTE } from '~/notes/constants';
@@ -27,9 +26,6 @@ describe('DiscussionNotes', () => {
const createComponent = (props, mountingMethod = shallowMount) => {
wrapper = mountingMethod(DiscussionNotes, {
store,
- provide: {
- discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
- },
propsData: {
discussion: discussionMock,
isExpanded: false,
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index 6aab60edc4e..727ef02dcbb 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -3,7 +3,6 @@ import { nextTick } from 'vue';
import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json';
import { trimText } from 'helpers/text_helper';
import mockDiffFile from 'jest/diffs/mock_data/diff_file';
-import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions';
import DiscussionNotes from '~/notes/components/discussion_notes.vue';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue';
@@ -32,9 +31,6 @@ describe('noteable_discussion component', () => {
wrapper = mount(NoteableDiscussion, {
store,
- provide: {
- discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
- },
propsData: { discussion: discussionMock },
});
});
@@ -171,9 +167,6 @@ describe('noteable_discussion component', () => {
wrapper = mount(NoteableDiscussion, {
store,
- provide: {
- discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
- },
propsData: { discussion: discussionMock },
});
});
@@ -192,9 +185,6 @@ describe('noteable_discussion component', () => {
wrapper = mount(NoteableDiscussion, {
store,
- provide: {
- discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
- },
propsData: { discussion: discussionMock },
});
});
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index b3dbc26878f..84d94857fe5 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -9,7 +9,6 @@ import DraftNote from '~/batch_comments/components/draft_note.vue';
import batchComments from '~/batch_comments/stores/modules/batch_comments';
import axios from '~/lib/utils/axios_utils';
import * as urlUtility from '~/lib/utils/url_utility';
-import { discussionIntersectionObserverHandlerFactory } from '~/diffs/utils/discussions';
import CommentForm from '~/notes/components/comment_form.vue';
import NotesApp from '~/notes/components/notes_app.vue';
import * as constants from '~/notes/constants';
@@ -79,9 +78,6 @@ describe('note_app', () => {
</div>`,
},
{
- provide: {
- discussionObserverHandler: discussionIntersectionObserverHandlerFactory(),
- },
propsData,
store,
},
@@ -378,6 +374,9 @@ describe('note_app', () => {
beforeEach(() => {
store = createStore();
store.state.discussionSortOrder = constants.DESC;
+ store.state.isLoading = true;
+ store.state.discussions = [mockData.discussionMock];
+
wrapper = shallowMount(NotesApp, {
propsData,
store,
@@ -390,11 +389,18 @@ describe('note_app', () => {
it('finds CommentForm before notes list', () => {
expect(getComponentOrder()).toStrictEqual([TYPE_COMMENT_FORM, TYPE_NOTES_LIST]);
});
+
+ it('shows skeleton notes before the loaded discussions', () => {
+ expect(wrapper.find('#notes-list').html()).toMatchSnapshot();
+ });
});
describe('when sort direction is asc', () => {
beforeEach(() => {
store = createStore();
+ store.state.isLoading = true;
+ store.state.discussions = [mockData.discussionMock];
+
wrapper = shallowMount(NotesApp, {
propsData,
store,
@@ -407,6 +413,10 @@ describe('note_app', () => {
it('finds CommentForm after notes list', () => {
expect(getComponentOrder()).toStrictEqual([TYPE_NOTES_LIST, TYPE_COMMENT_FORM]);
});
+
+ it('shows skeleton notes after the loaded discussions', () => {
+ expect(wrapper.find('#notes-list').html()).toMatchSnapshot();
+ });
});
describe('when multiple draft types are present', () => {
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index bbe074f0105..7424a87bc0f 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -1183,8 +1183,14 @@ describe('Actions Notes Store', () => {
dispatch.mockReturnValue(new Promise(() => {}));
});
+ it('clears existing discussions', () => {
+ actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: false });
+
+ expect(commit.mock.calls).toEqual([[mutationTypes.CLEAR_DISCUSSIONS]]);
+ });
+
it('fetches discussions with filter and persistFilter false', () => {
- actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: false });
+ actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: false });
expect(dispatch.mock.calls).toEqual([
['setLoadingState', true],
@@ -1193,7 +1199,7 @@ describe('Actions Notes Store', () => {
});
it('fetches discussions with filter and persistFilter true', () => {
- actions.filterDiscussion({ dispatch }, { path, filter, persistFilter: true });
+ actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: true });
expect(dispatch.mock.calls).toEqual([
['setLoadingState', true],
diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js
index c9e24039b64..da1547ab6e7 100644
--- a/spec/frontend/notes/stores/mutation_spec.js
+++ b/spec/frontend/notes/stores/mutation_spec.js
@@ -159,6 +159,18 @@ describe('Notes Store mutations', () => {
});
});
+ describe('CLEAR_DISCUSSIONS', () => {
+ it('should set discussions to an empty array', () => {
+ const state = {
+ discussions: [discussionMock],
+ };
+
+ mutations.CLEAR_DISCUSSIONS(state);
+
+ expect(state.discussions).toEqual([]);
+ });
+ });
+
describe('ADD_OR_UPDATE_DISCUSSIONS', () => {
it('should set the initial notes received', () => {
const state = {
diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js
deleted file mode 100644
index a1076b729f8..00000000000
--- a/spec/frontend/packages/shared/utils_spec.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import { PackageType, TrackingCategories } from '~/packages/shared/constants';
-import {
- packageTypeToTrackCategory,
- beautifyPath,
- getPackageTypeLabel,
- getCommitLink,
-} from '~/packages/shared/utils';
-import { packageList } from '../mock_data';
-
-describe('Packages shared utils', () => {
- describe('packageTypeToTrackCategory', () => {
- it('prepend UI to package category', () => {
- expect(packageTypeToTrackCategory()).toMatchInlineSnapshot(`"UI::undefined"`);
- });
-
- it.each(Object.keys(PackageType))('returns a correct category string for %s', (packageKey) => {
- const packageName = PackageType[packageKey];
- expect(packageTypeToTrackCategory(packageName)).toBe(
- `UI::${TrackingCategories[packageName]}`,
- );
- });
- });
-
- describe('beautifyPath', () => {
- it('returns a string with spaces around /', () => {
- expect(beautifyPath('foo/bar')).toBe('foo / bar');
- });
- it('does not fail for empty string', () => {
- expect(beautifyPath()).toBe('');
- });
- });
-
- 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);
- });
- });
- });
-
- describe('getCommitLink', () => {
- it('returns a relative link when isGroup is false', () => {
- const link = getCommitLink(packageList[0], false);
-
- expect(link).toContain('../commit');
- });
-
- describe('when isGroup is true', () => {
- it('returns an absolute link matching project path', () => {
- const mavenPackage = packageList[0];
- const link = getCommitLink(mavenPackage, true);
-
- expect(link).toContain(`/${mavenPackage.project_path}/commit`);
- });
- });
- });
-});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
index 9a42c82d7e0..56f12e2f0bb 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
@@ -1,18 +1,16 @@
-import { GlButton, GlKeysetPagination } from '@gitlab/ui';
import { shallowMount, createLocalVue } 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 { stripTypenames } from 'helpers/graphql_helpers';
import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue';
import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue';
import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue';
-import {
- TAGS_LIST_TITLE,
- REMOVE_TAGS_BUTTON_TITLE,
-} from '~/packages_and_registries/container_registry/explorer/constants/index';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql';
+import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index';
import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data';
const localVue = createLocalVue();
@@ -20,25 +18,20 @@ const localVue = createLocalVue();
describe('Tags List', () => {
let wrapper;
let apolloProvider;
+ let resolver;
const tags = [...tagsMock];
- const readOnlyTags = tags.map((t) => ({ ...t, canDelete: false }));
- const findTagsListRow = () => wrapper.findAll(TagsListRow);
- const findDeleteButton = () => wrapper.find(GlButton);
- const findListTitle = () => wrapper.find('[data-testid="list-title"]');
- const findPagination = () => wrapper.find(GlKeysetPagination);
- const findEmptyState = () => wrapper.find(EmptyTagsState);
- const findTagsLoader = () => wrapper.find(TagsLoader);
+ const findTagsListRow = () => wrapper.findAllComponents(TagsListRow);
+ const findRegistryList = () => wrapper.findComponent(RegistryList);
+ const findEmptyState = () => wrapper.findComponent(EmptyTagsState);
+ const findTagsLoader = () => wrapper.findComponent(TagsLoader);
const waitForApolloRequestRender = async () => {
await waitForPromises();
await nextTick();
};
- const mountComponent = ({
- propsData = { isMobile: false, id: 1 },
- resolver = jest.fn().mockResolvedValue(imageTagsMock()),
- } = {}) => {
+ const mountComponent = ({ propsData = { isMobile: false, id: 1 } } = {}) => {
localVue.use(VueApollo);
const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]];
@@ -48,6 +41,7 @@ describe('Tags List', () => {
localVue,
apolloProvider,
propsData,
+ stubs: { RegistryList },
provide() {
return {
config: {},
@@ -56,99 +50,58 @@ describe('Tags List', () => {
});
};
+ beforeEach(() => {
+ resolver = jest.fn().mockResolvedValue(imageTagsMock());
+ });
+
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
- describe('List title', () => {
- it('exists', async () => {
+ describe('registry list', () => {
+ beforeEach(() => {
mountComponent();
- await waitForApolloRequestRender();
-
- expect(findListTitle().exists()).toBe(true);
+ return waitForApolloRequestRender();
});
- it('has the correct text', async () => {
- mountComponent();
-
- await waitForApolloRequestRender();
-
- expect(findListTitle().text()).toBe(TAGS_LIST_TITLE);
+ it('binds the correct props', () => {
+ expect(findRegistryList().props()).toMatchObject({
+ title: '2 tags',
+ pagination: stripTypenames(tagsPageInfo),
+ items: stripTypenames(tags),
+ idProperty: 'name',
+ });
});
- });
- describe('delete button', () => {
- it.each`
- inputTags | isMobile | isVisible
- ${tags} | ${false} | ${true}
- ${tags} | ${true} | ${false}
- ${readOnlyTags} | ${false} | ${false}
- ${readOnlyTags} | ${true} | ${false}
- `(
- 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile',
- async ({ inputTags, isMobile, isVisible }) => {
- mountComponent({
- propsData: { tags: inputTags, isMobile, id: 1 },
- resolver: jest.fn().mockResolvedValue(imageTagsMock(inputTags)),
+ describe('events', () => {
+ it('prev-page fetch the previous page', () => {
+ findRegistryList().vm.$emit('prev-page');
+
+ expect(resolver).toHaveBeenCalledWith({
+ first: null,
+ before: tagsPageInfo.startCursor,
+ last: GRAPHQL_PAGE_SIZE,
+ id: '1',
});
-
- await waitForApolloRequestRender();
-
- expect(findDeleteButton().exists()).toBe(isVisible);
- },
- );
-
- it('has the correct text', async () => {
- mountComponent();
-
- await waitForApolloRequestRender();
-
- expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE);
- });
-
- it('has the correct props', async () => {
- mountComponent();
- await waitForApolloRequestRender();
-
- expect(findDeleteButton().attributes()).toMatchObject({
- category: 'secondary',
- variant: 'danger',
});
- });
-
- it.each`
- disabled | doSelect | buttonDisabled
- ${true} | ${false} | ${'true'}
- ${true} | ${true} | ${'true'}
- ${false} | ${false} | ${'true'}
- ${false} | ${true} | ${undefined}
- `(
- 'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag',
- async ({ disabled, buttonDisabled, doSelect }) => {
- mountComponent({ propsData: { tags, disabled, isMobile: false, id: 1 } });
-
- await waitForApolloRequestRender();
-
- if (doSelect) {
- findTagsListRow().at(0).vm.$emit('select');
- await nextTick();
- }
- expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled);
- },
- );
+ it('next-page fetch the previous page', () => {
+ findRegistryList().vm.$emit('next-page');
- it('click event emits a deleted event with selected items', async () => {
- mountComponent();
-
- await waitForApolloRequestRender();
+ expect(resolver).toHaveBeenCalledWith({
+ after: tagsPageInfo.endCursor,
+ first: GRAPHQL_PAGE_SIZE,
+ id: '1',
+ });
+ });
- findTagsListRow().at(0).vm.$emit('select');
- findDeleteButton().vm.$emit('click');
+ it('emits a delete event when list emits delete', () => {
+ const eventPayload = 'foo';
+ findRegistryList().vm.$emit('delete', eventPayload);
- expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name);
+ expect(wrapper.emitted('delete')).toEqual([[eventPayload]]);
+ });
});
});
@@ -199,10 +152,12 @@ describe('Tags List', () => {
});
describe('when the list of tags is empty', () => {
- const resolver = jest.fn().mockResolvedValue(imageTagsMock([]));
+ beforeEach(() => {
+ resolver = jest.fn().mockResolvedValue(imageTagsMock([]));
+ });
it('has the empty state', async () => {
- mountComponent({ resolver });
+ mountComponent();
await waitForApolloRequestRender();
@@ -210,7 +165,7 @@ describe('Tags List', () => {
});
it('does not show the loader', async () => {
- mountComponent({ resolver });
+ mountComponent();
await waitForApolloRequestRender();
@@ -218,76 +173,13 @@ describe('Tags List', () => {
});
it('does not show the list', async () => {
- mountComponent({ resolver });
-
- await waitForApolloRequestRender();
-
- expect(findTagsListRow().exists()).toBe(false);
- expect(findListTitle().exists()).toBe(false);
- });
- });
-
- describe('pagination', () => {
- it('exists', async () => {
- mountComponent();
-
- await waitForApolloRequestRender();
-
- expect(findPagination().exists()).toBe(true);
- });
-
- it('is hidden when loading', () => {
mountComponent();
- expect(findPagination().exists()).toBe(false);
- });
-
- it('is hidden when there are no more pages', async () => {
- mountComponent({ resolver: jest.fn().mockResolvedValue(imageTagsMock([])) });
-
await waitForApolloRequestRender();
- expect(findPagination().exists()).toBe(false);
- });
-
- it('is wired to the correct pagination props', async () => {
- mountComponent();
-
- await waitForApolloRequestRender();
-
- expect(findPagination().props()).toMatchObject({
- hasNextPage: tagsPageInfo.hasNextPage,
- hasPreviousPage: tagsPageInfo.hasPreviousPage,
- });
- });
-
- it('fetch next page when user clicks next', async () => {
- const resolver = jest.fn().mockResolvedValue(imageTagsMock());
- mountComponent({ resolver });
-
- await waitForApolloRequestRender();
-
- findPagination().vm.$emit('next');
-
- expect(resolver).toHaveBeenCalledWith(
- expect.objectContaining({ after: tagsPageInfo.endCursor }),
- );
- });
-
- it('fetch previous page when user clicks prev', async () => {
- const resolver = jest.fn().mockResolvedValue(imageTagsMock());
- mountComponent({ resolver });
-
- await waitForApolloRequestRender();
-
- findPagination().vm.$emit('prev');
-
- expect(resolver).toHaveBeenCalledWith(
- expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }),
- );
+ expect(findRegistryList().exists()).toBe(false);
});
});
-
describe('loading state', () => {
it.each`
isImageLoading | queryExecuting | loadingVisible
@@ -306,8 +198,6 @@ describe('Tags List', () => {
expect(findTagsLoader().exists()).toBe(loadingVisible);
expect(findTagsListRow().exists()).toBe(!loadingVisible);
- expect(findListTitle().exists()).toBe(!loadingVisible);
- expect(findPagination().exists()).toBe(!loadingVisible);
},
);
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap
index 46b07b4c2d6..4b52e84d1a6 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap
@@ -36,6 +36,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
<gl-form-input-group-stub
class="gl-mb-4"
+ inputclass=""
predefinedoptions="[object Object]"
value=""
>
@@ -57,6 +58,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
<gl-form-input-group-stub
class="gl-mb-4"
+ inputclass=""
predefinedoptions="[object Object]"
value=""
>
@@ -69,6 +71,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
</gl-form-input-group-stub>
<gl-form-input-group-stub
+ inputclass=""
predefinedoptions="[object Object]"
value=""
>
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
index 6a835a28807..16625d913a5 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
@@ -37,6 +37,7 @@ export const graphQLImageListMock = {
data: {
project: {
__typename: 'Project',
+ id: '1',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
@@ -51,6 +52,7 @@ export const graphQLEmptyImageListMock = {
data: {
project: {
__typename: 'Project',
+ id: '1',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
@@ -65,6 +67,7 @@ export const graphQLEmptyGroupImageListMock = {
data: {
group: {
__typename: 'Group',
+ id: '1',
containerRepositoriesCount: 2,
containerRepositories: {
__typename: 'ContainerRepositoryConnection',
@@ -120,6 +123,7 @@ export const containerRepositoryMock = {
project: {
visibility: 'public',
path: 'gitlab-test',
+ id: '1',
containerExpirationPolicy: {
enabled: false,
nextRunAt: '2020-11-27T08:59:27Z',
@@ -167,6 +171,7 @@ export const imageTagsMock = (nodes = tagsMock) => ({
data: {
containerRepository: {
id: containerRepositoryMock.id,
+ tagsCount: nodes.length,
tags: {
nodes,
pageInfo: { ...tagsPageInfo },
@@ -191,7 +196,7 @@ export const graphQLImageDetailsMock = (override) => ({
data: {
containerRepository: {
...containerRepositoryMock,
-
+ tagsCount: tagsMock.length,
tags: {
nodes: tagsMock,
pageInfo: { ...tagsPageInfo },
@@ -242,6 +247,7 @@ export const dockerCommands = {
export const graphQLProjectImageRepositoriesDetailsMock = {
data: {
project: {
+ id: '1',
containerRepositories: {
nodes: [
{
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
index adc9a64e5c9..9b821ba8ef3 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
@@ -1,6 +1,7 @@
import { GlKeysetPagination } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
+import { nextTick } from 'vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
@@ -22,6 +23,7 @@ import {
} from '~/packages_and_registries/container_registry/explorer/constants';
import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
+import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql';
import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue';
import Tracking from '~/tracking';
@@ -32,6 +34,7 @@ import {
containerRepositoryMock,
graphQLEmptyImageDetailsMock,
tagsMock,
+ imageTagsMock,
} from '../mock_data';
import { DeleteModal } from '../stubs';
@@ -67,12 +70,13 @@ describe('Details Page', () => {
const waitForApolloRequestRender = async () => {
await waitForPromises();
- await wrapper.vm.$nextTick();
+ await nextTick();
};
const mountComponent = ({
resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()),
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock),
+ tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)),
options,
config = {},
} = {}) => {
@@ -81,6 +85,7 @@ describe('Details Page', () => {
const requestHandlers = [
[getContainerRepositoryDetailsQuery, resolver],
[deleteContainerRepositoryTagsMutation, mutationResolver],
+ [getContainerRepositoryTagsQuery, tagsResolver],
];
apolloProvider = createMockApollo(requestHandlers);
@@ -242,38 +247,49 @@ describe('Details Page', () => {
describe('confirmDelete event', () => {
let mutationResolver;
+ let tagsResolver;
beforeEach(() => {
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock);
- mountComponent({ mutationResolver });
+ tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock));
+ mountComponent({ mutationResolver, tagsResolver });
return waitForApolloRequestRender();
});
+
describe('when one item is selected to be deleted', () => {
- it('calls apollo mutation with the right parameters', async () => {
+ it('calls apollo mutation with the right parameters and refetches the tags list query', async () => {
findTagsList().vm.$emit('delete', [cleanTags[0]]);
- await wrapper.vm.$nextTick();
+ await nextTick();
findDeleteModal().vm.$emit('confirmDelete');
expect(mutationResolver).toHaveBeenCalledWith(
expect.objectContaining({ tagNames: [cleanTags[0].name] }),
);
+
+ await waitForPromises();
+
+ expect(tagsResolver).toHaveBeenCalled();
});
});
describe('when more than one item is selected to be deleted', () => {
- it('calls apollo mutation with the right parameters', async () => {
+ it('calls apollo mutation with the right parameters and refetches the tags list query', async () => {
findTagsList().vm.$emit('delete', tagsMock);
- await wrapper.vm.$nextTick();
+ await nextTick();
findDeleteModal().vm.$emit('confirmDelete');
expect(mutationResolver).toHaveBeenCalledWith(
expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }),
);
+
+ await waitForPromises();
+
+ expect(tagsResolver).toHaveBeenCalled();
});
});
});
@@ -382,7 +398,7 @@ describe('Details Page', () => {
findPartialCleanupAlert().vm.$emit('dismiss');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(axios.post).toHaveBeenCalledWith(config.userCalloutsPath, {
feature_name: config.userCalloutId,
@@ -472,7 +488,7 @@ describe('Details Page', () => {
await waitForApolloRequestRender();
findDetailsHeader().vm.$emit('delete');
- await wrapper.vm.$nextTick();
+ await nextTick();
};
it('on delete event it deletes the image', async () => {
@@ -497,13 +513,13 @@ describe('Details Page', () => {
findDeleteImage().vm.$emit('start');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findTagsLoader().exists()).toBe(true);
findDeleteImage().vm.$emit('end');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findTagsLoader().exists()).toBe(false);
});
@@ -513,7 +529,7 @@ describe('Details Page', () => {
findDeleteImage().vm.$emit('error');
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE);
});
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
index 625f00a8666..44a7186904d 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
@@ -54,7 +54,6 @@ describe('DependencyProxyApp', () => {
}
const findProxyNotAvailableAlert = () => wrapper.findByTestId('proxy-not-available');
- const findProxyDisabledAlert = () => wrapper.findByTestId('proxy-disabled');
const findClipBoardButton = () => wrapper.findComponent(ClipboardButton);
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
@@ -219,28 +218,6 @@ describe('DependencyProxyApp', () => {
});
});
});
-
- describe('when the dependency proxy is disabled', () => {
- beforeEach(() => {
- resolver = jest
- .fn()
- .mockResolvedValue(proxyDetailsQuery({ extendSettings: { enabled: false } }));
- createComponent();
- return waitForPromises();
- });
-
- it('does not show the main area', () => {
- expect(findMainArea().exists()).toBe(false);
- });
-
- it('does not show the loader', () => {
- expect(findSkeletonLoader().exists()).toBe(false);
- });
-
- it('shows a proxy disabled alert', () => {
- expect(findProxyDisabledAlert().text()).toBe(DependencyProxyApp.i18n.proxyDisabledText);
- });
- });
});
});
});
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
index 8bad22b5287..2aa427bc6af 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js
@@ -8,8 +8,8 @@ export const proxyData = () => ({
export const proxySettings = (extend = {}) => ({ enabled: true, ...extend });
export const proxyManifests = () => [
- { createdAt: '2021-09-22T09:45:28Z', imageName: 'alpine:latest' },
- { createdAt: '2021-09-21T09:45:28Z', imageName: 'alpine:stable' },
+ { id: 'proxy-1', createdAt: '2021-09-22T09:45:28Z', imageName: 'alpine:latest' },
+ { id: 'proxy-2', createdAt: '2021-09-21T09:45:28Z', imageName: 'alpine:stable' },
];
export const pagination = (extend) => ({
@@ -26,6 +26,7 @@ export const proxyDetailsQuery = ({ extendSettings = {}, extend } = {}) => ({
group: {
...proxyData(),
__typename: 'Group',
+ id: '1',
dependencyProxySetting: {
...proxySettings(extendSettings),
__typename: 'DependencyProxySetting',
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
index c7c10cef504..2868af84181 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js
@@ -9,15 +9,15 @@ import PackagesApp from '~/packages_and_registries/infrastructure_registry/detai
import PackageFiles from '~/packages_and_registries/infrastructure_registry/details/components/package_files.vue';
import PackageHistory from '~/packages_and_registries/infrastructure_registry/details/components/package_history.vue';
import * as getters from '~/packages_and_registries/infrastructure_registry/details/store/getters';
-import PackageListRow from '~/packages/shared/components/package_list_row.vue';
-import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
-import { TrackingActions } from '~/packages/shared/constants';
-import * as SharedUtils from '~/packages/shared/utils';
+import PackageListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
+import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants';
+import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants';
import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue';
import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue';
import Tracking from '~/tracking';
-import { mavenPackage, mavenFiles, npmPackage } from 'jest/packages/mock_data';
+import { mavenPackage, mavenFiles, npmPackage } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -232,87 +232,78 @@ describe('PackagesApp', () => {
describe('tracking', () => {
let eventSpy;
- let utilSpy;
- const category = 'foo';
beforeEach(() => {
eventSpy = jest.spyOn(Tracking, 'event');
- utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category);
});
- it('tracking category calls packageTypeToTrackCategory', () => {
- createComponent({ packageEntity: npmPackage });
- expect(wrapper.vm.tracking.category).toBe(category);
- expect(utilSpy).toHaveBeenCalledWith('npm');
- });
-
- it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => {
+ it(`delete button on delete modal call event with ${TRACKING_ACTIONS.DELETE_PACKAGE}`, () => {
createComponent({ packageEntity: npmPackage });
findDeleteModal().vm.$emit('primary');
expect(eventSpy).toHaveBeenCalledWith(
- category,
- TrackingActions.DELETE_PACKAGE,
+ TRACK_CATEGORY,
+ TRACKING_ACTIONS.DELETE_PACKAGE,
expect.any(Object),
);
});
- it(`canceling a package deletion tracks ${TrackingActions.CANCEL_DELETE_PACKAGE}`, () => {
+ it(`canceling a package deletion tracks ${TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE}`, () => {
createComponent({ packageEntity: npmPackage });
findDeleteModal().vm.$emit('canceled');
expect(eventSpy).toHaveBeenCalledWith(
- category,
- TrackingActions.CANCEL_DELETE_PACKAGE,
+ TRACK_CATEGORY,
+ TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE,
expect.any(Object),
);
});
- it(`request a file deletion tracks ${TrackingActions.REQUEST_DELETE_PACKAGE_FILE}`, () => {
+ it(`request a file deletion tracks ${TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE}`, () => {
createComponent({ packageEntity: npmPackage });
findPackageFiles().vm.$emit('delete-file', mavenFiles[0]);
expect(eventSpy).toHaveBeenCalledWith(
- category,
- TrackingActions.REQUEST_DELETE_PACKAGE_FILE,
+ TRACK_CATEGORY,
+ TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE,
expect.any(Object),
);
});
- it(`confirming a file deletion tracks ${TrackingActions.DELETE_PACKAGE_FILE}`, () => {
+ it(`confirming a file deletion tracks ${TRACKING_ACTIONS.DELETE_PACKAGE_FILE}`, () => {
createComponent({ packageEntity: npmPackage });
findPackageFiles().vm.$emit('delete-file', npmPackage);
findDeleteFileModal().vm.$emit('primary');
expect(eventSpy).toHaveBeenCalledWith(
- category,
- TrackingActions.REQUEST_DELETE_PACKAGE_FILE,
+ TRACK_CATEGORY,
+ TRACKING_ACTIONS.REQUEST_DELETE_PACKAGE_FILE,
expect.any(Object),
);
});
- it(`canceling a file deletion tracks ${TrackingActions.CANCEL_DELETE_PACKAGE_FILE}`, () => {
+ it(`canceling a file deletion tracks ${TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE_FILE}`, () => {
createComponent({ packageEntity: npmPackage });
findPackageFiles().vm.$emit('delete-file', npmPackage);
findDeleteFileModal().vm.$emit('canceled');
expect(eventSpy).toHaveBeenCalledWith(
- category,
- TrackingActions.CANCEL_DELETE_PACKAGE_FILE,
+ TRACK_CATEGORY,
+ TRACKING_ACTIONS.CANCEL_DELETE_PACKAGE_FILE,
expect.any(Object),
);
});
- it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => {
+ it(`file download link call event with ${TRACKING_ACTIONS.PULL_PACKAGE}`, () => {
createComponent({ packageEntity: npmPackage });
findPackageFiles().vm.$emit('download-file');
expect(eventSpy).toHaveBeenCalledWith(
- category,
- TrackingActions.PULL_PACKAGE,
+ TRACK_CATEGORY,
+ TRACKING_ACTIONS.PULL_PACKAGE,
expect.any(Object),
);
});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
index a012ec4ab05..24bd80ba80c 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js
@@ -1,8 +1,8 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import { terraformModule, mavenFiles, npmPackage } from 'jest/packages/mock_data';
import component from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import { terraformModule, mavenFiles, npmPackage } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
index 0c5aa30223b..6b6c33b7561 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js
@@ -6,7 +6,7 @@ import component from '~/packages_and_registries/infrastructure_registry/details
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { npmFiles, mavenFiles } from 'jest/packages/mock_data';
+import { npmFiles, mavenFiles } from '../../mock_data';
describe('Package Files', () => {
let wrapper;
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js
index 4987af9f5b0..f10f05f4a0d 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js
@@ -6,7 +6,7 @@ import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/consta
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { mavenPackage, mockPipelineInfo } from 'jest/packages/mock_data';
+import { mavenPackage, mockPipelineInfo } from '../../mock_data';
describe('Package History', () => {
let wrapper;
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js
index c26784a4b75..6ff4a4c51ef 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js
@@ -1,8 +1,8 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import { terraformModule as packageEntity } from 'jest/packages/mock_data';
import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue';
import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue';
+import { terraformModule as packageEntity } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
index 61fa69c2f7a..b9383d6c38c 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
@@ -12,8 +12,8 @@ import {
DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
-} from '~/packages/shared/constants';
-import { npmPackage as packageEntity } from '../../../../../packages/mock_data';
+} from '~/packages_and_registries/shared/constants';
+import { npmPackage as packageEntity } from '../../mock_data';
jest.mock('~/flash.js');
jest.mock('~/api.js');
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js
index 8740691a8ee..b14aaa93e1f 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js
@@ -3,7 +3,7 @@ import {
npmPackage,
mockPipelineInfo,
mavenPackage as packageWithoutBuildInfo,
-} from 'jest/packages/mock_data';
+} from '../../mock_data';
describe('Getters PackageDetails Store', () => {
let state;
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js
index 6efefea4a14..0f0c84af7da 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js
@@ -1,6 +1,6 @@
import * as types from '~/packages_and_registries/infrastructure_registry/details/store/mutation_types';
import mutations from '~/packages_and_registries/infrastructure_registry/details/store/mutations';
-import { npmPackage as packageEntity } from 'jest/packages/mock_data';
+import { npmPackage as packageEntity } from '../../mock_data';
describe('Mutations package details Store', () => {
let mockState;
diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap
index 67e2594d29f..99a7b8e427a 100644
--- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -34,12 +34,16 @@ exports[`packages_list_app renders 1`] = `
class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
<h1
- class="h4"
+ class="gl-font-size-h-display gl-line-height-36 h4"
>
- There are no packages yet
+
+ There are no packages yet
+
</h1>
- <p>
+ <p
+ class="gl-mt-3"
+ >
Learn how to
<b-link-stub
class="gl-link"
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js
index 119b678cc37..b519ab00d06 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_search_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_search_spec.js
@@ -1,6 +1,6 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import component from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue';
+import component from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js
index db6e175b054..b0e586f189a 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_title_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/infrastructure_title_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import component from '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue';
+import component from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js
index 5f7555a3a2b..cad75d2a858 100644
--- a/spec/frontend/packages/list/components/packages_list_app_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js
@@ -4,12 +4,15 @@ 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';
-import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
-import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import PackageListApp from '~/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue';
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants';
+import {
+ SHOW_DELETE_SUCCESS_ALERT,
+ FILTERED_SEARCH_TERM,
+} from '~/packages_and_registries/shared/constants';
+
import * as packageUtils from '~/packages_and_registries/shared/utils';
-import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue';
+import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue';
jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
diff --git a/spec/frontend/packages/list/components/packages_list_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js
index b1478a5e6dc..2fb76b98925 100644
--- a/spec/frontend/packages/list/components/packages_list_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_spec.js
@@ -3,11 +3,11 @@ import { mount, createLocalVue } from '@vue/test-utils';
import { last } from 'lodash';
import Vuex from 'vuex';
import stubChildren from 'helpers/stub_children';
-import PackagesList from '~/packages/list/components/packages_list.vue';
-import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
-import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
-import { TrackingActions } from '~/packages/shared/constants';
-import * as SharedUtils from '~/packages/shared/utils';
+import PackagesList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue';
+import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
+import { TRACKING_ACTIONS } from '~/packages_and_registries/shared/constants';
+import { TRACK_CATEGORY } from '~/packages_and_registries/infrastructure_registry/shared/constants';
import Tracking from '~/tracking';
import { packageList } from '../../mock_data';
@@ -190,26 +190,18 @@ describe('packages_list', () => {
describe('tracking', () => {
let eventSpy;
- let utilSpy;
- const category = 'foo';
beforeEach(() => {
mountComponent();
eventSpy = jest.spyOn(Tracking, 'event');
- utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category);
wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } });
});
- it('tracking category calls packageTypeToTrackCategory', () => {
- expect(wrapper.vm.tracking.category).toBe(category);
- expect(utilSpy).toHaveBeenCalledWith('conan');
- });
-
it('deleteItemConfirmation calls event', () => {
wrapper.vm.deleteItemConfirmation();
expect(eventSpy).toHaveBeenCalledWith(
- category,
- TrackingActions.DELETE_PACKAGE,
+ TRACK_CATEGORY,
+ TRACKING_ACTIONS.DELETE_PACKAGE,
expect.any(Object),
);
});
diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js
index adccb7436e1..3fbfe1060dc 100644
--- a/spec/frontend/packages/list/stores/actions_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js
@@ -3,10 +3,10 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import Api from '~/api';
import createFlash from '~/flash';
-import { MISSING_DELETE_PATH_ERROR } from '~/packages/list/constants';
-import * as actions from '~/packages/list/stores/actions';
-import * as types from '~/packages/list/stores/mutation_types';
-import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants';
+import { MISSING_DELETE_PATH_ERROR } from '~/packages_and_registries/infrastructure_registry/list/constants';
+import * as actions from '~/packages_and_registries/infrastructure_registry/list/stores/actions';
+import * as types from '~/packages_and_registries/infrastructure_registry/list/stores/mutation_types';
+import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants';
jest.mock('~/flash.js');
jest.mock('~/api.js');
diff --git a/spec/frontend/packages/list/stores/getters_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/getters_spec.js
index 080bbc21d9f..f2d52ace34e 100644
--- a/spec/frontend/packages/list/stores/getters_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/getters_spec.js
@@ -1,4 +1,4 @@
-import getList from '~/packages/list/stores/getters';
+import getList from '~/packages_and_registries/infrastructure_registry/list/stores/getters';
import { packageList } from '../../mock_data';
describe('Getters registry list store', () => {
diff --git a/spec/frontend/packages/list/stores/mutations_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/mutations_spec.js
index 2ddf3a1da33..afd7a7e5439 100644
--- a/spec/frontend/packages/list/stores/mutations_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/mutations_spec.js
@@ -1,7 +1,7 @@
import * as commonUtils from '~/lib/utils/common_utils';
-import * as types from '~/packages/list/stores/mutation_types';
-import mutations from '~/packages/list/stores/mutations';
-import createState from '~/packages/list/stores/state';
+import * as types from '~/packages_and_registries/infrastructure_registry/list/stores/mutation_types';
+import mutations from '~/packages_and_registries/infrastructure_registry/list/stores/mutations';
+import createState from '~/packages_and_registries/infrastructure_registry/list/stores/state';
import { npmPackage, mavenPackage } from '../../mock_data';
describe('Mutations Registry Store', () => {
diff --git a/spec/frontend/packages/list/utils_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/utils_spec.js
index 4e4f7b8a723..a897fb90522 100644
--- a/spec/frontend/packages/list/utils_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/utils_spec.js
@@ -1,5 +1,8 @@
-import { SORT_FIELDS } from '~/packages/list/constants';
-import { getNewPaginationPage, sortableFields } from '~/packages/list/utils';
+import { SORT_FIELDS } from '~/packages_and_registries/infrastructure_registry/list/constants';
+import {
+ getNewPaginationPage,
+ sortableFields,
+} from '~/packages_and_registries/infrastructure_registry/list/utils';
describe('Packages list utils', () => {
describe('sortableFields', () => {
diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/mock_data.js
index 33b47cca68b..33b47cca68b 100644
--- a/spec/frontend/packages/mock_data.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/mock_data.js
diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap
index b576f1b2553..67c3b8b795a 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap
@@ -6,7 +6,7 @@ exports[`packages_list_row renders 1`] = `
data-qa-selector="package_row"
>
<div
- class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5"
+ class="gl-display-flex gl-align-items-center gl-py-3"
>
<!---->
@@ -86,7 +86,7 @@ exports[`packages_list_row renders 1`] = `
</div>
<div
- class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1"
+ class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1"
>
<gl-button-stub
aria-label="Remove package"
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/infrastructure_icon_and_name_spec.js
index ef26c729691..abb0d23b6e4 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/infrastructure_icon_and_name_spec.js
@@ -1,6 +1,6 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue';
+import InfrastructureIconAndName from '~/packages_and_registries/infrastructure_registry/shared/infrastructure_icon_and_name.vue';
describe('InfrastructureIconAndName', () => {
let wrapper;
diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js
index 5f2fc8ddfbd..1052fdd1dda 100644
--- a/spec/frontend/packages/shared/components/package_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/package_list_row_spec.js
@@ -2,13 +2,13 @@ import { GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
-import PackagePath from '~/packages/shared/components/package_path.vue';
-import PackageTags from '~/packages/shared/components/package_tags.vue';
-import { PACKAGE_ERROR_STATUS } from '~/packages/shared/constants';
+import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue';
+import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
+import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
+import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/shared/constants';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
-import { packageList } from '../../mock_data';
+import { packageList } from '../mock_data';
describe('packages_list_row', () => {
let wrapper;
@@ -17,12 +17,10 @@ describe('packages_list_row', () => {
const [packageWithoutTags, packageWithTags] = packageList;
const InfrastructureIconAndName = { name: 'InfrastructureIconAndName', template: '<div></div>' };
- const PackageIconAndName = { name: 'PackageIconAndName', template: '<div></div>' };
const findPackageTags = () => wrapper.findComponent(PackageTags);
const findPackagePath = () => wrapper.findComponent(PackagePath);
const findDeleteButton = () => wrapper.findByTestId('action-delete');
- const findPackageIconAndName = () => wrapper.findComponent(PackageIconAndName);
const findInfrastructureIconAndName = () => wrapper.findComponent(InfrastructureIconAndName);
const findListItem = () => wrapper.findComponent(ListItem);
const findPackageLink = () => wrapper.findComponent(GlLink);
@@ -41,7 +39,6 @@ describe('packages_list_row', () => {
stubs: {
ListItem,
InfrastructureIconAndName,
- PackageIconAndName,
},
propsData: {
packageLink: 'foo',
@@ -93,13 +90,13 @@ describe('packages_list_row', () => {
it('shows the type when set', () => {
mountComponent();
- expect(findPackageIconAndName().exists()).toBe(true);
+ expect(findInfrastructureIconAndName().exists()).toBe(true);
});
it('does not show the type when not set', () => {
mountComponent({ showPackageType: false });
- expect(findPackageIconAndName().exists()).toBe(false);
+ expect(findInfrastructureIconAndName().exists()).toBe(false);
});
});
@@ -135,27 +132,6 @@ describe('packages_list_row', () => {
});
});
- describe('Infrastructure config', () => {
- it('defaults to package registry components', () => {
- mountComponent();
-
- expect(findPackageIconAndName().exists()).toBe(true);
- expect(findInfrastructureIconAndName().exists()).toBe(false);
- });
-
- it('mounts different component based on the provided values', () => {
- mountComponent({
- provide: {
- iconComponent: 'InfrastructureIconAndName',
- },
- });
-
- expect(findPackageIconAndName().exists()).toBe(false);
-
- expect(findInfrastructureIconAndName().exists()).toBe(true);
- });
- });
-
describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => {
beforeEach(() => {
mountComponent({ packageEntity: { ...packageWithoutTags, status: PACKAGE_ERROR_STATUS } });
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
index c95538546c1..7aa42a1f1e5 100644
--- 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
@@ -5,7 +5,7 @@ exports[`VersionRow renders 1`] = `
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"
+ class="gl-display-flex gl-align-items-center gl-py-3"
>
<!---->
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
index d59c3184e4e..6ad6007c9da 100644
--- 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
@@ -2,7 +2,7 @@ import { GlIcon, GlSprintf } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import PackageTags from '~/packages/shared/components/package_tags.vue';
+import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
import {
PACKAGE_TYPE_CONAN,
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
index f7613949fe4..faeca76d746 100644
--- 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
@@ -1,8 +1,8 @@
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 PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
+import PublishMethod from '~/packages_and_registries/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';
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
index 2f2be797251..165ee962417 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap
@@ -6,7 +6,7 @@ exports[`packages_list_row renders 1`] = `
data-qa-selector="package_row"
>
<div
- class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5"
+ class="gl-display-flex gl-align-items-center gl-py-3"
>
<!---->
@@ -77,7 +77,9 @@ exports[`packages_list_row renders 1`] = `
<div
class="gl-display-flex gl-align-items-center gl-min-h-6"
>
- <span>
+ <span
+ data-testid="created-date"
+ >
Created
<timeago-tooltip-stub
cssclass=""
@@ -90,7 +92,7 @@ exports[`packages_list_row renders 1`] = `
</div>
<div
- class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1"
+ class="gl-w-9 gl-display-flex gl-justify-content-end gl-pr-1"
>
<gl-button-stub
aria-label="Remove package"
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap
index 919dbe25ffe..4407c4a2003 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap
@@ -37,6 +37,7 @@ exports[`publish_method renders 1`] = `
text="b83d6e391c22777fca1ed3012fce84f633d7fed0"
title="Copy commit SHA"
tooltipplacement="top"
+ variant="default"
/>
</div>
`;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
index a276db104d7..292667ec47c 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
@@ -3,9 +3,11 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
-import PackagePath from '~/packages/shared/components/package_path.vue';
-import PackageTags from '~/packages/shared/components/package_tags.vue';
-import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue';
+import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
+import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
+import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue';
+import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry/constants';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
@@ -29,6 +31,9 @@ describe('packages_list_row', () => {
const findPackageLink = () => wrapper.findComponent(GlLink);
const findWarningIcon = () => wrapper.findByTestId('warning-icon');
const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos');
+ const findPublishMethod = () => wrapper.findComponent(PublishMethod);
+ const findCreatedDateText = () => wrapper.findByTestId('created-date');
+ const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip);
const mountComponent = ({
packageEntity = packageWithoutTags,
@@ -153,4 +158,23 @@ describe('packages_list_row', () => {
expect(findPackageIconAndName().text()).toBe(packageWithoutTags.packageType.toLowerCase());
});
});
+
+ describe('right info', () => {
+ it('has publish method component', () => {
+ mountComponent({
+ packageEntity: { ...packageWithoutTags, pipelines: { nodes: packagePipelines() } },
+ });
+
+ expect(findPublishMethod().props('pipeline')).toEqual(packagePipelines()[0]);
+ });
+
+ it('has the created date', () => {
+ mountComponent();
+
+ expect(findCreatedDateText().text()).toMatchInterpolatedText(PackagesListRow.i18n.createdAt);
+ expect(findTimeAgoTooltip().props()).toMatchObject({
+ time: packageData().createdAt,
+ });
+ });
+ });
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
index de4e9c8ae5b..97978dee909 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_spec.js
@@ -1,8 +1,8 @@
import { GlKeysetPagination, GlModal, GlSprintf } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
-import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js
index bacc748db81..4c23b52b8a2 100644
--- a/spec/frontend/packages_and_registries/package_registry/mock_data.js
+++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js
@@ -16,11 +16,13 @@ export const packagePipelines = (extend) => [
ref: 'master',
sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
project: {
+ id: '1',
name: 'project14',
webUrl: 'http://gdk.test:3000/namespace14/project14',
__typename: 'Project',
},
user: {
+ id: 'user-1',
name: 'Administrator',
},
...extend,
@@ -89,6 +91,7 @@ export const dependencyLinks = () => [
];
export const packageProject = () => ({
+ id: '1',
fullPath: 'gitlab-org/gitlab-test',
webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-test',
__typename: 'Project',
@@ -127,6 +130,7 @@ export const packageData = (extend) => ({
});
export const conanMetadata = () => ({
+ id: 'conan-1',
packageChannel: 'stable',
packageUsername: 'gitlab-org+gitlab-test',
recipe: 'package-8/1.0.0@gitlab-org+gitlab-test/stable',
@@ -179,6 +183,7 @@ export const packageDetailsQuery = (extendPackage) => ({
...nugetMetadata(),
},
project: {
+ id: '1',
path: 'projectPath',
},
tags: {
@@ -270,6 +275,7 @@ export const packageDestroyFileMutationError = () => ({
export const packagesListQuery = ({ type = 'group', extend = {}, extendPagination = {} } = {}) => ({
data: {
[type]: {
+ id: '1',
packages: {
count: 2,
nodes: [
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap
index 5af75868084..dbe3c70c3cb 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap
@@ -4,7 +4,7 @@ exports[`PackagesListApp renders 1`] = `
<div>
<package-title-stub
count="2"
- helpurl="packageHelpUrl"
+ helpurl="/help/user/packages/index"
/>
<package-search-stub />
@@ -35,17 +35,21 @@ exports[`PackagesListApp renders 1`] = `
class="text-content gl-mx-auto gl-my-0 gl-p-5"
>
<h1
- class="h4"
+ class="gl-font-size-h-display gl-line-height-36 h4"
>
- There are no packages yet
+
+ There are no packages yet
+
</h1>
- <p>
+ <p
+ class="gl-mt-3"
+ >
Learn how to
<b-link-stub
class="gl-link"
event="click"
- href="emptyListHelpUrl"
+ href="/help/user/packages/package_registry/index"
routertag="a"
target="_blank"
>
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js
index ad848f367e0..2ac2a6455ef 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js
@@ -6,7 +6,7 @@ import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import PackageListApp from '~/packages_and_registries/package_registry/components/list/app.vue';
+import ListPage from '~/packages_and_registries/package_registry/pages/list.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue';
import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue';
import OriginalPackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
@@ -16,11 +16,13 @@ import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
GRAPHQL_PAGE_SIZE,
+ EMPTY_LIST_HELP_URL,
+ PACKAGE_HELP_URL,
} from '~/packages_and_registries/package_registry/constants';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
-import { packagesListQuery, packageData, pagination } from '../../mock_data';
+import { packagesListQuery, packageData, pagination } from '../mock_data';
jest.mock('~/lib/utils/common_utils');
jest.mock('~/flash');
@@ -32,9 +34,7 @@ describe('PackagesListApp', () => {
let apolloProvider;
const defaultProvide = {
- packageHelpUrl: 'packageHelpUrl',
emptyListIllustration: 'emptyListIllustration',
- emptyListHelpUrl: 'emptyListHelpUrl',
isGroupPage: true,
fullPath: 'gitlab-org',
};
@@ -66,7 +66,7 @@ describe('PackagesListApp', () => {
const requestHandlers = [[getPackagesQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMountExtended(PackageListApp, {
+ wrapper = shallowMountExtended(ListPage, {
localVue,
apolloProvider,
provide,
@@ -113,7 +113,10 @@ describe('PackagesListApp', () => {
await waitForFirstRequest();
expect(findPackageTitle().exists()).toBe(true);
- expect(findPackageTitle().props('count')).toBe(2);
+ expect(findPackageTitle().props()).toMatchObject({
+ count: 2,
+ helpUrl: PACKAGE_HELP_URL,
+ });
});
describe('search component', () => {
@@ -213,12 +216,12 @@ describe('PackagesListApp', () => {
it('generate the correct empty list link', () => {
const link = findListComponent().findComponent(GlLink);
- expect(link.attributes('href')).toBe(defaultProvide.emptyListHelpUrl);
+ expect(link.attributes('href')).toBe(EMPTY_LIST_HELP_URL);
expect(link.text()).toBe('publish and share your packages');
});
it('includes the right content on the default tab', () => {
- expect(findEmptyState().text()).toContain(PackageListApp.i18n.emptyPageTitle);
+ expect(findEmptyState().text()).toContain(ListPage.i18n.emptyPageTitle);
});
});
@@ -234,8 +237,8 @@ describe('PackagesListApp', () => {
});
it('should show specific empty message', () => {
- expect(findEmptyState().text()).toContain(PackageListApp.i18n.noResultsTitle);
- expect(findEmptyState().text()).toContain(PackageListApp.i18n.widenFilters);
+ expect(findEmptyState().text()).toContain(ListPage.i18n.noResultsTitle);
+ expect(findEmptyState().text()).toContain(ListPage.i18n.widenFilters);
});
});
diff --git a/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap b/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap
index f2087733d2b..5b56cb7f74e 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap
+++ b/spec/frontend/packages_and_registries/settings/group/components/__snapshots__/settings_titles_spec.js.snap
@@ -3,7 +3,7 @@
exports[`settings_titles renders properly 1`] = `
<div>
<h5
- class="gl-border-b-solid gl-border-b-1 gl-border-gray-200"
+ class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3"
>
foo
diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
index d3a970e86eb..f6c1d212b51 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js
@@ -1,6 +1,7 @@
-import { GlSprintf, GlLink, GlToggle } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlSprintf, GlToggle } from '@gitlab/ui';
+import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -12,14 +13,21 @@ import {
} from '~/packages_and_registries/settings/group/constants';
import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql';
+import updateDependencyProxyImageTtlGroupPolicy from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_image_ttl_group_policy.mutation.graphql';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
-import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
import {
- dependencyProxySettings,
+ updateGroupDependencyProxySettingsOptimisticResponse,
+ updateDependencyProxyImageTtlGroupPolicyOptimisticResponse,
+} from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+import {
+ dependencyProxySettings as dependencyProxySettingsMock,
+ dependencyProxyImageTtlPolicy as dependencyProxyImageTtlPolicyMock,
dependencyProxySettingMutationMock,
groupPackageSettingsMock,
- dependencyProxySettingMutationErrorMock,
+ mutationErrorMock,
+ dependencyProxyUpdateTllPolicyMutationMock,
} from '../mock_data';
jest.mock('~/flash');
@@ -30,46 +38,68 @@ const localVue = createLocalVue();
describe('DependencyProxySettings', () => {
let wrapper;
let apolloProvider;
+ let updateSettingsMutationResolver;
+ let updateTtlPoliciesMutationResolver;
const defaultProvide = {
defaultExpanded: false,
groupPath: 'foo_group_path',
+ groupDependencyProxyPath: 'group_dependency_proxy_path',
};
localVue.use(VueApollo);
const mountComponent = ({
provide = defaultProvide,
- mutationResolver = jest.fn().mockResolvedValue(dependencyProxySettingMutationMock()),
isLoading = false,
+ dependencyProxySettings = dependencyProxySettingsMock(),
+ dependencyProxyImageTtlPolicy = dependencyProxyImageTtlPolicyMock(),
} = {}) => {
- const requestHandlers = [[updateDependencyProxySettings, mutationResolver]];
+ const requestHandlers = [
+ [updateDependencyProxySettings, updateSettingsMutationResolver],
+ [updateDependencyProxyImageTtlGroupPolicy, updateTtlPoliciesMutationResolver],
+ ];
apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMount(component, {
+ wrapper = shallowMountExtended(component, {
localVue,
apolloProvider,
provide,
propsData: {
- dependencyProxySettings: dependencyProxySettings(),
+ dependencyProxySettings,
+ dependencyProxyImageTtlPolicy,
isLoading,
},
stubs: {
GlSprintf,
+ GlToggle,
SettingsBlock,
},
});
};
+ beforeEach(() => {
+ updateSettingsMutationResolver = jest
+ .fn()
+ .mockResolvedValue(dependencyProxySettingMutationMock());
+ updateTtlPoliciesMutationResolver = jest
+ .fn()
+ .mockResolvedValue(dependencyProxyUpdateTllPolicyMutationMock());
+ });
+
afterEach(() => {
wrapper.destroy();
});
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
- const findDescription = () => wrapper.find('[data-testid="description"');
- const findLink = () => wrapper.findComponent(GlLink);
- const findToggle = () => wrapper.findComponent(GlToggle);
+ const findSettingsTitles = () => wrapper.findComponent(SettingsTitles);
+ const findDescription = () => wrapper.findByTestId('description');
+ const findDescriptionLink = () => wrapper.findByTestId('description-link');
+ const findEnableProxyToggle = () => wrapper.findByTestId('dependency-proxy-setting-toggle');
+ const findEnableTtlPoliciesToggle = () =>
+ wrapper.findByTestId('dependency-proxy-ttl-policies-toggle');
+ const findToggleHelpLink = () => wrapper.findByTestId('toggle-help-link');
const fillApolloCache = () => {
apolloProvider.defaultClient.cache.writeQuery({
@@ -81,10 +111,6 @@ describe('DependencyProxySettings', () => {
});
};
- const emitSettingsUpdate = (value = false) => {
- findToggle().vm.$emit('change', value);
- };
-
it('renders a settings block', () => {
mountComponent();
@@ -112,19 +138,93 @@ describe('DependencyProxySettings', () => {
it('has the correct link', () => {
mountComponent();
- expect(findLink().attributes()).toMatchObject({
+ expect(findDescriptionLink().attributes()).toMatchObject({
href: DEPENDENCY_PROXY_DOCS_PATH,
});
- expect(findLink().text()).toBe('Learn more');
+ expect(findDescriptionLink().text()).toBe('Learn more');
+ });
+
+ describe('enable toggle', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findEnableProxyToggle().props()).toMatchObject({
+ label: component.i18n.enabledProxyLabel,
+ });
+ });
+
+ describe('when enabled', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('has the help prop correctly set', () => {
+ expect(findEnableProxyToggle().props()).toMatchObject({
+ help: component.i18n.enabledProxyHelpText,
+ });
+ });
+
+ it('has help text with a link', () => {
+ expect(findEnableProxyToggle().text()).toContain(
+ 'To see the image prefix and what is in the cache, visit the Dependency Proxy',
+ );
+ expect(findToggleHelpLink().attributes()).toMatchObject({
+ href: defaultProvide.groupDependencyProxyPath,
+ });
+ });
+ });
+
+ describe('when disabled', () => {
+ beforeEach(() => {
+ mountComponent({
+ dependencyProxySettings: dependencyProxySettingsMock({ enabled: false }),
+ });
+ });
+
+ it('has the help prop set to empty', () => {
+ expect(findEnableProxyToggle().props()).toMatchObject({
+ help: '',
+ });
+ });
+
+ it('the help text is not visible', () => {
+ expect(findToggleHelpLink().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('storage settings', () => {
+ it('the component has the settings title', () => {
+ mountComponent();
+
+ expect(findSettingsTitles().props()).toMatchObject({
+ title: component.i18n.storageSettingsTitle,
+ });
+ });
+
+ describe('enable proxy ttl policies', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findEnableTtlPoliciesToggle().props()).toMatchObject({
+ label: component.i18n.ttlPolicyEnabledLabel,
+ help: component.i18n.ttlPolicyEnabledHelpText,
+ });
+ });
+ });
});
- describe('settings update', () => {
+ describe.each`
+ toggleName | toggleFinder | localErrorMock | optimisticResponse
+ ${'enable proxy'} | ${findEnableProxyToggle} | ${dependencyProxySettingMutationMock} | ${updateGroupDependencyProxySettingsOptimisticResponse}
+ ${'enable ttl policies'} | ${findEnableTtlPoliciesToggle} | ${dependencyProxyUpdateTllPolicyMutationMock} | ${updateDependencyProxyImageTtlGroupPolicyOptimisticResponse}
+ `('$toggleName settings update ', ({ optimisticResponse, toggleFinder, localErrorMock }) => {
describe('success state', () => {
it('emits a success event', async () => {
mountComponent();
fillApolloCache();
- emitSettingsUpdate();
+ toggleFinder().vm.$emit('change', false);
await waitForPromises();
@@ -136,26 +236,28 @@ describe('DependencyProxySettings', () => {
fillApolloCache();
- expect(findToggle().props('value')).toBe(true);
+ expect(toggleFinder().props('value')).toBe(true);
- emitSettingsUpdate();
+ toggleFinder().vm.$emit('change', false);
- expect(updateGroupDependencyProxySettingsOptimisticResponse).toHaveBeenCalledWith({
- enabled: false,
- });
+ expect(optimisticResponse).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: false,
+ }),
+ );
});
});
describe('errors', () => {
it('mutation payload with root level errors', async () => {
- const mutationResolver = jest
- .fn()
- .mockResolvedValue(dependencyProxySettingMutationErrorMock);
- mountComponent({ mutationResolver });
+ updateSettingsMutationResolver = jest.fn().mockResolvedValue(mutationErrorMock);
+ updateTtlPoliciesMutationResolver = jest.fn().mockResolvedValue(mutationErrorMock);
+
+ mountComponent();
fillApolloCache();
- emitSettingsUpdate();
+ toggleFinder().vm.$emit('change', false);
await waitForPromises();
@@ -163,14 +265,16 @@ describe('DependencyProxySettings', () => {
});
it.each`
- type | mutationResolver
- ${'local'} | ${jest.fn().mockResolvedValue(dependencyProxySettingMutationMock({ errors: ['foo'] }))}
+ type | mutationResolverMock
+ ${'local'} | ${jest.fn().mockResolvedValue(localErrorMock({ errors: ['foo'] }))}
${'network'} | ${jest.fn().mockRejectedValue()}
- `('mutation payload with $type error', async ({ mutationResolver }) => {
- mountComponent({ mutationResolver });
+ `('mutation payload with $type error', async ({ mutationResolverMock }) => {
+ updateSettingsMutationResolver = mutationResolverMock;
+ updateTtlPoliciesMutationResolver = mutationResolverMock;
+ mountComponent();
fillApolloCache();
- emitSettingsUpdate();
+ toggleFinder().vm.$emit('change', false);
await waitForPromises();
@@ -180,10 +284,16 @@ describe('DependencyProxySettings', () => {
});
describe('when isLoading is true', () => {
- it('disables enable toggle', () => {
+ it('disables enable proxy toggle', () => {
+ mountComponent({ isLoading: true });
+
+ expect(findEnableProxyToggle().props('disabled')).toBe(true);
+ });
+
+ it('disables enable ttl policies toggle', () => {
mountComponent({ isLoading: true });
- expect(findToggle().props('disabled')).toBe(true);
+ expect(findEnableTtlPoliciesToggle().props('disabled')).toBe(true);
});
});
});
diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
index e4d62bc6a6e..933dac7f5a8 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js
@@ -10,7 +10,12 @@ import DependencyProxySettings from '~/packages_and_registries/settings/group/co
import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
-import { groupPackageSettingsMock, packageSettings, dependencyProxySettings } from '../mock_data';
+import {
+ groupPackageSettingsMock,
+ packageSettings,
+ dependencyProxySettings,
+ dependencyProxyImageTtlPolicy,
+} from '../mock_data';
jest.mock('~/flash');
@@ -66,11 +71,17 @@ describe('Group Settings App', () => {
await nextTick();
};
+ const packageSettingsProps = { packageSettings: packageSettings() };
+ const dependencyProxyProps = {
+ dependencyProxySettings: dependencyProxySettings(),
+ dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(),
+ };
+
describe.each`
- finder | entityProp | entityValue | successMessage | errorMessage
- ${findPackageSettings} | ${'packageSettings'} | ${packageSettings()} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'}
- ${findDependencyProxySettings} | ${'dependencyProxySettings'} | ${dependencyProxySettings()} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'}
- `('settings blocks', ({ finder, entityProp, entityValue, successMessage, errorMessage }) => {
+ finder | entitySpecificProps | successMessage | errorMessage
+ ${findPackageSettings} | ${packageSettingsProps} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'}
+ ${findDependencyProxySettings} | ${dependencyProxyProps} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'}
+ `('settings blocks', ({ finder, entitySpecificProps, successMessage, errorMessage }) => {
beforeEach(() => {
mountComponent();
return waitForApolloQueryAndRender();
@@ -83,7 +94,7 @@ describe('Group Settings App', () => {
it('binds the correctProps', () => {
expect(finder().props()).toMatchObject({
isLoading: false,
- [entityProp]: entityValue,
+ ...entitySpecificProps,
});
});
diff --git a/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js b/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js
index a61edad8685..fcfad4b42b8 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/settings_titles_spec.js
@@ -4,15 +4,19 @@ import SettingsTitles from '~/packages_and_registries/settings/group/components/
describe('settings_titles', () => {
let wrapper;
- const mountComponent = () => {
+ const defaultProps = {
+ title: 'foo',
+ subTitle: 'bar',
+ };
+
+ const mountComponent = (propsData = defaultProps) => {
wrapper = shallowMount(SettingsTitles, {
- propsData: {
- title: 'foo',
- subTitle: 'bar',
- },
+ propsData,
});
};
+ const findSubTitle = () => wrapper.find('p');
+
afterEach(() => {
wrapper.destroy();
});
@@ -22,4 +26,10 @@ describe('settings_titles', () => {
expect(wrapper.element).toMatchSnapshot();
});
+
+ it('does not render the subtitle paragraph when no subtitle is passed', () => {
+ mountComponent({ title: defaultProps.title });
+
+ expect(findSubTitle().exists()).toBe(false);
+ });
});
diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js
index 9d8504a1124..a5b571a0241 100644
--- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js
@@ -17,6 +17,13 @@ describe('Package and Registries settings group cache updates', () => {
},
};
+ const updateDependencyProxyImageTtlGroupPolicyPayload = {
+ dependencyProxyImageTtlPolicy: {
+ enabled: false,
+ ttl: 45,
+ },
+ };
+
const cacheMock = {
group: {
packageSettings: {
@@ -26,6 +33,10 @@ describe('Package and Registries settings group cache updates', () => {
dependencyProxySetting: {
enabled: true,
},
+ dependencyProxyImageTtlPolicy: {
+ enabled: true,
+ ttl: 45,
+ },
},
};
@@ -42,15 +53,26 @@ describe('Package and Registries settings group cache updates', () => {
});
describe.each`
- updateNamespacePackageSettings | updateDependencyProxySettings
- ${updateNamespacePackageSettingsPayload} | ${updateDependencyProxySettingsPayload}
- ${undefined} | ${updateDependencyProxySettingsPayload}
- ${updateNamespacePackageSettingsPayload} | ${undefined}
- ${undefined} | ${undefined}
+ updateNamespacePackageSettings | updateDependencyProxySettings | updateDependencyProxyImageTtlGroupPolicy
+ ${updateNamespacePackageSettingsPayload} | ${updateDependencyProxySettingsPayload} | ${undefined}
+ ${undefined} | ${updateDependencyProxySettingsPayload} | ${undefined}
+ ${updateNamespacePackageSettingsPayload} | ${undefined} | ${undefined}
+ ${undefined} | ${undefined} | ${updateDependencyProxyImageTtlGroupPolicyPayload}
+ ${undefined} | ${undefined} | ${undefined}
`(
'updateGroupPackageSettings',
- ({ updateNamespacePackageSettings, updateDependencyProxySettings }) => {
- const payload = { data: { updateNamespacePackageSettings, updateDependencyProxySettings } };
+ ({
+ updateNamespacePackageSettings,
+ updateDependencyProxySettings,
+ updateDependencyProxyImageTtlGroupPolicy,
+ }) => {
+ const payload = {
+ data: {
+ updateNamespacePackageSettings,
+ updateDependencyProxySettings,
+ updateDependencyProxyImageTtlGroupPolicy,
+ },
+ };
it('calls readQuery', () => {
updateGroupPackageSettings('foo')(client, payload);
expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables);
@@ -65,6 +87,7 @@ describe('Package and Registries settings group cache updates', () => {
...cacheMock.group,
...payload.data.updateNamespacePackageSettings,
...payload.data.updateDependencyProxySettings,
+ ...payload.data.updateDependencyProxyImageTtlGroupPolicy,
},
},
});
diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js
index debeb9aa89c..b4efda3e7b2 100644
--- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js
@@ -1,6 +1,7 @@
import {
updateGroupPackagesSettingsOptimisticResponse,
updateGroupDependencyProxySettingsOptimisticResponse,
+ updateDependencyProxyImageTtlGroupPolicyOptimisticResponse,
} from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
describe('Optimistic responses', () => {
@@ -38,4 +39,22 @@ describe('Optimistic responses', () => {
`);
});
});
+
+ describe('updateDependencyProxyImageTtlGroupPolicyOptimisticResponse', () => {
+ it('returns the correct structure', () => {
+ expect(updateDependencyProxyImageTtlGroupPolicyOptimisticResponse({ foo: 'bar' }))
+ .toMatchInlineSnapshot(`
+ Object {
+ "__typename": "Mutation",
+ "updateDependencyProxyImageTtlGroupPolicy": Object {
+ "__typename": "UpdateDependencyProxyImageTtlGroupPolicyPayload",
+ "dependencyProxyImageTtlPolicy": Object {
+ "foo": "bar",
+ },
+ "errors": Array [],
+ },
+ }
+ `);
+ });
+ });
});
diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js
index 81ba0795b7d..d53446de910 100644
--- a/spec/frontend/packages_and_registries/settings/group/mock_data.js
+++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js
@@ -5,16 +5,25 @@ export const packageSettings = () => ({
genericDuplicateExceptionRegex: '',
});
-export const dependencyProxySettings = () => ({
+export const dependencyProxySettings = (extend) => ({
enabled: true,
+ ...extend,
+});
+
+export const dependencyProxyImageTtlPolicy = (extend) => ({
+ ttl: 90,
+ enabled: true,
+ ...extend,
});
export const groupPackageSettingsMock = {
data: {
group: {
+ id: '1',
fullPath: 'foo_group_path',
packageSettings: packageSettings(),
dependencyProxySetting: dependencyProxySettings(),
+ dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(),
},
},
};
@@ -44,6 +53,16 @@ export const dependencyProxySettingMutationMock = (override) => ({
},
});
+export const dependencyProxyUpdateTllPolicyMutationMock = (override) => ({
+ data: {
+ updateDependencyProxyImageTtlGroupPolicy: {
+ dependencyProxyImageTtlPolicy: dependencyProxyImageTtlPolicy(),
+ errors: [],
+ ...override,
+ },
+ },
+});
+
export const groupPackageSettingsMutationErrorMock = {
errors: [
{
@@ -68,7 +87,8 @@ export const groupPackageSettingsMutationErrorMock = {
},
],
};
-export const dependencyProxySettingMutationErrorMock = {
+
+export const mutationErrorMock = {
errors: [
{
message: 'Some error',
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
index 9778f409010..a56bb75f8ed 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/mock_data.js
@@ -11,6 +11,7 @@ export const containerExpirationPolicyData = () => ({
export const expirationPolicyPayload = (override) => ({
data: {
project: {
+ id: '1',
containerExpirationPolicy: {
...containerExpirationPolicyData(),
...override,
diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap
index acdf7c49ebd..5f243799bae 100644
--- a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap
+++ b/spec/frontend/packages_and_registries/shared/__snapshots__/publish_method_spec.js.snap
@@ -37,6 +37,7 @@ exports[`publish_method renders 1`] = `
text="sha-baz"
title="Copy commit SHA"
tooltipplacement="top"
+ variant="default"
/>
</div>
`;
diff --git a/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js
new file mode 100644
index 00000000000..aaca58d21bb
--- /dev/null
+++ b/spec/frontend/packages_and_registries/shared/components/registry_list_spec.js
@@ -0,0 +1,199 @@
+import { GlButton, GlFormCheckbox, GlKeysetPagination } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import component from '~/packages_and_registries/shared/components/registry_list.vue';
+
+describe('Registry List', () => {
+ let wrapper;
+
+ const items = [{ id: 'a' }, { id: 'b' }];
+ const defaultPropsData = {
+ title: 'test_title',
+ items,
+ };
+
+ const rowScopedSlot = `
+ <div data-testid="scoped-slot">
+ <button @click="props.selectItem(props.item)">Select</button>
+ <span>{{props.first}}</span>
+ <p>{{props.isSelected(props.item)}}</p>
+ </div>`;
+
+ const mountComponent = ({ propsData = defaultPropsData } = {}) => {
+ wrapper = shallowMountExtended(component, {
+ propsData,
+ scopedSlots: {
+ default: rowScopedSlot,
+ },
+ });
+ };
+
+ const findSelectAll = () => wrapper.findComponent(GlFormCheckbox);
+ const findDeleteSelected = () => wrapper.findComponent(GlButton);
+ const findPagination = () => wrapper.findComponent(GlKeysetPagination);
+ const findScopedSlots = () => wrapper.findAllByTestId('scoped-slot');
+ const findScopedSlotSelectButton = (index) => findScopedSlots().at(index).find('button');
+ const findScopedSlotFirstValue = (index) => findScopedSlots().at(index).find('span');
+ const findScopedSlotIsSelectedValue = (index) => findScopedSlots().at(index).find('p');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('header', () => {
+ it('renders the title passed in the prop', () => {
+ mountComponent();
+
+ expect(wrapper.text()).toContain(defaultPropsData.title);
+ });
+
+ describe('select all checkbox', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('exists', () => {
+ expect(findSelectAll().exists()).toBe(true);
+ });
+
+ it('select and unselect all', async () => {
+ // no row is not selected
+ items.forEach((item, index) => {
+ expect(findScopedSlotIsSelectedValue(index).text()).toBe('');
+ });
+
+ // simulate selection
+ findSelectAll().vm.$emit('input', true);
+ await nextTick();
+
+ // all rows selected
+ items.forEach((item, index) => {
+ expect(findScopedSlotIsSelectedValue(index).text()).toBe('true');
+ });
+
+ // simulate de-selection
+ findSelectAll().vm.$emit('input', '');
+ await nextTick();
+
+ // no row is not selected
+ items.forEach((item, index) => {
+ expect(findScopedSlotIsSelectedValue(index).text()).toBe('');
+ });
+ });
+ });
+
+ describe('delete button', () => {
+ it('has the correct text', () => {
+ mountComponent();
+
+ expect(findDeleteSelected().text()).toBe(component.i18n.deleteSelected);
+ });
+
+ it('is hidden when hiddenDelete is true', () => {
+ mountComponent({ propsData: { ...defaultPropsData, hiddenDelete: true } });
+
+ expect(findDeleteSelected().exists()).toBe(false);
+ });
+
+ it('is disabled when isLoading is true', () => {
+ mountComponent({ propsData: { ...defaultPropsData, isLoading: true } });
+
+ expect(findDeleteSelected().props('disabled')).toBe(true);
+ });
+
+ it('is disabled when no row is selected', async () => {
+ mountComponent();
+
+ expect(findDeleteSelected().props('disabled')).toBe(true);
+
+ await findScopedSlotSelectButton(0).trigger('click');
+
+ expect(findDeleteSelected().props('disabled')).toBe(false);
+ });
+
+ it('on click emits the delete event with the selected rows', async () => {
+ mountComponent();
+
+ await findScopedSlotSelectButton(0).trigger('click');
+
+ findDeleteSelected().vm.$emit('click');
+
+ expect(wrapper.emitted('delete')).toEqual([[[items[0]]]]);
+ });
+ });
+ });
+
+ describe('main area', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('renders scopedSlots based on the items props', () => {
+ expect(findScopedSlots()).toHaveLength(items.length);
+ });
+
+ it('populates the scope of the slot correctly', async () => {
+ expect(findScopedSlots().at(0).exists()).toBe(true);
+
+ // it's the first slot
+ expect(findScopedSlotFirstValue(0).text()).toBe('true');
+
+ // item is not selected, falsy is translated to empty string
+ expect(findScopedSlotIsSelectedValue(0).text()).toBe('');
+
+ // find the button with the bound function
+ await findScopedSlotSelectButton(0).trigger('click');
+
+ // the item is selected
+ expect(findScopedSlotIsSelectedValue(0).text()).toBe('true');
+ });
+ });
+
+ describe('footer', () => {
+ let pagination;
+
+ beforeEach(() => {
+ pagination = { hasPreviousPage: false, hasNextPage: true };
+ });
+
+ it('has a pagination', () => {
+ mountComponent({
+ propsData: { ...defaultPropsData, pagination },
+ });
+
+ expect(findPagination().props()).toMatchObject(pagination);
+ });
+
+ it.each`
+ hasPreviousPage | hasNextPage | visible
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ ${false} | ${false} | ${false}
+ `(
+ 'when hasPreviousPage is $hasPreviousPage and hasNextPage is $hasNextPage is $visible that the pagination is shown',
+ ({ hasPreviousPage, hasNextPage, visible }) => {
+ pagination = { hasPreviousPage, hasNextPage };
+ mountComponent({
+ propsData: { ...defaultPropsData, pagination },
+ });
+
+ expect(findPagination().exists()).toBe(visible);
+ },
+ );
+
+ it('pagination emits the correct events', () => {
+ mountComponent({
+ propsData: { ...defaultPropsData, pagination },
+ });
+
+ findPagination().vm.$emit('prev');
+
+ expect(wrapper.emitted('prev-page')).toEqual([[]]);
+
+ findPagination().vm.$emit('next');
+
+ expect(wrapper.emitted('next-page')).toEqual([[]]);
+ });
+ });
+});
diff --git a/spec/frontend/packages/shared/components/package_icon_and_name_spec.js b/spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js
index c96a570a29c..d6d1970cb12 100644
--- a/spec/frontend/packages/shared/components/package_icon_and_name_spec.js
+++ b/spec/frontend/packages_and_registries/shared/package_icon_and_name_spec.js
@@ -1,6 +1,6 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue';
+import PackageIconAndName from '~/packages_and_registries/shared/components/package_icon_and_name.vue';
describe('PackageIconAndName', () => {
let wrapper;
diff --git a/spec/frontend/packages/shared/components/package_path_spec.js b/spec/frontend/packages_and_registries/shared/package_path_spec.js
index edbdd55c1d7..93425d4f399 100644
--- a/spec/frontend/packages/shared/components/package_path_spec.js
+++ b/spec/frontend/packages_and_registries/shared/package_path_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import PackagePath from '~/packages/shared/components/package_path.vue';
+import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
describe('PackagePath', () => {
let wrapper;
diff --git a/spec/frontend/packages/shared/components/package_tags_spec.js b/spec/frontend/packages_and_registries/shared/package_tags_spec.js
index d26e4e76b87..33e96c0775e 100644
--- a/spec/frontend/packages/shared/components/package_tags_spec.js
+++ b/spec/frontend/packages_and_registries/shared/package_tags_spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
-import PackageTags from '~/packages/shared/components/package_tags.vue';
-import { mockTags } from '../../mock_data';
+import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
+import { mockTags } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data';
describe('PackageTags', () => {
let wrapper;
diff --git a/spec/frontend/packages/shared/components/packages_list_loader_spec.js b/spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js
index 4ff01068f92..0005162e0bb 100644
--- a/spec/frontend/packages/shared/components/packages_list_loader_spec.js
+++ b/spec/frontend/packages_and_registries/shared/packages_list_loader_spec.js
@@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils';
-import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
describe('PackagesListLoader', () => {
let wrapper;
diff --git a/spec/frontend/packages/shared/components/publish_method_spec.js b/spec/frontend/packages_and_registries/shared/publish_method_spec.js
index 6014774990c..fa8f8f7641a 100644
--- a/spec/frontend/packages/shared/components/publish_method_spec.js
+++ b/spec/frontend/packages_and_registries/shared/publish_method_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import PublishMethod from '~/packages/shared/components/publish_method.vue';
-import { packageList } from '../../mock_data';
+import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue';
+import { packageList } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data';
describe('publish_method', () => {
let wrapper;
diff --git a/spec/frontend/packages_and_registries/shared/utils_spec.js b/spec/frontend/packages_and_registries/shared/utils_spec.js
index bbc8791ca21..962cb2257ce 100644
--- a/spec/frontend/packages_and_registries/shared/utils_spec.js
+++ b/spec/frontend/packages_and_registries/shared/utils_spec.js
@@ -4,8 +4,12 @@ import {
keyValueToFilterToken,
searchArrayToFilterTokens,
extractFilterAndSorting,
+ beautifyPath,
+ getCommitLink,
} from '~/packages_and_registries/shared/utils';
+import { packageList } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data';
+
describe('Packages And Registries shared utils', () => {
describe('getQueryParams', () => {
it('returns an object from a query string, with arrays', () => {
@@ -56,4 +60,30 @@ describe('Packages And Registries shared utils', () => {
},
);
});
+
+ describe('beautifyPath', () => {
+ it('returns a string with spaces around /', () => {
+ expect(beautifyPath('foo/bar')).toBe('foo / bar');
+ });
+ it('does not fail for empty string', () => {
+ expect(beautifyPath()).toBe('');
+ });
+ });
+
+ describe('getCommitLink', () => {
+ it('returns a relative link when isGroup is false', () => {
+ const link = getCommitLink(packageList[0], false);
+
+ expect(link).toContain('../commit');
+ });
+
+ describe('when isGroup is true', () => {
+ it('returns an absolute link matching project path', () => {
+ const mavenPackage = packageList[0];
+ const link = getCommitLink(mavenPackage, true);
+
+ expect(link).toContain(`/${mavenPackage.project_path}/commit`);
+ });
+ });
+ });
});
diff --git a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js
deleted file mode 100644
index f84800d8266..00000000000
--- a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import { GlBanner } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
-import axios from '~/lib/utils/axios_utils';
-import CustomizeHomepageBanner from '~/pages/dashboard/projects/index/components/customize_homepage_banner.vue';
-
-const svgPath = '/illustrations/background';
-const provide = {
- svgPath,
- preferencesBehaviorPath: 'some/behavior/path',
- calloutsPath: 'call/out/path',
- calloutsFeatureId: 'some-feature-id',
- trackLabel: 'home_page',
-};
-
-const createComponent = () => {
- return shallowMount(CustomizeHomepageBanner, { provide, stubs: { GlBanner } });
-};
-
-describe('CustomizeHomepageBanner', () => {
- let trackingSpy;
- let mockAxios;
- let wrapper;
-
- beforeEach(() => {
- mockAxios = new MockAdapter(axios);
- document.body.dataset.page = 'some:page';
- trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
- wrapper = createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- mockAxios.restore();
- unmockTracking();
- });
-
- it('should render the banner when not dismissed', () => {
- expect(wrapper.find(GlBanner).exists()).toBe(true);
- });
-
- it('should close the banner when dismiss is clicked', async () => {
- mockAxios.onPost(provide.calloutsPath).replyOnce(200);
- expect(wrapper.find(GlBanner).exists()).toBe(true);
- wrapper.find(GlBanner).vm.$emit('close');
-
- await wrapper.vm.$nextTick();
- expect(wrapper.find(GlBanner).exists()).toBe(false);
- });
-
- it('includes the body text from options', () => {
- expect(wrapper.html()).toContain(wrapper.vm.$options.i18n.body);
- });
-
- describe('tracking', () => {
- const preferencesTrackingEvent = 'click_go_to_preferences';
- const mockTrackingOnWrapper = () => {
- unmockTracking();
- trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
- };
-
- it('sets the needed data attributes for tracking button', async () => {
- await wrapper.vm.$nextTick();
- const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`);
-
- expect(button.attributes('data-track-action')).toEqual(preferencesTrackingEvent);
- expect(button.attributes('data-track-label')).toEqual(provide.trackLabel);
- });
-
- it('sends a tracking event when the banner is shown', () => {
- const trackCategory = undefined;
- const trackEvent = 'show_home_page_banner';
-
- expect(trackingSpy).toHaveBeenCalledWith(trackCategory, trackEvent, {
- label: provide.trackLabel,
- });
- });
-
- it('sends a tracking event when the banner is dismissed', async () => {
- mockTrackingOnWrapper();
- mockAxios.onPost(provide.calloutsPath).replyOnce(200);
- const trackCategory = undefined;
- const trackEvent = 'click_dismiss';
-
- wrapper.find(GlBanner).vm.$emit('close');
-
- await wrapper.vm.$nextTick();
- expect(trackingSpy).toHaveBeenCalledWith(trackCategory, trackEvent, {
- label: provide.trackLabel,
- });
- });
-
- it('sends a tracking event when the button is clicked', async () => {
- mockTrackingOnWrapper();
- mockAxios.onPost(provide.calloutsPath).replyOnce(200);
- const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`);
-
- triggerEvent(button.element);
-
- await wrapper.vm.$nextTick();
- expect(trackingSpy).toHaveBeenCalledWith('_category_', preferencesTrackingEvent, {
- label: provide.trackLabel,
- });
- });
- });
-});
diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
index d6b394a42c6..6fb03fa28fe 100644
--- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
+++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
@@ -2,7 +2,7 @@ import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
-import PaginationBar from '~/import_entities/components/pagination_bar.vue';
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
index 3e371a8765f..1586aded6e6 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
+++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
@@ -2,6 +2,8 @@
exports[`Learn GitLab renders correctly 1`] = `
<div>
+ <!---->
+
<div
class="row"
>
@@ -131,66 +133,60 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <span>
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
- data-track-label="Set up CI/CD"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Set up CI/CD
-
- </a>
- </span>
+ <a
+ class="gl-link"
+ data-track-action="click_link"
+ data-track-experiment="change_continuous_onboarding_link_urls"
+ data-track-label="Set up CI/CD"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ Set up CI/CD
+
+ </a>
<!---->
</div>
<div
class="gl-mb-4"
>
- <span>
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
- data-track-label="Start a free Ultimate trial"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Start a free Ultimate trial
-
- </a>
- </span>
+ <a
+ class="gl-link"
+ data-track-action="click_link"
+ data-track-experiment="change_continuous_onboarding_link_urls"
+ data-track-label="Start a free Ultimate trial"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ Start a free Ultimate trial
+
+ </a>
<!---->
</div>
<div
class="gl-mb-4"
>
- <span>
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
- data-track-label="Add code owners"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Add code owners
-
- </a>
- </span>
+ <a
+ class="gl-link"
+ data-track-action="click_link"
+ data-track-experiment="change_continuous_onboarding_link_urls"
+ data-track-label="Add code owners"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ Add code owners
+
+ </a>
<span
class="gl-font-style-italic gl-text-gray-500"
@@ -204,22 +200,20 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <span>
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
- data-track-label="Add merge request approval"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Add merge request approval
-
- </a>
- </span>
+ <a
+ class="gl-link"
+ data-track-action="click_link"
+ data-track-experiment="change_continuous_onboarding_link_urls"
+ data-track-label="Add merge request approval"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ Add merge request approval
+
+ </a>
<span
class="gl-font-style-italic gl-text-gray-500"
@@ -269,44 +263,40 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <span>
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
- data-track-label="Create an issue"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Create an issue
-
- </a>
- </span>
+ <a
+ class="gl-link"
+ data-track-action="click_link"
+ data-track-experiment="change_continuous_onboarding_link_urls"
+ data-track-label="Create an issue"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ Create an issue
+
+ </a>
<!---->
</div>
<div
class="gl-mb-4"
>
- <span>
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
- data-track-label="Submit a merge request"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Submit a merge request
-
- </a>
- </span>
+ <a
+ class="gl-link"
+ data-track-action="click_link"
+ data-track-experiment="change_continuous_onboarding_link_urls"
+ data-track-label="Submit a merge request"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ Submit a merge request
+
+ </a>
<!---->
</div>
@@ -349,22 +339,20 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <span>
- <a
- class="gl-link"
- data-track-action="click_link"
- data-track-experiment="change_continuous_onboarding_link_urls"
- data-track-label="Run a Security scan using CI/CD"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Run a Security scan using CI/CD
-
- </a>
- </span>
+ <a
+ class="gl-link"
+ data-track-action="click_link"
+ data-track-experiment="change_continuous_onboarding_link_urls"
+ data-track-label="Run a Security scan using CI/CD"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ Run a Security scan using CI/CD
+
+ </a>
<!---->
</div>
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
index 882d233a239..f7b2154a935 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
@@ -1,4 +1,7 @@
import { shallowMount } from '@vue/test-utils';
+import { stubExperiments } from 'helpers/experimentation_helper';
+import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper';
+import eventHub from '~/invite_members/event_hub';
import LearnGitlabSectionLink from '~/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue';
const defaultAction = 'gitWrite';
@@ -23,6 +26,9 @@ describe('Learn GitLab Section Link', () => {
});
};
+ const openInviteMembesrModalLink = () =>
+ wrapper.find('[data-testid="invite-for-help-continuous-onboarding-experiment-link"]');
+
it('renders no icon when not completed', () => {
createWrapper(undefined, { completed: false });
@@ -46,4 +52,54 @@ describe('Learn GitLab Section Link', () => {
expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true);
});
+
+ describe('rendering a link to open the invite_members modal instead of a regular link', () => {
+ it.each`
+ action | experimentVariant | showModal
+ ${'userAdded'} | ${'candidate'} | ${true}
+ ${'userAdded'} | ${'control'} | ${false}
+ ${defaultAction} | ${'candidate'} | ${false}
+ ${defaultAction} | ${'control'} | ${false}
+ `(
+ 'when the invite_for_help_continuous_onboarding experiment has variant: $experimentVariant and action is $action, the modal link is shown: $showModal',
+ ({ action, experimentVariant, showModal }) => {
+ stubExperiments({ invite_for_help_continuous_onboarding: experimentVariant });
+ createWrapper(action);
+
+ expect(openInviteMembesrModalLink().exists()).toBe(showModal);
+ },
+ );
+ });
+
+ describe('clicking the link to open the invite_members modal', () => {
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+
+ stubExperiments({ invite_for_help_continuous_onboarding: 'candidate' });
+ createWrapper('userAdded');
+ });
+
+ it('calls the eventHub', () => {
+ openInviteMembesrModalLink().vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('openModal', {
+ inviteeType: 'members',
+ source: 'learn_gitlab',
+ tasksToBeDoneEnabled: true,
+ });
+ });
+
+ it('tracks the click', async () => {
+ const trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+
+ triggerEvent(openInviteMembesrModalLink().element);
+
+ expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', {
+ label: 'Invite your colleagues',
+ property: 'Growth::Activation::Experiment::InviteForHelpContinuousOnboarding',
+ });
+
+ unmockTracking();
+ });
+ });
});
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js
index 7e97a539a99..7e71622770f 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_spec.js
@@ -1,20 +1,35 @@
-import { GlProgressBar } from '@gitlab/ui';
+import { GlProgressBar, GlAlert } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import LearnGitlab from '~/pages/projects/learn_gitlab/components/learn_gitlab.vue';
import eventHub from '~/invite_members/event_hub';
-import { testActions, testSections } from './mock_data';
+import { testActions, testSections, testProject } from './mock_data';
describe('Learn GitLab', () => {
let wrapper;
+ let sidebar;
let inviteMembersOpen = false;
const createWrapper = () => {
wrapper = mount(LearnGitlab, {
- propsData: { actions: testActions, sections: testSections, inviteMembersOpen },
+ propsData: {
+ actions: testActions,
+ sections: testSections,
+ project: testProject,
+ inviteMembersOpen,
+ },
});
};
beforeEach(() => {
+ sidebar = document.createElement('div');
+ sidebar.innerHTML = `
+ <div class="sidebar-top-level-items">
+ <div class="active">
+ <div class="count"></div>
+ </div>
+ </div>
+ `;
+ document.body.appendChild(sidebar);
createWrapper();
});
@@ -22,6 +37,7 @@ describe('Learn GitLab', () => {
wrapper.destroy();
wrapper = null;
inviteMembersOpen = false;
+ sidebar.remove();
});
it('renders correctly', () => {
@@ -66,4 +82,26 @@ describe('Learn GitLab', () => {
expect(spy).not.toHaveBeenCalled();
});
});
+
+ describe('when the showSuccessfulInvitationsAlert event is fired', () => {
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ beforeEach(() => {
+ eventHub.$emit('showSuccessfulInvitationsAlert');
+ });
+
+ it('displays the successful invitations alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('displays a message with the project name', () => {
+ expect(findAlert().text()).toBe(
+ "Your team is growing! You've successfully invited new team members to the test-project project.",
+ );
+ });
+
+ it('modifies the sidebar percentage', () => {
+ expect(sidebar.textContent.trim()).toBe('22%');
+ });
+ });
});
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
index 8d6ac737db8..1e633cb7cf5 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/mock_data.js
@@ -57,3 +57,7 @@ export const testSections = {
svg: 'plan.svg',
},
};
+
+export const testProject = {
+ name: 'test-project',
+};
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 9d510b3d231..f4236146d33 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
@@ -1,5 +1,6 @@
+import { nextTick } from 'vue';
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { mockTracking } from 'helpers/tracking_helper';
@@ -32,12 +33,15 @@ describe('WikiForm', () => {
const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button');
const findCancelButton = () => wrapper.findByRole('link', { name: 'Cancel' });
const findUseNewEditorButton = () => wrapper.findByRole('button', { name: 'Use the new editor' });
+ const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button');
const findDismissContentEditorAlertButton = () =>
wrapper.findByRole('button', { name: 'Try this later' });
const findSwitchToOldEditorButton = () =>
wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' });
- const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'More Information.' });
+ const findTitleHelpLink = () => wrapper.findByRole('link', { name: 'Learn more.' });
const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link');
+ const findContentEditor = () => wrapper.findComponent(ContentEditor);
+ const findClassicEditor = () => wrapper.findComponent(MarkdownField);
const setFormat = (value) => {
const format = findFormat();
@@ -73,18 +77,24 @@ describe('WikiForm', () => {
path: '/project/path/-/wikis/home',
};
- function createWrapper(persisted = false, { pageInfo } = {}) {
+ const formatOptions = {
+ Markdown: 'markdown',
+ RDoc: 'rdoc',
+ AsciiDoc: 'asciidoc',
+ Org: 'org',
+ };
+
+ function createWrapper(
+ persisted = false,
+ { pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {},
+ ) {
wrapper = extendedWrapper(
mount(
WikiForm,
{
provide: {
- formatOptions: {
- Markdown: 'markdown',
- RDoc: 'rdoc',
- AsciiDoc: 'asciidoc',
- Org: 'org',
- },
+ formatOptions,
+ glFeatures,
pageInfo: {
...(persisted ? pageInfoPersisted : pageInfoNew),
...pageInfo,
@@ -96,6 +106,27 @@ describe('WikiForm', () => {
);
}
+ const createShallowWrapper = (
+ persisted = false,
+ { pageInfo, glFeatures = { wikiSwitchBetweenContentEditorRawMarkdown: false } } = {},
+ ) => {
+ wrapper = extendedWrapper(
+ shallowMount(WikiForm, {
+ provide: {
+ formatOptions,
+ glFeatures,
+ pageInfo: {
+ ...(persisted ? pageInfoPersisted : pageInfoNew),
+ ...pageInfo,
+ },
+ },
+ stubs: {
+ MarkdownField,
+ },
+ }),
+ );
+ };
+
beforeEach(() => {
trackingSpy = mockTracking(undefined, null, jest.spyOn);
mock = new MockAdapter(axios);
@@ -193,14 +224,13 @@ describe('WikiForm', () => {
});
describe('when wiki content is updated', () => {
- beforeEach(() => {
+ beforeEach(async () => {
createWrapper(true);
const input = findContent();
input.setValue(' Lorem ipsum dolar sit! ');
- input.element.dispatchEvent(new Event('input'));
- return wrapper.vm.$nextTick();
+ await input.trigger('input');
});
it('sets before unload warning', () => {
@@ -279,6 +309,100 @@ describe('WikiForm', () => {
);
});
+ describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is not enabled', () => {
+ beforeEach(() => {
+ createShallowWrapper(true, {
+ glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: false },
+ });
+ });
+
+ it('hides toggle editing mode button', () => {
+ expect(findToggleEditingModeButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is enabled', () => {
+ beforeEach(() => {
+ createShallowWrapper(true, {
+ glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: true },
+ });
+ });
+
+ it('hides gl-alert containing "use new editor" button', () => {
+ expect(findUseNewEditorButton().exists()).toBe(false);
+ });
+
+ it('displays toggle editing mode button', () => {
+ expect(findToggleEditingModeButton().exists()).toBe(true);
+ });
+
+ describe('when content editor is not active', () => {
+ it('displays "Edit rich text" label in the toggle editing mode button', () => {
+ expect(findToggleEditingModeButton().text()).toBe('Edit rich text');
+ });
+
+ describe('when clicking the toggle editing mode button', () => {
+ beforeEach(() => {
+ findToggleEditingModeButton().vm.$emit('click');
+ });
+
+ it('hides the classic editor', () => {
+ expect(findClassicEditor().exists()).toBe(false);
+ });
+
+ it('hides the content editor', () => {
+ expect(findContentEditor().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('when content editor is active', () => {
+ let mockContentEditor;
+
+ beforeEach(() => {
+ mockContentEditor = {
+ getSerializedContent: jest.fn(),
+ setSerializedContent: jest.fn(),
+ };
+
+ findToggleEditingModeButton().vm.$emit('click');
+ });
+
+ it('hides switch to old editor button', () => {
+ expect(findSwitchToOldEditorButton().exists()).toBe(false);
+ });
+
+ it('displays "Edit source" label in the toggle editing mode button', () => {
+ expect(findToggleEditingModeButton().text()).toBe('Edit source');
+ });
+
+ describe('when clicking the toggle editing mode button', () => {
+ const contentEditorFakeSerializedContent = 'fake content';
+
+ beforeEach(() => {
+ mockContentEditor.getSerializedContent.mockReturnValueOnce(
+ contentEditorFakeSerializedContent,
+ );
+
+ findContentEditor().vm.$emit('initialized', mockContentEditor);
+ findToggleEditingModeButton().vm.$emit('click');
+ });
+
+ it('hides the content editor', () => {
+ expect(findContentEditor().exists()).toBe(false);
+ });
+
+ it('displays the classic editor', () => {
+ expect(findClassicEditor().exists()).toBe(true);
+ });
+
+ it('updates the classic editor content field', () => {
+ expect(findContent().element.value).toBe(contentEditorFakeSerializedContent);
+ });
+ });
+ });
+ });
+
describe('wiki content editor', () => {
beforeEach(() => {
createWrapper(true);
@@ -306,8 +430,8 @@ describe('WikiForm', () => {
});
const assertOldEditorIsVisible = () => {
- expect(wrapper.findComponent(ContentEditor).exists()).toBe(false);
- expect(wrapper.findComponent(MarkdownField).exists()).toBe(true);
+ expect(findContentEditor().exists()).toBe(false);
+ expect(findClassicEditor().exists()).toBe(true);
expect(findSubmitButton().props('disabled')).toBe(false);
expect(wrapper.text()).not.toContain(
@@ -376,10 +500,6 @@ describe('WikiForm', () => {
findUseNewEditorButton().trigger('click');
});
- it('shows a loading indicator for the rich text editor', () => {
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
- });
-
it('shows a tip to send feedback', () => {
expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor');
});
@@ -412,16 +532,8 @@ describe('WikiForm', () => {
});
describe('when wiki content is updated', () => {
- beforeEach(async () => {
- // wait for content editor to load
- await waitForPromises();
-
- wrapper.vm.contentEditor.tiptapEditor.commands.setContent(
- '<p>hello __world__ from content editor</p>',
- true,
- );
-
- return wrapper.vm.$nextTick();
+ beforeEach(() => {
+ findContentEditor().vm.$emit('change', { empty: false });
});
it('sets before unload warning', () => {
@@ -432,7 +544,7 @@ describe('WikiForm', () => {
it('unsets before unload warning on form submit', async () => {
triggerFormSubmit();
- await wrapper.vm.$nextTick();
+ await nextTick();
const e = dispatchBeforeUnload();
expect(e.preventDefault).not.toHaveBeenCalled();
@@ -450,8 +562,8 @@ describe('WikiForm', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, WIKI_FORMAT_UPDATED_ACTION, {
label: WIKI_FORMAT_LABEL,
- value: findFormat().element.value,
extra: {
+ value: findFormat().element.value,
old_format: pageInfoPersisted.format,
project_path: pageInfoPersisted.path,
},
diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
index 23219042008..7244a179820 100644
--- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
+++ b/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { GlFormInput, GlFormTextarea } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
@@ -32,7 +33,6 @@ describe('Pipeline Editor | Commit Form', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
describe('when the form is displayed', () => {
@@ -78,7 +78,7 @@ describe('Pipeline Editor | Commit Form', () => {
it('emits an event when the form resets', () => {
findCancelBtn().trigger('click');
- expect(wrapper.emitted('cancel')).toHaveLength(1);
+ expect(wrapper.emitted('resetContent')).toHaveLength(1);
});
});
@@ -121,7 +121,7 @@ describe('Pipeline Editor | Commit Form', () => {
beforeEach(async () => {
createComponent();
wrapper.setProps({ scrollToCommitForm: true });
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('scrolls into view', () => {
diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
index efc345d8877..bc77b7045eb 100644
--- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
+++ b/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js
@@ -1,5 +1,7 @@
+import VueApollo from 'vue-apollo';
import { GlFormTextarea, GlFormInput, GlLoadingIcon } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { createLocalVue, mount } from '@vue/test-utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { objectToQuery, redirectTo } from '~/lib/utils/url_utility';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
@@ -10,18 +12,22 @@ import {
COMMIT_SUCCESS,
} from '~/pipeline_editor/constants';
import commitCreate from '~/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql';
+import updatePipelineEtag from '~/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql';
import {
mockCiConfigPath,
mockCiYml,
+ mockCommitCreateResponse,
+ mockCommitCreateResponseNewEtag,
mockCommitSha,
- mockCommitNextSha,
mockCommitMessage,
mockDefaultBranch,
mockProjectFullPath,
mockNewMergeRequestPath,
} from '../../mock_data';
+const localVue = createLocalVue();
+
jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(),
refreshCurrentPage: jest.fn(),
@@ -47,7 +53,8 @@ const mockProvide = {
describe('Pipeline Editor | Commit section', () => {
let wrapper;
- let mockMutate;
+ let mockApollo;
+ const mockMutateCommitData = jest.fn();
const defaultProps = {
ciFileContent: mockCiYml,
@@ -55,18 +62,7 @@ describe('Pipeline Editor | Commit section', () => {
isNewCiConfigFile: false,
};
- const createComponent = ({ props = {}, options = {}, provide = {} } = {}) => {
- mockMutate = jest.fn().mockResolvedValue({
- data: {
- commitCreate: {
- errors: [],
- commit: {
- sha: mockCommitNextSha,
- },
- },
- },
- });
-
+ const createComponent = ({ apolloConfig = {}, props = {}, options = {}, provide = {} } = {}) => {
wrapper = mount(CommitSection, {
propsData: { ...defaultProps, ...props },
provide: { ...mockProvide, ...provide },
@@ -75,16 +71,25 @@ describe('Pipeline Editor | Commit section', () => {
currentBranch: mockDefaultBranch,
};
},
- mocks: {
- $apollo: {
- mutate: mockMutate,
- },
- },
attachTo: document.body,
+ ...apolloConfig,
...options,
});
};
+ const createComponentWithApollo = (options) => {
+ const handlers = [[commitCreate, mockMutateCommitData]];
+ localVue.use(VueApollo);
+ mockApollo = createMockApollo(handlers);
+
+ const apolloConfig = {
+ localVue,
+ apolloProvider: mockApollo,
+ };
+
+ createComponent({ ...options, apolloConfig });
+ };
+
const findCommitForm = () => wrapper.findComponent(CommitForm);
const findCommitBtnLoadingIcon = () =>
wrapper.find('[type="submit"]').findComponent(GlLoadingIcon);
@@ -103,72 +108,54 @@ describe('Pipeline Editor | Commit section', () => {
await waitForPromises();
};
- const cancelCommitForm = async () => {
- const findCancelBtn = () => wrapper.find('[type="reset"]');
- await findCancelBtn().trigger('click');
- };
-
afterEach(() => {
- mockMutate.mockReset();
wrapper.destroy();
});
describe('when the user commits a new file', () => {
beforeEach(async () => {
- createComponent({ props: { isNewCiConfigFile: true } });
+ mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse);
+ createComponentWithApollo({ props: { isNewCiConfigFile: true } });
await submitCommit();
});
it('calls the mutation with the CREATE action', () => {
- // the extra calls are for updating client queries (currentBranch and lastCommitBranch)
- expect(mockMutate).toHaveBeenCalledTimes(3);
- expect(mockMutate).toHaveBeenCalledWith({
- mutation: commitCreate,
- update: expect.any(Function),
- variables: {
- ...mockVariables,
- action: COMMIT_ACTION_CREATE,
- branch: mockDefaultBranch,
- },
+ expect(mockMutateCommitData).toHaveBeenCalledTimes(1);
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ action: COMMIT_ACTION_CREATE,
+ branch: mockDefaultBranch,
});
});
});
describe('when the user commits an update to an existing file', () => {
beforeEach(async () => {
- createComponent();
+ createComponentWithApollo();
await submitCommit();
});
it('calls the mutation with the UPDATE action', () => {
- expect(mockMutate).toHaveBeenCalledTimes(3);
- expect(mockMutate).toHaveBeenCalledWith({
- mutation: commitCreate,
- update: expect.any(Function),
- variables: {
- ...mockVariables,
- action: COMMIT_ACTION_UPDATE,
- branch: mockDefaultBranch,
- },
+ expect(mockMutateCommitData).toHaveBeenCalledTimes(1);
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ action: COMMIT_ACTION_UPDATE,
+ branch: mockDefaultBranch,
});
});
});
describe('when the user commits changes to the current branch', () => {
beforeEach(async () => {
- createComponent();
+ createComponentWithApollo();
await submitCommit();
});
it('calls the mutation with the current branch', () => {
- expect(mockMutate).toHaveBeenCalledTimes(3);
- expect(mockMutate).toHaveBeenCalledWith({
- mutation: commitCreate,
- update: expect.any(Function),
- variables: {
- ...mockVariables,
- branch: mockDefaultBranch,
- },
+ expect(mockMutateCommitData).toHaveBeenCalledTimes(1);
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ branch: mockDefaultBranch,
});
});
@@ -188,14 +175,10 @@ describe('Pipeline Editor | Commit section', () => {
it('a second commit submits the latest sha, keeping the form updated', async () => {
await submitCommit();
- expect(mockMutate).toHaveBeenCalledTimes(6);
- expect(mockMutate).toHaveBeenCalledWith({
- mutation: commitCreate,
- update: expect.any(Function),
- variables: {
- ...mockVariables,
- branch: mockDefaultBranch,
- },
+ expect(mockMutateCommitData).toHaveBeenCalledTimes(2);
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ branch: mockDefaultBranch,
});
});
});
@@ -204,20 +187,16 @@ describe('Pipeline Editor | Commit section', () => {
const newBranch = 'new-branch';
beforeEach(async () => {
- createComponent();
+ createComponentWithApollo();
await submitCommit({
branch: newBranch,
});
});
it('calls the mutation with the new branch', () => {
- expect(mockMutate).toHaveBeenCalledWith({
- mutation: commitCreate,
- update: expect.any(Function),
- variables: {
- ...mockVariables,
- branch: newBranch,
- },
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ branch: newBranch,
});
});
@@ -230,7 +209,7 @@ describe('Pipeline Editor | Commit section', () => {
const newBranch = 'new-branch';
beforeEach(async () => {
- createComponent();
+ createComponentWithApollo();
await submitCommit({
branch: newBranch,
openMergeRequest: true,
@@ -249,11 +228,11 @@ describe('Pipeline Editor | Commit section', () => {
describe('when the commit is ocurring', () => {
beforeEach(() => {
- createComponent();
+ createComponentWithApollo();
});
it('shows a saving state', async () => {
- mockMutate.mockImplementationOnce(() => {
+ mockMutateCommitData.mockImplementationOnce(() => {
expect(findCommitBtnLoadingIcon().exists()).toBe(true);
return Promise.resolve();
});
@@ -266,15 +245,23 @@ describe('Pipeline Editor | Commit section', () => {
});
});
- describe('when the commit form is cancelled', () => {
+ describe('when the commit returns a different etag path', () => {
beforeEach(async () => {
- createComponent();
+ createComponentWithApollo();
+ jest.spyOn(wrapper.vm.$apollo, 'mutate');
+ mockMutateCommitData.mockResolvedValue(mockCommitCreateResponseNewEtag);
+ await submitCommit();
});
- it('emits an event so that it cab be reseted', async () => {
- await cancelCommitForm();
-
- expect(wrapper.emitted('resetContent')).toHaveLength(1);
+ it('calls the client mutation to update the etag', () => {
+ // 1:Commit submission, 2:etag update, 3:currentBranch update, 4:lastCommit update
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(4);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenNthCalledWith(2, {
+ mutation: updatePipelineEtag,
+ variables: {
+ pipelineEtag: mockCommitCreateResponseNewEtag.data.commitCreate.commitPipelinePath,
+ },
+ });
});
});
diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
index a43da4b0f19..cab4810cbf1 100644
--- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
+++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js
@@ -1,7 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { EDITOR_READY_EVENT } from '~/editor/constants';
-import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
import {
mockCiConfigPath,
@@ -59,10 +58,6 @@ describe('Pipeline Editor | Text editor component', () => {
const findEditor = () => wrapper.findComponent(MockSourceEditor);
- beforeEach(() => {
- SourceEditorExtension.deferRerender = jest.fn();
- });
-
afterEach(() => {
wrapper.destroy();
diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js
index 6532c4e289d..ab9027a56a4 100644
--- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js
+++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js
@@ -11,7 +11,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue';
import { DEFAULT_FAILURE } from '~/pipeline_editor/constants';
-import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.graphql';
+import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.query.graphql';
import {
mockBranchPaginationLimit,
mockDefaultBranch,
@@ -22,7 +22,6 @@ import {
mockTotalBranches,
mockTotalBranchResults,
mockTotalSearchResults,
- mockNewBranch,
} from '../../mock_data';
const localVue = createLocalVue();
@@ -32,18 +31,14 @@ describe('Pipeline editor branch switcher', () => {
let wrapper;
let mockApollo;
let mockAvailableBranchQuery;
- let mockCurrentBranchQuery;
- let mockLastCommitBranchQuery;
-
- const createComponent = (
- { currentBranch, isQueryLoading, mountFn, options, props } = {
- currentBranch: mockDefaultBranch,
- hasUnsavedChanges: false,
- isQueryLoading: false,
- mountFn: shallowMount,
- options: {},
- },
- ) => {
+
+ const createComponent = ({
+ currentBranch = mockDefaultBranch,
+ isQueryLoading = false,
+ mountFn = shallowMount,
+ options = {},
+ props = {},
+ } = {}) => {
wrapper = mountFn(BranchSwitcher, {
propsData: {
...props,
@@ -74,17 +69,7 @@ describe('Pipeline editor branch switcher', () => {
const createComponentWithApollo = ({ mountFn = shallowMount, props = {} } = {}) => {
const handlers = [[getAvailableBranchesQuery, mockAvailableBranchQuery]];
- const resolvers = {
- Query: {
- currentBranch() {
- return mockCurrentBranchQuery();
- },
- lastCommitBranch() {
- return mockLastCommitBranchQuery();
- },
- },
- };
- mockApollo = createMockApollo(handlers, resolvers);
+ mockApollo = createMockApollo(handlers);
createComponent({
mountFn,
@@ -104,22 +89,12 @@ describe('Pipeline editor branch switcher', () => {
const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll);
const defaultBranchInDropdown = () => findDropdownItems().at(0);
- const setMockResolvedValues = ({ availableBranches, currentBranch, lastCommitBranch }) => {
- if (availableBranches) {
- mockAvailableBranchQuery.mockResolvedValue(availableBranches);
- }
-
- if (currentBranch) {
- mockCurrentBranchQuery.mockResolvedValue(currentBranch);
- }
-
- mockLastCommitBranchQuery.mockResolvedValue(lastCommitBranch || '');
+ const setAvailableBranchesMock = (availableBranches) => {
+ mockAvailableBranchQuery.mockResolvedValue(availableBranches);
};
beforeEach(() => {
mockAvailableBranchQuery = jest.fn();
- mockCurrentBranchQuery = jest.fn();
- mockLastCommitBranchQuery = jest.fn();
});
afterEach(() => {
@@ -148,10 +123,7 @@ describe('Pipeline editor branch switcher', () => {
describe('after querying', () => {
beforeEach(async () => {
- setMockResolvedValues({
- availableBranches: mockProjectBranches,
- currentBranch: mockDefaultBranch,
- });
+ setAvailableBranchesMock(mockProjectBranches);
createComponentWithApollo({ mountFn: mount });
await waitForPromises();
});
@@ -180,10 +152,7 @@ describe('Pipeline editor branch switcher', () => {
describe('on fetch error', () => {
beforeEach(async () => {
- setMockResolvedValues({
- availableBranches: new Error(),
- currentBranch: mockDefaultBranch,
- });
+ setAvailableBranchesMock(new Error());
createComponentWithApollo();
await waitForPromises();
});
@@ -200,10 +169,7 @@ describe('Pipeline editor branch switcher', () => {
describe('when switching branches', () => {
beforeEach(async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
- setMockResolvedValues({
- availableBranches: mockProjectBranches,
- currentBranch: mockDefaultBranch,
- });
+ setAvailableBranchesMock(mockProjectBranches);
createComponentWithApollo({ mountFn: mount });
await waitForPromises();
});
@@ -271,10 +237,7 @@ describe('Pipeline editor branch switcher', () => {
describe('when searching', () => {
beforeEach(async () => {
- setMockResolvedValues({
- availableBranches: mockProjectBranches,
- currentBranch: mockDefaultBranch,
- });
+ setAvailableBranchesMock(mockProjectBranches);
createComponentWithApollo({ mountFn: mount });
await waitForPromises();
});
@@ -374,10 +337,7 @@ describe('Pipeline editor branch switcher', () => {
describe('when scrolling to the bottom of the list', () => {
beforeEach(async () => {
- setMockResolvedValues({
- availableBranches: mockProjectBranches,
- currentBranch: mockDefaultBranch,
- });
+ setAvailableBranchesMock(mockProjectBranches);
createComponentWithApollo();
await waitForPromises();
});
@@ -433,35 +393,4 @@ describe('Pipeline editor branch switcher', () => {
});
});
});
-
- describe('when committing a new branch', () => {
- const createNewBranch = async () => {
- setMockResolvedValues({
- currentBranch: mockNewBranch,
- lastCommitBranch: mockNewBranch,
- });
- await wrapper.vm.$apollo.queries.currentBranch.refetch();
- await wrapper.vm.$apollo.queries.lastCommitBranch.refetch();
- };
-
- beforeEach(async () => {
- setMockResolvedValues({
- availableBranches: mockProjectBranches,
- currentBranch: mockDefaultBranch,
- });
- createComponentWithApollo({ mountFn: mount });
- await waitForPromises();
- await createNewBranch();
- });
-
- it('sets new branch as current branch', () => {
- expect(defaultBranchInDropdown().text()).toBe(mockNewBranch);
- expect(defaultBranchInDropdown().props('isChecked')).toBe(true);
- });
-
- it('adds new branch to branch switcher', () => {
- expect(defaultBranchInDropdown().text()).toBe(mockNewBranch);
- expect(findDropdownItems()).toHaveLength(mockTotalBranchResults + 1);
- });
- });
});
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 29ab52bde8f..c101b1d21c7 100644
--- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
+++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
@@ -4,7 +4,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue';
-import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
+import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql';
import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue';
import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data';
@@ -39,8 +39,6 @@ describe('Pipeline Status', () => {
const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]');
const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]');
const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]');
- const findPipelineNotTriggeredErrorMsg = () =>
- wrapper.find('[data-testid="pipeline-not-triggered-error-msg"]');
const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]');
const findPipelineViewBtn = () => wrapper.find('[data-testid="pipeline-view-btn"]');
const findStatusIcon = () => wrapper.find('[data-testid="pipeline-status-icon"]');
@@ -119,8 +117,7 @@ describe('Pipeline Status', () => {
await waitForPromises();
});
- it('renders api error', () => {
- expect(findPipelineNotTriggeredErrorMsg().exists()).toBe(false);
+ it('renders error', () => {
expect(findIcon().attributes('name')).toBe('warning-solid');
expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError);
});
@@ -132,22 +129,5 @@ describe('Pipeline Status', () => {
expect(findPipelineViewBtn().exists()).toBe(false);
});
});
-
- describe('when pipeline is null', () => {
- beforeEach(() => {
- mockPipelineQuery.mockResolvedValue({
- data: { project: { pipeline: null } },
- });
-
- createComponentWithApollo();
- waitForPromises();
- });
-
- it('renders pipeline not triggered error', () => {
- expect(findPipelineErrorMsg().exists()).toBe(false);
- expect(findIcon().attributes('name')).toBe('information-o');
- expect(findPipelineNotTriggeredErrorMsg().text()).toBe(i18n.pipelineNotTriggeredMsg);
- });
- });
});
});
diff --git a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js
index 5fc0880b09e..ae19ed9ab02 100644
--- a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js
+++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js
@@ -1,4 +1,4 @@
-import { GlTable, GlLink } from '@gitlab/ui';
+import { GlTableLite, GlLink } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue';
@@ -24,7 +24,7 @@ describe('CI Lint Results', () => {
});
};
- const findTable = () => wrapper.find(GlTable);
+ const findTable = () => wrapper.find(GlTableLite);
const findByTestId = (selector) => () => wrapper.find(`[data-testid="ci-lint-${selector}"]`);
const findAllByTestId = (selector) => () =>
wrapper.findAll(`[data-testid="ci-lint-${selector}"]`);
diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js
index 1bfc5c3b93d..fc2cbdeda0a 100644
--- a/spec/frontend/pipeline_editor/mock_data.js
+++ b/spec/frontend/pipeline_editor/mock_data.js
@@ -39,6 +39,7 @@ job_build:
export const mockCiTemplateQueryResponse = {
data: {
project: {
+ id: 'project-1',
ciTemplate: {
content: mockCiYml,
},
@@ -48,19 +49,22 @@ export const mockCiTemplateQueryResponse = {
export const mockBlobContentQueryResponse = {
data: {
- project: { repository: { blobs: { nodes: [{ rawBlob: mockCiYml }] } } },
+ project: {
+ id: 'project-1',
+ repository: { blobs: { nodes: [{ id: 'blob-1', rawBlob: mockCiYml }] } },
+ },
},
};
export const mockBlobContentQueryResponseNoCiFile = {
data: {
- project: { repository: { blobs: { nodes: [] } } },
+ project: { id: 'project-1', repository: { blobs: { nodes: [] } } },
},
};
export const mockBlobContentQueryResponseEmptyCiFile = {
data: {
- project: { repository: { blobs: { nodes: [{ rawBlob: '' }] } } },
+ project: { id: 'project-1', repository: { blobs: { nodes: [{ rawBlob: '' }] } } },
},
};
@@ -93,6 +97,7 @@ export const mockCiConfigQueryResponse = {
groups: {
nodes: [
{
+ id: 'group-1',
name: 'job_test_1',
size: 1,
jobs: {
@@ -108,6 +113,7 @@ export const mockCiConfigQueryResponse = {
__typename: 'CiConfigGroup',
},
{
+ id: 'group-2',
name: 'job_test_2',
size: 1,
jobs: {
@@ -170,9 +176,11 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => {
export const mockCommitShaResults = {
data: {
project: {
+ id: '1',
repository: {
tree: {
lastCommit: {
+ id: 'commit-1',
sha: mockCommitSha,
},
},
@@ -184,9 +192,11 @@ export const mockCommitShaResults = {
export const mockNewCommitShaResults = {
data: {
project: {
+ id: '1',
repository: {
tree: {
lastCommit: {
+ id: 'commit-1',
sha: 'eeff1122',
},
},
@@ -198,9 +208,11 @@ export const mockNewCommitShaResults = {
export const mockEmptyCommitShaResults = {
data: {
project: {
+ id: '1',
repository: {
tree: {
lastCommit: {
+ id: 'commit-1',
sha: '',
},
},
@@ -212,6 +224,7 @@ export const mockEmptyCommitShaResults = {
export const mockProjectBranches = {
data: {
project: {
+ id: '1',
repository: {
branchNames: [
'main',
@@ -236,6 +249,7 @@ export const mockTotalBranchResults =
export const mockSearchBranches = {
data: {
project: {
+ id: '1',
repository: {
branchNames: ['test', 'better-feature', 'update-ci', 'test-merge-request'],
},
@@ -248,6 +262,7 @@ export const mockTotalSearchResults = mockSearchBranches.data.project.repository
export const mockEmptySearchBranches = {
data: {
project: {
+ id: '1',
repository: {
branchNames: [],
},
@@ -284,16 +299,19 @@ export const mockProjectPipeline = ({ hasStages = true } = {}) => {
: null;
return {
+ id: '1',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/118',
iid: '28',
shortSha: mockCommitSha,
status: 'SUCCESS',
commit: {
+ id: 'commit-1',
title: 'Update .gitlabe-ci.yml',
webPath: '/-/commit/aabbccdd',
},
detailedStatus: {
+ id: 'status-1',
detailsPath: '/root/sample-ci-project/-/pipelines/118',
group: 'success',
icon: 'status_success',
@@ -453,3 +471,33 @@ export const mockErrors = [
export const mockWarnings = [
'"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"',
];
+
+export const mockCommitCreateResponse = {
+ data: {
+ commitCreate: {
+ __typename: 'CommitCreatePayload',
+ errors: [],
+ commit: {
+ __typename: 'Commit',
+ id: 'commit-1',
+ sha: mockCommitNextSha,
+ },
+ commitPipelinePath: '',
+ },
+ },
+};
+
+export const mockCommitCreateResponseNewEtag = {
+ data: {
+ commitCreate: {
+ __typename: 'CommitCreatePayload',
+ errors: [],
+ commit: {
+ __typename: 'Commit',
+ id: 'commit-2',
+ sha: mockCommitNextSha,
+ },
+ commitPipelinePath: '/api/graphql:pipelines/sha/550ceace1acd373c84d02bd539cb9d4614f786db',
+ },
+ },
+};
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
index f6afef595c6..09d7d4f7ca6 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
@@ -8,13 +8,12 @@ import waitForPromises from 'helpers/wait_for_promises';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue';
-import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants';
-import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.graphql';
-import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql';
+import { COMMIT_SUCCESS, COMMIT_FAILURE, LOAD_FAILURE_UNKNOWN } from '~/pipeline_editor/constants';
+import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql';
+import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.query.graphql';
import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql';
import getLatestCommitShaQuery from '~/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql';
-
-import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
+import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql';
import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue';
@@ -412,6 +411,94 @@ describe('Pipeline editor app component', () => {
});
});
+ describe('when multiple errors occurs in a row', () => {
+ const updateFailureMessage = 'The GitLab CI configuration could not be updated.';
+ const unknownFailureMessage = 'The CI configuration was not loaded, please try again.';
+ const unknownReasons = ['Commit failed'];
+ const alertErrorMessage = `${updateFailureMessage} ${unknownReasons[0]}`;
+
+ const emitError = (type = COMMIT_FAILURE, reasons = unknownReasons) =>
+ findEditorHome().vm.$emit('showError', {
+ type,
+ reasons,
+ });
+
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse);
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+
+ window.scrollTo = jest.fn();
+
+ await createComponentWithApollo({ stubs: { PipelineEditorMessages } });
+ await emitError();
+ });
+
+ it('shows an error message for the first error', () => {
+ expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage);
+ });
+
+ it('scrolls to the top of the page to bring attention to the error message', () => {
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not scroll to the top of the page if the same error occur multiple times in a row', async () => {
+ await emitError();
+
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
+ expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage);
+ });
+
+ it('scrolls to the top if the error is different', async () => {
+ await emitError(LOAD_FAILURE_UNKNOWN, []);
+
+ expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage);
+ expect(window.scrollTo).toHaveBeenCalledTimes(2);
+ });
+
+ describe('when a user dismiss the alert', () => {
+ beforeEach(async () => {
+ await findAlert().vm.$emit('dismiss');
+ });
+
+ it('shows an error if the type is the same, but the reason is different', async () => {
+ const newReason = 'Something broke';
+
+ await emitError(COMMIT_FAILURE, [newReason]);
+
+ expect(window.scrollTo).toHaveBeenCalledTimes(2);
+ expect(findAlert().text()).toMatchInterpolatedText(`${updateFailureMessage} ${newReason}`);
+ });
+
+ it('does not show an error or scroll if a new error with the same type occurs', async () => {
+ await emitError();
+
+ expect(window.scrollTo).toHaveBeenCalledTimes(1);
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('it shows an error and scroll when a new type is emitted', async () => {
+ await emitError(LOAD_FAILURE_UNKNOWN, []);
+
+ expect(window.scrollTo).toHaveBeenCalledTimes(2);
+ expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage);
+ });
+
+ it('it shows an error and scroll if a previously shown type happen again', async () => {
+ await emitError(LOAD_FAILURE_UNKNOWN, []);
+
+ expect(window.scrollTo).toHaveBeenCalledTimes(2);
+ expect(findAlert().text()).toMatchInterpolatedText(unknownFailureMessage);
+
+ await emitError();
+
+ expect(window.scrollTo).toHaveBeenCalledTimes(3);
+ expect(findAlert().text()).toMatchInterpolatedText(alertErrorMessage);
+ });
+ });
+ });
+
describe('when add_new_config_file query param is present', () => {
const originalLocation = window.location.href;
diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
index 60625d301c0..99de0d2a3ef 100644
--- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
+++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap
@@ -6,9 +6,11 @@ Array [
"groups": Array [
Object {
"__typename": "CiGroup",
+ "id": "4",
"jobs": Array [
Object {
"__typename": "CiJob",
+ "id": "6",
"name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl",
"needs": Array [],
"scheduledAt": null,
@@ -18,6 +20,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "8",
"path": "/root/abcd-dag/-/jobs/1482/retry",
"title": "Retry",
},
@@ -25,6 +28,7 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "7",
"tooltip": "passed",
},
},
@@ -36,14 +40,17 @@ Array [
"__typename": "DetailedStatus",
"group": "success",
"icon": "status_success",
+ "id": "5",
"label": "passed",
},
},
Object {
"__typename": "CiGroup",
+ "id": "9",
"jobs": Array [
Object {
"__typename": "CiJob",
+ "id": "11",
"name": "build_b",
"needs": Array [],
"scheduledAt": null,
@@ -53,6 +60,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "13",
"path": "/root/abcd-dag/-/jobs/1515/retry",
"title": "Retry",
},
@@ -60,6 +68,7 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "12",
"tooltip": "passed",
},
},
@@ -71,14 +80,17 @@ Array [
"__typename": "DetailedStatus",
"group": "success",
"icon": "status_success",
+ "id": "10",
"label": "passed",
},
},
Object {
"__typename": "CiGroup",
+ "id": "14",
"jobs": Array [
Object {
"__typename": "CiJob",
+ "id": "16",
"name": "build_c",
"needs": Array [],
"scheduledAt": null,
@@ -88,6 +100,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "18",
"path": "/root/abcd-dag/-/jobs/1484/retry",
"title": "Retry",
},
@@ -95,6 +108,7 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "17",
"tooltip": "passed",
},
},
@@ -106,14 +120,17 @@ Array [
"__typename": "DetailedStatus",
"group": "success",
"icon": "status_success",
+ "id": "15",
"label": "passed",
},
},
Object {
"__typename": "CiGroup",
+ "id": "19",
"jobs": Array [
Object {
"__typename": "CiJob",
+ "id": "21",
"name": "build_d 1/3",
"needs": Array [],
"scheduledAt": null,
@@ -123,6 +140,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "23",
"path": "/root/abcd-dag/-/jobs/1485/retry",
"title": "Retry",
},
@@ -130,11 +148,13 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "22",
"tooltip": "passed",
},
},
Object {
"__typename": "CiJob",
+ "id": "24",
"name": "build_d 2/3",
"needs": Array [],
"scheduledAt": null,
@@ -144,6 +164,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "26",
"path": "/root/abcd-dag/-/jobs/1486/retry",
"title": "Retry",
},
@@ -151,11 +172,13 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "25",
"tooltip": "passed",
},
},
Object {
"__typename": "CiJob",
+ "id": "27",
"name": "build_d 3/3",
"needs": Array [],
"scheduledAt": null,
@@ -165,6 +188,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "29",
"path": "/root/abcd-dag/-/jobs/1487/retry",
"title": "Retry",
},
@@ -172,6 +196,7 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "28",
"tooltip": "passed",
},
},
@@ -183,14 +208,17 @@ Array [
"__typename": "DetailedStatus",
"group": "success",
"icon": "status_success",
+ "id": "20",
"label": "passed",
},
},
Object {
"__typename": "CiGroup",
+ "id": "57",
"jobs": Array [
Object {
"__typename": "CiJob",
+ "id": "59",
"name": "test_c",
"needs": Array [],
"scheduledAt": null,
@@ -201,6 +229,7 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "60",
"tooltip": null,
},
},
@@ -212,6 +241,7 @@ Array [
"__typename": "DetailedStatus",
"group": "success",
"icon": "status_success",
+ "id": "58",
"label": null,
},
},
@@ -226,9 +256,11 @@ Array [
"groups": Array [
Object {
"__typename": "CiGroup",
+ "id": "32",
"jobs": Array [
Object {
"__typename": "CiJob",
+ "id": "34",
"name": "test_a",
"needs": Array [
"build_c",
@@ -242,6 +274,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "36",
"path": "/root/abcd-dag/-/jobs/1514/retry",
"title": "Retry",
},
@@ -249,6 +282,7 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "35",
"tooltip": "passed",
},
},
@@ -260,14 +294,17 @@ Array [
"__typename": "DetailedStatus",
"group": "success",
"icon": "status_success",
+ "id": "33",
"label": "passed",
},
},
Object {
"__typename": "CiGroup",
+ "id": "40",
"jobs": Array [
Object {
"__typename": "CiJob",
+ "id": "42",
"name": "test_b 1/2",
"needs": Array [
"build_d 3/3",
@@ -283,6 +320,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "44",
"path": "/root/abcd-dag/-/jobs/1489/retry",
"title": "Retry",
},
@@ -290,11 +328,13 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "43",
"tooltip": "passed",
},
},
Object {
"__typename": "CiJob",
+ "id": "67",
"name": "test_b 2/2",
"needs": Array [
"build_d 3/3",
@@ -310,6 +350,7 @@ Array [
"__typename": "StatusAction",
"buttonTitle": "Retry this job",
"icon": "retry",
+ "id": "51",
"path": "/root/abcd-dag/-/jobs/1490/retry",
"title": "Retry",
},
@@ -317,6 +358,7 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "50",
"tooltip": "passed",
},
},
@@ -328,14 +370,17 @@ Array [
"__typename": "DetailedStatus",
"group": "success",
"icon": "status_success",
+ "id": "41",
"label": "passed",
},
},
Object {
"__typename": "CiGroup",
+ "id": "61",
"jobs": Array [
Object {
"__typename": "CiJob",
+ "id": "53",
"name": "test_d",
"needs": Array [
"build_b",
@@ -348,6 +393,7 @@ Array [
"group": "success",
"hasDetails": true,
"icon": "status_success",
+ "id": "64",
"tooltip": null,
},
},
@@ -359,6 +405,7 @@ Array [
"__typename": "DetailedStatus",
"group": "success",
"icon": "status_success",
+ "id": "62",
"label": null,
},
},
diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
new file mode 100644
index 00000000000..1ea6096c922
--- /dev/null
+++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
@@ -0,0 +1,106 @@
+import { GlIntersectionObserver, GlSkeletonLoader } from '@gitlab/ui';
+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 createFlash from '~/flash';
+import JobsApp from '~/pipelines/components/jobs/jobs_app.vue';
+import JobsTable from '~/jobs/components/table/jobs_table.vue';
+import getPipelineJobsQuery from '~/pipelines/graphql/queries/get_pipeline_jobs.query.graphql';
+import { mockPipelineJobsQueryResponse } from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+jest.mock('~/flash');
+
+describe('Jobs app', () => {
+ let wrapper;
+ let resolverSpy;
+
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findJobsTable = () => wrapper.findComponent(JobsTable);
+
+ const triggerInfiniteScroll = () =>
+ wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
+
+ const createMockApolloProvider = (resolver) => {
+ const requestHandlers = [[getPipelineJobsQuery, resolver]];
+
+ return createMockApollo(requestHandlers);
+ };
+
+ const createComponent = (resolver) => {
+ wrapper = shallowMount(JobsApp, {
+ provide: {
+ fullPath: 'root/ci-project',
+ pipelineIid: 1,
+ },
+ localVue,
+ apolloProvider: createMockApolloProvider(resolver),
+ });
+ };
+
+ beforeEach(() => {
+ resolverSpy = jest.fn().mockResolvedValue(mockPipelineJobsQueryResponse);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays the loading state', () => {
+ createComponent(resolverSpy);
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ expect(findJobsTable().exists()).toBe(false);
+ });
+
+ it('displays the jobs table', async () => {
+ createComponent(resolverSpy);
+
+ await waitForPromises();
+
+ expect(findJobsTable().exists()).toBe(true);
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+
+ it('handles job fetch error correctly', async () => {
+ resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+
+ createComponent(resolverSpy);
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'An error occured while fetching the pipelines jobs.',
+ });
+ });
+
+ it('handles infinite scrolling by calling fetchMore', async () => {
+ createComponent(resolverSpy);
+
+ await waitForPromises();
+
+ triggerInfiniteScroll();
+
+ expect(resolverSpy).toHaveBeenCalledWith({
+ after: 'eyJpZCI6Ijg0NyJ9',
+ fullPath: 'root/ci-project',
+ iid: 1,
+ });
+ });
+
+ it('does not display main loading state again after fetchMore', async () => {
+ createComponent(resolverSpy);
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+
+ await waitForPromises();
+
+ triggerInfiniteScroll();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
index db4de6deeb7..04e004dc6c1 100644
--- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
@@ -1,7 +1,7 @@
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -98,7 +98,6 @@ describe('Pipeline graph wrapper', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
beforeAll(() => {
@@ -136,7 +135,7 @@ describe('Pipeline graph wrapper', () => {
beforeEach(async () => {
createComponentWithApollo();
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('does not display the loading icon', () => {
@@ -165,7 +164,7 @@ describe('Pipeline graph wrapper', () => {
getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')),
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('does not display the loading icon', () => {
@@ -189,7 +188,7 @@ describe('Pipeline graph wrapper', () => {
},
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('does not display the loading icon', () => {
@@ -211,7 +210,7 @@ describe('Pipeline graph wrapper', () => {
createComponentWithApollo();
jest.spyOn(wrapper.vm.$apollo.queries.headerPipeline, 'refetch');
jest.spyOn(wrapper.vm.$apollo.queries.pipeline, 'refetch');
- await wrapper.vm.$nextTick();
+ await nextTick();
getGraph().vm.$emit('refreshPipelineGraph');
});
@@ -225,8 +224,8 @@ describe('Pipeline graph wrapper', () => {
describe('when query times out', () => {
const advanceApolloTimers = async () => {
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
- await wrapper.vm.$nextTick();
+ await nextTick();
+ await nextTick();
};
beforeEach(async () => {
@@ -246,7 +245,7 @@ describe('Pipeline graph wrapper', () => {
.mockResolvedValueOnce(errorData);
createComponentWithApollo({ getPipelineDetailsHandler: failSucceedFail });
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('shows correct errors and does not overwrite populated data when data is empty', async () => {
@@ -276,7 +275,7 @@ describe('Pipeline graph wrapper', () => {
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('appears when pipeline uses needs', () => {
@@ -319,7 +318,7 @@ describe('Pipeline graph wrapper', () => {
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('sets showLinks to true', async () => {
@@ -329,7 +328,7 @@ describe('Pipeline graph wrapper', () => {
expect(getViewSelector().props('type')).toBe(LAYER_VIEW);
await getDependenciesToggle().vm.$emit('change', true);
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
expect(wrapper.findComponent(LinksLayer).props('showLinks')).toBe(true);
});
});
@@ -345,7 +344,7 @@ describe('Pipeline graph wrapper', () => {
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('shows the hover tip in the view selector', async () => {
@@ -366,7 +365,7 @@ describe('Pipeline graph wrapper', () => {
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('does not show the hover tip', async () => {
@@ -384,7 +383,7 @@ describe('Pipeline graph wrapper', () => {
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
afterEach(() => {
@@ -393,9 +392,10 @@ describe('Pipeline graph wrapper', () => {
it('reads the view type from localStorage when available', () => {
const viewSelectorNeedsSegment = wrapper
- .findAll('[data-testid="pipeline-view-selector"] > label')
+ .find(GlButtonGroup)
+ .findAllComponents(GlButton)
.at(1);
- expect(viewSelectorNeedsSegment.classes()).toContain('active');
+ expect(viewSelectorNeedsSegment.classes()).toContain('selected');
});
});
@@ -412,7 +412,7 @@ describe('Pipeline graph wrapper', () => {
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
afterEach(() => {
@@ -435,7 +435,7 @@ describe('Pipeline graph wrapper', () => {
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('does not appear when pipeline does not use needs', () => {
@@ -462,7 +462,7 @@ describe('Pipeline graph wrapper', () => {
beforeEach(async () => {
createComponentWithApollo();
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('is not called', () => {
@@ -506,7 +506,7 @@ describe('Pipeline graph wrapper', () => {
});
jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ await nextTick();
});
it('attempts to collect metrics', () => {
diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js
index f4faa25545b..f574f4dccc5 100644
--- a/spec/frontend/pipelines/graph/graph_view_selector_spec.js
+++ b/spec/frontend/pipelines/graph/graph_view_selector_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlLoadingIcon, GlSegmentedControl } from '@gitlab/ui';
+import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue';
@@ -7,9 +7,9 @@ describe('the graph view selector component', () => {
let wrapper;
const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]');
- const findViewTypeSelector = () => wrapper.findComponent(GlSegmentedControl);
- const findStageViewLabel = () => findViewTypeSelector().findAll('label').at(0);
- const findLayersViewLabel = () => findViewTypeSelector().findAll('label').at(1);
+ const findViewTypeSelector = () => wrapper.findComponent(GlButtonGroup);
+ const findStageViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(0);
+ const findLayerViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(1);
const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]');
const findToggleLoader = () => findDependenciesToggle().find(GlLoadingIcon);
const findHoverTip = () => wrapper.findComponent(GlAlert);
@@ -51,8 +51,13 @@ describe('the graph view selector component', () => {
createComponent({ mountFn: mount });
});
- it('shows the Stage view label as active in the selector', () => {
- expect(findStageViewLabel().classes()).toContain('active');
+ it('shows the Stage view button as selected', () => {
+ expect(findStageViewButton().classes('selected')).toBe(true);
+ });
+
+ it('shows the Job dependencies view button not selected', () => {
+ expect(findLayerViewButton().exists()).toBe(true);
+ expect(findLayerViewButton().classes('selected')).toBe(false);
});
it('does not show the Job dependencies (links) toggle', () => {
@@ -70,8 +75,13 @@ describe('the graph view selector component', () => {
});
});
- it('shows the Job dependencies view label as active in the selector', () => {
- expect(findLayersViewLabel().classes()).toContain('active');
+ it('shows the Job dependencies view as selected', () => {
+ expect(findLayerViewButton().classes('selected')).toBe(true);
+ });
+
+ it('shows the Stage button as not selected', () => {
+ expect(findStageViewButton().exists()).toBe(true);
+ expect(findStageViewButton().classes('selected')).toBe(false);
});
it('shows the Job dependencies (links) toggle', () => {
@@ -94,7 +104,7 @@ describe('the graph view selector component', () => {
expect(wrapper.emitted().updateViewType).toBeUndefined();
expect(findSwitcherLoader().exists()).toBe(false);
- await findStageViewLabel().trigger('click');
+ await findStageViewButton().trigger('click');
/*
Loading happens before the event is emitted or timers are run.
Then we run the timer because the event is emitted in setInterval
@@ -123,6 +133,14 @@ describe('the graph view selector component', () => {
expect(wrapper.emitted().updateShowLinksState).toHaveLength(1);
expect(wrapper.emitted().updateShowLinksState).toEqual([[true]]);
});
+
+ it('does not emit an event if the click occurs on the currently selected view button', async () => {
+ expect(wrapper.emitted().updateShowLinksState).toBeUndefined();
+
+ await findLayerViewButton().trigger('click');
+
+ expect(wrapper.emitted().updateShowLinksState).toBeUndefined();
+ });
});
describe('hover tip callout', () => {
diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js
index 3812483766d..dcbbde7bf36 100644
--- a/spec/frontend/pipelines/graph/mock_data.js
+++ b/spec/frontend/pipelines/graph/mock_data.js
@@ -4,6 +4,7 @@ export const mockPipelineResponse = {
data: {
project: {
__typename: 'Project',
+ id: '1',
pipeline: {
__typename: 'Pipeline',
id: 163,
@@ -21,9 +22,11 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiStage',
+ id: '2',
name: 'build',
status: {
__typename: 'DetailedStatus',
+ id: '3',
action: null,
},
groups: {
@@ -31,10 +34,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiGroup',
+ id: '4',
name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
size: 1,
status: {
__typename: 'DetailedStatus',
+ id: '5',
label: 'passed',
group: 'success',
icon: 'status_success',
@@ -44,10 +49,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiJob',
+ id: '6',
name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '7',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -55,6 +62,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '8',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1482/retry',
@@ -72,9 +80,11 @@ export const mockPipelineResponse = {
{
__typename: 'CiGroup',
name: 'build_b',
+ id: '9',
size: 1,
status: {
__typename: 'DetailedStatus',
+ id: '10',
label: 'passed',
group: 'success',
icon: 'status_success',
@@ -84,10 +94,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiJob',
+ id: '11',
name: 'build_b',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '12',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -95,6 +107,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '13',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1515/retry',
@@ -111,10 +124,12 @@ export const mockPipelineResponse = {
},
{
__typename: 'CiGroup',
+ id: '14',
name: 'build_c',
size: 1,
status: {
__typename: 'DetailedStatus',
+ id: '15',
label: 'passed',
group: 'success',
icon: 'status_success',
@@ -124,10 +139,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiJob',
+ id: '16',
name: 'build_c',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '17',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -135,6 +152,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '18',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1484/retry',
@@ -151,10 +169,12 @@ export const mockPipelineResponse = {
},
{
__typename: 'CiGroup',
+ id: '19',
name: 'build_d',
size: 3,
status: {
__typename: 'DetailedStatus',
+ id: '20',
label: 'passed',
group: 'success',
icon: 'status_success',
@@ -164,10 +184,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiJob',
+ id: '21',
name: 'build_d 1/3',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '22',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -175,6 +197,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '23',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1485/retry',
@@ -188,10 +211,12 @@ export const mockPipelineResponse = {
},
{
__typename: 'CiJob',
+ id: '24',
name: 'build_d 2/3',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '25',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -199,6 +224,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '26',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1486/retry',
@@ -212,10 +238,12 @@ export const mockPipelineResponse = {
},
{
__typename: 'CiJob',
+ id: '27',
name: 'build_d 3/3',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '28',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -223,6 +251,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '29',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1487/retry',
@@ -242,9 +271,11 @@ export const mockPipelineResponse = {
},
{
__typename: 'CiStage',
+ id: '30',
name: 'test',
status: {
__typename: 'DetailedStatus',
+ id: '31',
action: null,
},
groups: {
@@ -252,10 +283,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiGroup',
+ id: '32',
name: 'test_a',
size: 1,
status: {
__typename: 'DetailedStatus',
+ id: '33',
label: 'passed',
group: 'success',
icon: 'status_success',
@@ -265,10 +298,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiJob',
+ id: '34',
name: 'test_a',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '35',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -276,6 +311,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '36',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1514/retry',
@@ -287,14 +323,17 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiBuildNeed',
+ id: '37',
name: 'build_c',
},
{
__typename: 'CiBuildNeed',
+ id: '38',
name: 'build_b',
},
{
__typename: 'CiBuildNeed',
+ id: '39',
name:
'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
},
@@ -306,10 +345,12 @@ export const mockPipelineResponse = {
},
{
__typename: 'CiGroup',
+ id: '40',
name: 'test_b',
size: 2,
status: {
__typename: 'DetailedStatus',
+ id: '41',
label: 'passed',
group: 'success',
icon: 'status_success',
@@ -319,10 +360,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiJob',
+ id: '42',
name: 'test_b 1/2',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '43',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -330,6 +373,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '44',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1489/retry',
@@ -341,22 +385,27 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiBuildNeed',
+ id: '45',
name: 'build_d 3/3',
},
{
__typename: 'CiBuildNeed',
+ id: '46',
name: 'build_d 2/3',
},
{
__typename: 'CiBuildNeed',
+ id: '47',
name: 'build_d 1/3',
},
{
__typename: 'CiBuildNeed',
+ id: '48',
name: 'build_b',
},
{
__typename: 'CiBuildNeed',
+ id: '49',
name:
'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
},
@@ -365,10 +414,12 @@ export const mockPipelineResponse = {
},
{
__typename: 'CiJob',
+ id: '67',
name: 'test_b 2/2',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '50',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -376,6 +427,7 @@ export const mockPipelineResponse = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '51',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/abcd-dag/-/jobs/1490/retry',
@@ -387,22 +439,27 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiBuildNeed',
+ id: '52',
name: 'build_d 3/3',
},
{
__typename: 'CiBuildNeed',
+ id: '53',
name: 'build_d 2/3',
},
{
__typename: 'CiBuildNeed',
+ id: '54',
name: 'build_d 1/3',
},
{
__typename: 'CiBuildNeed',
+ id: '55',
name: 'build_b',
},
{
__typename: 'CiBuildNeed',
+ id: '56',
name:
'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl',
},
@@ -415,9 +472,11 @@ export const mockPipelineResponse = {
{
__typename: 'CiGroup',
name: 'test_c',
+ id: '57',
size: 1,
status: {
__typename: 'DetailedStatus',
+ id: '58',
label: null,
group: 'success',
icon: 'status_success',
@@ -427,10 +486,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiJob',
+ id: '59',
name: 'test_c',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '60',
icon: 'status_success',
tooltip: null,
hasDetails: true,
@@ -448,9 +509,11 @@ export const mockPipelineResponse = {
},
{
__typename: 'CiGroup',
+ id: '61',
name: 'test_d',
size: 1,
status: {
+ id: '62',
__typename: 'DetailedStatus',
label: null,
group: 'success',
@@ -461,10 +524,12 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiJob',
+ id: '53',
name: 'test_d',
scheduledAt: null,
status: {
__typename: 'DetailedStatus',
+ id: '64',
icon: 'status_success',
tooltip: null,
hasDetails: true,
@@ -477,6 +542,7 @@ export const mockPipelineResponse = {
nodes: [
{
__typename: 'CiBuildNeed',
+ id: '65',
name: 'build_b',
},
],
@@ -502,6 +568,7 @@ export const downstream = {
iid: '31',
path: '/root/elemenohpee/-/pipelines/175',
status: {
+ id: '70',
group: 'success',
label: 'passed',
icon: 'status_success',
@@ -509,6 +576,7 @@ export const downstream = {
},
sourceJob: {
name: 'test_c',
+ id: '71',
__typename: 'CiJob',
},
project: {
@@ -525,12 +593,14 @@ export const downstream = {
iid: '27',
path: '/root/abcd-dag/-/pipelines/181',
status: {
+ id: '72',
group: 'success',
label: 'passed',
icon: 'status_success',
__typename: 'DetailedStatus',
},
sourceJob: {
+ id: '73',
name: 'test_d',
__typename: 'CiJob',
},
@@ -551,6 +621,7 @@ export const upstream = {
iid: '24',
path: '/root/abcd-dag/-/pipelines/161',
status: {
+ id: '74',
group: 'success',
label: 'passed',
icon: 'status_success',
@@ -571,6 +642,7 @@ export const wrappedPipelineReturn = {
data: {
project: {
__typename: 'Project',
+ id: '75',
pipeline: {
__typename: 'Pipeline',
id: 'gid://gitlab/Ci::Pipeline/175',
@@ -592,12 +664,14 @@ export const wrappedPipelineReturn = {
__typename: 'Pipeline',
status: {
__typename: 'DetailedStatus',
+ id: '77',
group: 'success',
label: 'passed',
icon: 'status_success',
},
sourceJob: {
name: 'test_c',
+ id: '78',
__typename: 'CiJob',
},
project: {
@@ -613,8 +687,10 @@ export const wrappedPipelineReturn = {
{
name: 'build',
__typename: 'CiStage',
+ id: '79',
status: {
action: null,
+ id: '80',
__typename: 'DetailedStatus',
},
groups: {
@@ -622,8 +698,10 @@ export const wrappedPipelineReturn = {
nodes: [
{
__typename: 'CiGroup',
+ id: '81',
status: {
__typename: 'DetailedStatus',
+ id: '82',
label: 'passed',
group: 'success',
icon: 'status_success',
@@ -635,6 +713,7 @@ export const wrappedPipelineReturn = {
nodes: [
{
__typename: 'CiJob',
+ id: '83',
name: 'build_n',
scheduledAt: null,
needs: {
@@ -643,6 +722,7 @@ export const wrappedPipelineReturn = {
},
status: {
__typename: 'DetailedStatus',
+ id: '84',
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
@@ -650,6 +730,7 @@ export const wrappedPipelineReturn = {
group: 'success',
action: {
__typename: 'StatusAction',
+ id: '85',
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/elemenohpee/-/jobs/1662/retry',
diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js
index fdc78d48901..b9d20eb7ca5 100644
--- a/spec/frontend/pipelines/mock_data.js
+++ b/spec/frontend/pipelines/mock_data.js
@@ -14,6 +14,7 @@ export const mockPipelineHeader = {
},
createdAt: threeWeeksAgo.toISOString(),
user: {
+ id: 'user-1',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
@@ -27,6 +28,7 @@ export const mockFailedPipelineHeader = {
retryable: true,
cancelable: false,
detailedStatus: {
+ id: 'status-1',
group: 'failed',
icon: 'status_failed',
label: 'failed',
@@ -43,6 +45,7 @@ export const mockFailedPipelineNoPermissions = {
},
createdAt: threeWeeksAgo.toISOString(),
user: {
+ id: 'user-1',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
@@ -52,6 +55,7 @@ export const mockFailedPipelineNoPermissions = {
retryable: true,
cancelable: false,
detailedStatus: {
+ id: 'status-1',
group: 'running',
icon: 'status_running',
label: 'running',
@@ -66,6 +70,7 @@ export const mockRunningPipelineHeader = {
retryable: false,
cancelable: true,
detailedStatus: {
+ id: 'status-1',
group: 'running',
icon: 'status_running',
label: 'running',
@@ -82,6 +87,7 @@ export const mockRunningPipelineNoPermissions = {
},
createdAt: threeWeeksAgo.toISOString(),
user: {
+ id: 'user-1',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
@@ -91,6 +97,7 @@ export const mockRunningPipelineNoPermissions = {
retryable: false,
cancelable: true,
detailedStatus: {
+ id: 'status-1',
group: 'running',
icon: 'status_running',
label: 'running',
@@ -105,6 +112,7 @@ export const mockCancelledPipelineHeader = {
retryable: true,
cancelable: false,
detailedStatus: {
+ id: 'status-1',
group: 'cancelled',
icon: 'status_cancelled',
label: 'cancelled',
@@ -119,6 +127,7 @@ export const mockSuccessfulPipelineHeader = {
retryable: false,
cancelable: false,
detailedStatus: {
+ id: 'status-1',
group: 'success',
icon: 'status_success',
label: 'success',
@@ -130,13 +139,16 @@ export const mockSuccessfulPipelineHeader = {
export const mockRunningPipelineHeaderData = {
data: {
project: {
+ id: '1',
pipeline: {
...mockRunningPipelineHeader,
iid: '28',
user: {
+ id: 'user-1',
name: 'Foo',
username: 'foobar',
webPath: '/foo',
+ webUrl: '/foo',
email: 'foo@bar.com',
avatarUrl: 'link',
status: null,
@@ -493,3 +505,132 @@ export const mockSearch = [
export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag'];
+
+export const mockPipelineJobsQueryResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ __typename: 'Project',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/224',
+ __typename: 'Pipeline',
+ jobs: {
+ __typename: 'CiJobConnection',
+ pageInfo: {
+ endCursor: 'eyJpZCI6Ijg0NyJ9',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'eyJpZCI6IjYyMCJ9',
+ __typename: 'PageInfo',
+ },
+ nodes: [
+ {
+ artifacts: {
+ nodes: [
+ {
+ downloadPath: '/root/ci-project/-/jobs/620/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ allowFailure: false,
+ status: 'SUCCESS',
+ scheduledAt: null,
+ manualJob: false,
+ triggered: null,
+ createdByTag: false,
+ detailedStatus: {
+ id: 'success-620-620',
+ detailsPath: '/root/ci-project/-/jobs/620',
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed (retried)',
+ action: null,
+ __typename: 'DetailedStatus',
+ },
+ id: 'gid://gitlab/Ci::Build/620',
+ refName: 'main',
+ refPath: '/root/ci-project/-/commits/main',
+ tags: [],
+ shortSha: '5acce24b',
+ commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e',
+ stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' },
+ name: 'coverage_job',
+ duration: 4,
+ finishedAt: '2021-12-06T14:13:49Z',
+ coverage: 82.71,
+ retryable: false,
+ playable: false,
+ cancelable: false,
+ active: false,
+ stuck: false,
+ userPermissions: {
+ readBuild: true,
+ readJobArtifacts: true,
+ updateBuild: true,
+ __typename: 'JobPermissions',
+ },
+ __typename: 'CiJob',
+ },
+ {
+ artifacts: {
+ nodes: [
+ {
+ downloadPath: '/root/ci-project/-/jobs/619/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ allowFailure: false,
+ status: 'SUCCESS',
+ scheduledAt: null,
+ manualJob: false,
+ triggered: null,
+ createdByTag: false,
+ detailedStatus: {
+ id: 'success-619-619',
+ detailsPath: '/root/ci-project/-/jobs/619',
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed (retried)',
+ action: null,
+ __typename: 'DetailedStatus',
+ },
+ id: 'gid://gitlab/Ci::Build/619',
+ refName: 'main',
+ refPath: '/root/ci-project/-/commits/main',
+ tags: [],
+ shortSha: '5acce24b',
+ commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e',
+ stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' },
+ name: 'test_job_two',
+ duration: 4,
+ finishedAt: '2021-12-06T14:13:44Z',
+ coverage: null,
+ retryable: false,
+ playable: false,
+ cancelable: false,
+ active: false,
+ stuck: false,
+ userPermissions: {
+ readBuild: true,
+ readJobArtifacts: true,
+ updateBuild: true,
+ __typename: 'JobPermissions',
+ },
+ __typename: 'CiJob',
+ },
+ ],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js
index b3f177a1f12..258fa7636d4 100644
--- a/spec/frontend/projects/new/components/new_project_url_select_spec.js
+++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js
@@ -5,7 +5,8 @@ import {
GlDropdownSectionHeader,
GlSearchBoxByType,
} from '@gitlab/ui';
-import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
+import { mount, shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
@@ -19,6 +20,7 @@ describe('NewProjectUrlSelect component', () => {
const data = {
currentUser: {
+ id: 'user-1',
groups: {
nodes: [
{
@@ -51,8 +53,7 @@ describe('NewProjectUrlSelect component', () => {
},
};
- const localVue = createLocalVue();
- localVue.use(VueApollo);
+ Vue.use(VueApollo);
const defaultProvide = {
namespaceFullPath: 'h5bp',
@@ -63,17 +64,19 @@ describe('NewProjectUrlSelect component', () => {
userNamespaceId: '1',
};
+ let mockQueryResponse;
+
const mountComponent = ({
search = '',
queryResponse = data,
provide = defaultProvide,
mountFn = shallowMount,
} = {}) => {
- const requestHandlers = [[searchQuery, jest.fn().mockResolvedValue({ data: queryResponse })]];
+ mockQueryResponse = jest.fn().mockResolvedValue({ data: queryResponse });
+ const requestHandlers = [[searchQuery, mockQueryResponse]];
const apolloProvider = createMockApollo(requestHandlers);
return mountFn(NewProjectUrlSelect, {
- localVue,
apolloProvider,
provide,
data() {
@@ -87,12 +90,19 @@ describe('NewProjectUrlSelect component', () => {
const findButtonLabel = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findInput = () => wrapper.findComponent(GlSearchBoxByType);
- const findHiddenInput = () => wrapper.find('input');
+ const findHiddenInput = () => wrapper.find('[name="project[namespace_id]"]');
+
const clickDropdownItem = async () => {
wrapper.findComponent(GlDropdownItem).vm.$emit('click');
await wrapper.vm.$nextTick();
};
+ const showDropdown = async () => {
+ findDropdown().vm.$emit('shown');
+ await wrapper.vm.$apollo.queries.currentUser.refetch();
+ jest.runOnlyPendingTimers();
+ };
+
afterEach(() => {
wrapper.destroy();
});
@@ -140,20 +150,18 @@ describe('NewProjectUrlSelect component', () => {
it('focuses on the input when the dropdown is opened', async () => {
wrapper = mountComponent({ mountFn: mount });
- jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
const spy = jest.spyOn(findInput().vm, 'focusInput');
- findDropdown().vm.$emit('shown');
+ await showDropdown();
expect(spy).toHaveBeenCalledTimes(1);
});
it('renders expected dropdown items', async () => {
wrapper = mountComponent({ mountFn: mount });
- jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+
+ await showDropdown();
const listItems = wrapper.findAll('li');
@@ -166,15 +174,36 @@ describe('NewProjectUrlSelect component', () => {
expect(listItems.at(5).text()).toBe(data.currentUser.namespace.fullPath);
});
+ describe('query fetching', () => {
+ describe('on component mount', () => {
+ it('does not fetch query', () => {
+ wrapper = mountComponent({ mountFn: mount });
+
+ expect(mockQueryResponse).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('on dropdown shown', () => {
+ it('fetches query', async () => {
+ wrapper = mountComponent({ mountFn: mount });
+
+ await showDropdown();
+
+ expect(mockQueryResponse).toHaveBeenCalled();
+ });
+ });
+ });
+
describe('when selecting from a group template', () => {
- const groupId = getIdFromGraphQLId(data.currentUser.groups.nodes[1].id);
+ const { fullPath, id } = data.currentUser.groups.nodes[1];
beforeEach(async () => {
wrapper = mountComponent({ mountFn: mount });
- jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
- eventHub.$emit('select-template', groupId);
+ // Show dropdown to fetch projects
+ await showDropdown();
+
+ eventHub.$emit('select-template', getIdFromGraphQLId(id), fullPath);
});
it('filters the dropdown items to the selected group and children', async () => {
@@ -187,13 +216,14 @@ describe('NewProjectUrlSelect component', () => {
});
it('sets the selection to the group', async () => {
- expect(findDropdown().props('text')).toBe(data.currentUser.groups.nodes[1].fullPath);
+ expect(findDropdown().props('text')).toBe(fullPath);
});
});
it('renders `No matches found` when there are no matching dropdown items', async () => {
const queryResponse = {
currentUser: {
+ id: 'user-1',
groups: {
nodes: [],
},
@@ -212,12 +242,13 @@ describe('NewProjectUrlSelect component', () => {
});
it('emits `update-visibility` event to update the visibility radio options', async () => {
- wrapper = mountComponent();
- jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ wrapper = mountComponent({ mountFn: mount });
const spy = jest.spyOn(eventHub, '$emit');
+ // Show dropdown to fetch projects
+ await showDropdown();
+
await clickDropdownItem();
const namespace = data.currentUser.groups.nodes[0];
@@ -231,16 +262,16 @@ describe('NewProjectUrlSelect component', () => {
});
it('updates hidden input with selected namespace', async () => {
- wrapper = mountComponent();
- jest.runOnlyPendingTimers();
- await wrapper.vm.$nextTick();
+ wrapper = mountComponent({ mountFn: mount });
+
+ // Show dropdown to fetch projects
+ await showDropdown();
await clickDropdownItem();
- expect(findHiddenInput().attributes()).toMatchObject({
- name: 'project[namespace_id]',
- value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(),
- });
+ expect(findHiddenInput().attributes('value')).toBe(
+ getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(),
+ );
});
it('tracks clicking on the dropdown', () => {
diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap
index be3716c24e6..5ec0ad794fb 100644
--- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap
+++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap
@@ -25,9 +25,13 @@ exports[`StatisticsList displays the counts data with labels 1`] = `
Failed:
</span>
- <strong>
- 2 pipelines
- </strong>
+ <gl-link-stub
+ href="/flightjs/Flight/-/pipelines?page=1&scope=all&status=failed"
+ >
+
+ 2 pipelines
+
+ </gl-link-stub>
</li>
<li>
<span>
diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js
index b4067f6a72b..574756322c7 100644
--- a/spec/frontend/projects/pipelines/charts/components/app_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js
@@ -1,11 +1,12 @@
import { GlTabs, GlTab } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
import Component from '~/projects/pipelines/charts/components/app.vue';
import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_charts.vue';
+import API from '~/api';
jest.mock('~/lib/utils/url_utility');
@@ -17,7 +18,7 @@ describe('ProjectsPipelinesChartsApp', () => {
let wrapper;
function createComponent(mountOptions = {}) {
- wrapper = shallowMount(
+ wrapper = shallowMountExtended(
Component,
merge(
{},
@@ -118,6 +119,23 @@ describe('ProjectsPipelinesChartsApp', () => {
expect(updateHistory).not.toHaveBeenCalled();
});
+
+ describe('event tracking', () => {
+ it.each`
+ testId | event
+ ${'pipelines-tab'} | ${'p_analytics_ci_cd_pipelines'}
+ ${'deployment-frequency-tab'} | ${'p_analytics_ci_cd_deployment_frequency'}
+ ${'lead-time-tab'} | ${'p_analytics_ci_cd_lead_time'}
+ `('tracks the $event event when clicked', ({ testId, event }) => {
+ jest.spyOn(API, 'trackRedisHllUserEvent');
+
+ expect(API.trackRedisHllUserEvent).not.toHaveBeenCalled();
+
+ wrapper.findByTestId(testId).vm.$emit('click');
+
+ expect(API.trackRedisHllUserEvent).toHaveBeenCalledWith(event);
+ });
+ });
});
describe('when provided with a query param', () => {
diff --git a/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js b/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js
index 4e79f62ce81..57a864cb2c4 100644
--- a/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/statistics_list_spec.js
@@ -1,3 +1,4 @@
+import { GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Component from '~/projects/pipelines/charts/components/statistics_list.vue';
import { counts } from '../mock_data';
@@ -5,8 +6,15 @@ import { counts } from '../mock_data';
describe('StatisticsList', () => {
let wrapper;
+ const failedPipelinesLink = '/flightjs/Flight/-/pipelines?page=1&scope=all&status=failed';
+
+ const findFailedPipelinesLink = () => wrapper.findComponent(GlLink);
+
beforeEach(() => {
wrapper = shallowMount(Component, {
+ provide: {
+ failedPipelinesLink,
+ },
propsData: {
counts,
},
@@ -15,10 +23,13 @@ describe('StatisticsList', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
it('displays the counts data with labels', () => {
expect(wrapper.element).toMatchSnapshot();
});
+
+ it('displays failed pipelines link', () => {
+ expect(findFailedPipelinesLink().attributes('href')).toBe(failedPipelinesLink);
+ });
});
diff --git a/spec/frontend/projects/pipelines/charts/mock_data.js b/spec/frontend/projects/pipelines/charts/mock_data.js
index 2e2c594102c..04971b5b20e 100644
--- a/spec/frontend/projects/pipelines/charts/mock_data.js
+++ b/spec/frontend/projects/pipelines/charts/mock_data.js
@@ -48,6 +48,7 @@ export const transformedAreaChartData = [
export const mockPipelineCount = {
data: {
project: {
+ id: '1',
totalPipelines: { count: 34, __typename: 'PipelineConnection' },
successfulPipelines: { count: 23, __typename: 'PipelineConnection' },
failedPipelines: { count: 1, __typename: 'PipelineConnection' },
@@ -70,6 +71,7 @@ export const chartOptions = {
export const mockPipelineStatistics = {
data: {
project: {
+ id: '1',
pipelineAnalytics: {
weekPipelinesTotals: [0, 0, 0, 0, 0, 0, 0, 0],
weekPipelinesLabels: [
diff --git a/spec/frontend/projects/settings/components/transfer_project_form_spec.js b/spec/frontend/projects/settings/components/transfer_project_form_spec.js
new file mode 100644
index 00000000000..f7ce7c6f840
--- /dev/null
+++ b/spec/frontend/projects/settings/components/transfer_project_form_spec.js
@@ -0,0 +1,68 @@
+import { namespaces } from 'jest/vue_shared/components/namespace_select/mock_data';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import TransferProjectForm from '~/projects/settings/components/transfer_project_form.vue';
+import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
+import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
+
+describe('Transfer project form', () => {
+ let wrapper;
+
+ const confirmButtonText = 'Confirm';
+ const confirmationPhrase = 'You must construct additional pylons!';
+
+ const createComponent = () =>
+ shallowMountExtended(TransferProjectForm, {
+ propsData: {
+ namespaces,
+ confirmButtonText,
+ confirmationPhrase,
+ },
+ });
+
+ const findNamespaceSelect = () => wrapper.findComponent(NamespaceSelect);
+ const findConfirmDanger = () => wrapper.findComponent(ConfirmDanger);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the namespace selector', () => {
+ expect(findNamespaceSelect().exists()).toBe(true);
+ });
+
+ it('renders the confirm button', () => {
+ expect(findConfirmDanger().exists()).toBe(true);
+ });
+
+ it('disables the confirm button by default', () => {
+ expect(findConfirmDanger().attributes('disabled')).toBe('true');
+ });
+
+ describe('with a selected namespace', () => {
+ const [selectedItem] = namespaces.group;
+
+ beforeEach(() => {
+ findNamespaceSelect().vm.$emit('select', selectedItem);
+ });
+
+ it('emits the `selectNamespace` event when a namespace is selected', () => {
+ const args = [selectedItem.id];
+
+ expect(wrapper.emitted('selectNamespace')).toEqual([args]);
+ });
+
+ it('enables the confirm button', () => {
+ expect(findConfirmDanger().attributes('disabled')).toBeUndefined();
+ });
+
+ it('clicking the confirm button emits the `confirm` event', () => {
+ findConfirmDanger().vm.$emit('confirm');
+
+ expect(wrapper.emitted('confirm')).toBeDefined();
+ });
+ });
+});
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
index 0fd3e7446da..875c58583df 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
@@ -1,5 +1,5 @@
import { GlButton, GlDropdown, GlLoadingIcon, GlToggle } from '@gitlab/ui';
-import { shallowMount, mount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue';
@@ -11,14 +11,14 @@ describe('ServiceDeskSetting', () => {
const findButton = () => wrapper.find(GlButton);
const findClipboardButton = () => wrapper.find(ClipboardButton);
const findIncomingEmail = () => wrapper.findByTestId('incoming-email');
- const findIncomingEmailLabel = () => wrapper.findByTestId('incoming-email-describer');
+ const findIncomingEmailLabel = () => wrapper.findByTestId('incoming-email-label');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findTemplateDropdown = () => wrapper.find(GlDropdown);
const findToggle = () => wrapper.find(GlToggle);
- const createComponent = ({ props = {}, mountFunction = shallowMount } = {}) =>
+ const createComponent = ({ props = {} } = {}) =>
extendedWrapper(
- mountFunction(ServiceDeskSetting, {
+ mount(ServiceDeskSetting, {
propsData: {
isEnabled: true,
...props,
@@ -131,8 +131,7 @@ describe('ServiceDeskSetting', () => {
it('shows error when value contains uppercase or special chars', async () => {
wrapper = createComponent({
- props: { customEmailEnabled: true },
- mountFunction: mount,
+ props: { email: 'foo@bar.com', customEmailEnabled: true },
});
const input = wrapper.findByTestId('project-suffix');
@@ -142,7 +141,7 @@ describe('ServiceDeskSetting', () => {
await wrapper.vm.$nextTick();
- const errorText = wrapper.find('.text-danger');
+ const errorText = wrapper.find('.invalid-feedback');
expect(errorText.exists()).toBe(true);
});
});
diff --git a/spec/frontend/projects/storage_counter/components/app_spec.js b/spec/frontend/projects/storage_counter/components/app_spec.js
deleted file mode 100644
index f3da01e0602..00000000000
--- a/spec/frontend/projects/storage_counter/components/app_spec.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import StorageCounterApp from '~/projects/storage_counter/components/app.vue';
-import { TOTAL_USAGE_DEFAULT_TEXT } from '~/projects/storage_counter/constants';
-import getProjectStorageCount from '~/projects/storage_counter/queries/project_storage.query.graphql';
-import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue';
-import {
- mockGetProjectStorageCountGraphQLResponse,
- mockEmptyResponse,
- projectData,
- defaultProvideValues,
-} from '../mock_data';
-
-const localVue = createLocalVue();
-localVue.use(VueApollo);
-
-describe('Storage counter app', () => {
- let wrapper;
-
- const createMockApolloProvider = ({ reject = false, mockedValue } = {}) => {
- let response;
-
- if (reject) {
- response = jest.fn().mockRejectedValue(mockedValue || new Error('GraphQL error'));
- } else {
- response = jest.fn().mockResolvedValue(mockedValue);
- }
-
- const requestHandlers = [[getProjectStorageCount, response]];
-
- return createMockApollo(requestHandlers);
- };
-
- const createComponent = ({ provide = {}, mockApollo } = {}) => {
- wrapper = extendedWrapper(
- shallowMount(StorageCounterApp, {
- localVue,
- apolloProvider: mockApollo,
- provide: {
- ...defaultProvideValues,
- ...provide,
- },
- }),
- );
- };
-
- const findAlert = () => wrapper.findComponent(GlAlert);
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findUsagePercentage = () => wrapper.findByTestId('total-usage');
- const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link');
- const findUsageGraph = () => wrapper.findComponent(UsageGraph);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('with apollo fetching successful', () => {
- let mockApollo;
-
- beforeEach(async () => {
- mockApollo = createMockApolloProvider({
- mockedValue: mockGetProjectStorageCountGraphQLResponse,
- });
- createComponent({ mockApollo });
- await waitForPromises();
- });
-
- it('renders correct total usage', () => {
- expect(findUsagePercentage().text()).toBe(projectData.storage.totalUsage);
- });
-
- it('renders correct usage quotas help link', () => {
- expect(findUsageQuotasHelpLink().attributes('href')).toBe(
- defaultProvideValues.helpLinks.usageQuotasHelpPagePath,
- );
- });
- });
-
- describe('with apollo loading', () => {
- let mockApollo;
-
- beforeEach(() => {
- mockApollo = createMockApolloProvider({
- mockedValue: new Promise(() => {}),
- });
- createComponent({ mockApollo });
- });
-
- it('should show loading icon', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- });
- });
-
- describe('with apollo returning empty data', () => {
- let mockApollo;
-
- beforeEach(async () => {
- mockApollo = createMockApolloProvider({
- mockedValue: mockEmptyResponse,
- });
- createComponent({ mockApollo });
- await waitForPromises();
- });
-
- it('shows default text for total usage', () => {
- expect(findUsagePercentage().text()).toBe(TOTAL_USAGE_DEFAULT_TEXT);
- });
- });
-
- describe('with apollo fetching error', () => {
- let mockApollo;
-
- beforeEach(() => {
- mockApollo = createMockApolloProvider();
- createComponent({ mockApollo, reject: true });
- });
-
- it('renders gl-alert', () => {
- expect(findAlert().exists()).toBe(true);
- });
- });
-
- describe('rendering <usage-graph />', () => {
- let mockApollo;
-
- beforeEach(async () => {
- mockApollo = createMockApolloProvider({
- mockedValue: mockGetProjectStorageCountGraphQLResponse,
- });
- createComponent({ mockApollo });
- await waitForPromises();
- });
-
- it('renders usage-graph component if project.statistics exists', () => {
- expect(findUsageGraph().exists()).toBe(true);
- });
-
- it('passes project.statistics to usage-graph component', () => {
- const {
- __typename,
- ...statistics
- } = mockGetProjectStorageCountGraphQLResponse.data.project.statistics;
- expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics);
- });
- });
-});
diff --git a/spec/frontend/projects/storage_counter/components/storage_table_spec.js b/spec/frontend/projects/storage_counter/components/storage_table_spec.js
deleted file mode 100644
index c9e56d8f033..00000000000
--- a/spec/frontend/projects/storage_counter/components/storage_table_spec.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { GlTableLite } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import StorageTable from '~/projects/storage_counter/components/storage_table.vue';
-import { projectData, defaultProvideValues } from '../mock_data';
-
-describe('StorageTable', () => {
- let wrapper;
-
- const defaultProps = {
- storageTypes: projectData.storage.storageTypes,
- };
-
- const createComponent = (props = {}) => {
- wrapper = extendedWrapper(
- mount(StorageTable, {
- propsData: {
- ...defaultProps,
- ...props,
- },
- }),
- );
- };
-
- const findTable = () => wrapper.findComponent(GlTableLite);
-
- beforeEach(() => {
- createComponent();
- });
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('with storage types', () => {
- it.each(projectData.storage.storageTypes)(
- 'renders table row correctly %o',
- ({ storageType: { id, name, description } }) => {
- expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name);
- expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description);
- expect(wrapper.findByTestId(`${id}-icon`).props('name')).toBe(id);
- expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe(
- defaultProvideValues.helpLinks[id.replace(`Size`, `HelpPagePath`)]
- .replace(`Size`, ``)
- .replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`),
- );
- },
- );
- });
-
- describe('without storage types', () => {
- beforeEach(() => {
- createComponent({ storageTypes: [] });
- });
-
- it('should render the table header <th>', () => {
- expect(findTable().find('th').exists()).toBe(true);
- });
-
- it('should not render any table data <td>', () => {
- expect(findTable().find('td').exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js b/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js
deleted file mode 100644
index 01efd6f14bd..00000000000
--- a/spec/frontend/projects/storage_counter/components/storage_type_icon_spec.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { GlIcon } from '@gitlab/ui';
-import StorageTypeIcon from '~/projects/storage_counter/components/storage_type_icon.vue';
-
-describe('StorageTypeIcon', () => {
- let wrapper;
-
- const createComponent = (props = {}) => {
- wrapper = mount(StorageTypeIcon, {
- propsData: {
- ...props,
- },
- });
- };
-
- const findGlIcon = () => wrapper.findComponent(GlIcon);
-
- describe('rendering icon', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
- it.each`
- expected | provided
- ${'doc-image'} | ${'lfsObjectsSize'}
- ${'snippet'} | ${'snippetsSize'}
- ${'infrastructure-registry'} | ${'repositorySize'}
- ${'package'} | ${'packagesSize'}
- ${'upload'} | ${'uploadsSize'}
- ${'disk'} | ${'wikiSize'}
- ${'disk'} | ${'anything-else'}
- `(
- 'renders icon with name of $expected when name prop is $provided',
- ({ expected, provided }) => {
- createComponent({ name: provided });
-
- expect(findGlIcon().props('name')).toBe(expected);
- },
- );
- });
-});
diff --git a/spec/frontend/projects/storage_counter/mock_data.js b/spec/frontend/projects/storage_counter/mock_data.js
deleted file mode 100644
index 6b3e23ac386..00000000000
--- a/spec/frontend/projects/storage_counter/mock_data.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import mockGetProjectStorageCountGraphQLResponse from 'test_fixtures/graphql/projects/storage_counter/project_storage.query.graphql.json';
-
-export { mockGetProjectStorageCountGraphQLResponse };
-
-export const mockEmptyResponse = { data: { project: null } };
-
-export const defaultProvideValues = {
- projectPath: '/project-path',
- helpLinks: {
- usageQuotasHelpPagePath: '/usage-quotas',
- buildArtifactsHelpPagePath: '/build-artifacts',
- lfsObjectsHelpPagePath: '/lsf-objects',
- packagesHelpPagePath: '/packages',
- repositoryHelpPagePath: '/repository',
- snippetsHelpPagePath: '/snippets',
- uploadsHelpPagePath: '/uploads',
- wikiHelpPagePath: '/wiki',
- },
-};
-
-export const projectData = {
- storage: {
- totalUsage: '13.8 MiB',
- storageTypes: [
- {
- storageType: {
- id: 'buildArtifactsSize',
- name: 'Artifacts',
- description: 'Pipeline artifacts and job artifacts, created with CI/CD.',
- warningMessage:
- 'Because of a known issue, the artifact total for some projects may be incorrect. For more details, read %{warningLinkStart}the epic%{warningLinkEnd}.',
- helpPath: '/build-artifacts',
- },
- value: 400000,
- },
- {
- storageType: {
- id: 'lfsObjectsSize',
- name: 'LFS storage',
- description: 'Audio samples, videos, datasets, and graphics.',
- helpPath: '/lsf-objects',
- },
- value: 4800000,
- },
- {
- storageType: {
- id: 'packagesSize',
- name: 'Packages',
- description: 'Code packages and container images.',
- helpPath: '/packages',
- },
- value: 3800000,
- },
- {
- storageType: {
- id: 'repositorySize',
- name: 'Repository',
- description: 'Git repository.',
- helpPath: '/repository',
- },
- value: 3900000,
- },
- {
- storageType: {
- id: 'snippetsSize',
- name: 'Snippets',
- description: 'Shared bits of code and text.',
- helpPath: '/snippets',
- },
- value: 0,
- },
- {
- storageType: {
- id: 'uploadsSize',
- name: 'Uploads',
- description: 'File attachments and smaller design graphics.',
- helpPath: '/uploads',
- },
- value: 900000,
- },
- {
- storageType: {
- id: 'wikiSize',
- name: 'Wiki',
- description: 'Wiki content.',
- helpPath: '/wiki',
- },
- value: 300000,
- },
- ],
- },
-};
diff --git a/spec/frontend/projects/storage_counter/utils_spec.js b/spec/frontend/projects/storage_counter/utils_spec.js
deleted file mode 100644
index fb91975a3cf..00000000000
--- a/spec/frontend/projects/storage_counter/utils_spec.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import { parseGetProjectStorageResults } from '~/projects/storage_counter/utils';
-import {
- mockGetProjectStorageCountGraphQLResponse,
- projectData,
- defaultProvideValues,
-} from './mock_data';
-
-describe('parseGetProjectStorageResults', () => {
- it('parses project statistics correctly', () => {
- expect(
- parseGetProjectStorageResults(
- mockGetProjectStorageCountGraphQLResponse.data,
- defaultProvideValues.helpLinks,
- ),
- ).toMatchObject(projectData);
- });
-
- it('includes storage type with size of 0 in returned value', () => {
- const mockedResponse = mockGetProjectStorageCountGraphQLResponse.data;
- // ensuring a specific storage type item has size of 0
- mockedResponse.project.statistics.repositorySize = 0;
-
- const response = parseGetProjectStorageResults(mockedResponse, defaultProvideValues.helpLinks);
-
- expect(response.storage.storageTypes).toEqual(
- expect.arrayContaining([
- {
- storageType: expect.any(Object),
- value: 0,
- },
- ]),
- );
- });
-});
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
index b2580d47549..fd2a8eec4d4 100644
--- a/spec/frontend/releases/__snapshots__/util_spec.js.snap
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -44,6 +44,7 @@ Object {
"author": Object {
"__typename": "UserCore",
"avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon",
+ "id": Any<String>,
"username": "administrator",
"webUrl": "http://localhost/administrator",
},
@@ -139,6 +140,7 @@ Object {
"author": Object {
"__typename": "UserCore",
"avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon",
+ "id": Any<String>,
"username": "administrator",
"webUrl": "http://localhost/administrator",
},
@@ -153,6 +155,7 @@ Object {
"__typename": "ReleaseEvidence",
"collectedAt": "2018-12-03T00:00:00Z",
"filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json",
+ "id": "gid://gitlab/Releases::Evidence/1",
"sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d",
},
],
@@ -247,6 +250,7 @@ Object {
"evidences": Array [],
"milestones": Array [
Object {
+ "id": "gid://gitlab/Milestone/123",
"issueStats": Object {},
"stats": undefined,
"title": "12.3",
@@ -254,6 +258,7 @@ Object {
"webUrl": undefined,
},
Object {
+ "id": "gid://gitlab/Milestone/124",
"issueStats": Object {},
"stats": undefined,
"title": "12.4",
@@ -347,6 +352,7 @@ Object {
"author": Object {
"__typename": "UserCore",
"avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon",
+ "id": Any<String>,
"username": "administrator",
"webUrl": "http://localhost/administrator",
},
@@ -361,6 +367,7 @@ Object {
"__typename": "ReleaseEvidence",
"collectedAt": "2018-12-03T00:00:00Z",
"filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json",
+ "id": "gid://gitlab/Releases::Evidence/1",
"sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d",
},
],
diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js
index 72ebaaaf76c..a60b9bda66a 100644
--- a/spec/frontend/releases/components/app_show_spec.js
+++ b/spec/frontend/releases/components/app_show_spec.js
@@ -58,7 +58,6 @@ describe('Release show component', () => {
const expectFlashWithMessage = (message) => {
it(`shows a flash message that reads "${message}"`, () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message,
captureError: true,
diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js
index 3c1060cb0e8..055c8e8b39f 100644
--- a/spec/frontend/releases/util_spec.js
+++ b/spec/frontend/releases/util_spec.js
@@ -104,13 +104,32 @@ describe('releases/util.js', () => {
describe('convertAllReleasesGraphQLResponse', () => {
it('matches snapshot', () => {
- expect(convertAllReleasesGraphQLResponse(originalAllReleasesQueryResponse)).toMatchSnapshot();
+ expect(convertAllReleasesGraphQLResponse(originalAllReleasesQueryResponse)).toMatchSnapshot({
+ data: [
+ {
+ author: {
+ id: expect.any(String),
+ },
+ },
+ {
+ author: {
+ id: expect.any(String),
+ },
+ },
+ ],
+ });
});
});
describe('convertOneReleaseGraphQLResponse', () => {
it('matches snapshot', () => {
- expect(convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse)).toMatchSnapshot();
+ expect(convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse)).toMatchSnapshot({
+ data: {
+ author: {
+ id: expect.any(String),
+ },
+ },
+ });
});
});
diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js
index d924974aede..697fa7c4fd1 100644
--- a/spec/frontend/repository/commits_service_spec.js
+++ b/spec/frontend/repository/commits_service_spec.js
@@ -52,13 +52,6 @@ describe('commits service', () => {
expect(axios.get.mock.calls.length).toEqual(1);
});
- it('calls axios get twice if an offset is larger than 25', async () => {
- await requestCommits(100);
-
- expect(axios.get.mock.calls[0][1]).toEqual({ params: { format: 'json', offset: 75 } });
- expect(axios.get.mock.calls[1][1]).toEqual({ params: { format: 'json', offset: 100 } });
- });
-
it('updates the list of requested offsets', async () => {
await requestCommits(200);
diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
index be4f8a688e0..7854325e4ed 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -2,7 +2,7 @@
exports[`Repository last commit component renders commit widget 1`] = `
<div
- class="info-well d-none d-sm-flex project-last-commit commit p-3"
+ class="well-segment commit gl-p-5 gl-w-full"
>
<user-avatar-link-stub
class="avatar-cell"
@@ -99,6 +99,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
text="123456789"
title="Copy commit SHA"
tooltipplacement="top"
+ variant="default"
/>
</gl-button-group-stub>
</div>
@@ -108,7 +109,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = `
<div
- class="info-well d-none d-sm-flex project-last-commit commit p-3"
+ class="well-segment commit gl-p-5 gl-w-full"
>
<user-avatar-link-stub
class="avatar-cell"
@@ -209,6 +210,7 @@ exports[`Repository last commit component renders the signature HTML as returned
text="123456789"
title="Copy commit SHA"
tooltipplacement="top"
+ variant="default"
/>
</gl-button-group-stub>
</div>
diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js
index f2a3354f204..9f9d574a8ed 100644
--- a/spec/frontend/repository/components/blob_button_group_spec.js
+++ b/spec/frontend/repository/components/blob_button_group_spec.js
@@ -9,6 +9,7 @@ const DEFAULT_PROPS = {
name: 'some name',
path: 'some/path',
canPushCode: true,
+ canPushToBranch: true,
replacePath: 'some/replace/path',
deletePath: 'some/delete/path',
emptyRepo: false,
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index d40e97bf5a3..9e00a2d0408 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -15,7 +15,7 @@ import ForkSuggestion from '~/repository/components/fork_suggestion.vue';
import { loadViewer, viewerProps } from '~/repository/components/blob_viewers';
import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue';
import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue';
-import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue';
+import SourceViewer from '~/vue_shared/components/source_viewer.vue';
import blobInfoQuery from '~/repository/queries/blob_info.query.graphql';
import { redirectTo } from '~/lib/utils/url_utility';
import { isLoggedIn } from '~/lib/utils/common_utils';
@@ -98,7 +98,7 @@ describe('Blob content viewer component', () => {
const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion);
beforeEach(() => {
- gon.features = { refactorTextViewer: true };
+ gon.features = { highlightJs: true };
isLoggedIn.mockReturnValue(true);
});
@@ -215,7 +215,7 @@ describe('Blob content viewer component', () => {
viewer | loadViewerReturnValue | viewerPropsReturnValue
${'empty'} | ${EmptyViewer} | ${{}}
${'download'} | ${DownloadViewer} | ${{ filePath: '/some/file/path', fileName: 'test.js', fileSize: 100 }}
- ${'text'} | ${TextViewer} | ${{ content: 'test', fileName: 'test.js', readOnly: true }}
+ ${'text'} | ${SourceViewer} | ${{ content: 'test', autoDetect: true }}
`(
'renders viewer component for $viewer files',
async ({ viewer, loadViewerReturnValue, viewerPropsReturnValue }) => {
@@ -318,8 +318,14 @@ describe('Blob content viewer component', () => {
repository: { empty },
} = projectMock;
+ afterEach(() => {
+ delete gon.current_user_id;
+ delete gon.current_username;
+ });
+
it('renders component', async () => {
window.gon.current_user_id = 1;
+ window.gon.current_username = 'root';
await createComponent({ pushCode, downloadCode, empty }, mount);
@@ -330,28 +336,34 @@ describe('Blob content viewer component', () => {
deletePath: webPath,
canPushCode: pushCode,
canLock: true,
- isLocked: false,
+ isLocked: true,
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 }) => {
- await createComponent(
- {
- pushCode: canPushCode,
- downloadCode: canDownloadCode,
- empty,
- },
- mount,
- );
+ canPushCode | canDownloadCode | username | canLock
+ ${true} | ${true} | ${'root'} | ${true}
+ ${false} | ${true} | ${'root'} | ${false}
+ ${true} | ${false} | ${'root'} | ${false}
+ ${true} | ${true} | ${'peter'} | ${false}
+ `(
+ 'passes the correct lock states',
+ async ({ canPushCode, canDownloadCode, username, canLock }) => {
+ gon.current_username = username;
+
+ await createComponent(
+ {
+ pushCode: canPushCode,
+ downloadCode: canDownloadCode,
+ empty,
+ },
+ mount,
+ );
- expect(findBlobButtonGroup().props('canLock')).toBe(canLock);
- });
+ expect(findBlobButtonGroup().props('canLock')).toBe(canLock);
+ },
+ );
it('does not render if not logged in', async () => {
isLoggedIn.mockReturnValueOnce(false);
diff --git a/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js
new file mode 100644
index 00000000000..fd910002529
--- /dev/null
+++ b/spec/frontend/repository/components/blob_viewers/pdf_viewer_spec.js
@@ -0,0 +1,59 @@
+import { GlButton } from '@gitlab/ui';
+import Component from '~/repository/components/blob_viewers/pdf_viewer.vue';
+import PdfViewer from '~/blob/pdf/pdf_viewer.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('PDF Viewer', () => {
+ let wrapper;
+
+ const defaultPropsData = { url: 'some/pdf_blob.pdf' };
+
+ const createComponent = (fileSize = 999) => {
+ wrapper = shallowMountExtended(Component, { propsData: { ...defaultPropsData, fileSize } });
+ };
+
+ const findPDFViewer = () => wrapper.findComponent(PdfViewer);
+ const findHelpText = () => wrapper.find('p');
+ const findDownLoadButton = () => wrapper.findComponent(GlButton);
+
+ it('renders a PDF Viewer component', () => {
+ createComponent();
+
+ expect(findPDFViewer().exists()).toBe(true);
+ expect(findPDFViewer().props('pdf')).toBe(defaultPropsData.url);
+ });
+
+ describe('Too large', () => {
+ beforeEach(() => createComponent(20000000));
+
+ it('does not a PDF Viewer component', () => {
+ expect(findPDFViewer().exists()).toBe(false);
+ });
+
+ it('renders help text', () => {
+ expect(findHelpText().text()).toBe(
+ 'This PDF is too large to display. Please download to view.',
+ );
+ });
+
+ it('renders a download button', () => {
+ expect(findDownLoadButton().text()).toBe('Download PDF');
+ expect(findDownLoadButton().props('icon')).toBe('download');
+ });
+ });
+
+ describe('Too many pages', () => {
+ beforeEach(() => {
+ createComponent();
+ findPDFViewer().vm.$emit('pdflabload', 100);
+ });
+
+ it('does not a PDF Viewer component', () => {
+ expect(findPDFViewer().exists()).toBe(false);
+ });
+
+ it('renders a download button', () => {
+ expect(findDownLoadButton().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js
deleted file mode 100644
index 88c5bee6564..00000000000
--- a/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import waitForPromises from 'helpers/wait_for_promises';
-import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue';
-import SourceEditor from '~/vue_shared/components/source_editor.vue';
-
-describe('Text Viewer', () => {
- let wrapper;
- const propsData = {
- content: 'Some content',
- fileName: 'file_name.js',
- readOnly: true,
- };
-
- const createComponent = () => {
- wrapper = shallowMount(TextViewer, { propsData });
- };
-
- const findEditor = () => wrapper.findComponent(SourceEditor);
-
- it('renders a Source Editor component', async () => {
- createComponent();
-
- await waitForPromises();
-
- expect(findEditor().exists()).toBe(true);
- expect(findEditor().props('value')).toBe(propsData.content);
- expect(findEditor().props('fileName')).toBe(propsData.fileName);
- expect(findEditor().props('editorOptions')).toEqual({ readOnly: propsData.readOnly });
- });
-});
diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js
index 2c62868f391..785783b2e75 100644
--- a/spec/frontend/repository/components/delete_blob_modal_spec.js
+++ b/spec/frontend/repository/components/delete_blob_modal_spec.js
@@ -13,6 +13,7 @@ const initialProps = {
targetBranch: 'some-target-branch',
originalBranch: 'main',
canPushCode: true,
+ canPushToBranch: true,
emptyRepo: false,
};
@@ -103,22 +104,25 @@ describe('DeleteBlobModal', () => {
);
it.each`
- input | value | emptyRepo | canPushCode | exist
- ${'authenticity_token'} | ${'mock-csrf-token'} | ${false} | ${true} | ${true}
- ${'authenticity_token'} | ${'mock-csrf-token'} | ${true} | ${false} | ${true}
- ${'_method'} | ${'delete'} | ${false} | ${true} | ${true}
- ${'_method'} | ${'delete'} | ${true} | ${false} | ${true}
- ${'original_branch'} | ${initialProps.originalBranch} | ${false} | ${true} | ${true}
- ${'original_branch'} | ${undefined} | ${true} | ${true} | ${false}
- ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${true}
- ${'create_merge_request'} | ${'1'} | ${false} | ${true} | ${true}
- ${'create_merge_request'} | ${undefined} | ${true} | ${false} | ${false}
+ input | value | emptyRepo | canPushCode | canPushToBranch | exist
+ ${'authenticity_token'} | ${'mock-csrf-token'} | ${false} | ${true} | ${true} | ${true}
+ ${'authenticity_token'} | ${'mock-csrf-token'} | ${true} | ${false} | ${true} | ${true}
+ ${'_method'} | ${'delete'} | ${false} | ${true} | ${true} | ${true}
+ ${'_method'} | ${'delete'} | ${true} | ${false} | ${true} | ${true}
+ ${'original_branch'} | ${initialProps.originalBranch} | ${false} | ${true} | ${true} | ${true}
+ ${'original_branch'} | ${undefined} | ${true} | ${true} | ${true} | ${false}
+ ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${true} | ${true}
+ ${'create_merge_request'} | ${'1'} | ${false} | ${true} | ${true} | ${true}
+ ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${false} | ${true}
+ ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${true} | ${true}
+ ${'create_merge_request'} | ${undefined} | ${true} | ${false} | ${true} | ${false}
`(
'passes $input as a hidden input with the correct value',
- ({ input, value, emptyRepo, canPushCode, exist }) => {
+ ({ input, value, emptyRepo, canPushCode, canPushToBranch, exist }) => {
createComponent({
emptyRepo,
canPushCode,
+ canPushToBranch,
});
const inputMethod = findForm().find(`input[name="${input}"]`);
diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js
index 76e9f7da011..7f59dbfe0d1 100644
--- a/spec/frontend/repository/components/table/row_spec.js
+++ b/spec/frontend/repository/components/table/row_spec.js
@@ -4,6 +4,7 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import TableRow from '~/repository/components/table/row.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import { FILE_SYMLINK_MODE } from '~/vue_shared/constants';
+import { ROW_APPEAR_DELAY } from '~/repository/constants';
const COMMIT_MOCK = { lockLabel: 'Locked by Root', committedDate: '2019-01-01' };
@@ -17,12 +18,12 @@ function factory(propsData = {}) {
vm = shallowMount(TableRow, {
propsData: {
+ commitInfo: COMMIT_MOCK,
...propsData,
name: propsData.path,
projectPath: 'gitlab-org/gitlab-ce',
url: `https://test.com`,
totalEntries: 10,
- commitInfo: COMMIT_MOCK,
rowNumber: 123,
},
directives: {
@@ -251,6 +252,8 @@ describe('Repository table row component', () => {
});
describe('row visibility', () => {
+ beforeAll(() => jest.useFakeTimers());
+
beforeEach(() => {
factory({
id: '1',
@@ -258,18 +261,20 @@ describe('Repository table row component', () => {
path: 'test',
type: 'tree',
currentPath: '/',
+ commitInfo: null,
});
});
- it('emits a `row-appear` event', () => {
+
+ afterAll(() => jest.useRealTimers());
+
+ it('emits a `row-appear` event', async () => {
findIntersectionObserver().vm.$emit('appear');
- expect(vm.emitted('row-appear')).toEqual([
- [
- {
- hasCommit: true,
- rowNumber: 123,
- },
- ],
- ]);
+
+ jest.runAllTimers();
+
+ expect(setTimeout).toHaveBeenCalledTimes(1);
+ expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), ROW_APPEAR_DELAY);
+ expect(vm.emitted('row-appear')).toEqual([[123]]);
});
});
});
diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js
index 49397c77215..9c5d07eede3 100644
--- a/spec/frontend/repository/components/tree_content_spec.js
+++ b/spec/frontend/repository/components/tree_content_spec.js
@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import FilePreview from '~/repository/components/preview/index.vue';
import FileTable from '~/repository/components/table/index.vue';
-import TreeContent from '~/repository/components/tree_content.vue';
+import TreeContent from 'jh_else_ce/repository/components/tree_content.vue';
import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service';
jest.mock('~/repository/commits_service', () => ({
@@ -190,14 +190,28 @@ describe('Repository table component', () => {
});
});
- it('loads commit data when row-appear event is emitted', () => {
+ describe('commit data', () => {
const path = 'some/path';
- const rowNumber = 1;
- factory(path);
- findFileTable().vm.$emit('row-appear', { hasCommit: false, rowNumber });
+ it('loads commit data for both top and bottom batches when row-appear event is emitted', () => {
+ const rowNumber = 50;
- expect(isRequested).toHaveBeenCalledWith(rowNumber);
- expect(loadCommits).toHaveBeenCalledWith('', path, '', rowNumber);
+ factory(path);
+ findFileTable().vm.$emit('row-appear', rowNumber);
+
+ expect(isRequested).toHaveBeenCalledWith(rowNumber);
+
+ expect(loadCommits.mock.calls).toEqual([
+ ['', path, '', rowNumber],
+ ['', path, '', rowNumber - 25],
+ ]);
+ });
+
+ it('loads commit data once if rowNumber is zero', () => {
+ factory(path);
+ findFileTable().vm.$emit('row-appear', 0);
+
+ expect(loadCommits.mock.calls).toEqual([['', path, '', 0]]);
+ });
});
});
diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js
index 36847107558..e9dfa3cd495 100644
--- a/spec/frontend/repository/components/upload_blob_modal_spec.js
+++ b/spec/frontend/repository/components/upload_blob_modal_spec.js
@@ -212,8 +212,8 @@ describe('UploadBlobModal', () => {
createComponent();
});
- it('displays the default "Upload New File" modal title ', () => {
- expect(findModal().props('title')).toBe('Upload New File');
+ it('displays the default "Upload new file" modal title ', () => {
+ expect(findModal().props('title')).toBe('Upload new file');
});
it('display the defaul primary button text', () => {
diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js
index adf5991ac3c..74d35daf578 100644
--- a/spec/frontend/repository/mock_data.js
+++ b/spec/frontend/repository/mock_data.js
@@ -1,4 +1,5 @@
export const simpleViewerMock = {
+ id: '1',
name: 'some_file.js',
size: 123,
rawSize: 123,
@@ -11,6 +12,7 @@ export const simpleViewerMock = {
forkAndEditPath: 'some_file.js/fork/edit',
ideForkAndEditPath: 'some_file.js/fork/ide',
canModifyBlob: true,
+ canCurrentUserPushToBranch: true,
storedExternally: false,
rawPath: 'some_file.js',
replacePath: 'some_file.js/replace',
@@ -45,7 +47,13 @@ export const projectMock = {
id: '1234',
userPermissions: userPermissionsMock,
pathLocks: {
- nodes: [],
+ nodes: [
+ {
+ id: 'test',
+ path: simpleViewerMock.path,
+ user: { id: '123', username: 'root' },
+ },
+ ],
},
repository: {
empty: false,
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index 7eda9aa2850..7015fe809b0 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -147,7 +147,7 @@ describe('AdminRunnersApp', () => {
}),
expect.objectContaining({
type: PARAM_KEY_TAG,
- recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`,
+ recentSuggestionsStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`,
}),
]);
});
@@ -155,9 +155,7 @@ describe('AdminRunnersApp', () => {
it('shows the active runner count', () => {
createComponent({ mountFn: mount });
- expect(findRunnerFilteredSearchBar().text()).toMatch(
- `Runners currently online: ${mockActiveRunnersCount}`,
- );
+ expect(wrapper.text()).toMatch(new RegExp(`Online Runners ${mockActiveRunnersCount}`));
});
describe('when a filter is preselected', () => {
diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
index 2874bdbe280..95c212cb0a9 100644
--- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
+++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
@@ -3,13 +3,17 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import createFlash from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+import { captureException } from '~/runner/sentry_utils';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
+import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
-import { captureException } from '~/runner/sentry_utils';
import { runnersData } from '../../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
@@ -25,12 +29,16 @@ jest.mock('~/runner/sentry_utils');
describe('RunnerTypeCell', () => {
let wrapper;
+
+ const mockToastShow = jest.fn();
const runnerDeleteMutationHandler = jest.fn();
const runnerActionsUpdateMutationHandler = jest.fn();
const findEditBtn = () => wrapper.findByTestId('edit-runner');
const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner');
+ const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal);
const findDeleteBtn = () => wrapper.findByTestId('delete-runner');
+ const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value;
const createComponent = ({ active = true } = {}, options) => {
wrapper = extendedWrapper(
@@ -38,6 +46,7 @@ describe('RunnerTypeCell', () => {
propsData: {
runner: {
id: mockRunner.id,
+ shortSha: mockRunner.shortSha,
adminUrl: mockRunner.adminUrl,
active,
},
@@ -47,6 +56,15 @@ describe('RunnerTypeCell', () => {
[runnerDeleteMutation, runnerDeleteMutationHandler],
[runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler],
]),
+ directives: {
+ GlTooltip: createMockDirective(),
+ GlModal: createMockDirective(),
+ },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
+ },
...options,
}),
);
@@ -72,197 +90,85 @@ describe('RunnerTypeCell', () => {
});
afterEach(() => {
+ mockToastShow.mockReset();
runnerDeleteMutationHandler.mockReset();
runnerActionsUpdateMutationHandler.mockReset();
wrapper.destroy();
});
- it('Displays the runner edit link with the correct href', () => {
- createComponent();
-
- expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl);
- });
-
- describe.each`
- state | label | icon | isActive | newActiveValue
- ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false}
- ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true}
- `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => {
- beforeEach(() => {
- createComponent({ active: isActive });
- });
-
- it(`Displays a ${icon} button`, () => {
- expect(findToggleActiveBtn().props('loading')).toBe(false);
- expect(findToggleActiveBtn().props('icon')).toBe(icon);
- expect(findToggleActiveBtn().attributes('title')).toBe(label);
- expect(findToggleActiveBtn().attributes('aria-label')).toBe(label);
- });
-
- it(`After clicking the ${icon} button, the button has a loading state`, async () => {
- await findToggleActiveBtn().vm.$emit('click');
-
- expect(findToggleActiveBtn().props('loading')).toBe(true);
- });
-
- it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => {
- await findToggleActiveBtn().vm.$emit('click');
+ describe('Edit Action', () => {
+ it('Displays the runner edit link with the correct href', () => {
+ createComponent();
- expect(findToggleActiveBtn().attributes('title')).toBe('');
- expect(findToggleActiveBtn().attributes('aria-label')).toBe('');
+ expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl);
});
+ });
- describe(`When clicking on the ${icon} button`, () => {
- it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => {
- expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0);
-
- await findToggleActiveBtn().vm.$emit('click');
+ describe('Toggle active action', () => {
+ describe.each`
+ state | label | icon | isActive | newActiveValue
+ ${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false}
+ ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true}
+ `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => {
+ beforeEach(() => {
+ createComponent({ active: isActive });
+ });
- expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1);
- expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({
- input: {
- id: mockRunner.id,
- active: newActiveValue,
- },
- });
+ it(`Displays a ${icon} button`, () => {
+ expect(findToggleActiveBtn().props('loading')).toBe(false);
+ expect(findToggleActiveBtn().props('icon')).toBe(icon);
+ expect(getTooltip(findToggleActiveBtn())).toBe(label);
+ expect(findToggleActiveBtn().attributes('aria-label')).toBe(label);
});
- it('The button does not have a loading state after the mutation occurs', async () => {
+ it(`After clicking the ${icon} button, the button has a loading state`, async () => {
await findToggleActiveBtn().vm.$emit('click');
expect(findToggleActiveBtn().props('loading')).toBe(true);
-
- await waitForPromises();
-
- expect(findToggleActiveBtn().props('loading')).toBe(false);
});
- });
- describe('When update fails', () => {
- describe('On a network error', () => {
- const mockErrorMsg = 'Update error!';
-
- beforeEach(async () => {
- runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
-
- await findToggleActiveBtn().vm.$emit('click');
- });
-
- it('error is reported to sentry', () => {
- expect(captureException).toHaveBeenCalledWith({
- error: new Error(`Network error: ${mockErrorMsg}`),
- component: 'RunnerActionsCell',
- });
- });
+ it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => {
+ await findToggleActiveBtn().vm.$emit('click');
- it('error is shown to the user', () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- });
+ expect(getTooltip(findToggleActiveBtn())).toBe('');
+ expect(findToggleActiveBtn().attributes('aria-label')).toBe('');
});
- describe('On a validation error', () => {
- const mockErrorMsg = 'Runner not found!';
- const mockErrorMsg2 = 'User not allowed!';
-
- beforeEach(async () => {
- runnerActionsUpdateMutationHandler.mockResolvedValue({
- data: {
- runnerUpdate: {
- runner: mockRunner,
- errors: [mockErrorMsg, mockErrorMsg2],
- },
- },
- });
+ describe(`When clicking on the ${icon} button`, () => {
+ it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => {
+ expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0);
await findToggleActiveBtn().vm.$emit('click');
- });
-
- it('error is reported to sentry', () => {
- expect(captureException).toHaveBeenCalledWith({
- error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
- component: 'RunnerActionsCell',
- });
- });
- it('error is shown to the user', () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- });
- });
- });
- });
-
- describe('When the user clicks a runner', () => {
- beforeEach(() => {
- jest.spyOn(window, 'confirm');
-
- createComponent();
- });
-
- afterEach(() => {
- window.confirm.mockRestore();
- });
-
- describe('When the user confirms deletion', () => {
- beforeEach(async () => {
- window.confirm.mockReturnValue(true);
- await findDeleteBtn().vm.$emit('click');
- });
-
- it('The user sees a confirmation alert', () => {
- expect(window.confirm).toHaveBeenCalledTimes(1);
- expect(window.confirm).toHaveBeenCalledWith(expect.any(String));
- });
-
- it('The delete mutation is called correctly', () => {
- expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1);
- expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({
- input: { id: mockRunner.id },
- });
- });
-
- it('When delete mutation is called, current runners are refetched', async () => {
- jest.spyOn(wrapper.vm.$apollo, 'mutate');
-
- await findDeleteBtn().vm.$emit('click');
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: runnerDeleteMutation,
- variables: {
+ expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1);
+ expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({
input: {
id: mockRunner.id,
+ active: newActiveValue,
},
- },
- awaitRefetchQueries: true,
- refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName],
+ });
});
- });
-
- it('The delete button does not have a loading state', () => {
- expect(findDeleteBtn().props('loading')).toBe(false);
- expect(findDeleteBtn().attributes('title')).toBe('Remove');
- });
- it('After the delete button is clicked, loading state is shown', async () => {
- await findDeleteBtn().vm.$emit('click');
+ it('The button does not have a loading state after the mutation occurs', async () => {
+ await findToggleActiveBtn().vm.$emit('click');
- expect(findDeleteBtn().props('loading')).toBe(true);
- });
+ expect(findToggleActiveBtn().props('loading')).toBe(true);
- it('After the delete button is clicked, stale tooltip is removed', async () => {
- await findDeleteBtn().vm.$emit('click');
+ await waitForPromises();
- expect(findDeleteBtn().attributes('title')).toBe('');
+ expect(findToggleActiveBtn().props('loading')).toBe(false);
+ });
});
- describe('When delete fails', () => {
+ describe('When update fails', () => {
describe('On a network error', () => {
- const mockErrorMsg = 'Delete error!';
+ const mockErrorMsg = 'Update error!';
beforeEach(async () => {
- runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
+ runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
- await findDeleteBtn().vm.$emit('click');
+ await findToggleActiveBtn().vm.$emit('click');
});
it('error is reported to sentry', () => {
@@ -282,15 +188,16 @@ describe('RunnerTypeCell', () => {
const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => {
- runnerDeleteMutationHandler.mockResolvedValue({
+ runnerActionsUpdateMutationHandler.mockResolvedValue({
data: {
- runnerDelete: {
+ runnerUpdate: {
+ runner: mockRunner,
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
- await findDeleteBtn().vm.$emit('click');
+ await findToggleActiveBtn().vm.$emit('click');
});
it('error is reported to sentry', () => {
@@ -306,24 +213,129 @@ describe('RunnerTypeCell', () => {
});
});
});
+ });
- describe('When the user does not confirm deletion', () => {
- beforeEach(async () => {
- window.confirm.mockReturnValue(false);
- await findDeleteBtn().vm.$emit('click');
+ describe('Delete action', () => {
+ beforeEach(() => {
+ createComponent(
+ {},
+ {
+ stubs: { RunnerDeleteModal },
+ },
+ );
+ });
+
+ it('Delete button opens delete modal', () => {
+ const modalId = getBinding(findDeleteBtn().element, 'gl-modal').value;
+
+ expect(findRunnerDeleteModal().attributes('modal-id')).toBeDefined();
+ expect(findRunnerDeleteModal().attributes('modal-id')).toBe(modalId);
+ });
+
+ it('Delete modal shows the runner name', () => {
+ expect(findRunnerDeleteModal().props('runnerName')).toBe(
+ `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`,
+ );
+ });
+ it('The delete button does not have a loading icon', () => {
+ expect(findDeleteBtn().props('loading')).toBe(false);
+ expect(getTooltip(findDeleteBtn())).toBe('Delete runner');
+ });
+
+ it('When delete mutation is called, current runners are refetched', () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate');
+
+ findRunnerDeleteModal().vm.$emit('primary');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: runnerDeleteMutation,
+ variables: {
+ input: {
+ id: mockRunner.id,
+ },
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName],
});
+ });
- it('The user sees a confirmation alert', () => {
- expect(window.confirm).toHaveBeenCalledTimes(1);
+ describe('When delete is clicked', () => {
+ beforeEach(() => {
+ findRunnerDeleteModal().vm.$emit('primary');
});
- it('The delete mutation is not called', () => {
- expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(0);
+ it('The delete mutation is called correctly', () => {
+ expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1);
+ expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({
+ input: { id: mockRunner.id },
+ });
});
- it('The delete button does not have a loading state', () => {
- expect(findDeleteBtn().props('loading')).toBe(false);
- expect(findDeleteBtn().attributes('title')).toBe('Remove');
+ it('The delete button has a loading icon', () => {
+ expect(findDeleteBtn().props('loading')).toBe(true);
+ expect(getTooltip(findDeleteBtn())).toBe('');
+ });
+
+ it('The toast notification is shown', () => {
+ expect(mockToastShow).toHaveBeenCalledTimes(1);
+ expect(mockToastShow).toHaveBeenCalledWith(
+ expect.stringContaining(`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`),
+ );
+ });
+ });
+
+ describe('When delete fails', () => {
+ describe('On a network error', () => {
+ const mockErrorMsg = 'Delete error!';
+
+ beforeEach(() => {
+ runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
+
+ findRunnerDeleteModal().vm.$emit('primary');
+ });
+
+ it('error is reported to sentry', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ error: new Error(`Network error: ${mockErrorMsg}`),
+ component: 'RunnerActionsCell',
+ });
+ });
+
+ it('error is shown to the user', () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ });
+
+ it('toast notification is not shown', () => {
+ expect(mockToastShow).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('On a validation error', () => {
+ const mockErrorMsg = 'Runner not found!';
+ const mockErrorMsg2 = 'User not allowed!';
+
+ beforeEach(() => {
+ runnerDeleteMutationHandler.mockResolvedValue({
+ data: {
+ runnerDelete: {
+ errors: [mockErrorMsg, mockErrorMsg2],
+ },
+ },
+ });
+
+ findRunnerDeleteModal().vm.$emit('primary');
+ });
+
+ it('error is reported to sentry', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
+ component: 'RunnerActionsCell',
+ });
+ });
+
+ it('error is shown to the user', () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ });
});
});
});
diff --git a/spec/frontend/runner/components/runner_contacted_state_badge_spec.js b/spec/frontend/runner/components/runner_contacted_state_badge_spec.js
deleted file mode 100644
index 57a27f39826..00000000000
--- a/spec/frontend/runner/components/runner_contacted_state_badge_spec.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { GlBadge } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import RunnerContactedStateBadge from '~/runner/components/runner_contacted_state_badge.vue';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_NOT_CONNECTED } from '~/runner/constants';
-
-describe('RunnerTypeBadge', () => {
- let wrapper;
-
- const findBadge = () => wrapper.findComponent(GlBadge);
- const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip');
-
- const createComponent = ({ runner = {} } = {}) => {
- wrapper = shallowMount(RunnerContactedStateBadge, {
- propsData: {
- runner: {
- contactedAt: '2021-01-01T00:00:00Z',
- status: STATUS_ONLINE,
- ...runner,
- },
- },
- directives: {
- GlTooltip: createMockDirective(),
- },
- });
- };
-
- beforeEach(() => {
- jest.useFakeTimers('modern');
- });
-
- afterEach(() => {
- jest.useFakeTimers('legacy');
-
- wrapper.destroy();
- });
-
- it('renders online state', () => {
- jest.setSystemTime(new Date('2021-01-01T00:01:00Z'));
-
- createComponent();
-
- expect(wrapper.text()).toBe('online');
- expect(findBadge().props('variant')).toBe('success');
- expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago');
- });
-
- it('renders offline state', () => {
- jest.setSystemTime(new Date('2021-01-02T00:00:00Z'));
-
- createComponent({
- runner: {
- status: STATUS_OFFLINE,
- },
- });
-
- expect(wrapper.text()).toBe('offline');
- expect(findBadge().props('variant')).toBe('muted');
- expect(getTooltip().value).toBe(
- 'No recent contact from this runner; last contact was 1 day ago',
- );
- });
-
- it('renders not connected state', () => {
- createComponent({
- runner: {
- contactedAt: null,
- status: STATUS_NOT_CONNECTED,
- },
- });
-
- expect(wrapper.text()).toBe('not connected');
- expect(findBadge().props('variant')).toBe('muted');
- expect(getTooltip().value).toMatch('This runner has never connected');
- });
-
- it('does not fail when data is missing', () => {
- createComponent({
- runner: {
- status: null,
- },
- });
-
- expect(wrapper.text()).toBe('');
- });
-});
diff --git a/spec/frontend/runner/components/runner_delete_modal_spec.js b/spec/frontend/runner/components/runner_delete_modal_spec.js
new file mode 100644
index 00000000000..3e5b634d815
--- /dev/null
+++ b/spec/frontend/runner/components/runner_delete_modal_spec.js
@@ -0,0 +1,60 @@
+import { GlModal } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
+
+describe('RunnerDeleteModal', () => {
+ let wrapper;
+
+ const findGlModal = () => wrapper.findComponent(GlModal);
+
+ const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(RunnerDeleteModal, {
+ attachTo: document.body,
+ propsData: {
+ runnerName: '#99 (AABBCCDD)',
+ ...props,
+ },
+ attrs: {
+ modalId: 'delete-runner-modal-99',
+ },
+ });
+ };
+
+ it('Displays title', () => {
+ createComponent();
+
+ expect(findGlModal().props('title')).toBe('Delete runner #99 (AABBCCDD)?');
+ });
+
+ it('Displays buttons', () => {
+ createComponent();
+
+ expect(findGlModal().props('actionPrimary')).toMatchObject({ text: 'Delete runner' });
+ expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' });
+ });
+
+ it('Displays contents', () => {
+ createComponent();
+
+ expect(findGlModal().html()).toContain(
+ 'The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
+ );
+ });
+
+ describe('When modal is confirmed by the user', () => {
+ let hideModalSpy;
+
+ beforeEach(() => {
+ createComponent({}, mount);
+ hideModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide').mockImplementation(() => {});
+ });
+
+ it('Modal gets hidden', () => {
+ expect(hideModalSpy).toHaveBeenCalledTimes(0);
+
+ findGlModal().vm.$emit('primary');
+
+ expect(hideModalSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
index 9ea0955f2a1..5ab0db019a3 100644
--- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
+++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
@@ -15,7 +15,6 @@ describe('RunnerList', () => {
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
- const findActiveRunnersMessage = () => wrapper.findByTestId('runner-count');
const mockDefaultSort = 'CREATED_DESC';
const mockOtherSort = 'CONTACTED_DESC';
@@ -23,7 +22,6 @@ describe('RunnerList', () => {
{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
];
- const mockActiveRunnersCount = 2;
const expectToHaveLastEmittedInput = (value) => {
const inputs = wrapper.emitted('input');
@@ -43,9 +41,6 @@ describe('RunnerList', () => {
},
...props,
},
- slots: {
- 'runner-count': `Runners currently online: ${mockActiveRunnersCount}`,
- },
stubs: {
FilteredSearch,
GlFilteredSearch,
@@ -69,12 +64,6 @@ describe('RunnerList', () => {
expect(findFilteredSearch().props('namespace')).toBe('runners');
});
- it('Displays an active runner count', () => {
- expect(findActiveRunnersMessage().text()).toBe(
- `Runners currently online: ${mockActiveRunnersCount}`,
- );
- });
-
it('sets sorting options', () => {
const SORT_OPTIONS_COUNT = 2;
diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
index 986e55a2132..5a14fa5a2d5 100644
--- a/spec/frontend/runner/components/runner_list_spec.js
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -46,12 +46,19 @@ describe('RunnerList', () => {
'Runner ID',
'Version',
'IP Address',
+ 'Jobs',
'Tags',
'Last contact',
'', // actions has no label
]);
});
+ it('Sets runner id as a row key', () => {
+ createComponent({}, shallowMount);
+
+ expect(findTable().attributes('primary-key')).toBe('id');
+ });
+
it('Displays a list of runners', () => {
expect(findRows()).toHaveLength(4);
@@ -73,6 +80,7 @@ describe('RunnerList', () => {
// Other fields
expect(findCell({ fieldKey: 'version' }).text()).toBe(version);
expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress);
+ expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0');
expect(findCell({ fieldKey: 'tagList' }).text()).toBe('');
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
@@ -83,6 +91,42 @@ describe('RunnerList', () => {
expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true);
});
+ describe('Table data formatting', () => {
+ let mockRunnersCopy;
+
+ beforeEach(() => {
+ mockRunnersCopy = [
+ {
+ ...mockRunners[0],
+ },
+ ];
+ });
+
+ it('Formats job counts', () => {
+ mockRunnersCopy[0].jobCount = 1;
+
+ createComponent({ props: { runners: mockRunnersCopy } }, mount);
+
+ expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1');
+ });
+
+ it('Formats large job counts', () => {
+ mockRunnersCopy[0].jobCount = 1000;
+
+ createComponent({ props: { runners: mockRunnersCopy } }, mount);
+
+ expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000');
+ });
+
+ it('Formats large job counts with a plus symbol', () => {
+ mockRunnersCopy[0].jobCount = 1001;
+
+ createComponent({ props: { runners: mockRunnersCopy } }, mount);
+
+ expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000+');
+ });
+ });
+
it('Shows runner identifier', () => {
const { id, shortSha } = mockRunners[0];
const numericId = getIdFromGraphQLId(id);
diff --git a/spec/frontend/runner/components/runner_status_badge_spec.js b/spec/frontend/runner/components/runner_status_badge_spec.js
new file mode 100644
index 00000000000..a19515d6ed2
--- /dev/null
+++ b/spec/frontend/runner/components/runner_status_badge_spec.js
@@ -0,0 +1,130 @@
+import { GlBadge } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import {
+ STATUS_ONLINE,
+ STATUS_OFFLINE,
+ STATUS_STALE,
+ STATUS_NOT_CONNECTED,
+ STATUS_NEVER_CONTACTED,
+} from '~/runner/constants';
+
+describe('RunnerTypeBadge', () => {
+ let wrapper;
+
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip');
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(RunnerStatusBadge, {
+ propsData: {
+ runner: {
+ contactedAt: '2020-12-31T23:59:00Z',
+ status: STATUS_ONLINE,
+ },
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.useFakeTimers('modern');
+ jest.setSystemTime(new Date('2021-01-01T00:00:00Z'));
+ });
+
+ afterEach(() => {
+ jest.useFakeTimers('legacy');
+
+ wrapper.destroy();
+ });
+
+ it('renders online state', () => {
+ createComponent();
+
+ expect(wrapper.text()).toBe('online');
+ expect(findBadge().props('variant')).toBe('success');
+ expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago');
+ });
+
+ it('renders not connected state', () => {
+ createComponent({
+ runner: {
+ contactedAt: null,
+ status: STATUS_NOT_CONNECTED,
+ },
+ });
+
+ expect(wrapper.text()).toBe('not connected');
+ expect(findBadge().props('variant')).toBe('muted');
+ expect(getTooltip().value).toMatch('This runner has never connected');
+ });
+
+ it('renders never contacted state as not connected, for backwards compatibility', () => {
+ createComponent({
+ runner: {
+ contactedAt: null,
+ status: STATUS_NEVER_CONTACTED,
+ },
+ });
+
+ expect(wrapper.text()).toBe('not connected');
+ expect(findBadge().props('variant')).toBe('muted');
+ expect(getTooltip().value).toMatch('This runner has never connected');
+ });
+
+ it('renders offline state', () => {
+ createComponent({
+ runner: {
+ contactedAt: '2020-12-31T00:00:00Z',
+ status: STATUS_OFFLINE,
+ },
+ });
+
+ expect(wrapper.text()).toBe('offline');
+ expect(findBadge().props('variant')).toBe('muted');
+ expect(getTooltip().value).toBe(
+ 'No recent contact from this runner; last contact was 1 day ago',
+ );
+ });
+
+ it('renders stale state', () => {
+ createComponent({
+ runner: {
+ contactedAt: '2020-01-01T00:00:00Z',
+ status: STATUS_STALE,
+ },
+ });
+
+ expect(wrapper.text()).toBe('stale');
+ expect(findBadge().props('variant')).toBe('warning');
+ expect(getTooltip().value).toBe('No contact from this runner in over 3 months');
+ });
+
+ describe('does not fail when data is missing', () => {
+ it('contacted_at is missing', () => {
+ createComponent({
+ runner: {
+ contactedAt: null,
+ status: STATUS_ONLINE,
+ },
+ });
+
+ expect(wrapper.text()).toBe('online');
+ expect(getTooltip().value).toBe('Runner is online; last contact was n/a');
+ });
+
+ it('status is missing', () => {
+ createComponent({
+ runner: {
+ status: null,
+ },
+ });
+
+ expect(wrapper.text()).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/runner/components/search_tokens/tag_token_spec.js
index 52b87542243..89c06ba2df4 100644
--- a/spec/frontend/runner/components/search_tokens/tag_token_spec.js
+++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js
@@ -41,7 +41,7 @@ const mockTagTokenConfig = {
title: 'Tags',
type: 'tag',
token: TagToken,
- recentTokenValuesStorageKey: mockStorageKey,
+ recentSuggestionsStorageKey: mockStorageKey,
operators: OPERATOR_IS_ONLY,
};
diff --git a/spec/frontend/runner/components/stat/runner_online_stat_spec.js b/spec/frontend/runner/components/stat/runner_online_stat_spec.js
new file mode 100644
index 00000000000..18f865aa22c
--- /dev/null
+++ b/spec/frontend/runner/components/stat/runner_online_stat_spec.js
@@ -0,0 +1,34 @@
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { shallowMount, mount } from '@vue/test-utils';
+import RunnerOnlineBadge from '~/runner/components/stat/runner_online_stat.vue';
+
+describe('RunnerOnlineBadge', () => {
+ let wrapper;
+
+ const findSingleStat = () => wrapper.findComponent(GlSingleStat);
+
+ const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(RunnerOnlineBadge, {
+ propsData: {
+ value: '99',
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Uses a success appearance', () => {
+ createComponent({}, shallowMount);
+
+ expect(findSingleStat().props('variant')).toBe('success');
+ });
+
+ it('Renders a value', () => {
+ createComponent({}, mount);
+
+ expect(wrapper.text()).toMatch(new RegExp(`Online Runners 99\\s+online`));
+ });
+});
diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js
index 39bca743c80..4451100de19 100644
--- a/spec/frontend/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -130,24 +130,24 @@ describe('GroupRunnersApp', () => {
});
describe('shows the active runner count', () => {
+ const expectedOnlineCount = (count) => new RegExp(`Online Runners ${count}`);
+
it('with a regular value', () => {
createComponent({ mountFn: mount });
- expect(findRunnerFilteredSearchBar().text()).toMatch(
- `Runners in this group: ${mockGroupRunnersLimitedCount}`,
- );
+ expect(wrapper.text()).toMatch(expectedOnlineCount(mockGroupRunnersLimitedCount));
});
it('at the limit', () => {
createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount });
- expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000`);
+ expect(wrapper.text()).toMatch(expectedOnlineCount('1,000'));
});
it('over the limit', () => {
createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount });
- expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000+`);
+ expect(wrapper.text()).toMatch(expectedOnlineCount('1,000\\+'));
});
});
diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js
index d4ee9e6e43d..0a2b18caf25 100644
--- a/spec/frontend/security_configuration/components/app_spec.js
+++ b/spec/frontend/security_configuration/components/app_spec.js
@@ -20,6 +20,7 @@ import {
AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY,
} from '~/security_configuration/components/constants';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
+import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
import UpgradeBanner from '~/security_configuration/components/upgrade_banner.vue';
import {
@@ -39,7 +40,11 @@ describe('App component', () => {
let wrapper;
let userCalloutDismissSpy;
- const createComponent = ({ shouldShowCallout = true, ...propsData }) => {
+ const createComponent = ({
+ shouldShowCallout = true,
+ secureVulnerabilityTraining = true,
+ ...propsData
+ }) => {
userCalloutDismissSpy = jest.fn();
wrapper = extendedWrapper(
@@ -50,6 +55,9 @@ describe('App component', () => {
autoDevopsHelpPagePath,
autoDevopsPath,
projectPath,
+ glFeatures: {
+ secureVulnerabilityTraining,
+ },
},
stubs: {
...stubChildren(SecurityConfigurationApp),
@@ -71,6 +79,7 @@ describe('App component', () => {
const findTabs = () => wrapper.findAllComponents(GlTab);
const findByTestId = (id) => wrapper.findByTestId(id);
const findFeatureCards = () => wrapper.findAllComponents(FeatureCard);
+ const findTrainingProviderList = () => wrapper.findComponent(TrainingProviderList);
const findManageViaMRErrorAlert = () => wrapper.findByTestId('manage-via-mr-error-alert');
const findLink = ({ href, text, container = wrapper }) => {
const selector = `a[href="${href}"]`;
@@ -138,20 +147,20 @@ describe('App component', () => {
expect(mainHeading.text()).toContain('Security Configuration');
});
- it('renders GlTab Component ', () => {
- expect(findTab().exists()).toBe(true);
- });
+ describe('tabs', () => {
+ const expectedTabs = ['security-testing', 'compliance-testing', 'vulnerability-management'];
- it('renders right amount of tabs with correct title ', () => {
- expect(findTabs()).toHaveLength(2);
- });
+ it('renders GlTab Component', () => {
+ expect(findTab().exists()).toBe(true);
+ });
- it('renders security-testing tab', () => {
- expect(findByTestId('security-testing-tab').exists()).toBe(true);
- });
+ it('renders correct amount of tabs', () => {
+ expect(findTabs()).toHaveLength(expectedTabs.length);
+ });
- it('renders compliance-testing tab', () => {
- expect(findByTestId('compliance-testing-tab').exists()).toBe(true);
+ it.each(expectedTabs)('renders the %s tab', (tabName) => {
+ expect(findByTestId(`${tabName}-tab`).exists()).toBe(true);
+ });
});
it('renders right amount of feature cards for given props with correct props', () => {
@@ -173,6 +182,10 @@ describe('App component', () => {
expect(findComplianceViewHistoryLink().exists()).toBe(false);
expect(findSecurityViewHistoryLink().exists()).toBe(false);
});
+
+ it('renders TrainingProviderList component', () => {
+ expect(findTrainingProviderList().exists()).toBe(true);
+ });
});
describe('Manage via MR Error Alert', () => {
@@ -418,4 +431,22 @@ describe('App component', () => {
expect(findSecurityViewHistoryLink().attributes('href')).toBe('test/historyPath');
});
});
+
+ describe('when secureVulnerabilityTraining feature flag is disabled', () => {
+ beforeEach(() => {
+ createComponent({
+ augmentedSecurityFeatures: securityFeaturesMock,
+ augmentedComplianceFeatures: complianceFeaturesMock,
+ secureVulnerabilityTraining: false,
+ });
+ });
+
+ it('renders correct amount of tabs', () => {
+ expect(findTabs()).toHaveLength(2);
+ });
+
+ it('does not render the vulnerability-management tab', () => {
+ expect(wrapper.findByTestId('vulnerability-management-tab').exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js
new file mode 100644
index 00000000000..60cc36a634c
--- /dev/null
+++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js
@@ -0,0 +1,88 @@
+import { GlLink, GlToggle, GlCard, GlSkeletonLoader } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+import { securityTrainingProviders, mockResolvers } from '../mock_data';
+
+Vue.use(VueApollo);
+
+describe('TrainingProviderList component', () => {
+ let wrapper;
+ let mockApollo;
+ let mockSecurityTrainingProvidersData;
+
+ const createComponent = () => {
+ mockApollo = createMockApollo([], mockResolvers);
+
+ wrapper = shallowMount(TrainingProviderList, {
+ apolloProvider: mockApollo,
+ });
+ };
+
+ const waitForQueryToBeLoaded = () => waitForPromises();
+
+ const findCards = () => wrapper.findAllComponents(GlCard);
+ const findLinks = () => wrapper.findAllComponents(GlLink);
+ const findToggles = () => wrapper.findAllComponents(GlToggle);
+ const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ beforeEach(() => {
+ mockSecurityTrainingProvidersData = jest.fn();
+ mockSecurityTrainingProvidersData.mockResolvedValue(securityTrainingProviders);
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mockApollo = null;
+ });
+
+ describe('when loading', () => {
+ it('shows the loader', () => {
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('does not show the cards', () => {
+ expect(findCards().exists()).toBe(false);
+ });
+ });
+
+ describe('basic structure', () => {
+ beforeEach(async () => {
+ await waitForQueryToBeLoaded();
+ });
+
+ it('renders correct amount of cards', () => {
+ expect(findCards()).toHaveLength(securityTrainingProviders.length);
+ });
+
+ securityTrainingProviders.forEach(({ name, description, url, isEnabled }, index) => {
+ it(`shows the name for card ${index}`, () => {
+ expect(findCards().at(index).text()).toContain(name);
+ });
+
+ it(`shows the description for card ${index}`, () => {
+ expect(findCards().at(index).text()).toContain(description);
+ });
+
+ it(`shows the learn more link for card ${index}`, () => {
+ expect(findLinks().at(index).attributes()).toEqual({
+ target: '_blank',
+ href: url,
+ });
+ });
+
+ it(`shows the toggle with the correct value for card ${index}`, () => {
+ expect(findToggles().at(index).props('value')).toEqual(isEnabled);
+ });
+
+ it('does not show loader when query is populated', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js
new file mode 100644
index 00000000000..cdb859c3800
--- /dev/null
+++ b/spec/frontend/security_configuration/mock_data.js
@@ -0,0 +1,30 @@
+export const securityTrainingProviders = [
+ {
+ id: 101,
+ name: 'Kontra',
+ description: 'Interactive developer security education.',
+ url: 'https://application.security/',
+ isEnabled: false,
+ },
+ {
+ id: 102,
+ name: 'SecureCodeWarrior',
+ description: 'Security training with guide and learning pathways.',
+ url: 'https://www.securecodewarrior.com/',
+ isEnabled: true,
+ },
+];
+
+export const securityTrainingProvidersResponse = {
+ data: {
+ securityTrainingProviders,
+ },
+};
+
+export const mockResolvers = {
+ Query: {
+ securityTrainingProviders() {
+ return securityTrainingProviders;
+ },
+ },
+};
diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js
index eaed4532baa..241e69204d2 100644
--- a/spec/frontend/security_configuration/utils_spec.js
+++ b/spec/frontend/security_configuration/utils_spec.js
@@ -1,101 +1,120 @@
-import { augmentFeatures } from '~/security_configuration/utils';
-
-const mockSecurityFeatures = [
- {
- name: 'SAST',
- type: 'SAST',
- },
-];
-
-const mockComplianceFeatures = [
- {
- name: 'LICENSE_COMPLIANCE',
- type: 'LICENSE_COMPLIANCE',
- },
-];
-
-const mockFeaturesWithSecondary = [
- {
- name: 'DAST',
- type: 'DAST',
- secondary: {
- type: 'DAST PROFILES',
- name: 'DAST PROFILES',
+import { augmentFeatures, translateScannerNames } from '~/security_configuration/utils';
+import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants';
+
+describe('augmentFeatures', () => {
+ const mockSecurityFeatures = [
+ {
+ name: 'SAST',
+ type: 'SAST',
},
- },
-];
-
-const mockInvalidCustomFeature = [
- {
- foo: 'bar',
- },
-];
-
-const mockValidCustomFeature = [
- {
- name: 'SAST',
- type: 'SAST',
- customField: 'customvalue',
- },
-];
-
-const mockValidCustomFeatureSnakeCase = [
- {
- name: 'SAST',
- type: 'SAST',
- custom_field: 'customvalue',
- },
-];
-
-const expectedOutputDefault = {
- augmentedSecurityFeatures: mockSecurityFeatures,
- augmentedComplianceFeatures: mockComplianceFeatures,
-};
-
-const expectedOutputSecondary = {
- augmentedSecurityFeatures: mockSecurityFeatures,
- augmentedComplianceFeatures: mockFeaturesWithSecondary,
-};
-
-const expectedOutputCustomFeature = {
- augmentedSecurityFeatures: mockValidCustomFeature,
- augmentedComplianceFeatures: mockComplianceFeatures,
-};
-
-describe('returns an object with augmentedSecurityFeatures and augmentedComplianceFeatures when', () => {
- it('given an empty array', () => {
- expect(augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, [])).toEqual(
- expectedOutputDefault,
- );
+ ];
+
+ const mockComplianceFeatures = [
+ {
+ name: 'LICENSE_COMPLIANCE',
+ type: 'LICENSE_COMPLIANCE',
+ },
+ ];
+
+ const mockFeaturesWithSecondary = [
+ {
+ name: 'DAST',
+ type: 'DAST',
+ secondary: {
+ type: 'DAST PROFILES',
+ name: 'DAST PROFILES',
+ },
+ },
+ ];
+
+ const mockInvalidCustomFeature = [
+ {
+ foo: 'bar',
+ },
+ ];
+
+ const mockValidCustomFeature = [
+ {
+ name: 'SAST',
+ type: 'SAST',
+ customField: 'customvalue',
+ },
+ ];
+
+ const mockValidCustomFeatureSnakeCase = [
+ {
+ name: 'SAST',
+ type: 'SAST',
+ custom_field: 'customvalue',
+ },
+ ];
+
+ const expectedOutputDefault = {
+ augmentedSecurityFeatures: mockSecurityFeatures,
+ augmentedComplianceFeatures: mockComplianceFeatures,
+ };
+
+ const expectedOutputSecondary = {
+ augmentedSecurityFeatures: mockSecurityFeatures,
+ augmentedComplianceFeatures: mockFeaturesWithSecondary,
+ };
+
+ const expectedOutputCustomFeature = {
+ augmentedSecurityFeatures: mockValidCustomFeature,
+ augmentedComplianceFeatures: mockComplianceFeatures,
+ };
+
+ describe('returns an object with augmentedSecurityFeatures and augmentedComplianceFeatures when', () => {
+ it('given an empty array', () => {
+ expect(augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, [])).toEqual(
+ expectedOutputDefault,
+ );
+ });
+
+ it('given an invalid populated array', () => {
+ expect(
+ augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockInvalidCustomFeature),
+ ).toEqual(expectedOutputDefault);
+ });
+
+ it('features have secondary key', () => {
+ expect(augmentFeatures(mockSecurityFeatures, mockFeaturesWithSecondary, [])).toEqual(
+ expectedOutputSecondary,
+ );
+ });
+
+ it('given a valid populated array', () => {
+ expect(
+ augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockValidCustomFeature),
+ ).toEqual(expectedOutputCustomFeature);
+ });
});
- it('given an invalid populated array', () => {
- expect(
- augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockInvalidCustomFeature),
- ).toEqual(expectedOutputDefault);
+ describe('returns an object with camelcased keys', () => {
+ it('given a customfeature in snakecase', () => {
+ expect(
+ augmentFeatures(
+ mockSecurityFeatures,
+ mockComplianceFeatures,
+ mockValidCustomFeatureSnakeCase,
+ ),
+ ).toEqual(expectedOutputCustomFeature);
+ });
});
+});
- it('features have secondary key', () => {
- expect(augmentFeatures(mockSecurityFeatures, mockFeaturesWithSecondary, [])).toEqual(
- expectedOutputSecondary,
- );
+describe('translateScannerNames', () => {
+ it.each(['', undefined, null, 1, 'UNKNOWN_SCANNER_KEY'])('returns %p as is', (key) => {
+ expect(translateScannerNames([key])).toEqual([key]);
});
- it('given a valid populated array', () => {
- expect(
- augmentFeatures(mockSecurityFeatures, mockComplianceFeatures, mockValidCustomFeature),
- ).toEqual(expectedOutputCustomFeature);
+ it('returns an empty array if no input is provided', () => {
+ expect(translateScannerNames([])).toEqual([]);
});
-});
-describe('returns an object with camelcased keys', () => {
- it('given a customfeature in snakecase', () => {
- expect(
- augmentFeatures(
- mockSecurityFeatures,
- mockComplianceFeatures,
- mockValidCustomFeatureSnakeCase,
- ),
- ).toEqual(expectedOutputCustomFeature);
+ it('returns translated scanner names', () => {
+ expect(translateScannerNames(Object.keys(SCANNER_NAMES_MAP))).toEqual(
+ Object.values(SCANNER_NAMES_MAP),
+ );
});
});
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 1a874c3dcd6..c968c28c811 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
@@ -52,6 +52,7 @@ exports[`self monitor component When the self monitor project has not been creat
<gl-form-group-stub
labeldescription=""
+ optionaltext="(optional)"
>
<gl-toggle-stub
label="Self monitoring"
diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
index 53bef449c2f..c25a8d4bb92 100644
--- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
+++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
@@ -7,8 +7,10 @@ exports[`EmptyStateComponent should render content 1`] = `
</div>
<div class=\\"col-12\\">
<div class=\\"text-content gl-mx-auto gl-my-0 gl-p-5\\">
- <h1 class=\\"h4\\">Getting started with serverless</h1>
- <p>In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub>
+ <h1 class=\\"gl-font-size-h-display gl-line-height-36 h4\\">
+ Getting started with serverless
+ </h1>
+ <p class=\\"gl-mt-3\\">In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub>
</p>
<div class=\\"gl-display-flex gl-flex-wrap gl-justify-content-center\\">
<!---->
diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
index 3ff6d1f9597..d7261784edc 100644
--- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
+++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
@@ -1,6 +1,6 @@
import { GlModal, GlFormCheckbox } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { initEmojiMock } from 'helpers/emoji';
+import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import * as UserApi from '~/api/user_api';
import EmojiPicker from '~/emoji/components/picker.vue';
import createFlash from '~/flash';
@@ -12,7 +12,6 @@ jest.mock('~/flash');
describe('SetStatusModalWrapper', () => {
let wrapper;
- let mockEmoji;
const $toast = {
show: jest.fn(),
};
@@ -63,12 +62,12 @@ describe('SetStatusModalWrapper', () => {
afterEach(() => {
wrapper.destroy();
- mockEmoji.restore();
+ clearEmojiMock();
});
describe('with minimum props', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent();
return initModal();
});
@@ -112,7 +111,7 @@ describe('SetStatusModalWrapper', () => {
describe('improvedEmojiPicker is true', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent({}, true);
return initModal();
});
@@ -126,7 +125,7 @@ describe('SetStatusModalWrapper', () => {
describe('with no currentMessage set', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent({ currentMessage: '' });
return initModal();
});
@@ -146,7 +145,7 @@ describe('SetStatusModalWrapper', () => {
describe('with no currentEmoji set', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent({ currentEmoji: '' });
return initModal();
});
@@ -161,7 +160,7 @@ describe('SetStatusModalWrapper', () => {
describe('with no currentMessage set', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
return initModal();
});
@@ -174,7 +173,7 @@ describe('SetStatusModalWrapper', () => {
describe('with currentClearStatusAfter set', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent({ currentClearStatusAfter: '2021-01-01 00:00:00 UTC' });
return initModal();
});
@@ -190,7 +189,7 @@ describe('SetStatusModalWrapper', () => {
describe('update status', () => {
describe('succeeds', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent();
await initModal();
@@ -198,7 +197,7 @@ describe('SetStatusModalWrapper', () => {
});
it('clicking "removeStatus" clears the emoji and message fields', async () => {
- findModal().vm.$emit('cancel');
+ findModal().vm.$emit('secondary');
await wrapper.vm.$nextTick();
expect(findFormField('message').element.value).toBe('');
@@ -206,7 +205,7 @@ describe('SetStatusModalWrapper', () => {
});
it('clicking "setStatus" submits the user status', async () => {
- findModal().vm.$emit('ok');
+ findModal().vm.$emit('primary');
await wrapper.vm.$nextTick();
// set the availability status
@@ -215,7 +214,7 @@ describe('SetStatusModalWrapper', () => {
// set the currentClearStatusAfter to 30 minutes
wrapper.find('[data-testid="thirtyMinutes"]').vm.$emit('click');
- findModal().vm.$emit('ok');
+ findModal().vm.$emit('primary');
await wrapper.vm.$nextTick();
const commonParams = {
@@ -237,7 +236,7 @@ describe('SetStatusModalWrapper', () => {
});
it('calls the "onUpdateSuccess" handler', async () => {
- findModal().vm.$emit('ok');
+ findModal().vm.$emit('primary');
await wrapper.vm.$nextTick();
expect(wrapper.vm.onUpdateSuccess).toHaveBeenCalled();
@@ -246,14 +245,14 @@ describe('SetStatusModalWrapper', () => {
describe('success message', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
jest.spyOn(UserApi, 'updateUserStatus').mockResolvedValue();
return initModal({ mockOnUpdateSuccess: false });
});
it('displays a toast success message', async () => {
- findModal().vm.$emit('ok');
+ findModal().vm.$emit('primary');
await wrapper.vm.$nextTick();
expect($toast.show).toHaveBeenCalledWith('Status updated');
@@ -262,7 +261,7 @@ describe('SetStatusModalWrapper', () => {
describe('with errors', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent();
await initModal();
@@ -270,7 +269,7 @@ describe('SetStatusModalWrapper', () => {
});
it('calls the "onUpdateFail" handler', async () => {
- findModal().vm.$emit('ok');
+ findModal().vm.$emit('primary');
await wrapper.vm.$nextTick();
expect(wrapper.vm.onUpdateFail).toHaveBeenCalled();
@@ -279,14 +278,14 @@ describe('SetStatusModalWrapper', () => {
describe('error message', () => {
beforeEach(async () => {
- mockEmoji = await initEmojiMock();
+ await initEmojiMock();
wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
jest.spyOn(UserApi, 'updateUserStatus').mockRejectedValue();
return initModal({ mockOnUpdateFailure: false });
});
it('flashes an error message', async () => {
- findModal().vm.$emit('ok');
+ findModal().vm.$emit('primary');
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenCalledWith({
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js
index 455db325066..49148123a1c 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/shortcuts_spec.js
@@ -25,6 +25,7 @@ describe('Shortcuts', () => {
jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus');
jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus');
+ jest.spyOn(document.querySelector('#search'), 'focus');
new Shortcuts(); // eslint-disable-line no-new
});
@@ -111,4 +112,12 @@ describe('Shortcuts', () => {
});
});
});
+
+ describe('focusSearch', () => {
+ it('focuses the search bar', () => {
+ Shortcuts.focusSearch(createEvent('KeyboardEvent'));
+
+ expect(document.querySelector('#search').focus).toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
index 39f63b2a9f4..07da4acef8c 100644
--- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
+++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js
@@ -5,7 +5,7 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
diff --git a/spec/frontend/sidebar/components/attention_required_toggle_spec.js b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js
index 8555068cdd8..0939297a754 100644
--- a/spec/frontend/sidebar/components/attention_required_toggle_spec.js
+++ b/spec/frontend/sidebar/components/attention_requested_toggle_spec.js
@@ -23,8 +23,8 @@ describe('Attention require toggle', () => {
it.each`
attentionRequested | icon
- ${true} | ${'star'}
- ${false} | ${'star-o'}
+ ${true} | ${'attention-solid'}
+ ${false} | ${'attention'}
`(
'renders $icon icon when attention_requested is $attentionRequested',
({ attentionRequested, icon }) => {
diff --git a/spec/frontend/sidebar/components/crm_contacts_spec.js b/spec/frontend/sidebar/components/crm_contacts_spec.js
new file mode 100644
index 00000000000..758cff30e2d
--- /dev/null
+++ b/spec/frontend/sidebar/components/crm_contacts_spec.js
@@ -0,0 +1,87 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import CrmContacts from '~/sidebar/components/crm_contacts/crm_contacts.vue';
+import getIssueCrmContactsQuery from '~/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql';
+import issueCrmContactsSubscription from '~/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql';
+import {
+ getIssueCrmContactsQueryResponse,
+ issueCrmContactsUpdateResponse,
+ issueCrmContactsUpdateNullResponse,
+} from './mock_data';
+
+jest.mock('~/flash');
+
+describe('Issue crm contacts component', () => {
+ Vue.use(VueApollo);
+ let wrapper;
+ let fakeApollo;
+
+ const successQueryHandler = jest.fn().mockResolvedValue(getIssueCrmContactsQueryResponse);
+ const successSubscriptionHandler = jest.fn().mockResolvedValue(issueCrmContactsUpdateResponse);
+ const nullSubscriptionHandler = jest.fn().mockResolvedValue(issueCrmContactsUpdateNullResponse);
+
+ const mountComponent = ({
+ queryHandler = successQueryHandler,
+ subscriptionHandler = successSubscriptionHandler,
+ } = {}) => {
+ fakeApollo = createMockApollo([
+ [getIssueCrmContactsQuery, queryHandler],
+ [issueCrmContactsSubscription, subscriptionHandler],
+ ]);
+ wrapper = shallowMountExtended(CrmContacts, {
+ propsData: { issueId: '123' },
+ apolloProvider: fakeApollo,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ it('should render error message on reject', async () => {
+ mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+
+ it('calls the query with correct variables', () => {
+ mountComponent();
+
+ expect(successQueryHandler).toHaveBeenCalledWith({
+ id: 'gid://gitlab/Issue/123',
+ });
+ });
+
+ it('calls the subscription with correct variable for issue', () => {
+ mountComponent();
+
+ expect(successSubscriptionHandler).toHaveBeenCalledWith({
+ id: 'gid://gitlab/Issue/123',
+ });
+ });
+
+ it('renders correct initial results', async () => {
+ mountComponent({ subscriptionHandler: nullSubscriptionHandler });
+ await waitForPromises();
+
+ expect(wrapper.find('#contact_0').text()).toContain('Someone Important');
+ expect(wrapper.find('#contact_container_0').text()).toContain('si@gitlab.com');
+ expect(wrapper.find('#contact_1').text()).toContain('Marty McFly');
+ });
+
+ it('renders correct results after subscription update', async () => {
+ mountComponent();
+ await waitForPromises();
+
+ const contact = ['Dave Davies', 'dd@gitlab.com', '+44 20 1111 2222', 'Vice President'];
+ contact.forEach((property) => {
+ expect(wrapper.find('#contact_container_0').text()).toContain(property);
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js
index 619e89beb23..1e2173e2988 100644
--- a/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js
+++ b/spec/frontend/sidebar/components/date/sidebar_date_widget_spec.js
@@ -145,13 +145,20 @@ describe('Sidebar date Widget', () => {
${false} | ${SidebarInheritDate} | ${'SidebarInheritDate'} | ${false}
`(
'when canInherit is $canInherit, $componentName display is $expected',
- ({ canInherit, component, expected }) => {
+ async ({ canInherit, component, expected }) => {
createComponent({ canInherit });
+ await waitForPromises();
expect(wrapper.find(component).exists()).toBe(expected);
},
);
+ it('does not render SidebarInheritDate when canInherit is true and date is loading', async () => {
+ createComponent({ canInherit: true });
+
+ expect(wrapper.find(SidebarInheritDate).exists()).toBe(false);
+ });
+
it('displays a flash message when query is rejected', async () => {
createComponent({
dueDateQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'),
diff --git a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js
index 4d38eba8035..fda21e06987 100644
--- a/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js
+++ b/spec/frontend/sidebar/components/date/sidebar_inherit_date_spec.js
@@ -10,7 +10,7 @@ describe('SidebarInheritDate', () => {
const findFixedRadio = () => wrapper.findAll(GlFormRadio).at(0);
const findInheritRadio = () => wrapper.findAll(GlFormRadio).at(1);
- const createComponent = () => {
+ const createComponent = ({ dueDateIsFixed = false } = {}) => {
wrapper = shallowMount(SidebarInheritDate, {
provide: {
canUpdate: true,
@@ -18,11 +18,10 @@ describe('SidebarInheritDate', () => {
propsData: {
issuable: {
dueDate: '2021-04-15',
- dueDateIsFixed: true,
+ dueDateIsFixed,
dueDateFixed: '2021-04-15',
dueDateFromMilestones: '2021-05-15',
},
- isLoading: false,
dateType: 'dueDate',
},
});
@@ -45,6 +44,13 @@ describe('SidebarInheritDate', () => {
expect(findInheritRadio().text()).toBe('Inherited:');
});
+ it('does not emit set-date if fixed value does not change', () => {
+ createComponent({ dueDateIsFixed: true });
+ findFixedRadio().vm.$emit('input', true);
+
+ expect(wrapper.emitted('set-date')).toBeUndefined();
+ });
+
it('emits set-date event on click on radio button', () => {
findFixedRadio().vm.$emit('input', true);
diff --git a/spec/frontend/sidebar/components/mock_data.js b/spec/frontend/sidebar/components/mock_data.js
new file mode 100644
index 00000000000..70c3f8a3012
--- /dev/null
+++ b/spec/frontend/sidebar/components/mock_data.js
@@ -0,0 +1,56 @@
+export const getIssueCrmContactsQueryResponse = {
+ data: {
+ issue: {
+ id: 'gid://gitlab/Issue/123',
+ customerRelationsContacts: {
+ nodes: [
+ {
+ id: 'gid://gitlab/CustomerRelations::Contact/1',
+ firstName: 'Someone',
+ lastName: 'Important',
+ email: 'si@gitlab.com',
+ phone: null,
+ description: null,
+ organization: null,
+ },
+ {
+ id: 'gid://gitlab/CustomerRelations::Contact/5',
+ firstName: 'Marty',
+ lastName: 'McFly',
+ email: null,
+ phone: null,
+ description: null,
+ organization: null,
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const issueCrmContactsUpdateNullResponse = {
+ data: {
+ issueCrmContactsUpdated: null,
+ },
+};
+
+export const issueCrmContactsUpdateResponse = {
+ data: {
+ issueCrmContactsUpdated: {
+ id: 'gid://gitlab/Issue/123',
+ customerRelationsContacts: {
+ nodes: [
+ {
+ id: 'gid://gitlab/CustomerRelations::Contact/13',
+ firstName: 'Dave',
+ lastName: 'Davies',
+ email: 'dd@gitlab.com',
+ phone: '+44 20 1111 2222',
+ description: 'Vice President',
+ organization: null,
+ },
+ ],
+ },
+ },
+ },
+};
diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js
index cc428693930..69e35cd1d05 100644
--- a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js
+++ b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
index ca6e5ac5e7f..d7471d99477 100644
--- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
+++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
@@ -17,7 +17,7 @@ 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 { IssuableType } from '~/issues/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';
@@ -369,16 +369,18 @@ describe('SidebarDropdownWidget', () => {
describe('when a user is searching', () => {
describe('when search result is not found', () => {
- it('renders "No milestone found"', async () => {
- createComponent();
+ describe('when milestone', () => {
+ it('renders "No milestone found"', async () => {
+ createComponent();
- await toggleDropdown();
+ await toggleDropdown();
- findSearchBox().vm.$emit('input', 'non existing milestones');
+ findSearchBox().vm.$emit('input', 'non existing milestones');
- await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
- expect(findDropdownText().text()).toBe('No milestone found');
+ expect(findDropdownText().text()).toBe('No milestone found');
+ });
});
});
});
diff --git a/spec/frontend/sidebar/components/time_tracking/mock_data.js b/spec/frontend/sidebar/components/time_tracking/mock_data.js
index 938750bd58b..3f1b3fa8ec1 100644
--- a/spec/frontend/sidebar/components/time_tracking/mock_data.js
+++ b/spec/frontend/sidebar/components/time_tracking/mock_data.js
@@ -11,11 +11,13 @@ export const getIssueTimelogsQueryResponse = {
__typename: 'Timelog',
timeSpent: 14400,
user: {
+ id: 'user-1',
name: 'John Doe18',
__typename: 'UserCore',
},
spentAt: '2020-05-01T00:00:00Z',
note: {
+ id: 'note-1',
body: 'A note',
__typename: 'Note',
},
@@ -25,6 +27,7 @@ export const getIssueTimelogsQueryResponse = {
__typename: 'Timelog',
timeSpent: 1800,
user: {
+ id: 'user-2',
name: 'Administrator',
__typename: 'UserCore',
},
@@ -36,11 +39,13 @@ export const getIssueTimelogsQueryResponse = {
__typename: 'Timelog',
timeSpent: 14400,
user: {
+ id: 'user-2',
name: 'Administrator',
__typename: 'UserCore',
},
spentAt: '2021-05-01T00:00:00Z',
note: {
+ id: 'note-2',
body: 'A note',
__typename: 'Note',
},
@@ -65,11 +70,13 @@ export const getMrTimelogsQueryResponse = {
__typename: 'Timelog',
timeSpent: 1800,
user: {
+ id: 'user-1',
name: 'Administrator',
__typename: 'UserCore',
},
spentAt: '2021-05-07T14:44:55Z',
note: {
+ id: 'note-1',
body: 'Thirty minutes!',
__typename: 'Note',
},
@@ -79,6 +86,7 @@ export const getMrTimelogsQueryResponse = {
__typename: 'Timelog',
timeSpent: 3600,
user: {
+ id: 'user-1',
name: 'Administrator',
__typename: 'UserCore',
},
@@ -90,11 +98,13 @@ export const getMrTimelogsQueryResponse = {
__typename: 'Timelog',
timeSpent: 300,
user: {
+ id: 'user-1',
name: 'Administrator',
__typename: 'UserCore',
},
spentAt: '2021-03-10T00:00:00Z',
note: {
+ id: 'note-2',
body: 'A note with some time',
__typename: 'Note',
},
diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js
index 1ebd3c622ca..42e89a3ba84 100644
--- a/spec/frontend/sidebar/mock_data.js
+++ b/spec/frontend/sidebar/mock_data.js
@@ -223,6 +223,7 @@ const mockData = {
export const issueConfidentialityResponse = (confidential = false) => ({
data: {
workspace: {
+ id: '1',
__typename: 'Project',
issuable: {
__typename: 'Issue',
@@ -236,6 +237,7 @@ export const issueConfidentialityResponse = (confidential = false) => ({
export const issuableDueDateResponse = (dueDate = null) => ({
data: {
workspace: {
+ id: '1',
__typename: 'Project',
issuable: {
__typename: 'Issue',
@@ -249,6 +251,7 @@ export const issuableDueDateResponse = (dueDate = null) => ({
export const issuableStartDateResponse = (startDate = null) => ({
data: {
workspace: {
+ id: '1',
__typename: 'Group',
issuable: {
__typename: 'Epic',
@@ -265,6 +268,7 @@ export const issuableStartDateResponse = (startDate = null) => ({
export const epicParticipantsResponse = () => ({
data: {
workspace: {
+ id: '1',
__typename: 'Group',
issuable: {
__typename: 'Epic',
@@ -290,6 +294,7 @@ export const epicParticipantsResponse = () => ({
export const issueReferenceResponse = (reference) => ({
data: {
workspace: {
+ id: '1',
__typename: 'Project',
issuable: {
__typename: 'Issue',
@@ -303,6 +308,7 @@ export const issueReferenceResponse = (reference) => ({
export const issueSubscriptionsResponse = (subscribed = false, emailsDisabled = false) => ({
data: {
workspace: {
+ id: '1',
__typename: 'Project',
issuable: {
__typename: 'Issue',
@@ -318,6 +324,7 @@ export const issuableQueryResponse = {
data: {
workspace: {
__typename: 'Project',
+ id: '1',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
@@ -344,6 +351,7 @@ export const searchQueryResponse = {
data: {
workspace: {
__typename: 'Project',
+ id: '1',
users: {
nodes: [
{
@@ -428,12 +436,15 @@ export const searchResponse = {
data: {
workspace: {
__typename: 'Project',
+ id: '1',
users: {
nodes: [
{
+ id: 'gid://gitlab/User/1',
user: mockUser1,
},
{
+ id: 'gid://gitlab/User/4',
user: mockUser2,
},
],
@@ -445,6 +456,7 @@ export const searchResponse = {
export const projectMembersResponse = {
data: {
workspace: {
+ id: '1',
__typename: 'Project',
users: {
nodes: [
@@ -452,10 +464,11 @@ export const projectMembersResponse = {
null,
null,
// Remove duplicated entry https://gitlab.com/gitlab-org/gitlab/-/issues/327822
- { user: mockUser1 },
- { user: mockUser1 },
- { user: mockUser2 },
+ { id: 'user-1', user: mockUser1 },
+ { id: 'user-2', user: mockUser1 },
+ { id: 'user-3', user: mockUser2 },
{
+ id: 'user-4',
user: {
id: 'gid://gitlab/User/2',
avatarUrl:
@@ -477,16 +490,18 @@ export const projectMembersResponse = {
export const groupMembersResponse = {
data: {
workspace: {
- __typename: 'roup',
+ id: '1',
+ __typename: 'Group',
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 },
+ { id: 'user-1', user: mockUser1 },
+ { id: 'user-2', user: mockUser1 },
{
+ id: 'user-3',
user: {
id: 'gid://gitlab/User/2',
avatarUrl:
@@ -509,6 +524,7 @@ export const participantsQueryResponse = {
data: {
workspace: {
__typename: 'Project',
+ id: '1',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
@@ -578,6 +594,7 @@ export const mockMilestone2 = {
export const mockProjectMilestonesResponse = {
data: {
workspace: {
+ id: 'gid://gitlab/Project/1',
attributes: {
nodes: [mockMilestone1, mockMilestone2],
},
@@ -663,6 +680,7 @@ export const todosResponse = {
data: {
workspace: {
__typename: 'Group',
+ id: '1',
issuable: {
__typename: 'Epic',
id: 'gid://gitlab/Epic/4',
@@ -681,6 +699,7 @@ export const todosResponse = {
export const noTodosResponse = {
data: {
workspace: {
+ id: '1',
__typename: 'Group',
issuable: {
__typename: 'Epic',
diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js
deleted file mode 100644
index 8437ee1b723..00000000000
--- a/spec/frontend/sidebar/sidebar_labels_spec.js
+++ /dev/null
@@ -1,190 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import {
- mockLabels,
- mockRegularLabel,
-} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
-import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
-import { MutationOperationMode } from '~/graphql_shared/utils';
-import { IssuableType } from '~/issue_show/constants';
-import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue';
-import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
-import { toLabelGid } from '~/sidebar/utils';
-import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
-import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
-
-describe('sidebar labels', () => {
- let wrapper;
-
- const defaultProps = {
- allowLabelCreate: true,
- allowLabelEdit: true,
- allowScopedLabels: true,
- canEdit: true,
- iid: '1',
- initiallySelectedLabels: mockLabels,
- issuableType: 'issue',
- labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true',
- labelsManagePath: '/gitlab-org/gitlab-test/-/labels',
- projectIssuesPath: '/gitlab-org/gitlab-test/-/issues',
- projectPath: 'gitlab-org/gitlab-test',
- fullPath: 'gitlab-org/gitlab-test',
- };
-
- const $apollo = {
- mutate: jest.fn().mockResolvedValue(),
- };
-
- const userUpdatedLabels = [
- {
- ...mockRegularLabel,
- set: false,
- },
- {
- id: 40,
- title: 'Security',
- color: '#ddd',
- text_color: '#fff',
- set: true,
- },
- {
- id: 55,
- title: 'Tooling',
- color: '#ddd',
- text_color: '#fff',
- set: false,
- },
- ];
-
- const findLabelsSelect = () => wrapper.find(LabelsSelect);
-
- const mountComponent = (props = {}) => {
- wrapper = shallowMount(SidebarLabels, {
- provide: {
- ...defaultProps,
- ...props,
- },
- mocks: {
- $apollo,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('LabelsSelect props', () => {
- beforeEach(() => {
- mountComponent();
- });
-
- it('are as expected', () => {
- expect(findLabelsSelect().props()).toMatchObject({
- allowLabelCreate: defaultProps.allowLabelCreate,
- allowLabelEdit: defaultProps.allowLabelEdit,
- allowMultiselect: true,
- allowScopedLabels: defaultProps.allowScopedLabels,
- footerCreateLabelTitle: 'Create project label',
- footerManageLabelTitle: 'Manage project labels',
- labelsCreateTitle: 'Create project label',
- labelsFetchPath: defaultProps.labelsFetchPath,
- labelsFilterBasePath: defaultProps.projectIssuesPath,
- labelsManagePath: defaultProps.labelsManagePath,
- labelsSelectInProgress: false,
- selectedLabels: defaultProps.initiallySelectedLabels,
- variant: DropdownVariant.Sidebar,
- });
- });
- });
-
- describe('when type is issue', () => {
- beforeEach(() => {
- mountComponent({ issuableType: IssuableType.Issue });
- });
-
- describe('when labels are updated', () => {
- it('invokes a mutation', () => {
- findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels);
-
- const expected = {
- mutation: updateIssueLabelsMutation,
- variables: {
- input: {
- iid: defaultProps.iid,
- projectPath: defaultProps.projectPath,
- labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)],
- },
- },
- };
-
- expect($apollo.mutate).toHaveBeenCalledWith(expected);
- });
- });
-
- describe('when label `x` is clicked', () => {
- it('invokes a mutation', () => {
- findLabelsSelect().vm.$emit('onLabelRemove', 27);
-
- const expected = {
- mutation: updateIssueLabelsMutation,
- variables: {
- input: {
- iid: defaultProps.iid,
- projectPath: defaultProps.projectPath,
- removeLabelIds: [27],
- },
- },
- };
-
- expect($apollo.mutate).toHaveBeenCalledWith(expected);
- });
- });
- });
-
- describe('when type is merge_request', () => {
- beforeEach(() => {
- mountComponent({ issuableType: IssuableType.MergeRequest });
- });
-
- describe('when labels are updated', () => {
- it('invokes a mutation', () => {
- findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels);
-
- const expected = {
- mutation: updateMergeRequestLabelsMutation,
- variables: {
- input: {
- iid: defaultProps.iid,
- labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)],
- operationMode: MutationOperationMode.Replace,
- projectPath: defaultProps.projectPath,
- },
- },
- };
-
- expect($apollo.mutate).toHaveBeenCalledWith(expected);
- });
- });
-
- describe('when label `x` is clicked', () => {
- it('invokes a mutation', () => {
- findLabelsSelect().vm.$emit('onLabelRemove', 27);
-
- const expected = {
- mutation: updateMergeRequestLabelsMutation,
- variables: {
- input: {
- iid: defaultProps.iid,
- labelIds: [toLabelGid(27)],
- operationMode: MutationOperationMode.Remove,
- projectPath: defaultProps.projectPath,
- },
- },
- };
-
- expect($apollo.mutate).toHaveBeenCalledWith(expected);
- });
- });
- });
-});
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
index 5df69ffb5f8..f4ebc5c3e3f 100644
--- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap
@@ -23,6 +23,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] =
class="gl-mb-0"
id="visibility-level-setting"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-radio-group-stub
checked="private"
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index 4e88ab9504e..80a8b8ec489 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -53,6 +53,7 @@ const createMutationResponse = (key, obj = {}) => ({
errors: [],
snippet: {
__typename: 'Snippet',
+ id: 1,
webUrl: TEST_WEB_URL,
},
},
diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js
index 552a1c6fcde..2d5e0cfd615 100644
--- a/spec/frontend/snippets/components/snippet_header_spec.js
+++ b/spec/frontend/snippets/components/snippet_header_spec.js
@@ -252,7 +252,7 @@ describe('Snippet header component', () => {
disabled: false,
href: `/foo/-/snippets/new`,
text: 'New snippet',
- variant: 'success',
+ variant: 'confirm',
},
]),
);
diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js
index 8ba5a2fe5dc..dcef8fc9a8b 100644
--- a/spec/frontend/snippets/test_utils.js
+++ b/spec/frontend/snippets/test_utils.js
@@ -27,6 +27,7 @@ export const createGQLSnippet = () => ({
},
project: {
__typename: 'Project',
+ id: 'project-1',
fullPath: 'group/project',
webUrl: `${TEST_HOST}/group/project`,
},
diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js
new file mode 100644
index 00000000000..98617b404ff
--- /dev/null
+++ b/spec/frontend/tabs/index_spec.js
@@ -0,0 +1,260 @@
+import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs';
+import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants';
+import { getFixture, setHTMLFixture } from 'helpers/fixtures';
+
+const tabsFixture = getFixture('tabs/tabs.html');
+
+describe('GlTabsBehavior', () => {
+ let glTabs;
+ let tabShownEventSpy;
+
+ const findByTestId = (testId) => document.querySelector(`[data-testid="${testId}"]`);
+ const findTab = (name) => findByTestId(`${name}-tab`);
+ const findPanel = (name) => findByTestId(`${name}-panel`);
+
+ const getAttributes = (element) =>
+ Array.from(element.attributes).reduce((acc, attr) => {
+ acc[attr.name] = attr.value;
+ return acc;
+ }, {});
+
+ const expectActiveTabAndPanel = (name) => {
+ const tab = findTab(name);
+ const panel = findPanel(name);
+
+ expect(glTabs.activeTab).toBe(tab);
+
+ expect(getAttributes(tab)).toMatchObject({
+ 'aria-controls': panel.id,
+ 'aria-selected': 'true',
+ role: 'tab',
+ id: expect.any(String),
+ });
+
+ ACTIVE_TAB_CLASSES.forEach((klass) => {
+ expect(tab.classList.contains(klass)).toBe(true);
+ });
+
+ expect(getAttributes(panel)).toMatchObject({
+ 'aria-labelledby': tab.id,
+ role: 'tabpanel',
+ });
+
+ expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(true);
+ };
+
+ const expectInactiveTabAndPanel = (name) => {
+ const tab = findTab(name);
+ const panel = findPanel(name);
+
+ expect(glTabs.activeTab).not.toBe(tab);
+
+ expect(getAttributes(tab)).toMatchObject({
+ 'aria-controls': panel.id,
+ 'aria-selected': 'false',
+ role: 'tab',
+ tabindex: '-1',
+ id: expect.any(String),
+ });
+
+ ACTIVE_TAB_CLASSES.forEach((klass) => {
+ expect(tab.classList.contains(klass)).toBe(false);
+ });
+
+ expect(getAttributes(panel)).toMatchObject({
+ 'aria-labelledby': tab.id,
+ role: 'tabpanel',
+ });
+
+ expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(false);
+ };
+
+ const expectGlTabShownEvent = (name) => {
+ expect(tabShownEventSpy).toHaveBeenCalledTimes(1);
+
+ const [event] = tabShownEventSpy.mock.calls[0];
+ expect(event.target).toBe(findTab(name));
+
+ expect(event.detail).toEqual({
+ activeTabPanel: findPanel(name),
+ });
+ };
+
+ const triggerKeyDown = (code, element) => {
+ const event = new KeyboardEvent('keydown', { code });
+
+ element.dispatchEvent(event);
+ };
+
+ it('throws when instantiated without an element', () => {
+ expect(() => new GlTabsBehavior()).toThrow('Cannot instantiate');
+ });
+
+ describe('when given an element', () => {
+ afterEach(() => {
+ glTabs.destroy();
+ });
+
+ beforeEach(() => {
+ setHTMLFixture(tabsFixture);
+
+ const tabsEl = findByTestId('tabs');
+ tabShownEventSpy = jest.fn();
+ tabsEl.addEventListener(TAB_SHOWN_EVENT, tabShownEventSpy);
+
+ glTabs = new GlTabsBehavior(tabsEl);
+ });
+
+ it('instantiates', () => {
+ expect(glTabs).toEqual(expect.any(GlTabsBehavior));
+ });
+
+ it('sets the active tab', () => {
+ expectActiveTabAndPanel('foo');
+ });
+
+ it(`does not fire an initial ${TAB_SHOWN_EVENT} event`, () => {
+ expect(tabShownEventSpy).not.toHaveBeenCalled();
+ });
+
+ describe('clicking on an inactive tab', () => {
+ beforeEach(() => {
+ findTab('bar').click();
+ });
+
+ it('changes the active tab', () => {
+ expectActiveTabAndPanel('bar');
+ });
+
+ it('deactivates the previously active tab', () => {
+ expectInactiveTabAndPanel('foo');
+ });
+
+ it(`dispatches a ${TAB_SHOWN_EVENT} event`, () => {
+ expectGlTabShownEvent('bar');
+ });
+ });
+
+ describe('clicking on the active tab', () => {
+ beforeEach(() => {
+ findTab('foo').click();
+ });
+
+ it('does nothing', () => {
+ expectActiveTabAndPanel('foo');
+ expect(tabShownEventSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('keyboard navigation', () => {
+ it.each(['ArrowRight', 'ArrowDown'])('pressing %s moves to next tab', (code) => {
+ expectActiveTabAndPanel('foo');
+
+ triggerKeyDown(code, glTabs.activeTab);
+
+ expectActiveTabAndPanel('bar');
+ expectInactiveTabAndPanel('foo');
+ expectGlTabShownEvent('bar');
+ tabShownEventSpy.mockClear();
+
+ triggerKeyDown(code, glTabs.activeTab);
+
+ expectActiveTabAndPanel('qux');
+ expectInactiveTabAndPanel('bar');
+ expectGlTabShownEvent('qux');
+ tabShownEventSpy.mockClear();
+
+ // We're now on the last tab, so the active tab should not change
+ triggerKeyDown(code, glTabs.activeTab);
+
+ expectActiveTabAndPanel('qux');
+ expect(tabShownEventSpy).not.toHaveBeenCalled();
+ });
+
+ it.each(['ArrowLeft', 'ArrowUp'])('pressing %s moves to previous tab', (code) => {
+ // First, make the last tab active
+ findTab('qux').click();
+ tabShownEventSpy.mockClear();
+
+ // Now start moving backwards
+ expectActiveTabAndPanel('qux');
+
+ triggerKeyDown(code, glTabs.activeTab);
+
+ expectActiveTabAndPanel('bar');
+ expectInactiveTabAndPanel('qux');
+ expectGlTabShownEvent('bar');
+ tabShownEventSpy.mockClear();
+
+ triggerKeyDown(code, glTabs.activeTab);
+
+ expectActiveTabAndPanel('foo');
+ expectInactiveTabAndPanel('bar');
+ expectGlTabShownEvent('foo');
+ tabShownEventSpy.mockClear();
+
+ // We're now on the first tab, so the active tab should not change
+ triggerKeyDown(code, glTabs.activeTab);
+
+ expectActiveTabAndPanel('foo');
+ expect(tabShownEventSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('destroying', () => {
+ beforeEach(() => {
+ glTabs.destroy();
+ });
+
+ it('removes interactivity', () => {
+ const inactiveTab = findTab('bar');
+
+ // clicks do nothing
+ inactiveTab.click();
+ expectActiveTabAndPanel('foo');
+ expect(tabShownEventSpy).not.toHaveBeenCalled();
+
+ // keydown events do nothing
+ triggerKeyDown('ArrowDown', inactiveTab);
+ expectActiveTabAndPanel('foo');
+ expect(tabShownEventSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('activateTab method', () => {
+ it.each`
+ tabState | name
+ ${'active'} | ${'foo'}
+ ${'inactive'} | ${'bar'}
+ `('can programmatically activate an $tabState tab', ({ name }) => {
+ glTabs.activateTab(findTab(name));
+ expectActiveTabAndPanel(name);
+ expectGlTabShownEvent(name, 'foo');
+ });
+ });
+ });
+
+ describe('using aria-controls instead of href to link tabs to panels', () => {
+ beforeEach(() => {
+ setHTMLFixture(tabsFixture);
+
+ const tabsEl = findByTestId('tabs');
+ ['foo', 'bar', 'qux'].forEach((name) => {
+ const tab = findTab(name);
+ const panel = findPanel(name);
+
+ tab.setAttribute('href', '#');
+ tab.setAttribute('aria-controls', panel.id);
+ });
+
+ glTabs = new GlTabsBehavior(tabsEl);
+ });
+
+ it('connects the panels to their tabs correctly', () => {
+ findTab('bar').click();
+
+ expectActiveTabAndPanel('bar');
+ expectInactiveTabAndPanel('foo');
+ });
+ });
+});
diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js
index c622f86072d..8e565df81ae 100644
--- a/spec/frontend/terraform/components/terraform_list_spec.js
+++ b/spec/frontend/terraform/components/terraform_list_spec.js
@@ -23,6 +23,7 @@ describe('TerraformList', () => {
const apolloQueryResponse = {
data: {
project: {
+ id: '1',
terraformStates,
},
},
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 40f68c6385f..4fe51db8412 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -1,30 +1,10 @@
-import { config as testUtilsConfig } from '@vue/test-utils';
-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 { loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures';
-import { initializeTestTimeout } from './__helpers__/timeout';
-import customMatchers from './matchers';
-import { setupManualMocks } from './mocks/mocks_helper';
+/* Setup for unit test environment */
+import 'helpers/shared_test_setup';
+import { initializeTestTimeout } from 'helpers/timeout';
-import './__helpers__/dom_shims';
-import './__helpers__/jquery';
-import '~/commons/bootstrap';
+jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils'));
-// This module has some fairly decent visual test coverage in it's own repository.
-jest.mock('@gitlab/favicon-overlay');
-
-process.on('unhandledRejection', global.promiseRejectionHandler);
-
-setupManualMocks();
-
-// Fake the `Date` for the rest of the jest spec runtime environment.
-// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39496#note_503084332
-setGlobalDateToFakeDate();
+initializeTestTimeout(process.env.CI ? 6000 : 500);
afterEach(() =>
// give Promises a bit more time so they fail the right test
@@ -33,71 +13,3 @@ afterEach(() =>
jest.runOnlyPendingTimers();
}),
);
-
-initializeTestTimeout(process.env.CI ? 6000 : 500);
-
-Vue.config.devtools = false;
-Vue.config.productionTip = false;
-
-Vue.use(Translate);
-
-// convenience wrapper for migration from Karma
-Object.assign(global, {
- loadFixtures: loadHTMLFixture,
- setFixtures: setHTMLFixture,
-});
-
-const JQUERY_MATCHERS_TO_EXCLUDE = ['toHaveLength', 'toExist'];
-
-// custom-jquery-matchers was written for an old Jest version, we need to make it compatible
-Object.entries(jqueryMatchers).forEach(([matcherName, matcherFactory]) => {
- // Exclude these jQuery matchers
- if (JQUERY_MATCHERS_TO_EXCLUDE.includes(matcherName)) {
- return;
- }
-
- expect.extend({
- [matcherName]: matcherFactory().compare,
- });
-});
-
-expect.extend(customMatchers);
-
-testUtilsConfig.deprecationWarningHandler = (method, message) => {
- const ALLOWED_DEPRECATED_METHODS = [
- // https://gitlab.com/gitlab-org/gitlab/-/issues/295679
- 'finding components with `find` or `get`',
-
- // https://gitlab.com/gitlab-org/gitlab/-/issues/295680
- 'finding components with `findAll`',
- ];
- if (!ALLOWED_DEPRECATED_METHODS.includes(method)) {
- global.console.error(message);
- }
-};
-
-Object.assign(global, {
- requestIdleCallback(cb) {
- const start = Date.now();
- return setTimeout(() => {
- cb({
- didTimeout: false,
- timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
- });
- });
- },
- cancelIdleCallback(id) {
- clearTimeout(id);
- },
-});
-
-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/mock_data.js b/spec/frontend/token_access/mock_data.js
index 14d7b00cb6d..0f121fd1beb 100644
--- a/spec/frontend/token_access/mock_data.js
+++ b/spec/frontend/token_access/mock_data.js
@@ -1,6 +1,7 @@
export const enabledJobTokenScope = {
data: {
project: {
+ id: '1',
ciCdSettings: {
jobTokenScopeEnabled: true,
__typename: 'ProjectCiCdSetting',
@@ -13,6 +14,7 @@ export const enabledJobTokenScope = {
export const disabledJobTokenScope = {
data: {
project: {
+ id: '1',
ciCdSettings: {
jobTokenScopeEnabled: false,
__typename: 'ProjectCiCdSetting',
@@ -39,12 +41,14 @@ export const projectsWithScope = {
data: {
project: {
__typename: 'Project',
+ id: '1',
ciJobTokenScope: {
__typename: 'CiJobTokenScopeType',
projects: {
__typename: 'ProjectConnection',
nodes: [
{
+ id: '2',
fullPath: 'root/332268-test',
name: 'root/332268-test',
},
@@ -75,10 +79,17 @@ export const removeProjectSuccess = {
export const mockProjects = [
{
+ id: '1',
name: 'merge-train-stuff',
fullPath: 'root/merge-train-stuff',
isLocked: false,
__typename: 'Project',
},
- { name: 'ci-project', fullPath: 'root/ci-project', isLocked: true, __typename: 'Project' },
+ {
+ id: '2',
+ name: 'ci-project',
+ fullPath: 'root/ci-project',
+ isLocked: true,
+ __typename: 'Project',
+ },
];
diff --git a/spec/frontend/transfer_edit_spec.js b/spec/frontend/transfer_edit_spec.js
index ad8c9c68f37..4091d753fe5 100644
--- a/spec/frontend/transfer_edit_spec.js
+++ b/spec/frontend/transfer_edit_spec.js
@@ -4,11 +4,11 @@ import { loadHTMLFixture } from 'helpers/fixtures';
import setupTransferEdit from '~/transfer_edit';
describe('setupTransferEdit', () => {
- const formSelector = '.js-project-transfer-form';
- const targetSelector = 'select.select2';
+ const formSelector = '.js-group-transfer-form';
+ const targetSelector = '#new_parent_group_id';
beforeEach(() => {
- loadHTMLFixture('projects/edit.html');
+ loadHTMLFixture('groups/edit.html');
setupTransferEdit(formSelector, targetSelector);
});
@@ -17,8 +17,8 @@ describe('setupTransferEdit', () => {
});
it('enables submit button when selection changes to non-empty value', () => {
- const nonEmptyValue = $(formSelector).find(targetSelector).find('option').not(':empty').val();
- $(formSelector).find(targetSelector).val(nonEmptyValue).trigger('change');
+ const lastValue = $(formSelector).find(targetSelector).find('.dropdown-content li').last();
+ $(formSelector).find(targetSelector).val(lastValue).trigger('change');
expect($(formSelector).find(':submit').prop('disabled')).toBeFalsy();
});
diff --git a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js
new file mode 100644
index 00000000000..64e802c4fa5
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js
@@ -0,0 +1,18 @@
+import { generateText } from '~/vue_merge_request_widget/components/extensions/utils';
+
+describe('generateText', () => {
+ it.each`
+ text | expectedText
+ ${'%{strong_start}Hello world%{strong_end}'} | ${'<span class="gl-font-weight-bold">Hello world</span>'}
+ ${'%{success_start}Hello world%{success_end}'} | ${'<span class="gl-font-weight-bold gl-text-green-500">Hello world</span>'}
+ ${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'}
+ ${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'}
+ ${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'}
+ ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm">Hello world</span>'}
+ ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'}
+ ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'}
+ ${['array']} | ${null}
+ `('generates $expectedText from $text', ({ text, expectedText }) => {
+ expect(generateText(text)).toBe(expectedText);
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js
index f965fc32dc1..c30f6f1dfd1 100644
--- a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js
@@ -3,7 +3,6 @@ import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit
const testCommitMessage = 'Test commit message';
const testLabel = 'Test label';
-const testTextMuted = 'Test text muted';
const testInputId = 'test-input-id';
describe('Commits edit component', () => {
@@ -64,7 +63,6 @@ describe('Commits edit component', () => {
beforeEach(() => {
createComponent({
header: `<div class="test-header">${testCommitMessage}</div>`,
- 'text-muted': `<p class="test-text-muted">${testTextMuted}</p>`,
});
});
@@ -74,12 +72,5 @@ describe('Commits edit component', () => {
expect(headerSlotElement.exists()).toBe(true);
expect(headerSlotElement.text()).toBe(testCommitMessage);
});
-
- it('renders text-muted slot correctly', () => {
- const textMutedElement = wrapper.find('.test-text-muted');
-
- expect(textMutedElement.exists()).toBe(true);
- expect(textMutedElement.text()).toBe(testTextMuted);
- });
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js
index 4bdc6c95f22..f3061d792d0 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_archived_spec.js
@@ -25,7 +25,7 @@ describe('MRWidgetArchived', () => {
it('renders information', () => {
expect(vm.$el.querySelector('.bold').textContent.trim()).toEqual(
- 'This project is archived, write access has been disabled',
+ 'Merge unavailable: merge requests are read-only on archived projects.',
);
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
index e1bce7f0474..89de160b02f 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js
@@ -12,6 +12,14 @@ describe('MRWidgetConflicts', () => {
const findResolveButton = () => wrapper.findByTestId('resolve-conflicts-button');
const findMergeLocalButton = () => wrapper.findByTestId('merge-locally-button');
+ const mergeConflictsText = 'Merge blocked: merge conflicts must be resolved.';
+ const fastForwardMergeText =
+ 'Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.';
+ const userCannotMergeText =
+ 'Users who can write to the source or target branches can resolve the conflicts.';
+ const resolveConflictsBtnText = 'Resolve conflicts';
+ const mergeLocallyBtnText = 'Merge locally';
+
function createComponent(propsData = {}) {
wrapper = extendedWrapper(
shallowMount(ConflictsComponent, {
@@ -81,16 +89,16 @@ describe('MRWidgetConflicts', () => {
});
it('should tell you about conflicts without bothering other people', () => {
- expect(wrapper.text()).toContain('There are merge conflicts');
- expect(wrapper.text()).not.toContain('ask someone with write access');
+ expect(wrapper.text()).toContain(mergeConflictsText);
+ expect(wrapper.text()).not.toContain(userCannotMergeText);
});
it('should not allow you to resolve the conflicts', () => {
- expect(wrapper.text()).not.toContain('Resolve conflicts');
+ expect(wrapper.text()).not.toContain(resolveConflictsBtnText);
});
it('should have merge buttons', () => {
- expect(findMergeLocalButton().text()).toContain('Merge locally');
+ expect(findMergeLocalButton().text()).toContain(mergeLocallyBtnText);
});
});
@@ -107,17 +115,17 @@ describe('MRWidgetConflicts', () => {
});
it('should tell you about conflicts', () => {
- expect(wrapper.text()).toContain('There are merge conflicts');
- expect(wrapper.text()).toContain('ask someone with write access');
+ expect(wrapper.text()).toContain(mergeConflictsText);
+ expect(wrapper.text()).toContain(userCannotMergeText);
});
it('should allow you to resolve the conflicts', () => {
- expect(findResolveButton().text()).toContain('Resolve conflicts');
+ expect(findResolveButton().text()).toContain(resolveConflictsBtnText);
expect(findResolveButton().attributes('href')).toEqual(path);
});
it('should not have merge buttons', () => {
- expect(wrapper.text()).not.toContain('Merge locally');
+ expect(wrapper.text()).not.toContain(mergeLocallyBtnText);
});
});
@@ -134,17 +142,17 @@ describe('MRWidgetConflicts', () => {
});
it('should tell you about conflicts without bothering other people', () => {
- expect(wrapper.text()).toContain('There are merge conflicts');
- expect(wrapper.text()).not.toContain('ask someone with write access');
+ expect(wrapper.text()).toContain(mergeConflictsText);
+ expect(wrapper.text()).not.toContain(userCannotMergeText);
});
it('should allow you to resolve the conflicts', () => {
- expect(findResolveButton().text()).toContain('Resolve conflicts');
+ expect(findResolveButton().text()).toContain(resolveConflictsBtnText);
expect(findResolveButton().attributes('href')).toEqual(path);
});
it('should have merge buttons', () => {
- expect(findMergeLocalButton().text()).toContain('Merge locally');
+ expect(findMergeLocalButton().text()).toContain(mergeLocallyBtnText);
});
});
@@ -158,9 +166,7 @@ describe('MRWidgetConflicts', () => {
},
});
- expect(wrapper.text().trim().replace(/\s\s+/g, ' ')).toContain(
- 'ask someone with write access',
- );
+ expect(wrapper.text().trim().replace(/\s\s+/g, ' ')).toContain(userCannotMergeText);
});
it('should not have action buttons', async () => {
@@ -198,9 +204,7 @@ describe('MRWidgetConflicts', () => {
},
});
- expect(removeBreakLine(wrapper.text()).trim()).toContain(
- 'Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.',
- );
+ expect(removeBreakLine(wrapper.text()).trim()).toContain(fastForwardMergeText);
});
});
@@ -236,7 +240,7 @@ describe('MRWidgetConflicts', () => {
});
it('should allow you to resolve the conflicts', () => {
- expect(findResolveButton().text()).toContain('Resolve conflicts');
+ expect(findResolveButton().text()).toContain(resolveConflictsBtnText);
expect(findResolveButton().attributes('href')).toEqual(TEST_HOST);
});
});
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 016b6b2220b..7082a19a8e7 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,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
+import { GlSprintf } from '@gitlab/ui';
import simplePoll from '~/lib/utils/simple_poll';
import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue';
import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue';
@@ -487,6 +488,7 @@ describe('ReadyToMerge', () => {
const findCommitEditElements = () => wrapper.findAll(CommitEdit);
const findCommitDropdownElement = () => wrapper.find(CommitMessageDropdown);
const findFirstCommitEditLabel = () => findCommitEditElements().at(0).props('label');
+ const findTipLink = () => wrapper.find(GlSprintf);
describe('squash checkbox', () => {
it('should be rendered when squash before merge is enabled and there is more than 1 commit', () => {
@@ -503,10 +505,10 @@ describe('ReadyToMerge', () => {
expect(findCheckboxElement().exists()).toBeFalsy();
});
- it('should not be rendered when there is only 1 commit', () => {
+ it('should be rendered when there is only 1 commit', () => {
createComponent({ mr: { commitsCount: 1, enableSquashBeforeMerge: true } });
- expect(findCheckboxElement().exists()).toBeFalsy();
+ expect(findCheckboxElement().exists()).toBe(true);
});
describe('squash options', () => {
@@ -751,6 +753,12 @@ describe('ReadyToMerge', () => {
expect(findCommitDropdownElement().exists()).toBeTruthy();
});
});
+
+ it('renders a tip including a link to docs on templates', () => {
+ createComponent();
+
+ expect(findTipLink().exists()).toBe(true);
+ });
});
describe('Merge request project settings', () => {
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
index 0fb0d5b0b68..4070ca8d8dc 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
@@ -81,7 +81,9 @@ describe('Wip', () => {
it('should have correct elements', () => {
expect(el.classList.contains('mr-widget-body')).toBeTruthy();
- expect(el.innerText).toContain('This merge request is still a draft.');
+ expect(el.innerText).toContain(
+ "Merge blocked: merge request must be marked as ready. It's still marked as draft.",
+ );
expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy();
expect(el.querySelector('button').innerText).toContain('Merge');
expect(el.querySelector('.js-remove-draft').innerText.replace(/\s\s+/g, ' ')).toContain(
diff --git a/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js b/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js
index f95a92c2cb1..3c9f6c2e165 100644
--- a/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js
+++ b/spec/frontend/vue_mr_widget/components/terraform/terraform_plan_spec.js
@@ -32,9 +32,7 @@ describe('TerraformPlan', () => {
});
it('diplays the header text with a name', () => {
- expect(wrapper.text()).toContain(
- `The report ${validPlanWithName.job_name} was generated in your pipelines.`,
- );
+ expect(wrapper.text()).toContain(`The job ${validPlanWithName.job_name} generated a report.`);
});
it('diplays the reported changes', () => {
@@ -70,7 +68,7 @@ describe('TerraformPlan', () => {
it('diplays the header text with a name', () => {
expect(wrapper.text()).toContain(
- `The report ${invalidPlanWithName.job_name} failed to generate.`,
+ `The job ${invalidPlanWithName.job_name} failed to generate a report.`,
);
});
diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js
index f0c1da346a1..4538c1320d0 100644
--- a/spec/frontend/vue_mr_widget/mock_data.js
+++ b/spec/frontend/vue_mr_widget/mock_data.js
@@ -271,8 +271,6 @@ export default {
mr_troubleshooting_docs_path: 'help',
ci_troubleshooting_docs_path: 'help2',
merge_request_pipelines_docs_path: '/help/ci/pipelines/merge_request_pipelines.md',
- merge_train_when_pipeline_succeeds_docs_path:
- '/help/ci/pipelines/merge_trains.md#startadd-to-merge-train-when-pipeline-succeeds',
squash: true,
visual_review_app_available: true,
merge_trains_enabled: true,
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 550f156d095..8d41f6620ff 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import * as Sentry from '@sentry/browser';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data';
@@ -19,10 +20,15 @@ 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 StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue';
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';
-import testExtension from './test_extension';
+import {
+ workingExtension,
+ collapsedDataErrorExtension,
+ fullDataErrorExtension,
+} from './test_extensions';
jest.mock('~/api.js');
@@ -892,7 +898,7 @@ describe('MrWidgetOptions', () => {
describe('mock extension', () => {
beforeEach(() => {
- registerExtension(testExtension);
+ registerExtension(workingExtension);
createComponent();
});
@@ -914,7 +920,7 @@ describe('MrWidgetOptions', () => {
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click');
- await Vue.nextTick();
+ await nextTick();
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith('test_expand_event');
});
@@ -926,7 +932,7 @@ describe('MrWidgetOptions', () => {
.find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
.trigger('click');
- await Vue.nextTick();
+ await nextTick();
expect(
wrapper.find('[data-testid="widget-extension-top-level"]').find(GlDropdown).exists(),
@@ -952,4 +958,50 @@ describe('MrWidgetOptions', () => {
expect(collapsedSection.find(GlButton).text()).toBe('Full report');
});
});
+
+ describe('mock extension errors', () => {
+ let captureException;
+
+ const itHandlesTheException = () => {
+ expect(captureException).toHaveBeenCalledTimes(1);
+ expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
+ expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
+ };
+
+ beforeEach(() => {
+ captureException = jest.spyOn(Sentry, 'captureException');
+ });
+
+ afterEach(() => {
+ registeredExtensions.extensions = [];
+ captureException = null;
+ });
+
+ it('handles collapsed data fetch errors', async () => {
+ registerExtension(collapsedDataErrorExtension);
+ createComponent();
+ await waitForPromises();
+
+ expect(
+ wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(),
+ ).toBe(false);
+ itHandlesTheException();
+ });
+
+ it('handles full data fetch errors', async () => {
+ registerExtension(fullDataErrorExtension);
+ createComponent();
+ await waitForPromises();
+
+ expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error');
+ wrapper
+ .find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
+ .trigger('click');
+
+ await nextTick();
+ await waitForPromises();
+
+ itHandlesTheException();
+ });
+ });
});
diff --git a/spec/frontend/vue_mr_widget/test_extension.js b/spec/frontend/vue_mr_widget/test_extension.js
deleted file mode 100644
index 65c1bd8473b..00000000000
--- a/spec/frontend/vue_mr_widget/test_extension.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
-
-export default {
- name: 'WidgetTestExtension',
- props: ['targetProjectFullPath'],
- expandEvent: 'test_expand_event',
- computed: {
- summary({ count, targetProjectFullPath }) {
- return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
- },
- statusIcon({ count }) {
- return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
- },
- },
- methods: {
- fetchCollapsedData({ targetProjectFullPath }) {
- return Promise.resolve({ targetProjectFullPath, count: 1 });
- },
- fetchFullData() {
- return Promise.resolve([
- {
- id: 1,
- text: 'Hello world',
- icon: {
- name: EXTENSION_ICONS.failed,
- },
- badge: {
- text: 'Closed',
- },
- link: {
- href: 'https://gitlab.com',
- text: 'GitLab.com',
- },
- actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
- },
- ]);
- },
- },
-};
diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js
new file mode 100644
index 00000000000..c7ff02ab726
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/test_extensions.js
@@ -0,0 +1,99 @@
+import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
+
+export const workingExtension = {
+ name: 'WidgetTestExtension',
+ props: ['targetProjectFullPath'],
+ expandEvent: 'test_expand_event',
+ computed: {
+ summary({ count, targetProjectFullPath }) {
+ return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
+ },
+ statusIcon({ count }) {
+ return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
+ },
+ },
+ methods: {
+ fetchCollapsedData({ targetProjectFullPath }) {
+ return Promise.resolve({ targetProjectFullPath, count: 1 });
+ },
+ fetchFullData() {
+ return Promise.resolve([
+ {
+ id: 1,
+ text: 'Hello world',
+ icon: {
+ name: EXTENSION_ICONS.failed,
+ },
+ badge: {
+ text: 'Closed',
+ },
+ link: {
+ href: 'https://gitlab.com',
+ text: 'GitLab.com',
+ },
+ actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
+ },
+ ]);
+ },
+ },
+};
+
+export const collapsedDataErrorExtension = {
+ name: 'WidgetTestCollapsedErrorExtension',
+ props: ['targetProjectFullPath'],
+ expandEvent: 'test_expand_event',
+ computed: {
+ summary({ count, targetProjectFullPath }) {
+ return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
+ },
+ statusIcon({ count }) {
+ return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
+ },
+ },
+ methods: {
+ fetchCollapsedData() {
+ return Promise.reject(new Error('Fetch error'));
+ },
+ fetchFullData() {
+ return Promise.resolve([
+ {
+ id: 1,
+ text: 'Hello world',
+ icon: {
+ name: EXTENSION_ICONS.failed,
+ },
+ badge: {
+ text: 'Closed',
+ },
+ link: {
+ href: 'https://gitlab.com',
+ text: 'GitLab.com',
+ },
+ actions: [{ text: 'Full report', href: 'https://gitlab.com', target: '_blank' }],
+ },
+ ]);
+ },
+ },
+};
+
+export const fullDataErrorExtension = {
+ name: 'WidgetTestCollapsedErrorExtension',
+ props: ['targetProjectFullPath'],
+ expandEvent: 'test_expand_event',
+ computed: {
+ summary({ count, targetProjectFullPath }) {
+ return `Test extension summary count: ${count} & ${targetProjectFullPath}`;
+ },
+ statusIcon({ count }) {
+ return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
+ },
+ },
+ methods: {
+ fetchCollapsedData({ targetProjectFullPath }) {
+ return Promise.resolve({ targetProjectFullPath, count: 1 });
+ },
+ fetchFullData() {
+ return Promise.reject(new Error('Fetch error'));
+ },
+ },
+};
diff --git a/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap
index 7ce155f6a5d..f414359fef2 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap
@@ -3,6 +3,7 @@
exports[`Source Editor component rendering matches the snapshot 1`] = `
<div
data-editor-loading=""
+ data-qa-selector="source_editor_container"
id="source-editor-snippet_777"
>
<pre
diff --git a/spec/frontend/vue_shared/components/chronic_duration_input_spec.js b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js
new file mode 100644
index 00000000000..530d01402c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/chronic_duration_input_spec.js
@@ -0,0 +1,390 @@
+import { mount } from '@vue/test-utils';
+import ChronicDurationInput from '~/vue_shared/components/chronic_duration_input.vue';
+
+const MOCK_VALUE = 2 * 3600 + 20 * 60;
+
+describe('vue_shared/components/chronic_duration_input', () => {
+ let wrapper;
+ let textElement;
+ let hiddenElement;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ textElement = null;
+ hiddenElement = null;
+ });
+
+ const findComponents = () => {
+ textElement = wrapper.find('input[type=text]').element;
+ hiddenElement = wrapper.find('input[type=hidden]').element;
+ };
+
+ const createComponent = (props = {}) => {
+ if (wrapper) {
+ throw new Error('There should only be one wrapper created per test');
+ }
+
+ wrapper = mount(ChronicDurationInput, { propsData: props });
+ findComponents();
+ };
+
+ describe('value', () => {
+ it('has human-readable output with value', () => {
+ createComponent({ value: MOCK_VALUE });
+
+ expect(textElement.value).toBe('2 hrs 20 mins');
+ expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
+ });
+
+ it('has empty output with no value', () => {
+ createComponent({ value: null });
+
+ expect(textElement.value).toBe('');
+ expect(hiddenElement.value).toBe('');
+ });
+ });
+
+ describe('change', () => {
+ const createAndDispatch = async (initialValue, humanReadableInput) => {
+ createComponent({ value: initialValue });
+ await wrapper.vm.$nextTick();
+ textElement.value = humanReadableInput;
+ textElement.dispatchEvent(new Event('input'));
+ };
+
+ describe('when starting with no value and receiving human-readable input', () => {
+ beforeEach(() => {
+ createAndDispatch(null, '2hr20min');
+ });
+
+ it('updates hidden field', () => {
+ expect(textElement.value).toBe('2hr20min');
+ expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
+ });
+
+ it('emits change event', () => {
+ expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
+ });
+ });
+
+ describe('when starting with a value and receiving empty input', () => {
+ beforeEach(() => {
+ createAndDispatch(MOCK_VALUE, '');
+ });
+
+ it('updates hidden field', () => {
+ expect(textElement.value).toBe('');
+ expect(hiddenElement.value).toBe('');
+ });
+
+ it('emits change event', () => {
+ expect(wrapper.emitted('change')).toEqual([[null]]);
+ });
+ });
+
+ describe('when starting with a value and receiving invalid input', () => {
+ beforeEach(() => {
+ createAndDispatch(MOCK_VALUE, 'gobbledygook');
+ });
+
+ it('does not update hidden field', () => {
+ expect(textElement.value).toBe('gobbledygook');
+ expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
+ });
+
+ it('does not emit change event', () => {
+ expect(wrapper.emitted('change')).toBeUndefined();
+ });
+ });
+ });
+
+ describe('valid', () => {
+ describe('initial value', () => {
+ beforeEach(() => {
+ createComponent({ value: MOCK_VALUE });
+ });
+
+ it('emits valid with initial value', () => {
+ expect(wrapper.emitted('valid')).toEqual([[{ valid: true, feedback: '' }]]);
+ expect(textElement.validity.valid).toBe(true);
+ expect(textElement.validity.customError).toBe(false);
+ expect(textElement.validationMessage).toBe('');
+ expect(hiddenElement.validity.valid).toBe(true);
+ expect(hiddenElement.validity.customError).toBe(false);
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+
+ it('emits valid with user input', async () => {
+ textElement.value = '1m10s';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('valid')).toEqual([
+ [{ valid: true, feedback: '' }],
+ [{ valid: true, feedback: '' }],
+ ]);
+ expect(textElement.validity.valid).toBe(true);
+ expect(textElement.validity.customError).toBe(false);
+ expect(textElement.validationMessage).toBe('');
+ expect(hiddenElement.validity.valid).toBe(true);
+ expect(hiddenElement.validity.customError).toBe(false);
+ expect(hiddenElement.validationMessage).toBe('');
+
+ textElement.value = '';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('valid')).toEqual([
+ [{ valid: true, feedback: '' }],
+ [{ valid: true, feedback: '' }],
+ [{ valid: null, feedback: '' }],
+ ]);
+ expect(textElement.validity.valid).toBe(true);
+ expect(textElement.validity.customError).toBe(false);
+ expect(textElement.validationMessage).toBe('');
+ expect(hiddenElement.validity.valid).toBe(true);
+ expect(hiddenElement.validity.customError).toBe(false);
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+
+ it('emits invalid with user input', async () => {
+ textElement.value = 'gobbledygook';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('valid')).toEqual([
+ [{ valid: true, feedback: '' }],
+ [{ valid: false, feedback: ChronicDurationInput.i18n.INVALID_INPUT_FEEDBACK }],
+ ]);
+ expect(textElement.validity.valid).toBe(false);
+ expect(textElement.validity.customError).toBe(true);
+ expect(textElement.validationMessage).toBe(
+ ChronicDurationInput.i18n.INVALID_INPUT_FEEDBACK,
+ );
+ expect(hiddenElement.validity.valid).toBe(false);
+ expect(hiddenElement.validity.customError).toBe(true);
+ // Hidden elements do not have validationMessage
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+ });
+
+ describe('no initial value', () => {
+ beforeEach(() => {
+ createComponent({ value: null });
+ });
+
+ it('emits valid with no initial value', () => {
+ expect(wrapper.emitted('valid')).toEqual([[{ valid: null, feedback: '' }]]);
+ expect(textElement.validity.valid).toBe(true);
+ expect(textElement.validity.customError).toBe(false);
+ expect(textElement.validationMessage).toBe('');
+ expect(hiddenElement.validity.valid).toBe(true);
+ expect(hiddenElement.validity.customError).toBe(false);
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+
+ it('emits valid with updated value', async () => {
+ wrapper.setProps({ value: MOCK_VALUE });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('valid')).toEqual([
+ [{ valid: null, feedback: '' }],
+ [{ valid: true, feedback: '' }],
+ ]);
+ expect(textElement.validity.valid).toBe(true);
+ expect(textElement.validity.customError).toBe(false);
+ expect(textElement.validationMessage).toBe('');
+ expect(hiddenElement.validity.valid).toBe(true);
+ expect(hiddenElement.validity.customError).toBe(false);
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+ });
+
+ describe('decimal input', () => {
+ describe('when integerRequired is false', () => {
+ beforeEach(() => {
+ createComponent({ value: null, integerRequired: false });
+ });
+
+ it('emits valid when input is integer', async () => {
+ textElement.value = '2hr20min';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
+ expect(wrapper.emitted('valid')).toEqual([
+ [{ valid: null, feedback: '' }],
+ [{ valid: true, feedback: '' }],
+ ]);
+ expect(textElement.validity.valid).toBe(true);
+ expect(textElement.validity.customError).toBe(false);
+ expect(textElement.validationMessage).toBe('');
+ expect(hiddenElement.validity.valid).toBe(true);
+ expect(hiddenElement.validity.customError).toBe(false);
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+
+ it('emits valid when input is decimal', async () => {
+ textElement.value = '1.5s';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('change')).toEqual([[1.5]]);
+ expect(wrapper.emitted('valid')).toEqual([
+ [{ valid: null, feedback: '' }],
+ [{ valid: true, feedback: '' }],
+ ]);
+ expect(textElement.validity.valid).toBe(true);
+ expect(textElement.validity.customError).toBe(false);
+ expect(textElement.validationMessage).toBe('');
+ expect(hiddenElement.validity.valid).toBe(true);
+ expect(hiddenElement.validity.customError).toBe(false);
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+ });
+
+ describe('when integerRequired is unspecified', () => {
+ beforeEach(() => {
+ createComponent({ value: null });
+ });
+
+ it('emits valid when input is integer', async () => {
+ textElement.value = '2hr20min';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('change')).toEqual([[MOCK_VALUE]]);
+ expect(wrapper.emitted('valid')).toEqual([
+ [{ valid: null, feedback: '' }],
+ [{ valid: true, feedback: '' }],
+ ]);
+ expect(textElement.validity.valid).toBe(true);
+ expect(textElement.validity.customError).toBe(false);
+ expect(textElement.validationMessage).toBe('');
+ expect(hiddenElement.validity.valid).toBe(true);
+ expect(hiddenElement.validity.customError).toBe(false);
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+
+ it('emits invalid when input is decimal', async () => {
+ textElement.value = '1.5s';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('change')).toBeUndefined();
+ expect(wrapper.emitted('valid')).toEqual([
+ [{ valid: null, feedback: '' }],
+ [
+ {
+ valid: false,
+ feedback: ChronicDurationInput.i18n.INVALID_DECIMAL_FEEDBACK,
+ },
+ ],
+ ]);
+ expect(textElement.validity.valid).toBe(false);
+ expect(textElement.validity.customError).toBe(true);
+ expect(textElement.validationMessage).toBe(
+ ChronicDurationInput.i18n.INVALID_DECIMAL_FEEDBACK,
+ );
+ expect(hiddenElement.validity.valid).toBe(false);
+ expect(hiddenElement.validity.customError).toBe(true);
+ // Hidden elements do not have validationMessage
+ expect(hiddenElement.validationMessage).toBe('');
+ });
+ });
+ });
+ });
+
+ describe('v-model', () => {
+ beforeEach(() => {
+ wrapper = mount({
+ data() {
+ return { value: 1 * 60 + 10 };
+ },
+ components: { ChronicDurationInput },
+ template: '<div><chronic-duration-input v-model="value"/></div>',
+ });
+ findComponents();
+ });
+
+ describe('value', () => {
+ it('passes initial prop via v-model', () => {
+ expect(textElement.value).toBe('1 min 10 secs');
+ expect(hiddenElement.value).toBe((1 * 60 + 10).toString());
+ });
+
+ it('passes updated prop via v-model', async () => {
+ wrapper.setData({ value: MOCK_VALUE });
+ await wrapper.vm.$nextTick();
+
+ expect(textElement.value).toBe('2 hrs 20 mins');
+ expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
+ });
+ });
+
+ describe('change', () => {
+ it('passes user input to parent via v-model', async () => {
+ textElement.value = '2hr20min';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findComponent(ChronicDurationInput).props('value')).toBe(MOCK_VALUE);
+ expect(textElement.value).toBe('2hr20min');
+ expect(hiddenElement.value).toBe(MOCK_VALUE.toString());
+ });
+ });
+ });
+
+ describe('name', () => {
+ beforeEach(() => {
+ createComponent({ name: 'myInput' });
+ });
+
+ it('sets name of hidden field', () => {
+ expect(hiddenElement.name).toBe('myInput');
+ });
+
+ it('does not set name of text field', () => {
+ expect(textElement.name).toBe('');
+ });
+ });
+
+ describe('form submission', () => {
+ beforeEach(() => {
+ wrapper = mount({
+ template: `<form data-testid="myForm"><chronic-duration-input name="myInput" :value="${MOCK_VALUE}"/></form>`,
+ components: {
+ ChronicDurationInput,
+ },
+ });
+ findComponents();
+ });
+
+ it('creates form data with initial value', () => {
+ const formData = new FormData(wrapper.find('[data-testid=myForm]').element);
+ const iter = formData.entries();
+
+ expect(iter.next()).toEqual({
+ value: ['myInput', MOCK_VALUE.toString()],
+ done: false,
+ });
+ expect(iter.next()).toEqual({ value: undefined, done: true });
+ });
+
+ it('creates form data with user-specified value', async () => {
+ textElement.value = '1m10s';
+ textElement.dispatchEvent(new Event('input'));
+ await wrapper.vm.$nextTick();
+
+ const formData = new FormData(wrapper.find('[data-testid=myForm]').element);
+ const iter = formData.entries();
+
+ expect(iter.next()).toEqual({
+ value: ['myInput', (1 * 60 + 10).toString()],
+ done: false,
+ });
+ expect(iter.next()).toEqual({ value: undefined, done: true });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js
index ab4008484e5..33445923a49 100644
--- a/spec/frontend/vue_shared/components/clipboard_button_spec.js
+++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js
@@ -89,6 +89,16 @@ describe('clipboard button', () => {
expect(onClick).toHaveBeenCalled();
});
+ it('passes the category and variant props to the GlButton', () => {
+ const category = 'tertiary';
+ const variant = 'confirm';
+
+ createWrapper({ title: '', text: '', category, variant });
+
+ expect(findButton().props('category')).toBe(category);
+ expect(findButton().props('variant')).toBe(variant);
+ });
+
describe('integration', () => {
it('actually copies to clipboard', () => {
initCopyToClipboard();
diff --git a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js
index 220f897c035..af7f85769aa 100644
--- a/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_danger/confirm_danger_spec.js
@@ -9,6 +9,7 @@ describe('Confirm Danger Modal', () => {
const phrase = 'En Taro Adun';
const buttonText = 'Click me!';
+ const buttonClass = 'gl-w-full';
const modalId = CONFIRM_DANGER_MODAL_ID;
const findBtn = () => wrapper.findComponent(GlButton);
@@ -19,6 +20,7 @@ describe('Confirm Danger Modal', () => {
shallowMountExtended(ConfirmDanger, {
propsData: {
buttonText,
+ buttonClass,
phrase,
...props,
},
@@ -51,6 +53,10 @@ describe('Confirm Danger Modal', () => {
expect(findBtn().attributes('disabled')).toBe('true');
});
+ it('passes `buttonClass` prop to button', () => {
+ expect(findBtn().classes()).toContain(buttonClass);
+ });
+
it('will emit `confirm` when the modal confirms', () => {
expect(wrapper.emitted('confirm')).toBeUndefined();
diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js
index db8d0674121..3ca1c943398 100644
--- a/spec/frontend/vue_shared/components/confirm_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js
@@ -1,6 +1,9 @@
import { shallowMount } from '@vue/test-utils';
+import { merge } from 'lodash';
import { TEST_HOST } from 'helpers/test_constants';
+import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub';
import ConfirmModal from '~/vue_shared/components/confirm_modal.vue';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'test-csrf-token' }));
@@ -54,12 +57,50 @@ describe('vue_shared/components/confirm_modal', () => {
findForm()
.findAll('input')
.wrappers.map((x) => ({ name: x.attributes('name'), value: x.attributes('value') }));
+ const findDomElementListener = () => wrapper.find(DomElementListener);
+ const triggerOpenWithEventHub = (modalData) => {
+ eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, modalData);
+ };
+ const triggerOpenWithDomListener = (modalData) => {
+ const element = document.createElement('button');
+
+ element.dataset.path = modalData.path;
+ element.dataset.method = modalData.method;
+ element.dataset.modalAttributes = JSON.stringify(modalData.modalAttributes);
+
+ findDomElementListener().vm.$emit('click', {
+ preventDefault: jest.fn(),
+ currentTarget: element,
+ });
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty GlModal', () => {
+ expect(findModal().props()).toEqual({});
+ });
+
+ it('renders form missing values', () => {
+ expect(findForm().attributes('action')).toBe('');
+ expect(findFormData()).toEqual([
+ { name: '_method', value: undefined },
+ { name: 'authenticity_token', value: 'test-csrf-token' },
+ ]);
+ });
+ });
describe('template', () => {
- describe('when modal data is set', () => {
+ describe.each`
+ desc | trigger
+ ${'when opened from eventhub'} | ${triggerOpenWithEventHub}
+ ${'when opened from dom listener'} | ${triggerOpenWithDomListener}
+ `('$desc', ({ trigger }) => {
beforeEach(() => {
createComponent();
- wrapper.vm.modalAttributes = MOCK_MODAL_DATA.modalAttributes;
+ trigger(MOCK_MODAL_DATA);
});
it('renders GlModal with data', () => {
@@ -71,6 +112,14 @@ describe('vue_shared/components/confirm_modal', () => {
}),
);
});
+
+ it('renders form', () => {
+ expect(findForm().attributes('action')).toBe(MOCK_MODAL_DATA.path);
+ expect(findFormData()).toEqual([
+ { name: '_method', value: MOCK_MODAL_DATA.method },
+ { name: 'authenticity_token', value: 'test-csrf-token' },
+ ]);
+ });
});
describe.each`
@@ -79,11 +128,10 @@ describe('vue_shared/components/confirm_modal', () => {
${'when message has html'} | ${{ messageHtml: '<p>Header</p><ul onhover="alert(1)"><li>First</li></ul>' }} | ${'<p>Header</p><ul><li>First</li></ul>'}
`('$desc', ({ attrs, expectation }) => {
beforeEach(() => {
+ const modalData = merge({ ...MOCK_MODAL_DATA }, { modalAttributes: attrs });
+
createComponent();
- wrapper.vm.modalAttributes = {
- ...MOCK_MODAL_DATA.modalAttributes,
- ...attrs,
- };
+ triggerOpenWithEventHub(modalData);
});
it('renders message', () => {
@@ -96,8 +144,7 @@ describe('vue_shared/components/confirm_modal', () => {
describe('submitModal', () => {
beforeEach(() => {
createComponent();
- wrapper.vm.path = MOCK_MODAL_DATA.path;
- wrapper.vm.method = MOCK_MODAL_DATA.method;
+ triggerOpenWithEventHub(MOCK_MODAL_DATA);
});
it('does not submit form', () => {
diff --git a/spec/frontend/vue_shared/components/design_management/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/vue_shared/components/design_management/__snapshots__/design_note_pin_spec.js.snap
new file mode 100644
index 00000000000..eb0adb0bebd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/design_management/__snapshots__/design_note_pin_spec.js.snap
@@ -0,0 +1,55 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design note pin component should match the snapshot of note with index 1`] = `
+<button
+ aria-label="Comment '1' position"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm js-image-badge design-note-pin gl-absolute"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+
+ 1
+
+</button>
+`;
+
+exports[`Design note pin component should match the snapshot of note without index 1`] = `
+<button
+ aria-label="Comment form position"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm btn-transparent comment-indicator gl-absolute"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+ <gl-icon-stub
+ name="image-comment-dark"
+ size="24"
+ />
+</button>
+`;
+
+exports[`Design note pin component should match the snapshot when pin is resolved 1`] = `
+<button
+ aria-label="Comment form position"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm btn-transparent comment-indicator resolved gl-absolute"
+ style="left: 10px; top: 10px;"
+ type="button"
+>
+ <gl-icon-stub
+ name="image-comment-dark"
+ size="24"
+ />
+</button>
+`;
+
+exports[`Design note pin component should match the snapshot when position is absent 1`] = `
+<button
+ aria-label="Comment form position"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm btn-transparent comment-indicator"
+ type="button"
+>
+ <gl-icon-stub
+ name="image-comment-dark"
+ size="24"
+ />
+</button>
+`;
diff --git a/spec/frontend/design_management/components/design_note_pin_spec.js b/spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js
index a6219923aca..984a28c93d6 100644
--- a/spec/frontend/design_management/components/design_note_pin_spec.js
+++ b/spec/frontend/vue_shared/components/design_management/design_note_pin_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import DesignNotePin from '~/design_management/components/design_note_pin.vue';
+import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
describe('Design note pin component', () => {
let wrapper;
@@ -29,4 +29,14 @@ describe('Design note pin component', () => {
createComponent({ label: 1 });
expect(wrapper.element).toMatchSnapshot();
});
+
+ it('should match the snapshot when pin is resolved', () => {
+ createComponent({ isResolved: true });
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should match the snapshot when position is absent', () => {
+ createComponent({ position: null });
+ expect(wrapper.element).toMatchSnapshot();
+ });
});
diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js
index 9f433816b34..b8d3cbebe16 100644
--- a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js
+++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js
@@ -1,4 +1,5 @@
-import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
+import { shallowMount, mount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import {
TRANSITION_LOAD_START,
@@ -11,15 +12,13 @@ import {
} from '~/diffs/constants';
import Renamed from '~/vue_shared/components/diff_viewer/viewers/renamed.vue';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
function createRenamedComponent({ props = {}, store = new Vuex.Store({}), deep = false }) {
const mnt = deep ? mount : shallowMount;
return mnt(Renamed, {
propsData: { ...props },
- localVue,
store,
});
}
diff --git a/spec/frontend/vue_shared/components/dismissible_alert_spec.js b/spec/frontend/vue_shared/components/dismissible_alert_spec.js
index fcd004d35a7..879d4aba441 100644
--- a/spec/frontend/vue_shared/components/dismissible_alert_spec.js
+++ b/spec/frontend/vue_shared/components/dismissible_alert_spec.js
@@ -43,6 +43,10 @@ describe('vue_shared/components/dismissible_alert', () => {
it('hides the alert', () => {
expect(findAlert().exists()).toBe(false);
});
+
+ it('emmits alertDismissed', () => {
+ expect(wrapper.emitted('alertDismissed')).toBeTruthy();
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/dom_element_listener_spec.js b/spec/frontend/vue_shared/components/dom_element_listener_spec.js
new file mode 100644
index 00000000000..a848c34b7ce
--- /dev/null
+++ b/spec/frontend/vue_shared/components/dom_element_listener_spec.js
@@ -0,0 +1,116 @@
+import { mount } from '@vue/test-utils';
+import { setHTMLFixture } from 'helpers/fixtures';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+
+const DEFAULT_SLOT_CONTENT = 'Default slot content';
+const SELECTOR = '.js-test-include';
+const HTML = `
+<div>
+ <button class="js-test-include" data-testid="lorem">Lorem</button>
+ <button class="js-test-include" data-testid="ipsum">Ipsum</button>
+ <button data-testid="hello">Hello</a>
+</div>
+`;
+
+describe('~/vue_shared/components/dom_element_listener.vue', () => {
+ let wrapper;
+ let spies;
+
+ const createComponent = () => {
+ wrapper = mount(DomElementListener, {
+ propsData: {
+ selector: SELECTOR,
+ },
+ listeners: spies,
+ slots: {
+ default: DEFAULT_SLOT_CONTENT,
+ },
+ });
+ };
+
+ const findElement = (testId) => document.querySelector(`[data-testid="${testId}"]`);
+ const spiesCallCount = () =>
+ Object.values(spies)
+ .map((x) => x.mock.calls.length)
+ .reduce((a, b) => a + b);
+
+ beforeEach(() => {
+ setHTMLFixture(HTML);
+ spies = {
+ click: jest.fn(),
+ focus: jest.fn(),
+ };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders default slot', () => {
+ expect(wrapper.text()).toBe(DEFAULT_SLOT_CONTENT);
+ });
+
+ it('does not initially trigger listeners', () => {
+ expect(spiesCallCount()).toBe(0);
+ });
+
+ describe.each`
+ event | testId
+ ${'click'} | ${'lorem'}
+ ${'focus'} | ${'ipsum'}
+ `(
+ 'when matching element triggers event (testId=$testId, event=$event)',
+ ({ event, testId }) => {
+ beforeEach(() => {
+ findElement(testId).dispatchEvent(new Event(event));
+ });
+
+ it('triggers listener', () => {
+ expect(spiesCallCount()).toBe(1);
+ expect(spies[event]).toHaveBeenCalledWith(expect.any(Event));
+ expect(spies[event]).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target: findElement(testId),
+ }),
+ );
+ });
+ },
+ );
+
+ describe.each`
+ desc | event | testId
+ ${'when non-matching element triggers event'} | ${'click'} | ${'hello'}
+ ${'when matching element triggers unlistened event'} | ${'hover'} | ${'lorem'}
+ `('$desc', ({ event, testId }) => {
+ beforeEach(() => {
+ findElement(testId).dispatchEvent(new Event(event));
+ });
+
+ it('does not trigger listeners', () => {
+ expect(spiesCallCount()).toBe(0);
+ });
+ });
+ });
+
+ describe('after destroyed', () => {
+ beforeEach(() => {
+ createComponent();
+ wrapper.destroy();
+ });
+
+ describe('when matching element triggers event', () => {
+ beforeEach(() => {
+ findElement('lorem').dispatchEvent(new Event('click'));
+ });
+
+ it('does not trigger any listeners', () => {
+ expect(spiesCallCount()).toBe(0);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js
index c10663f6c14..b0e623520a8 100644
--- a/spec/frontend/vue_shared/components/file_icon_spec.js
+++ b/spec/frontend/vue_shared/components/file_icon_spec.js
@@ -34,7 +34,7 @@ describe('File Icon component', () => {
it.each`
fileName | iconName
- ${'test.js'} | ${'javascript'}
+ ${'index.js'} | ${'javascript'}
${'test.png'} | ${'image'}
${'test.PNG'} | ${'image'}
${'.npmrc'} | ${'npm'}
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
index 238c5d16db5..e3e2ef5610d 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
@@ -5,12 +5,9 @@ import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/co
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
-import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
-import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_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 ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue';
-import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
export const mockAuthor1 = {
id: 1,
@@ -65,11 +62,6 @@ export const mockMilestones = [
mockEscapedMilestone,
];
-export const mockEpics = [
- { iid: 1, id: 1, title: 'Foo', group_full_path: 'gitlab-org' },
- { iid: 2, id: 2, title: 'Bar', group_full_path: 'gitlab-org/design' },
-];
-
export const mockEmoji1 = {
name: 'thumbsup',
};
@@ -102,27 +94,6 @@ export const mockAuthorToken = {
fetchAuthors: Api.projectUsers.bind(Api),
};
-export const mockIterationToken = {
- type: 'iteration',
- icon: 'iteration',
- title: 'Iteration',
- unique: true,
- token: IterationToken,
- fetchIterations: () => Promise.resolve(),
-};
-
-export const mockIterations = [
- {
- id: 1,
- title: 'Iteration 1',
- startDate: '2021-11-05',
- dueDate: '2021-11-10',
- iterationCadence: {
- title: 'Cadence 1',
- },
- },
-];
-
export const mockLabelToken = {
type: 'label_name',
icon: 'labels',
@@ -153,73 +124,6 @@ export const mockReleaseToken = {
fetchReleases: () => Promise.resolve(),
};
-export const mockEpicToken = {
- type: 'epic_iid',
- icon: 'clock',
- title: 'Epic',
- unique: true,
- symbol: '&',
- token: EpicToken,
- operators: OPERATOR_IS_ONLY,
- idProperty: 'iid',
- fullPath: 'gitlab-org',
-};
-
-export const mockEpicNode1 = {
- __typename: 'Epic',
- parent: null,
- id: 'gid://gitlab/Epic/40',
- iid: '2',
- title: 'Marketing epic',
- description: 'Mock epic description',
- state: 'opened',
- startDate: '2017-12-25',
- dueDate: '2018-02-15',
- webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/1',
- hasChildren: false,
- hasParent: false,
- confidential: false,
-};
-
-export const mockEpicNode2 = {
- __typename: 'Epic',
- parent: null,
- id: 'gid://gitlab/Epic/41',
- iid: '3',
- title: 'Another marketing',
- startDate: '2017-12-26',
- dueDate: '2018-03-10',
- state: 'opened',
- webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/2',
-};
-
-export const mockGroupEpicsQueryResponse = {
- data: {
- group: {
- id: 'gid://gitlab/Group/1',
- name: 'Gitlab Org',
- epics: {
- edges: [
- {
- node: {
- ...mockEpicNode1,
- },
- __typename: 'EpicEdge',
- },
- {
- node: {
- ...mockEpicNode2,
- },
- __typename: 'EpicEdge',
- },
- ],
- __typename: 'EpicConnection',
- },
- __typename: 'Group',
- },
- },
-};
-
export const mockReactionEmojiToken = {
type: 'my_reaction_emoji',
icon: 'thumb-up',
@@ -243,14 +147,6 @@ export const mockMembershipToken = {
],
};
-export const mockWeightToken = {
- type: 'weight',
- icon: 'weight',
- title: 'Weight',
- unique: true,
- token: WeightToken,
-};
-
export const mockMembershipTokenOptionsWithoutTitles = {
...mockMembershipToken,
options: [{ value: 'exclude' }, { value: 'only' }],
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 f9ce0338d2f..84f0151d9db 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
@@ -14,7 +14,13 @@ import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_t
import { mockLabelToken } from '../mock_data';
-jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils');
+jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
+ getRecentlyUsedSuggestions: jest.fn(),
+ setTokenValueToRecentlyUsed: jest.fn(),
+ stripQuotes: jest.requireActual(
+ '~/vue_shared/components/filtered_search_bar/filtered_search_utils',
+ ).stripQuotes,
+}));
const mockStorageKey = 'recent-tokens-label_name';
@@ -46,13 +52,13 @@ const defaultSlots = {
};
const mockProps = {
- config: mockLabelToken,
+ config: { ...mockLabelToken, recentSuggestionsStorageKey: mockStorageKey },
value: { data: '' },
active: false,
suggestions: [],
suggestionsLoading: false,
defaultSuggestions: DEFAULT_NONE_ANY,
- recentSuggestionsStorageKey: mockStorageKey,
+ getActiveTokenValue: (labels, data) => labels.find((label) => label.title === data),
};
function createComponent({
@@ -152,30 +158,22 @@ describe('BaseToken', () => {
describe('methods', () => {
describe('handleTokenValueSelected', () => {
- it('calls `setTokenValueToRecentlyUsed` when `recentSuggestionsStorageKey` is defined', () => {
- const mockTokenValue = {
- id: 1,
- title: 'Foo',
- };
+ const mockTokenValue = mockLabels[0];
- wrapper.vm.handleTokenValueSelected(mockTokenValue);
+ it('calls `setTokenValueToRecentlyUsed` when `recentSuggestionsStorageKey` is defined', () => {
+ wrapper.vm.handleTokenValueSelected(mockTokenValue.title);
expect(setTokenValueToRecentlyUsed).toHaveBeenCalledWith(mockStorageKey, mockTokenValue);
});
it('does not add token from preloadedSuggestions', async () => {
- const mockTokenValue = {
- id: 1,
- title: 'Foo',
- };
-
wrapper.setProps({
preloadedSuggestions: [mockTokenValue],
});
await wrapper.vm.$nextTick();
- wrapper.vm.handleTokenValueSelected(mockTokenValue);
+ wrapper.vm.handleTokenValueSelected(mockTokenValue.title);
expect(setTokenValueToRecentlyUsed).not.toHaveBeenCalled();
});
@@ -190,7 +188,7 @@ describe('BaseToken', () => {
const glFilteredSearchToken = wrapperWithNoStubs.find(GlFilteredSearchToken);
expect(glFilteredSearchToken.exists()).toBe(true);
- expect(glFilteredSearchToken.props('config')).toBe(mockLabelToken);
+ expect(glFilteredSearchToken.props('config')).toEqual(mockProps.config);
wrapperWithNoStubs.destroy();
});
@@ -239,6 +237,7 @@ describe('BaseToken', () => {
stubs: { Portal: true },
});
});
+
it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => {
jest.useFakeTimers();
@@ -250,6 +249,32 @@ describe('BaseToken', () => {
expect(wrapperWithNoStubs.emitted('fetch-suggestions')).toBeTruthy();
expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']);
});
+
+ describe('when search is started with a quote', () => {
+ it('emits `fetch-suggestions` with filtered value', async () => {
+ jest.useFakeTimers();
+
+ wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: '"foo' });
+ await wrapperWithNoStubs.vm.$nextTick();
+
+ jest.runAllTimers();
+
+ expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']);
+ });
+ });
+
+ describe('when search starts and ends with a quote', () => {
+ it('emits `fetch-suggestions` with filtered value', async () => {
+ jest.useFakeTimers();
+
+ wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: '"foo"' });
+ await wrapperWithNoStubs.vm.$nextTick();
+
+ jest.runAllTimers();
+
+ expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']);
+ });
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js
deleted file mode 100644
index 6ee5d50d396..00000000000
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js
+++ /dev/null
@@ -1,169 +0,0 @@
-import { GlFilteredSearchTokenSegment } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import MockAdapter from 'axios-mock-adapter';
-import Vue 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 axios from '~/lib/utils/axios_utils';
-
-import searchEpicsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql';
-import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
-import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
-
-import { mockEpicToken, mockEpics, mockGroupEpicsQueryResponse } from '../mock_data';
-
-jest.mock('~/flash');
-Vue.use(VueApollo);
-
-const defaultStubs = {
- Portal: true,
- GlFilteredSearchSuggestionList: {
- template: '<div></div>',
- methods: {
- getValue: () => '=',
- },
- },
-};
-
-describe('EpicToken', () => {
- let mock;
- let wrapper;
- let fakeApollo;
-
- const findBaseToken = () => wrapper.findComponent(BaseToken);
-
- function createComponent(
- options = {},
- epicsQueryHandler = jest.fn().mockResolvedValue(mockGroupEpicsQueryResponse),
- ) {
- fakeApollo = createMockApollo([[searchEpicsQuery, epicsQueryHandler]]);
- const {
- config = mockEpicToken,
- value = { data: '' },
- active = false,
- stubs = defaultStubs,
- } = options;
- return mount(EpicToken, {
- apolloProvider: fakeApollo,
- propsData: {
- config,
- value,
- active,
- },
- provide: {
- portalName: 'fake target',
- alignSuggestions: function fakeAlignSuggestions() {},
- suggestionsListClass: 'custom-class',
- },
- stubs,
- });
- }
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- wrapper = createComponent();
- });
-
- afterEach(() => {
- mock.restore();
- wrapper.destroy();
- });
-
- describe('computed', () => {
- beforeEach(async () => {
- wrapper = createComponent({
- data: {
- epics: mockEpics,
- },
- });
-
- await wrapper.vm.$nextTick();
- });
- });
-
- describe('methods', () => {
- describe('fetchEpicsBySearchTerm', () => {
- it('calls fetchEpics with provided searchTerm param', () => {
- jest.spyOn(wrapper.vm, 'fetchEpics');
-
- findBaseToken().vm.$emit('fetch-suggestions', 'foo');
-
- expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo');
- });
-
- it('sets response to `epics` when request is successful', async () => {
- jest.spyOn(wrapper.vm, 'fetchEpics').mockResolvedValue({
- data: mockEpics,
- });
-
- findBaseToken().vm.$emit('fetch-suggestions');
-
- await waitForPromises();
-
- expect(wrapper.vm.epics).toEqual(mockEpics);
- });
-
- it('calls `createFlash` with flash error message when request fails', async () => {
- jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({});
-
- findBaseToken().vm.$emit('fetch-suggestions', 'foo');
-
- await waitForPromises();
-
- expect(createFlash).toHaveBeenCalledWith({
- message: 'There was a problem fetching epics.',
- });
- });
-
- it('sets `loading` to false when request completes', async () => {
- jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({});
-
- findBaseToken().vm.$emit('fetch-suggestions', 'foo');
-
- await waitForPromises();
-
- expect(wrapper.vm.loading).toBe(false);
- });
- });
- });
-
- describe('template', () => {
- const getTokenValueEl = () => wrapper.findAllComponents(GlFilteredSearchTokenSegment).at(2);
-
- beforeEach(async () => {
- wrapper = createComponent({
- value: { data: `${mockEpics[0].title}::&${mockEpics[0].iid}` },
- data: { epics: mockEpics },
- });
-
- await wrapper.vm.$nextTick();
- });
-
- it('renders BaseToken component', () => {
- expect(findBaseToken().exists()).toBe(true);
- });
-
- it('renders token item when value is selected', () => {
- const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
-
- expect(tokenSegments).toHaveLength(3);
- expect(tokenSegments.at(2).text()).toBe(`${mockEpics[0].title}::&${mockEpics[0].iid}`);
- });
-
- it.each`
- value | valueType | tokenValueString
- ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`}
- ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`}
- `('renders token item when selection is a $valueType', async ({ value, tokenValueString }) => {
- wrapper.setProps({
- value: { data: value },
- });
-
- await wrapper.vm.$nextTick();
-
- expect(getTokenValueEl().text()).toBe(tokenValueString);
- });
- });
-});
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
deleted file mode 100644
index 44bc16adb97..00000000000
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import {
- GlFilteredSearchToken,
- GlFilteredSearchTokenSegment,
- GlFilteredSearchSuggestion,
-} 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, mockIterations } from '../mock_data';
-
-jest.mock('~/flash');
-
-describe('IterationToken', () => {
- const id = 123;
- let wrapper;
-
- const createComponent = ({
- config = mockIterationToken,
- value = { data: '' },
- active = false,
- stubs = {},
- provide = {},
- } = {}) =>
- mount(IterationToken, {
- propsData: {
- active,
- config,
- value,
- },
- provide: {
- portalName: 'fake target',
- alignSuggestions: function fakeAlignSuggestions() {},
- suggestionsListClass: () => 'custom-class',
- ...provide,
- },
- stubs,
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('when iteration cadence feature is available', () => {
- beforeEach(async () => {
- wrapper = createComponent({
- active: true,
- config: { ...mockIterationToken, initialIterations: mockIterations },
- value: { data: 'i' },
- stubs: { Portal: true },
- provide: {
- glFeatures: {
- iterationCadences: true,
- },
- },
- });
-
- await wrapper.setData({ loading: false });
- });
-
- it('renders iteration start date and due date', () => {
- const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
-
- expect(suggestions.at(3).text()).toContain('Nov 5, 2021 - Nov 10, 2021');
- });
- });
-
- it('renders iteration value', async () => {
- wrapper = createComponent({ value: { data: id } });
-
- await wrapper.vm.$nextTick();
-
- const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
-
- expect(tokenSegments).toHaveLength(3); // `Iteration` `=` `gitlab-org: #1`
- expect(tokenSegments.at(2).text()).toBe(id.toString());
- });
-
- it('fetches initial values', () => {
- const fetchIterationsSpy = jest.fn().mockResolvedValue();
-
- wrapper = createComponent({
- config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
- value: { data: id },
- });
-
- expect(fetchIterationsSpy).toHaveBeenCalledWith(id);
- });
-
- it('fetches iterations on user input', () => {
- const search = 'hello';
- const fetchIterationsSpy = jest.fn().mockResolvedValue();
-
- wrapper = createComponent({
- config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
- });
-
- wrapper.findComponent(GlFilteredSearchToken).vm.$emit('input', { data: search });
-
- expect(fetchIterationsSpy).toHaveBeenCalledWith(search);
- });
-
- it('renders error message when request fails', async () => {
- const fetchIterationsSpy = jest.fn().mockRejectedValue();
-
- wrapper = createComponent({
- config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
- });
-
- await waitForPromises();
-
- expect(createFlash).toHaveBeenCalledWith({
- message: 'There was a problem fetching iterations.',
- });
- });
-});
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 936841651d1..4a098db33c5 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
@@ -9,18 +9,15 @@ import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
+import { sortMilestonesByDueDate } from '~/milestones/utils';
-import {
- DEFAULT_MILESTONES,
- DEFAULT_MILESTONES_GRAPHQL,
-} from '~/vue_shared/components/filtered_search_bar/constants';
+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 } from '../mock_data';
jest.mock('~/flash');
-jest.mock('~/milestones/milestone_utils');
+jest.mock('~/milestones/utils');
const defaultStubs = {
Portal: true,
@@ -199,12 +196,12 @@ describe('MilestoneToken', () => {
beforeEach(() => {
wrapper = createComponent({
active: true,
- config: { ...mockMilestoneToken, defaultMilestones: DEFAULT_MILESTONES_GRAPHQL },
+ config: { ...mockMilestoneToken, defaultMilestones: DEFAULT_MILESTONES },
});
});
it('finds the correct value from the activeToken', () => {
- DEFAULT_MILESTONES_GRAPHQL.forEach(({ value, title }) => {
+ DEFAULT_MILESTONES.forEach(({ value, title }) => {
const activeToken = wrapper.vm.getActiveMilestone([], value);
expect(activeToken.title).toEqual(title);
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
index b804ff97b82..b2f246a5985 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
@@ -8,7 +8,7 @@ import { mockReleaseToken } from '../mock_data';
jest.mock('~/flash');
describe('ReleaseToken', () => {
- const id = 123;
+ const id = '123';
let wrapper;
const createComponent = ({ config = mockReleaseToken, value = { data: '' } } = {}) =>
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
deleted file mode 100644
index 4277899f8db..00000000000
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { GlFilteredSearchTokenSegment } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
-import { mockWeightToken } from '../mock_data';
-
-jest.mock('~/flash');
-
-describe('WeightToken', () => {
- const weight = '3';
- let wrapper;
-
- const createComponent = ({ config = mockWeightToken, value = { data: '' } } = {}) =>
- mount(WeightToken, {
- propsData: {
- active: false,
- config,
- value,
- },
- provide: {
- portalName: 'fake target',
- alignSuggestions: function fakeAlignSuggestions() {},
- suggestionsListClass: () => 'custom-class',
- },
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders weight value', () => {
- wrapper = createComponent({ value: { data: weight } });
-
- const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
-
- expect(tokenSegments).toHaveLength(3); // `Weight` `=` `3`
- expect(tokenSegments.at(2).text()).toBe(weight);
- });
-});
diff --git a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap
index ff1dad2de68..58ad1f681bc 100644
--- a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap
+++ b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap
@@ -5,6 +5,7 @@ exports[`Title edit field matches the snapshot 1`] = `
label="Title"
label-for="title-field-edit"
labeldescription=""
+ optionaltext="(optional)"
>
<gl-form-input-stub />
</gl-form-group-stub>
diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
new file mode 100644
index 00000000000..b67385cc43e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
@@ -0,0 +1,231 @@
+import { merge } from 'lodash';
+import { GlFormInputGroup } from '@gitlab/ui';
+
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('InputCopyToggleVisibility', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const valueProp = 'hR8x1fuJbzwu5uFKLf9e';
+
+ const createComponent = (options = {}) => {
+ wrapper = mountExtended(
+ InputCopyToggleVisibility,
+ merge({}, options, {
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ }),
+ );
+ };
+
+ const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
+ const findFormInput = () => findFormInputGroup().find('input');
+ const findRevealButton = () =>
+ wrapper.findByRole('button', {
+ name: InputCopyToggleVisibility.i18n.toggleVisibilityLabelReveal,
+ });
+ const findHideButton = () =>
+ wrapper.findByRole('button', {
+ name: InputCopyToggleVisibility.i18n.toggleVisibilityLabelHide,
+ });
+ const findCopyButton = () => wrapper.findComponent(ClipboardButton);
+ const createCopyEvent = () => {
+ const event = new Event('copy', { cancelable: true });
+ Object.assign(event, { preventDefault: jest.fn(), clipboardData: { setData: jest.fn() } });
+
+ return event;
+ };
+
+ const itDoesNotModifyCopyEvent = () => {
+ it('does not modify copy event', () => {
+ const event = createCopyEvent();
+
+ findFormInput().element.dispatchEvent(event);
+
+ expect(event.clipboardData.setData).not.toHaveBeenCalled();
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ });
+ };
+
+ describe('when `value` prop is passed', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ value: valueProp,
+ },
+ });
+ });
+
+ it('displays value as hidden', () => {
+ expect(findFormInputGroup().props('value')).toBe('********************');
+ });
+
+ it('saves actual value to clipboard when manually copied', () => {
+ const event = createCopyEvent();
+ findFormInput().element.dispatchEvent(event);
+
+ expect(event.clipboardData.setData).toHaveBeenCalledWith('text/plain', valueProp);
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
+
+ describe('visibility toggle button', () => {
+ it('renders a reveal button', () => {
+ const revealButton = findRevealButton();
+
+ expect(revealButton.exists()).toBe(true);
+
+ const tooltip = getBinding(revealButton.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelReveal);
+ });
+
+ describe('when clicked', () => {
+ beforeEach(async () => {
+ await findRevealButton().trigger('click');
+ });
+
+ it('displays value', () => {
+ expect(findFormInputGroup().props('value')).toBe(valueProp);
+ });
+
+ it('renders a hide button', () => {
+ const hideButton = findHideButton();
+
+ expect(hideButton.exists()).toBe(true);
+
+ const tooltip = getBinding(hideButton.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelHide);
+ });
+
+ it('emits `visibility-change` event', () => {
+ expect(wrapper.emitted('visibility-change')[0]).toEqual([true]);
+ });
+ });
+ });
+
+ describe('copy button', () => {
+ it('renders button with correct props passed', () => {
+ expect(findCopyButton().props()).toMatchObject({
+ text: valueProp,
+ title: 'Copy',
+ });
+ });
+
+ describe('when clicked', () => {
+ beforeEach(async () => {
+ await findCopyButton().trigger('click');
+ });
+
+ it('emits `copy` event', () => {
+ expect(wrapper.emitted('copy')[0]).toEqual([]);
+ });
+ });
+ });
+ });
+
+ describe('when `value` prop is not passed', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays value as hidden with 20 asterisks', () => {
+ expect(findFormInputGroup().props('value')).toBe('********************');
+ });
+ });
+
+ describe('when `initialVisibility` prop is `true`', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ value: valueProp,
+ initialVisibility: true,
+ },
+ });
+ });
+
+ it('displays value', () => {
+ expect(findFormInputGroup().props('value')).toBe(valueProp);
+ });
+
+ itDoesNotModifyCopyEvent();
+ });
+
+ describe('when `showToggleVisibilityButton` is `false`', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ value: valueProp,
+ showToggleVisibilityButton: false,
+ },
+ });
+ });
+
+ it('does not render visibility toggle button', () => {
+ expect(findRevealButton().exists()).toBe(false);
+ expect(findHideButton().exists()).toBe(false);
+ });
+
+ it('displays value', () => {
+ expect(findFormInputGroup().props('value')).toBe(valueProp);
+ });
+
+ itDoesNotModifyCopyEvent();
+ });
+
+ describe('when `showCopyButton` is `false`', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ showCopyButton: false,
+ },
+ });
+ });
+
+ it('does not render copy button', () => {
+ expect(findCopyButton().exists()).toBe(false);
+ });
+ });
+
+ it('passes `formInputGroupProps` prop to `GlFormInputGroup`', () => {
+ createComponent({
+ propsData: {
+ formInputGroupProps: {
+ label: 'Foo bar',
+ },
+ },
+ });
+
+ expect(findFormInputGroup().props('label')).toBe('Foo bar');
+ });
+
+ it('passes `copyButtonTitle` prop to `ClipboardButton`', () => {
+ createComponent({
+ propsData: {
+ copyButtonTitle: 'Copy token',
+ },
+ });
+
+ expect(findCopyButton().props('title')).toBe('Copy token');
+ });
+
+ it('renders slots in `gl-form-group`', () => {
+ const description = 'Mock input description';
+ createComponent({
+ slots: {
+ description,
+ },
+ });
+
+ expect(wrapper.findByText(description).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js
index 390a70792f3..b837a998cd6 100644
--- a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js
+++ b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js
@@ -1,12 +1,12 @@
import { GlModal } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import createState from '~/vuex_shared/modules/modal/state';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
const TEST_SLOT = 'Lorem ipsum modal dolar sit.';
const TEST_MODAL_ID = 'my-modal-id';
@@ -36,7 +36,6 @@ describe('GlModalVuex', () => {
wrapper = shallowMount(GlModalVuex, {
...options,
- localVue,
store,
propsData,
stubs: {
diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js
index b76f475a6fb..aea76f164f0 100644
--- a/spec/frontend/vue_shared/components/header_ci_component_spec.js
+++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js
@@ -1,4 +1,4 @@
-import { GlButton, GlAvatarLink } from '@gitlab/ui';
+import { GlButton, GlAvatarLink, GlTooltip } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CiIconBadge from '~/vue_shared/components/ci_badge_link.vue';
@@ -32,6 +32,7 @@ describe('Header CI Component', () => {
const findTimeAgo = () => wrapper.findComponent(TimeagoTooltip);
const findUserLink = () => wrapper.findComponent(GlAvatarLink);
const findSidebarToggleBtn = () => wrapper.findComponent(GlButton);
+ const findStatusTooltip = () => wrapper.findComponent(GlTooltip);
const findActionButtons = () => wrapper.findByTestId('ci-header-action-buttons');
const findHeaderItemText = () => wrapper.findByTestId('ci-header-item-text');
@@ -91,6 +92,21 @@ describe('Header CI Component', () => {
});
});
+ describe('when the user has a status', () => {
+ const STATUS_MESSAGE = 'Working on exciting features...';
+
+ beforeEach(() => {
+ createComponent({
+ itemName: 'Pipeline',
+ user: { ...defaultProps.user, status: { message: STATUS_MESSAGE } },
+ });
+ });
+
+ it('renders a tooltip', () => {
+ expect(findStatusTooltip().text()).toBe(STATUS_MESSAGE);
+ });
+ });
+
describe('with data from GraphQL', () => {
const userId = 1;
diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js
new file mode 100644
index 00000000000..5bedd0ccd02
--- /dev/null
+++ b/spec/frontend/vue_shared/components/line_numbers_spec.js
@@ -0,0 +1,71 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon, GlLink } from '@gitlab/ui';
+import LineNumbers from '~/vue_shared/components/line_numbers.vue';
+
+describe('Line Numbers component', () => {
+ let wrapper;
+ const lines = 10;
+
+ const createComponent = () => {
+ wrapper = shallowMount(LineNumbers, { propsData: { lines } });
+ };
+
+ const findGlIcon = () => wrapper.findComponent(GlIcon);
+ const findLineNumbers = () => wrapper.findAllComponents(GlLink);
+ const findFirstLineNumber = () => findLineNumbers().at(0);
+ const findSecondLineNumber = () => findLineNumbers().at(1);
+
+ beforeEach(() => createComponent());
+
+ afterEach(() => wrapper.destroy());
+
+ describe('rendering', () => {
+ it('renders Line Numbers', () => {
+ expect(findLineNumbers().length).toBe(lines);
+ expect(findFirstLineNumber().attributes()).toMatchObject({
+ id: 'L1',
+ href: '#L1',
+ });
+ });
+
+ it('renders a link icon', () => {
+ expect(findGlIcon().props()).toMatchObject({
+ size: 12,
+ name: 'link',
+ });
+ });
+ });
+
+ describe('clicking a line number', () => {
+ let firstLineNumber;
+ let firstLineNumberElement;
+
+ beforeEach(() => {
+ firstLineNumber = findFirstLineNumber();
+ firstLineNumberElement = firstLineNumber.element;
+
+ jest.spyOn(firstLineNumberElement, 'scrollIntoView');
+ jest.spyOn(firstLineNumberElement.classList, 'add');
+ jest.spyOn(firstLineNumberElement.classList, 'remove');
+
+ firstLineNumber.vm.$emit('click');
+ });
+
+ it('adds the highlight (hll) class', () => {
+ expect(firstLineNumberElement.classList.add).toHaveBeenCalledWith('hll');
+ });
+
+ it('removes the highlight (hll) class from a previously highlighted line', () => {
+ findSecondLineNumber().vm.$emit('click');
+
+ expect(firstLineNumberElement.classList.remove).toHaveBeenCalledWith('hll');
+ });
+
+ it('scrolls the line into view', () => {
+ expect(firstLineNumberElement.scrollIntoView).toHaveBeenCalledWith({
+ behavior: 'smooth',
+ block: 'center',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
index eddc4033a65..8bff85b0bda 100644
--- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
@@ -1,24 +1,17 @@
import { mount } from '@vue/test-utils';
-import { isExperimentVariant } from '~/experimentation/utils';
-import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
-import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
import Toolbar from '~/vue_shared/components/markdown/toolbar.vue';
-jest.mock('~/experimentation/utils', () => ({ isExperimentVariant: jest.fn() }));
-
describe('toolbar', () => {
let wrapper;
const createMountedWrapper = (props = {}) => {
wrapper = mount(Toolbar, {
propsData: { markdownDocsPath: '', ...props },
- stubs: { 'invite-members-trigger': true },
});
};
afterEach(() => {
wrapper.destroy();
- isExperimentVariant.mockReset();
});
describe('user can attach file', () => {
@@ -40,36 +33,4 @@ describe('toolbar', () => {
expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull();
});
});
-
- describe('user can invite member', () => {
- const findInviteLink = () => wrapper.find(InviteMembersTrigger);
-
- beforeEach(() => {
- isExperimentVariant.mockReturnValue(true);
- createMountedWrapper();
- });
-
- it('should render the invite members trigger', () => {
- expect(findInviteLink().exists()).toBe(true);
- });
-
- it('should have correct props', () => {
- expect(findInviteLink().props().displayText).toBe('Invite Member');
- expect(findInviteLink().props().trackExperiment).toBe(INVITE_MEMBERS_IN_COMMENT);
- expect(findInviteLink().props().triggerSource).toBe(INVITE_MEMBERS_IN_COMMENT);
- });
- });
-
- describe('user can not invite member', () => {
- const findInviteLink = () => wrapper.find(InviteMembersTrigger);
-
- beforeEach(() => {
- isExperimentVariant.mockReturnValue(false);
- createMountedWrapper();
- });
-
- it('should render the invite members trigger', () => {
- expect(findInviteLink().exists()).toBe(false);
- });
- });
});
diff --git a/spec/frontend/vue_shared/components/namespace_select/mock_data.js b/spec/frontend/vue_shared/components/namespace_select/mock_data.js
new file mode 100644
index 00000000000..c9d96672e85
--- /dev/null
+++ b/spec/frontend/vue_shared/components/namespace_select/mock_data.js
@@ -0,0 +1,11 @@
+export const group = [
+ { id: 1, name: 'Group 1', humanName: 'Group 1' },
+ { id: 2, name: 'Subgroup 1', humanName: 'Group 1 / Subgroup 1' },
+];
+
+export const user = [{ id: 3, name: 'User namespace 1', humanName: 'User namespace 1' }];
+
+export const namespaces = {
+ group,
+ user,
+};
diff --git a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js b/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js
new file mode 100644
index 00000000000..8f07f63993d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js
@@ -0,0 +1,86 @@
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import NamespaceSelect, {
+ i18n,
+} from '~/vue_shared/components/namespace_select/namespace_select.vue';
+import { user, group, namespaces } from './mock_data';
+
+describe('Namespace Select', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) =>
+ shallowMountExtended(NamespaceSelect, {
+ propsData: {
+ data: namespaces,
+ ...props,
+ },
+ });
+
+ const wrappersText = (arr) => arr.wrappers.map((w) => w.text());
+ const flatNamespaces = () => [...group, ...user];
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownAttributes = (attr) => findDropdown().attributes(attr);
+ const selectedDropdownItemText = () => findDropdownAttributes('text');
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findSectionHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader);
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the dropdown', () => {
+ expect(findDropdown().exists()).toBe(true);
+ });
+
+ it('renders each dropdown item', () => {
+ const items = findDropdownItems().wrappers;
+ expect(items).toHaveLength(flatNamespaces().length);
+ });
+
+ it('renders the human name for each item', () => {
+ const dropdownItems = wrappersText(findDropdownItems());
+ const flatNames = flatNamespaces().map(({ humanName }) => humanName);
+ expect(dropdownItems).toEqual(flatNames);
+ });
+
+ it('sets the initial dropdown text', () => {
+ expect(selectedDropdownItemText()).toBe(i18n.DEFAULT_TEXT);
+ });
+
+ it('splits group and user namespaces', () => {
+ const headers = findSectionHeaders();
+ expect(headers).toHaveLength(2);
+ expect(wrappersText(headers)).toEqual([i18n.GROUPS, i18n.USERS]);
+ });
+
+ it('sets the dropdown to full width', () => {
+ expect(findDropdownAttributes('block')).toBeUndefined();
+
+ wrapper = createComponent({ fullWidth: true });
+
+ expect(findDropdownAttributes('block')).not.toBeUndefined();
+ expect(findDropdownAttributes('block')).toBe('true');
+ });
+
+ describe('with a selected namespace', () => {
+ const selectedGroupIndex = 1;
+ const selectedItem = group[selectedGroupIndex];
+
+ beforeEach(() => {
+ findDropdownItems().at(selectedGroupIndex).vm.$emit('click');
+ });
+
+ it('sets the dropdown text', () => {
+ expect(selectedDropdownItemText()).toBe(selectedItem.humanName);
+ });
+
+ it('emits the `select` event when a namespace is selected', () => {
+ const args = [selectedItem];
+ expect(wrapper.emitted('select')).toEqual([args]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
index 0f30b50da0b..c8dab0204d3 100644
--- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
+++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
@@ -1,10 +1,11 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { userDataMock } from '../../../notes/mock_data';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
const getters = {
getUserData: () => userDataMock,
@@ -15,9 +16,8 @@ describe('Issue placeholder note component', () => {
const findNote = () => wrapper.find({ ref: 'note' });
- const createComponent = (isIndividual = false) => {
+ const createComponent = (isIndividual = false, propsData = {}) => {
wrapper = shallowMount(IssuePlaceholderNote, {
- localVue,
store: new Vuex.Store({
getters,
}),
@@ -26,6 +26,7 @@ describe('Issue placeholder note component', () => {
body: 'Foo',
individual_note: isIndividual,
},
+ ...propsData,
},
});
};
@@ -52,4 +53,17 @@ describe('Issue placeholder note component', () => {
expect(findNote().classes()).toContain('discussion');
});
+
+ describe('avatar size', () => {
+ it.each`
+ size | line | isOverviewTab
+ ${40} | ${null} | ${false}
+ ${24} | ${{ line_code: '123' }} | ${false}
+ ${40} | ${{ line_code: '123' }} | ${true}
+ `('renders avatar $size for $line and $isOverviewTab', ({ size, line, isOverviewTab }) => {
+ createComponent(false, { line, isOverviewTab });
+
+ expect(wrapper.findComponent(UserAvatarLink).props('imgSize')).toBe(size);
+ });
+ });
});
diff --git a/spec/frontend/import_entities/components/pagination_bar_spec.js b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js
index 163ce11a8db..08119dee8af 100644
--- a/spec/frontend/import_entities/components/pagination_bar_spec.js
+++ b/spec/frontend/vue_shared/components/pagination_bar/pagination_bar_spec.js
@@ -1,16 +1,16 @@
import { GlPagination, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import PaginationBar from '~/import_entities/components/pagination_bar.vue';
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
describe('Pagination bar', () => {
const DEFAULT_PROPS = {
pageInfo: {
total: 50,
- page: 1,
+ totalPages: 3,
+ page: 3,
perPage: 20,
},
- itemsCount: 17,
};
let wrapper;
@@ -73,7 +73,7 @@ describe('Pagination bar', () => {
createComponent();
expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText(
- 'Showing 1 - 17 of 50',
+ 'Showing 41 - 50 of 50',
);
});
@@ -82,11 +82,12 @@ describe('Pagination bar', () => {
pageInfo: {
...DEFAULT_PROPS.pageInfo,
total: 1200,
+ page: 2,
},
});
expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText(
- 'Showing 1 - 17 of 1000+',
+ 'Showing 21 - 40 of 1000+',
);
});
});
diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
index 7fdacbe83a2..5afa017aa76 100644
--- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
+++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
@@ -1,13 +1,12 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import mockProjects from 'test_fixtures_static/projects.json';
import { trimText } from 'helpers/text_helper';
import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
-const localVue = createLocalVue();
-
describe('ProjectListItem component', () => {
- const Component = localVue.extend(ProjectListItem);
+ const Component = Vue.extend(ProjectListItem);
let wrapper;
let vm;
let options;
@@ -20,7 +19,6 @@ describe('ProjectListItem component', () => {
project,
selected: false,
},
- localVue,
};
});
diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
index de5cee846a1..34cee10392d 100644
--- a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
+++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js
@@ -1,5 +1,5 @@
import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
-import { mount, createLocalVue } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import { head } from 'lodash';
import Vue from 'vue';
import mockProjects from 'test_fixtures_static/projects.json';
@@ -7,8 +7,6 @@ import { trimText } from 'helpers/text_helper';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
-const localVue = createLocalVue();
-
describe('ProjectSelector component', () => {
let wrapper;
let vm;
@@ -28,7 +26,6 @@ describe('ProjectSelector component', () => {
beforeEach(() => {
wrapper = mount(Vue.extend(ProjectSelector), {
- localVue,
propsData: {
projectSearchResults: searchResults,
selectedProjects: selected,
diff --git a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js
index 1ccf3ddc5a5..e4abdc15fd5 100644
--- a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js
@@ -2,7 +2,7 @@ import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import component from '~/vue_shared/components/registry/metadata_item.vue';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
describe('Metadata Item', () => {
let wrapper;
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
index 8536ffed573..e74a867ec97 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
@@ -1,7 +1,7 @@
import { GlAlert, GlModal, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -16,8 +16,7 @@ import {
mockGraphqlInstructionsWindows,
} from './mock_data';
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
let resizeCallback;
const MockResizeObserver = {
@@ -33,7 +32,7 @@ const MockResizeObserver = {
},
};
-localVue.directive('gl-resize-observer', MockResizeObserver);
+Vue.directive('gl-resize-observer', MockResizeObserver);
jest.mock('@gitlab/ui/dist/utils');
@@ -67,7 +66,6 @@ describe('RunnerInstructionsModal component', () => {
registrationToken: 'MY_TOKEN',
...props,
},
- localVue,
apolloProvider: fakeApollo,
...options,
}),
diff --git a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js b/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js
deleted file mode 100644
index e72b3bf45c4..00000000000
--- a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-
-import CollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
-import CollapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue';
-
-describe('CollapsedGroupedDatePicker', () => {
- let wrapper;
-
- const defaultProps = {
- showToggleSidebar: true,
- };
-
- const minDate = new Date('07/17/2016');
- const maxDate = new Date('07/17/2017');
-
- const createComponent = ({ props = {} } = {}) => {
- wrapper = shallowMount(CollapsedGroupedDatePicker, {
- propsData: { ...defaultProps, ...props },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const findCollapsedCalendarIcon = () => wrapper.findComponent(CollapsedCalendarIcon);
- const findAllCollapsedCalendarIcons = () => wrapper.findAllComponents(CollapsedCalendarIcon);
-
- describe('toggleCollapse events', () => {
- it('should emit when collapsed-calendar-icon is clicked', () => {
- createComponent();
-
- findCollapsedCalendarIcon().trigger('click');
-
- expect(wrapper.emitted('toggleCollapse')[0]).toBeDefined();
- });
- });
-
- describe('minDate and maxDate', () => {
- it('should render both collapsed-calendar-icon', () => {
- createComponent({
- props: {
- minDate,
- maxDate,
- },
- });
-
- const icons = findAllCollapsedCalendarIcons();
-
- expect(icons.length).toBe(2);
- expect(icons.at(0).text()).toBe('Jul 17 2016');
- expect(icons.at(1).text()).toBe('Jul 17 2017');
- });
- });
-
- describe('minDate', () => {
- it('should render minDate in collapsed-calendar-icon', () => {
- createComponent({
- props: {
- minDate,
- },
- });
-
- const icons = findAllCollapsedCalendarIcons();
-
- expect(icons.length).toBe(1);
- expect(icons.at(0).text()).toBe('From Jul 17 2016');
- });
- });
-
- describe('maxDate', () => {
- it('should render maxDate in collapsed-calendar-icon', () => {
- createComponent({
- props: {
- maxDate,
- },
- });
- const icons = findAllCollapsedCalendarIcons();
-
- expect(icons.length).toBe(1);
- expect(icons.at(0).text()).toBe('Until Jul 17 2017');
- });
- });
-
- describe('no dates', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('should render None', () => {
- const icons = findAllCollapsedCalendarIcons();
-
- expect(icons.length).toBe(1);
- expect(icons.at(0).text()).toBe('None');
- });
-
- it('should have tooltip as `Start and due date`', () => {
- const icons = findAllCollapsedCalendarIcons();
-
- expect(icons.at(0).props('tooltipText')).toBe('Start and due date');
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
index 59b170bfba9..c4ed975e746 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js
@@ -1,5 +1,6 @@
import { GlIcon, GlButton } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue';
@@ -9,8 +10,7 @@ import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue
import { mockConfig } from './mock_data';
let store;
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
store = new Vuex.Store(labelSelectModule());
@@ -18,7 +18,6 @@ const createComponent = (initialState = mockConfig) => {
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownButton, {
- localVue,
store,
});
};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
index c4a645082e6..1fe85637a62 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js
@@ -1,5 +1,6 @@
import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue';
@@ -8,8 +9,7 @@ import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue
import { mockConfig, mockSuggestedColors } from './mock_data';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelSelectModule());
@@ -17,7 +17,6 @@ const createComponent = (initialState = mockConfig) => {
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownContentsCreateView, {
- localVue,
store,
});
};
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 e39e8794fdd..80b8edd28ba 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
@@ -5,7 +5,8 @@ import {
GlSearchBoxByType,
GlLink,
} from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue';
@@ -18,8 +19,7 @@ import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/stor
import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
describe('DropdownContentsLabelsView', () => {
let wrapper;
@@ -43,7 +43,6 @@ describe('DropdownContentsLabelsView', () => {
store.dispatch('receiveLabelsSuccess', mockLabels);
wrapper = shallowMount(DropdownContentsLabelsView, {
- localVue,
store,
});
};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js
index 88557917cb5..9781d9c4de0 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js
@@ -1,4 +1,5 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
@@ -7,8 +8,7 @@ import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vu
import { mockConfig } from './mock_data';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
const createComponent = (initialState = mockConfig, propsData = {}) => {
const store = new Vuex.Store(labelsSelectModule());
@@ -17,7 +17,6 @@ const createComponent = (initialState = mockConfig, propsData = {}) => {
return shallowMount(DropdownContents, {
propsData,
- localVue,
store,
});
};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js
index 726a113dbd9..110c1d1b7eb 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js
@@ -1,5 +1,6 @@
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue';
@@ -8,8 +9,7 @@ import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vu
import { mockConfig } from './mock_data';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
const createComponent = (initialState = mockConfig) => {
const store = new Vuex.Store(labelsSelectModule());
@@ -17,7 +17,6 @@ const createComponent = (initialState = mockConfig) => {
store.dispatch('setInitialState', initialState);
return shallowMount(DropdownTitle, {
- localVue,
store,
propsData: {
labelsSelectInProgress: false,
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js
index 960ea77cb6e..f3c4839002b 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js
@@ -1,5 +1,6 @@
import { GlLabel } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue';
@@ -8,8 +9,7 @@ import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vu
import { mockConfig, mockLabels, mockRegularLabel, mockScopedLabel } from './mock_data';
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
describe('DropdownValue', () => {
let wrapper;
@@ -23,7 +23,6 @@ describe('DropdownValue', () => {
store.dispatch('setInitialState', { ...mockConfig, ...initialState });
wrapper = shallowMount(DropdownValue, {
- localVue,
store,
slots,
});
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 bc1ec8b812b..4b0ba075eda 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
@@ -1,4 +1,5 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import Vuex from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
@@ -18,8 +19,7 @@ jest.mock('~/lib/utils/common_utils', () => ({
isInViewport: jest.fn().mockReturnValue(true),
}));
-const localVue = createLocalVue();
-localVue.use(Vuex);
+Vue.use(Vuex);
describe('LabelsSelectRoot', () => {
let wrapper;
@@ -27,7 +27,6 @@ describe('LabelsSelectRoot', () => {
const createComponent = (config = mockConfig, slots = {}) => {
wrapper = shallowMount(LabelsSelectRoot, {
- localVue,
slots,
store,
propsData: config,
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
index 1faa3b0af1d..884bc4684ba 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
@@ -75,7 +75,7 @@ export const mockSuggestedColors = {
'#013220': 'Dark green',
'#6699cc': 'Blue-gray',
'#0000ff': 'Blue',
- '#e6e6fa': 'Lavendar',
+ '#e6e6fa': 'Lavender',
'#9400d3': 'Dark violet',
'#330066': 'Deep violet',
'#808080': 'Gray',
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 bf873f9162b..d8491334b5d 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 { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -18,8 +18,7 @@ jest.mock('~/flash');
const colors = Object.keys(mockSuggestedColors);
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
const userRecoverableError = {
...createLabelSuccessfulResponse,
@@ -63,7 +62,6 @@ describe('DropdownContentsCreateView', () => {
});
wrapper = shallowMount(DropdownContentsCreateView, {
- localVue,
apolloProvider: mockApollo,
propsData: {
fullPath: '',
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 2980409fdce..6f5a4b7e613 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
@@ -4,8 +4,8 @@ import {
GlDropdownItem,
GlIntersectionObserver,
} from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -19,8 +19,7 @@ import { mockConfig, workspaceLabelsQueryResponse } from './mock_data';
jest.mock('~/flash');
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
const localSelectedLabels = [
{
@@ -47,7 +46,6 @@ describe('DropdownContentsLabelsView', () => {
const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]);
wrapper = shallowMount(DropdownContentsLabelsView, {
- localVue,
apolloProvider: mockApollo,
provide: {
variant: DropdownVariant.Sidebar,
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 8bcef347c96..00da9b74957 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
@@ -4,12 +4,12 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
-import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue';
import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue';
import { mockLabels } from './mock_data';
const showDropdown = jest.fn();
+const focusInput = jest.fn();
const GlDropdownStub = {
template: `
@@ -25,6 +25,15 @@ const GlDropdownStub = {
},
};
+const DropdownHeaderStub = {
+ template: `
+ <div>Hello, I am a header</div>
+ `,
+ methods: {
+ focusInput,
+ },
+};
+
describe('DropdownContent', () => {
let wrapper;
@@ -52,6 +61,7 @@ describe('DropdownContent', () => {
},
stubs: {
GlDropdown: GlDropdownStub,
+ DropdownHeader: DropdownHeaderStub,
},
});
};
@@ -62,7 +72,7 @@ describe('DropdownContent', () => {
const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView);
const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView);
- const findDropdownHeader = () => wrapper.findComponent(DropdownHeader);
+ const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub);
const findDropdownFooter = () => wrapper.findComponent(DropdownFooter);
const findDropdown = () => wrapper.findComponent(GlDropdownStub);
@@ -114,19 +124,7 @@ describe('DropdownContent', () => {
expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]);
});
- it('does not render header on standalone variant', () => {
- createComponent({ props: { variant: DropdownVariant.Standalone } });
-
- expect(findDropdownHeader().exists()).toBe(false);
- });
-
- it('renders header on embedded variant', () => {
- createComponent({ props: { variant: DropdownVariant.Embedded } });
-
- expect(findDropdownHeader().exists()).toBe(true);
- });
-
- it('renders header on sidebar variant', () => {
+ it('renders header', () => {
createComponent();
expect(findDropdownHeader().exists()).toBe(true);
@@ -135,11 +133,20 @@ describe('DropdownContent', () => {
it('sets searchKey for labels view on input event from header', async () => {
createComponent();
- expect(wrapper.vm.searchKey).toEqual('');
+ expect(findLabelsView().props('searchKey')).toBe('');
findDropdownHeader().vm.$emit('input', '123');
await nextTick();
- expect(findLabelsView().props('searchKey')).toEqual('123');
+ expect(findLabelsView().props('searchKey')).toBe('123');
+ });
+
+ it('clears and focuses search input on selecting a label', () => {
+ createComponent();
+ findDropdownHeader().vm.$emit('input', '123');
+ findLabelsView().vm.$emit('input', []);
+
+ expect(findLabelsView().props('searchKey')).toBe('');
+ expect(focusInput).toHaveBeenCalled();
});
describe('Create view', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js
index 592559ef305..c4faef8ccdd 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js
@@ -9,6 +9,7 @@ describe('DropdownHeader', () => {
const createComponent = ({
showDropdownContentsCreateView = false,
labelsFetchInProgress = false,
+ isStandalone = false,
} = {}) => {
wrapper = extendedWrapper(
shallowMount(DropdownHeader, {
@@ -18,6 +19,7 @@ describe('DropdownHeader', () => {
labelsCreateTitle: 'Create label',
labelsListTitle: 'Select label',
searchKey: '',
+ isStandalone,
},
stubs: {
GlSearchBoxByType,
@@ -32,6 +34,7 @@ describe('DropdownHeader', () => {
const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findGoBackButton = () => wrapper.findByTestId('go-back-button');
+ const findDropdownTitle = () => wrapper.findByTestId('dropdown-header-title');
beforeEach(() => {
createComponent();
@@ -72,4 +75,18 @@ describe('DropdownHeader', () => {
},
);
});
+
+ describe('Standalone variant', () => {
+ beforeEach(() => {
+ createComponent({ isStandalone: true });
+ });
+
+ it('renders search input', () => {
+ expect(findSearchInput().exists()).toBe(true);
+ });
+
+ it('does not render title', async () => {
+ expect(findDropdownTitle().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
index e7e78cd7a33..0c4f4b7d504 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
@@ -95,5 +95,10 @@ describe('DropdownValue', () => {
findRegularLabel().vm.$emit('close');
expect(wrapper.emitted('onLabelRemove')).toEqual([[mockRegularLabel.id]]);
});
+
+ it('emits `onCollapsedValueClick` when clicking on collapsed value', () => {
+ wrapper.find('.sidebar-collapsed-icon').trigger('click');
+ expect(wrapper.emitted('onCollapsedValueClick')).toEqual([[]]);
+ });
});
});
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 d4203528874..a4199bb3e27 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
@@ -1,25 +1,34 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import Vue, { 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 { IssuableType } from '~/issue_show/constants';
+import { IssuableType } from '~/issues/constants';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
+import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
+import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
+import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
-import { mockConfig, issuableLabelsQueryResponse } from './mock_data';
+import { mockConfig, issuableLabelsQueryResponse, updateLabelsMutationResponse } from './mock_data';
jest.mock('~/flash');
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse);
+const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse);
const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+const updateLabelsMutation = {
+ [IssuableType.Issue]: updateIssueLabelsMutation,
+ [IssuableType.MergeRequest]: updateMergeRequestLabelsMutation,
+ [IssuableType.Epic]: updateEpicLabelsMutation,
+};
+
describe('LabelsSelectRoot', () => {
let wrapper;
@@ -30,17 +39,21 @@ describe('LabelsSelectRoot', () => {
const createComponent = ({
config = mockConfig,
slots = {},
+ issuableType = IssuableType.Issue,
queryHandler = successfulQueryHandler,
+ mutationHandler = successfulMutationHandler,
} = {}) => {
- const mockApollo = createMockApollo([[issueLabelsQuery, queryHandler]]);
+ const mockApollo = createMockApollo([
+ [issueLabelsQuery, queryHandler],
+ [updateLabelsMutation[issuableType], mutationHandler],
+ ]);
wrapper = shallowMount(LabelsSelectRoot, {
slots,
apolloProvider: mockApollo,
- localVue,
propsData: {
...config,
- issuableType: IssuableType.Issue,
+ issuableType,
labelCreateType: 'project',
workspaceType: 'project',
},
@@ -60,9 +73,9 @@ describe('LabelsSelectRoot', () => {
wrapper.destroy();
});
- it('renders component with classes `labels-select-wrapper position-relative`', () => {
+ it('renders component with classes `labels-select-wrapper gl-relative`', () => {
createComponent();
- expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'position-relative']);
+ expect(wrapper.classes()).toEqual(['labels-select-wrapper', 'gl-relative']);
});
it.each`
@@ -130,4 +143,46 @@ describe('LabelsSelectRoot', () => {
findDropdownContents().vm.$emit('setLabels', [label]);
expect(wrapper.emitted('updateSelectedLabels')).toEqual([[{ labels: [label] }]]);
});
+
+ describe.each`
+ issuableType
+ ${IssuableType.Issue}
+ ${IssuableType.MergeRequest}
+ ${IssuableType.Epic}
+ `('when updating labels for $issuableType', ({ issuableType }) => {
+ const label = { id: 'gid://gitlab/ProjectLabel/2' };
+
+ it('sets the loading state', async () => {
+ createComponent({ issuableType });
+ await nextTick();
+ findDropdownContents().vm.$emit('setLabels', [label]);
+ await nextTick();
+
+ expect(findSidebarEditableItem().props('loading')).toBe(true);
+ });
+
+ it('updates labels correctly after successful mutation', async () => {
+ createComponent({ issuableType });
+ await nextTick();
+ findDropdownContents().vm.$emit('setLabels', [label]);
+ await waitForPromises();
+
+ expect(findDropdownValue().props('selectedLabels')).toEqual(
+ updateLabelsMutationResponse.data.updateIssuableLabels.issuable.labels.nodes,
+ );
+ });
+
+ it('displays an error if mutation was rejected', async () => {
+ createComponent({ issuableType, mutationHandler: errorQueryHandler });
+ await nextTick();
+ findDropdownContents().vm.$emit('setLabels', [label]);
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ captureError: true,
+ error: expect.anything(),
+ message: 'An error occurred while updating labels.',
+ });
+ });
+ });
});
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 5c5bf5f2187..6ef54ce37ce 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
@@ -118,7 +118,9 @@ export const workspaceLabelsQueryResponse = {
export const issuableLabelsQueryResponse = {
data: {
workspace: {
+ id: 'workspace-1',
issuable: {
+ __typename: 'Issue',
id: '1',
labels: {
nodes: [
@@ -135,3 +137,18 @@ export const issuableLabelsQueryResponse = {
},
},
};
+
+export const updateLabelsMutationResponse = {
+ data: {
+ updateIssuableLabels: {
+ errors: [],
+ issuable: {
+ __typename: 'Issue',
+ id: '1',
+ labels: {
+ nodes: [],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/vue_shared/components/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer_spec.js
new file mode 100644
index 00000000000..758068379de
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer_spec.js
@@ -0,0 +1,59 @@
+import hljs from 'highlight.js/lib/core';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import SourceViewer from '~/vue_shared/components/source_viewer.vue';
+import LineNumbers from '~/vue_shared/components/line_numbers.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('highlight.js/lib/core');
+
+describe('Source Viewer component', () => {
+ let wrapper;
+ const content = `// Some source code`;
+ const highlightedContent = `<span data-testid='test-highlighted'>${content}</span>`;
+ const language = 'javascript';
+
+ hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
+ hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
+
+ const createComponent = async (props = {}) => {
+ wrapper = shallowMountExtended(SourceViewer, { propsData: { content, language, ...props } });
+ await waitForPromises();
+ };
+
+ const findLineNumbers = () => wrapper.findComponent(LineNumbers);
+ const findHighlightedContent = () => wrapper.findByTestId('test-highlighted');
+
+ beforeEach(() => createComponent());
+
+ afterEach(() => wrapper.destroy());
+
+ describe('highlight.js', () => {
+ it('registers the language definition', async () => {
+ const languageDefinition = await import(`highlight.js/lib/languages/${language}`);
+
+ expect(hljs.registerLanguage).toHaveBeenCalledWith(language, languageDefinition.default);
+ });
+
+ it('highlights the content', () => {
+ expect(hljs.highlight).toHaveBeenCalledWith(content, { language });
+ });
+
+ describe('auto-detect enabled', () => {
+ beforeEach(() => createComponent({ autoDetect: true }));
+
+ it('highlights the content with auto-detection', () => {
+ expect(hljs.highlightAuto).toHaveBeenCalledWith(content);
+ });
+ });
+ });
+
+ describe('rendering', () => {
+ it('renders Line Numbers', () => {
+ expect(findLineNumbers().props('lines')).toBe(1);
+ });
+
+ it('renders the highlighted content', () => {
+ expect(findHighlightedContent().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js b/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js
deleted file mode 100644
index 103eee4b9a8..00000000000
--- a/spec/frontend/vue_shared/components/storage_counter/usage_graph_spec.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue';
-
-let data;
-let wrapper;
-
-function mountComponent({ rootStorageStatistics, limit }) {
- wrapper = shallowMount(UsageGraph, {
- propsData: {
- rootStorageStatistics,
- limit,
- },
- });
-}
-function findStorageTypeUsagesSerialized() {
- return wrapper
- .findAll('[data-testid="storage-type-usage"]')
- .wrappers.map((wp) => wp.element.style.flex);
-}
-
-describe('Storage Counter usage graph component', () => {
- beforeEach(() => {
- data = {
- rootStorageStatistics: {
- wikiSize: 5000,
- repositorySize: 4000,
- packagesSize: 3000,
- lfsObjectsSize: 2000,
- buildArtifactsSize: 500,
- pipelineArtifactsSize: 500,
- snippetsSize: 2000,
- storageSize: 17000,
- uploadsSize: 1000,
- },
- limit: 2000,
- };
- mountComponent(data);
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders the legend in order', () => {
- const types = wrapper.findAll('[data-testid="storage-type-legend"]');
-
- const {
- buildArtifactsSize,
- pipelineArtifactsSize,
- lfsObjectsSize,
- packagesSize,
- repositorySize,
- wikiSize,
- snippetsSize,
- uploadsSize,
- } = data.rootStorageStatistics;
-
- expect(types.at(0).text()).toMatchInterpolatedText(`Wikis ${numberToHumanSize(wikiSize)}`);
- expect(types.at(1).text()).toMatchInterpolatedText(
- `Repositories ${numberToHumanSize(repositorySize)}`,
- );
- expect(types.at(2).text()).toMatchInterpolatedText(
- `Packages ${numberToHumanSize(packagesSize)}`,
- );
- expect(types.at(3).text()).toMatchInterpolatedText(
- `LFS Objects ${numberToHumanSize(lfsObjectsSize)}`,
- );
- expect(types.at(4).text()).toMatchInterpolatedText(
- `Snippets ${numberToHumanSize(snippetsSize)}`,
- );
- expect(types.at(5).text()).toMatchInterpolatedText(
- `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`,
- );
- expect(types.at(6).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`);
- });
-
- describe('when storage type is not used', () => {
- beforeEach(() => {
- data.rootStorageStatistics.wikiSize = 0;
- mountComponent(data);
- });
-
- it('filters the storage type', () => {
- expect(wrapper.text()).not.toContain('Wikis');
- });
- });
-
- describe('when there is no storage usage', () => {
- beforeEach(() => {
- data.rootStorageStatistics.storageSize = 0;
- mountComponent(data);
- });
-
- it('it does not render', () => {
- expect(wrapper.html()).toEqual('');
- });
- });
-
- describe('when limit is 0', () => {
- beforeEach(() => {
- data.limit = 0;
- mountComponent(data);
- });
-
- it('sets correct flex values', () => {
- expect(findStorageTypeUsagesSerialized()).toStrictEqual([
- '0.29411764705882354',
- '0.23529411764705882',
- '0.17647058823529413',
- '0.11764705882352941',
- '0.11764705882352941',
- '0.058823529411764705',
- '0.058823529411764705',
- ]);
- });
- });
-
- describe('when storage exceeds limit', () => {
- beforeEach(() => {
- data.limit = data.rootStorageStatistics.storageSize - 1;
- mountComponent(data);
- });
-
- it('it does render correclty', () => {
- expect(findStorageTypeUsagesSerialized()).toStrictEqual([
- '0.29411764705882354',
- '0.23529411764705882',
- '0.17647058823529413',
- '0.11764705882352941',
- '0.11764705882352941',
- '0.058823529411764705',
- '0.058823529411764705',
- ]);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
index 380b7231acd..9e7e5c1263f 100644
--- a/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
+++ b/spec/frontend/vue_shared/components/tooltip_on_truncate_spec.js
@@ -1,25 +1,20 @@
import { mount, shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-const DUMMY_TEXT = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do';
+const MOCK_TITLE = 'lorem-ipsum-dolar-sit-amit-consectur-adipiscing-elit-sed-do';
+const SHORT_TITLE = 'my-text';
-const createChildElement = () => `<a href="#">${DUMMY_TEXT}</a>`;
+const createChildElement = () => `<a href="#">${MOCK_TITLE}</a>`;
jest.mock('~/lib/utils/dom_utils', () => ({
- hasHorizontalOverflow: jest.fn(() => {
+ ...jest.requireActual('~/lib/utils/dom_utils'),
+ hasHorizontalOverflow: jest.fn().mockImplementation(() => {
throw new Error('this needs to be mocked');
}),
}));
-jest.mock('@gitlab/ui', () => ({
- GlTooltipDirective: {
- bind(el, binding) {
- el.classList.add('gl-tooltip');
- el.setAttribute('data-original-title', el.title);
- el.dataset.placement = binding.value.placement;
- },
- },
-}));
describe('TooltipOnTruncate component', () => {
let wrapper;
@@ -27,15 +22,31 @@ describe('TooltipOnTruncate component', () => {
const createComponent = ({ propsData, ...options } = {}) => {
wrapper = shallowMount(TooltipOnTruncate, {
- attachTo: document.body,
propsData: {
+ title: MOCK_TITLE,
...propsData,
},
+ slots: {
+ default: [MOCK_TITLE],
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ GlResizeObserver: createMockDirective(),
+ },
...options,
});
};
const createWrappedComponent = ({ propsData, ...options }) => {
+ const WrappedTooltipOnTruncate = {
+ ...TooltipOnTruncate,
+ directives: {
+ ...TooltipOnTruncate.directives,
+ GlTooltip: createMockDirective(),
+ GlResizeObserver: createMockDirective(),
+ },
+ };
+
// set a parent around the tested component
parent = mount(
{
@@ -43,74 +54,85 @@ describe('TooltipOnTruncate component', () => {
title: { default: '' },
},
template: `
- <TooltipOnTruncate :title="title" truncate-target="child">
- <div>{{title}}</div>
- </TooltipOnTruncate>
+ <TooltipOnTruncate :title="title" truncate-target="child">
+ <div>{{title}}</div>
+ </TooltipOnTruncate>
`,
components: {
- TooltipOnTruncate,
+ TooltipOnTruncate: WrappedTooltipOnTruncate,
},
},
{
propsData: { ...propsData },
- attachTo: document.body,
...options,
},
);
- wrapper = parent.find(TooltipOnTruncate);
+ wrapper = parent.find(WrappedTooltipOnTruncate);
};
- const hasTooltip = () => wrapper.classes('gl-tooltip');
+ const getTooltipValue = () => getBinding(wrapper.element, 'gl-tooltip')?.value;
+ const resize = async ({ truncate }) => {
+ hasHorizontalOverflow.mockReturnValueOnce(truncate);
+ getBinding(wrapper.element, 'gl-resize-observer').value();
+ await nextTick();
+ };
afterEach(() => {
wrapper.destroy();
});
- describe('with default target', () => {
- it('renders tooltip if truncated', () => {
+ describe('when truncated', () => {
+ beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
- createComponent({
- propsData: {
- title: DUMMY_TEXT,
- },
- slots: {
- default: [DUMMY_TEXT],
- },
- });
+ createComponent();
+ });
- return wrapper.vm.$nextTick().then(() => {
- expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element);
- expect(hasTooltip()).toBe(true);
- expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT);
- expect(wrapper.attributes('data-placement')).toEqual('top');
+ it('renders tooltip', async () => {
+ expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
+ expect(getTooltipValue()).toMatchObject({
+ title: MOCK_TITLE,
+ placement: 'top',
+ disabled: false,
});
+ expect(wrapper.classes('js-show-tooltip')).toBe(true);
});
+ });
- it('does not render tooltip if normal', () => {
+ describe('with default target', () => {
+ beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(false);
- createComponent({
- propsData: {
- title: DUMMY_TEXT,
- },
- slots: {
- default: [DUMMY_TEXT],
- },
+ createComponent();
+ });
+
+ it('does not render tooltip if not truncated', () => {
+ expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
+ expect(getTooltipValue()).toMatchObject({
+ disabled: true,
});
+ expect(wrapper.classes('js-show-tooltip')).toBe(false);
+ });
- return wrapper.vm.$nextTick().then(() => {
- expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element);
- expect(hasTooltip()).toBe(false);
+ it('renders tooltip on resize', async () => {
+ await resize({ truncate: true });
+
+ expect(getTooltipValue()).toMatchObject({
+ disabled: false,
+ });
+
+ await resize({ truncate: false });
+
+ expect(getTooltipValue()).toMatchObject({
+ disabled: true,
});
});
});
describe('with child target', () => {
- it('renders tooltip if truncated', () => {
+ it('renders tooltip if truncated', async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({
propsData: {
- title: DUMMY_TEXT,
truncateTarget: 'child',
},
slots: {
@@ -118,13 +140,18 @@ describe('TooltipOnTruncate component', () => {
},
});
- return wrapper.vm.$nextTick().then(() => {
- expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]);
- expect(hasTooltip()).toBe(true);
+ expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[0]);
+
+ await nextTick();
+
+ expect(getTooltipValue()).toMatchObject({
+ title: MOCK_TITLE,
+ placement: 'top',
+ disabled: false,
});
});
- it('does not render tooltip if normal', () => {
+ it('does not render tooltip if normal', async () => {
hasHorizontalOverflow.mockReturnValueOnce(false);
createComponent({
propsData: {
@@ -135,19 +162,21 @@ describe('TooltipOnTruncate component', () => {
},
});
- return wrapper.vm.$nextTick().then(() => {
- expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[0]);
- expect(hasTooltip()).toBe(false);
+ expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[0]);
+
+ await nextTick();
+
+ expect(getTooltipValue()).toMatchObject({
+ disabled: true,
});
});
});
describe('with fn target', () => {
- it('renders tooltip if truncated', () => {
+ it('renders tooltip if truncated', async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({
propsData: {
- title: DUMMY_TEXT,
truncateTarget: (el) => el.childNodes[1],
},
slots: {
@@ -155,93 +184,97 @@ describe('TooltipOnTruncate component', () => {
},
});
- return wrapper.vm.$nextTick().then(() => {
- expect(hasHorizontalOverflow).toHaveBeenCalledWith(wrapper.element.childNodes[1]);
- expect(hasTooltip()).toBe(true);
+ expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element.childNodes[1]);
+
+ await nextTick();
+
+ expect(getTooltipValue()).toMatchObject({
+ disabled: false,
});
});
});
describe('placement', () => {
- it('sets data-placement when tooltip is rendered', () => {
- const placement = 'bottom';
+ it('sets placement when tooltip is rendered', () => {
+ const mockPlacement = 'bottom';
hasHorizontalOverflow.mockReturnValueOnce(true);
createComponent({
propsData: {
- placement,
- },
- slots: {
- default: DUMMY_TEXT,
+ placement: mockPlacement,
},
});
- return wrapper.vm.$nextTick().then(() => {
- expect(hasTooltip()).toBe(true);
- expect(wrapper.attributes('data-placement')).toEqual(placement);
+ expect(hasHorizontalOverflow).toHaveBeenLastCalledWith(wrapper.element);
+ expect(getTooltipValue()).toMatchObject({
+ placement: mockPlacement,
});
});
});
describe('updates when title and slot content changes', () => {
describe('is initialized with a long text', () => {
- beforeEach(() => {
+ beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
createWrappedComponent({
- propsData: { title: DUMMY_TEXT },
+ propsData: { title: MOCK_TITLE },
});
- return parent.vm.$nextTick();
+ await nextTick();
});
it('renders tooltip', () => {
- expect(hasTooltip()).toBe(true);
- expect(wrapper.attributes('data-original-title')).toEqual(DUMMY_TEXT);
- expect(wrapper.attributes('data-placement')).toEqual('top');
+ expect(getTooltipValue()).toMatchObject({
+ title: MOCK_TITLE,
+ placement: 'top',
+ disabled: false,
+ });
});
- it('does not render tooltip after updated to a short text', () => {
+ it('does not render tooltip after updated to a short text', async () => {
hasHorizontalOverflow.mockReturnValueOnce(false);
parent.setProps({
- title: 'new-text',
+ title: SHORT_TITLE,
});
- return wrapper.vm
- .$nextTick()
- .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot
- .then(() => {
- expect(hasTooltip()).toBe(false);
- });
+ await nextTick();
+ await nextTick(); // wait 2 times to get an updated slot
+
+ expect(getTooltipValue()).toMatchObject({
+ title: SHORT_TITLE,
+ disabled: true,
+ });
});
});
- describe('is initialized with a short text', () => {
- beforeEach(() => {
+ describe('is initialized with a short text that does not overflow', () => {
+ beforeEach(async () => {
hasHorizontalOverflow.mockReturnValueOnce(false);
createWrappedComponent({
- propsData: { title: DUMMY_TEXT },
+ propsData: { title: MOCK_TITLE },
});
- return wrapper.vm.$nextTick();
+ await nextTick();
});
it('does not render tooltip', () => {
- expect(hasTooltip()).toBe(false);
+ expect(getTooltipValue()).toMatchObject({
+ title: MOCK_TITLE,
+ disabled: true,
+ });
});
- it('renders tooltip after text is updated', () => {
+ it('renders tooltip after text is updated', async () => {
hasHorizontalOverflow.mockReturnValueOnce(true);
- const newText = 'new-text';
parent.setProps({
- title: newText,
+ title: SHORT_TITLE,
});
- return wrapper.vm
- .$nextTick()
- .then(() => wrapper.vm.$nextTick()) // wait 2 times to get an updated slot
- .then(() => {
- expect(hasTooltip()).toBe(true);
- expect(wrapper.attributes('data-original-title')).toEqual(newText);
- expect(wrapper.attributes('data-placement')).toEqual('top');
- });
+ await nextTick();
+ await nextTick(); // wait 2 times to get an updated slot
+
+ expect(getTooltipValue()).toMatchObject({
+ title: SHORT_TITLE,
+ disabled: false,
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js
index b777ac0a0a4..8994e16e517 100644
--- a/spec/frontend/vue_shared/components/user_select_spec.js
+++ b/spec/frontend/vue_shared/components/user_select_spec.js
@@ -1,7 +1,7 @@
import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui';
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -33,8 +33,7 @@ const waitForSearch = async () => {
await waitForPromises();
};
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
describe('User select dropdown', () => {
let wrapper;
@@ -62,7 +61,6 @@ describe('User select dropdown', () => {
[getIssueParticipantsQuery, participantsQueryHandler],
]);
wrapper = shallowMount(UserSelect, {
- localVue,
apolloProvider: fakeApollo,
propsData: {
headerText: 'test',
diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
index ebd396bd87c..c136c2054ac 100644
--- a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
+++ b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
@@ -1,4 +1,4 @@
-import { mount, createLocalVue } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
@@ -38,10 +38,9 @@ describe('~/vue_shared/components/vuex_module_provider', () => {
it('does not blow up when used with vue-apollo', () => {
// See https://github.com/vuejs/vue-apollo/pull/1153 for details
- const localVue = createLocalVue();
- localVue.use(VueApollo);
+ Vue.use(VueApollo);
- createComponent({ localVue });
+ createComponent();
expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
});
});
diff --git a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js
index 3fb60c254c9..7738a69a174 100644
--- a/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js
+++ b/spec/frontend/vue_shared/gl_feature_flags_plugin_spec.js
@@ -1,9 +1,8 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import GlFeatureFlags from '~/vue_shared/gl_feature_flags_plugin';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-const localVue = createLocalVue();
-
describe('GitLab Feature Flags Plugin', () => {
beforeEach(() => {
window.gon = {
@@ -17,7 +16,7 @@ describe('GitLab Feature Flags Plugin', () => {
},
};
- localVue.use(GlFeatureFlags);
+ Vue.use(GlFeatureFlags);
});
it('should provide glFeatures to components', () => {
@@ -25,7 +24,7 @@ describe('GitLab Feature Flags Plugin', () => {
template: `<span></span>`,
inject: ['glFeatures'],
};
- const wrapper = shallowMount(component, { localVue });
+ const wrapper = shallowMount(component);
expect(wrapper.vm.glFeatures).toEqual({
aFeature: true,
bFeature: false,
@@ -39,7 +38,7 @@ describe('GitLab Feature Flags Plugin', () => {
template: `<span></span>`,
mixins: [glFeatureFlagsMixin()],
};
- const wrapper = shallowMount(component, { localVue });
+ const wrapper = shallowMount(component);
expect(wrapper.vm.glFeatures).toEqual({
aFeature: true,
bFeature: false,
diff --git a/spec/frontend/issuable_create/components/issuable_create_root_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js
index 675d01ae4af..81362edaf37 100644
--- a/spec/frontend/issuable_create/components/issuable_create_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/create/components/issuable_create_root_spec.js
@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils';
-import IssuableCreateRoot from '~/issuable_create/components/issuable_create_root.vue';
-import IssuableForm from '~/issuable_create/components/issuable_form.vue';
+import IssuableCreateRoot from '~/vue_shared/issuable/create/components/issuable_create_root.vue';
+import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue';
const createComponent = ({
descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown',
diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js
index 30b116bc35c..cbfd05e7903 100644
--- a/spec/frontend/issuable_create/components/issuable_form_spec.js
+++ b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js
@@ -1,7 +1,7 @@
import { GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import IssuableForm from '~/issuable_create/components/issuable_form.vue';
+import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
diff --git a/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js
index 52a238eac7c..0f33a3d1122 100644
--- a/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
-import IssuableBulkEditSidebar from '~/issuable_list/components/issuable_bulk_edit_sidebar.vue';
+import IssuableBulkEditSidebar from '~/vue_shared/issuable/list/components/issuable_bulk_edit_sidebar.vue';
const createComponent = ({ expanded = true } = {}) =>
shallowMount(IssuableBulkEditSidebar, {
diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
index ac3bf7f3269..e38a80e7734 100644
--- a/spec/frontend/issuable_list/components/issuable_item_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
@@ -1,19 +1,25 @@
import { GlLink, GlLabel, GlIcon, GlFormCheckbox, GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
-import IssuableItem from '~/issuable_list/components/issuable_item.vue';
-import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+import { shallowMountExtended as shallowMount } from 'helpers/vue_test_utils_helper';
+import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue';
+import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data';
-const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots = {} } = {}) =>
+const createComponent = ({
+ issuableSymbol = '#',
+ issuable = mockIssuable,
+ enableLabelPermalinks = true,
+ showCheckbox = true,
+ slots = {},
+} = {}) =>
shallowMount(IssuableItem, {
propsData: {
issuableSymbol,
issuable,
- enableLabelPermalinks: true,
+ enableLabelPermalinks,
showDiscussions: true,
- showCheckbox: false,
+ showCheckbox,
},
slots,
stubs: {
@@ -34,7 +40,6 @@ describe('IssuableItem', () => {
beforeEach(() => {
gon.gitlab_url = MOCK_GITLAB_URL;
- wrapper = createComponent();
});
afterEach(() => {
@@ -45,6 +50,8 @@ describe('IssuableItem', () => {
describe('computed', () => {
describe('author', () => {
it('returns `issuable.author` reference', () => {
+ wrapper = createComponent();
+
expect(wrapper.vm.author).toEqual(mockIssuable.author);
});
});
@@ -59,7 +66,7 @@ describe('IssuableItem', () => {
`(
'returns $returnValue when value of `issuable.author.id` is $authorId',
async ({ authorId, returnValue }) => {
- wrapper.setProps({
+ wrapper = createComponent({
issuable: {
...mockIssuable,
author: {
@@ -86,7 +93,7 @@ describe('IssuableItem', () => {
`(
'returns $returnValue when `issuable.webUrl` is $urlType',
async ({ issuableWebUrl, returnValue }) => {
- wrapper.setProps({
+ wrapper = createComponent({
issuable: {
...mockIssuable,
webUrl: issuableWebUrl,
@@ -102,11 +109,13 @@ describe('IssuableItem', () => {
describe('labels', () => {
it('returns `issuable.labels.nodes` reference when it is available', () => {
+ wrapper = createComponent();
+
expect(wrapper.vm.labels).toEqual(mockLabels);
});
it('returns `issuable.labels` reference when it is available', async () => {
- wrapper.setProps({
+ wrapper = createComponent({
issuable: {
...mockIssuable,
labels: mockLabels,
@@ -119,7 +128,7 @@ describe('IssuableItem', () => {
});
it('returns empty array when none of `issuable.labels.nodes` or `issuable.labels` are available', async () => {
- wrapper.setProps({
+ wrapper = createComponent({
issuable: {
...mockIssuable,
labels: null,
@@ -134,12 +143,16 @@ describe('IssuableItem', () => {
describe('assignees', () => {
it('returns `issuable.assignees` reference when it is available', () => {
+ wrapper = createComponent();
+
expect(wrapper.vm.assignees).toBe(mockIssuable.assignees);
});
});
describe('updatedAt', () => {
it('returns string containing timeago string based on `issuable.updatedAt`', () => {
+ wrapper = createComponent();
+
expect(wrapper.vm.updatedAt).toContain('updated');
expect(wrapper.vm.updatedAt).toContain('ago');
});
@@ -155,7 +168,7 @@ describe('IssuableItem', () => {
`(
'returns $returnValue when issuable.userDiscussionsCount is $userDiscussionsCount',
({ userDiscussionsCount, returnValue }) => {
- const wrapperWithDiscussions = createComponent({
+ wrapper = createComponent({
issuableSymbol: '#',
issuable: {
...mockIssuable,
@@ -163,9 +176,7 @@ describe('IssuableItem', () => {
},
});
- expect(wrapperWithDiscussions.vm.showDiscussions).toBe(returnValue);
-
- wrapperWithDiscussions.destroy();
+ expect(wrapper.findByTestId('issuable-discussions').exists()).toBe(returnValue);
},
);
});
@@ -180,6 +191,8 @@ describe('IssuableItem', () => {
`(
'return $returnValue when provided label param is a $labelType label',
({ label, returnValue }) => {
+ wrapper = createComponent();
+
expect(wrapper.vm.scopedLabel(label)).toBe(returnValue);
},
);
@@ -191,19 +204,23 @@ describe('IssuableItem', () => {
${{ title: 'foo' }} | ${'title'} | ${'foo'}
${{ name: 'foo' }} | ${'name'} | ${'foo'}
`('returns string value of `label.$propWithTitle`', ({ label, returnValue }) => {
+ wrapper = createComponent();
+
expect(wrapper.vm.labelTitle(label)).toBe(returnValue);
});
});
describe('labelTarget', () => {
it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => {
+ wrapper = createComponent();
+
expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe(
'?label_name[]=Documentation%20Update',
);
});
it('returns string "#" for a provided label param when `enableLabelPermalinks` is false', async () => {
- wrapper.setProps({
+ wrapper = createComponent({
enableLabelPermalinks: false,
});
@@ -223,7 +240,7 @@ describe('IssuableItem', () => {
`(
'renders issuable title correctly when `gitlabWebUrl` is `$gitlabWebUrl` and webUrl is `$webUrl`',
async ({ webUrl, gitlabWebUrl, expectedHref, expectedTarget }) => {
- wrapper.setProps({
+ wrapper = createComponent({
issuable: {
...mockIssuable,
webUrl,
@@ -243,7 +260,7 @@ describe('IssuableItem', () => {
);
it('renders checkbox when `showCheckbox` prop is true', async () => {
- wrapper.setProps({
+ wrapper = createComponent({
showCheckbox: true,
});
@@ -262,7 +279,7 @@ describe('IssuableItem', () => {
});
it('renders issuable title with `target` set as "_blank" when issuable.webUrl is external', async () => {
- wrapper.setProps({
+ wrapper = createComponent({
issuable: {
...mockIssuable,
webUrl: 'http://jira.atlassian.net/browse/IG-1',
@@ -277,7 +294,7 @@ describe('IssuableItem', () => {
});
it('renders issuable confidential icon when issuable is confidential', async () => {
- wrapper.setProps({
+ wrapper = createComponent({
issuable: {
...mockIssuable,
confidential: true,
@@ -296,7 +313,21 @@ describe('IssuableItem', () => {
});
});
+ it('renders spam icon when issuable is hidden', async () => {
+ wrapper = createComponent({ issuable: { ...mockIssuable, hidden: true } });
+
+ const hiddenIcon = wrapper.findComponent(GlIcon);
+
+ expect(hiddenIcon.props('name')).toBe('spam');
+ expect(hiddenIcon.attributes()).toMatchObject({
+ title: 'This issue is hidden because its author has been banned',
+ arialabel: 'Hidden',
+ });
+ });
+
it('renders task status', () => {
+ wrapper = createComponent();
+
const taskStatus = wrapper.find('[data-testid="task-status"]');
const expected = `${mockIssuable.taskCompletionStatus.completedCount} of ${mockIssuable.taskCompletionStatus.count} tasks completed`;
@@ -304,6 +335,8 @@ describe('IssuableItem', () => {
});
it('renders issuable reference', () => {
+ wrapper = createComponent();
+
const referenceEl = wrapper.find('[data-testid="issuable-reference"]');
expect(referenceEl.exists()).toBe(true);
@@ -311,7 +344,7 @@ describe('IssuableItem', () => {
});
it('renders issuable reference via slot', () => {
- const wrapperWithRefSlot = createComponent({
+ wrapper = createComponent({
issuableSymbol: '#',
issuable: mockIssuable,
slots: {
@@ -320,15 +353,15 @@ describe('IssuableItem', () => {
`,
},
});
- const referenceEl = wrapperWithRefSlot.find('.js-reference');
+ const referenceEl = wrapper.find('.js-reference');
expect(referenceEl.exists()).toBe(true);
expect(referenceEl.text()).toBe(`${mockIssuable.iid}`);
-
- wrapperWithRefSlot.destroy();
});
it('renders issuable createdAt info', () => {
+ wrapper = createComponent();
+
const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]');
expect(createdAtEl.exists()).toBe(true);
@@ -337,6 +370,8 @@ describe('IssuableItem', () => {
});
it('renders issuable author info', () => {
+ wrapper = createComponent();
+
const authorEl = wrapper.find('[data-testid="issuable-author"]');
expect(authorEl.exists()).toBe(true);
@@ -351,7 +386,7 @@ describe('IssuableItem', () => {
});
it('renders issuable author info via slot', () => {
- const wrapperWithAuthorSlot = createComponent({
+ wrapper = createComponent({
issuableSymbol: '#',
issuable: mockIssuable,
slots: {
@@ -360,16 +395,14 @@ describe('IssuableItem', () => {
`,
},
});
- const authorEl = wrapperWithAuthorSlot.find('.js-author');
+ const authorEl = wrapper.find('.js-author');
expect(authorEl.exists()).toBe(true);
expect(authorEl.text()).toBe(mockAuthor.name);
-
- wrapperWithAuthorSlot.destroy();
});
it('renders timeframe via slot', () => {
- const wrapperWithTimeframeSlot = createComponent({
+ wrapper = createComponent({
issuableSymbol: '#',
issuable: mockIssuable,
slots: {
@@ -378,15 +411,15 @@ describe('IssuableItem', () => {
`,
},
});
- const timeframeEl = wrapperWithTimeframeSlot.find('.js-timeframe');
+ const timeframeEl = wrapper.find('.js-timeframe');
expect(timeframeEl.exists()).toBe(true);
expect(timeframeEl.text()).toBe('Jan 1, 2020 - Mar 31, 2020');
-
- wrapperWithTimeframeSlot.destroy();
});
it('renders gl-label component for each label present within `issuable` prop', () => {
+ wrapper = createComponent();
+
const labelsEl = wrapper.findAll(GlLabel);
expect(labelsEl.exists()).toBe(true);
@@ -402,7 +435,7 @@ describe('IssuableItem', () => {
});
it('renders issuable status via slot', () => {
- const wrapperWithStatusSlot = createComponent({
+ wrapper = createComponent({
issuableSymbol: '#',
issuable: mockIssuable,
slots: {
@@ -411,15 +444,15 @@ describe('IssuableItem', () => {
`,
},
});
- const statusEl = wrapperWithStatusSlot.find('.js-status');
+ const statusEl = wrapper.find('.js-status');
expect(statusEl.exists()).toBe(true);
expect(statusEl.text()).toBe(`${mockIssuable.state}`);
-
- wrapperWithStatusSlot.destroy();
});
it('renders discussions count', () => {
+ wrapper = createComponent();
+
const discussionsEl = wrapper.find('[data-testid="issuable-discussions"]');
expect(discussionsEl.exists()).toBe(true);
@@ -432,6 +465,8 @@ describe('IssuableItem', () => {
});
it('renders issuable-assignees component', () => {
+ wrapper = createComponent();
+
const assigneesEl = wrapper.find(IssuableAssignees);
expect(assigneesEl.exists()).toBe(true);
@@ -443,6 +478,8 @@ describe('IssuableItem', () => {
});
it('renders issuable updatedAt info', () => {
+ wrapper = createComponent();
+
const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]');
expect(updatedAtEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC');
diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
index 7dddd2c3405..5979a65e3cd 100644
--- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
@@ -1,12 +1,12 @@
-import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueDraggable from 'vuedraggable';
import { TEST_HOST } from 'helpers/test_constants';
-import IssuableItem from '~/issuable_list/components/issuable_item.vue';
-import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue';
-import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue';
+import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vue';
+import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
+import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { mockIssuableListProps, mockIssuables } from '../mock_data';
@@ -36,6 +36,7 @@ const createComponent = ({ props = {}, data = {} } = {}) =>
describe('IssuableListRoot', () => {
let wrapper;
+ const findAlert = () => wrapper.findComponent(GlAlert);
const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findGlPagination = () => wrapper.findComponent(GlPagination);
@@ -310,6 +311,30 @@ describe('IssuableListRoot', () => {
hasPreviousPage: true,
});
});
+
+ describe('alert', () => {
+ const error = 'oopsie!';
+
+ it('shows alert when there is an error', () => {
+ wrapper = createComponent({ props: { error } });
+
+ expect(findAlert().text()).toBe(error);
+ });
+
+ it('emits "dismiss-alert" event when dismissed', () => {
+ wrapper = createComponent({ props: { error } });
+
+ findAlert().vm.$emit('dismiss');
+
+ expect(wrapper.emitted('dismiss-alert')).toEqual([[]]);
+ });
+
+ it('does not render when there is no error', () => {
+ wrapper = createComponent();
+
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
});
describe('events', () => {
diff --git a/spec/frontend/issuable_list/components/issuable_tabs_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js
index cbf5765078a..8c22b67bdbe 100644
--- a/spec/frontend/issuable_list/components/issuable_tabs_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_tabs_spec.js
@@ -1,7 +1,7 @@
import { GlTab, GlBadge } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue';
+import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue';
import { mockIssuableListProps } from '../mock_data';
diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/vue_shared/issuable/list/mock_data.js
index e2fa99f7cc9..e2fa99f7cc9 100644
--- a/spec/frontend/issuable_list/mock_data.js
+++ b/spec/frontend/vue_shared/issuable/list/mock_data.js
diff --git a/spec/frontend/issuable_show/components/issuable_body_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js
index 6fa298ca3f2..41bacf18a68 100644
--- a/spec/frontend/issuable_show/components/issuable_body_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_body_spec.js
@@ -1,11 +1,11 @@
import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
-import IssuableBody from '~/issuable_show/components/issuable_body.vue';
+import IssuableBody from '~/vue_shared/issuable/show/components/issuable_body.vue';
-import IssuableDescription from '~/issuable_show/components/issuable_description.vue';
-import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue';
-import IssuableTitle from '~/issuable_show/components/issuable_title.vue';
+import IssuableDescription from '~/vue_shared/issuable/show/components/issuable_description.vue';
+import IssuableEditForm from '~/vue_shared/issuable/show/components/issuable_edit_form.vue';
+import IssuableTitle from '~/vue_shared/issuable/show/components/issuable_title.vue';
import TaskList from '~/task_list';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
diff --git a/spec/frontend/issuable_show/components/issuable_description_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js
index 1058e5decfd..f2211e5b2bb 100644
--- a/spec/frontend/issuable_show/components/issuable_description_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js
@@ -1,7 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import $ from 'jquery';
-import IssuableDescription from '~/issuable_show/components/issuable_description.vue';
+import IssuableDescription from '~/vue_shared/issuable/show/components/issuable_description.vue';
import { mockIssuable } from '../mock_data';
diff --git a/spec/frontend/issuable_show/components/issuable_edit_form_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
index 184c9fe251c..051ffd27af4 100644
--- a/spec/frontend/issuable_show/components/issuable_edit_form_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_edit_form_spec.js
@@ -1,8 +1,8 @@
import { GlFormInput } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue';
-import IssuableEventHub from '~/issuable_show/event_hub';
+import IssuableEditForm from '~/vue_shared/issuable/show/components/issuable_edit_form.vue';
+import IssuableEventHub from '~/vue_shared/issuable/show/event_hub';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { mockIssuableShowProps, mockIssuable } from '../mock_data';
diff --git a/spec/frontend/issuable_show/components/issuable_header_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js
index b85f2dd1999..41735923957 100644
--- a/spec/frontend/issuable_show/components/issuable_header_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_header_spec.js
@@ -2,7 +2,7 @@ import { GlIcon, GlAvatarLabeled } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import IssuableHeader from '~/issuable_show/components/issuable_header.vue';
+import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue';
import { mockIssuableShowProps, mockIssuable } from '../mock_data';
diff --git a/spec/frontend/issuable_show/components/issuable_show_root_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js
index 7ad409c3a74..d1eb1366225 100644
--- a/spec/frontend/issuable_show/components/issuable_show_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_show_root_spec.js
@@ -1,10 +1,10 @@
import { shallowMount } from '@vue/test-utils';
-import IssuableBody from '~/issuable_show/components/issuable_body.vue';
-import IssuableHeader from '~/issuable_show/components/issuable_header.vue';
-import IssuableShowRoot from '~/issuable_show/components/issuable_show_root.vue';
+import IssuableBody from '~/vue_shared/issuable/show/components/issuable_body.vue';
+import IssuableHeader from '~/vue_shared/issuable/show/components/issuable_header.vue';
+import IssuableShowRoot from '~/vue_shared/issuable/show/components/issuable_show_root.vue';
-import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue';
+import IssuableSidebar from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue';
import { mockIssuableShowProps, mockIssuable } from '../mock_data';
diff --git a/spec/frontend/issuable_show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
index df6fbdea76b..1fcf37a0477 100644
--- a/spec/frontend/issuable_show/components/issuable_title_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
@@ -2,7 +2,7 @@ import { GlIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import IssuableTitle from '~/issuable_show/components/issuable_title.vue';
+import IssuableTitle from '~/vue_shared/issuable/show/components/issuable_title.vue';
import { mockIssuableShowProps, mockIssuable } from '../mock_data';
diff --git a/spec/frontend/issuable_show/mock_data.js b/spec/frontend/vue_shared/issuable/show/mock_data.js
index 986d32b4982..f5f3ed58655 100644
--- a/spec/frontend/issuable_show/mock_data.js
+++ b/spec/frontend/vue_shared/issuable/show/mock_data.js
@@ -1,4 +1,4 @@
-import { mockIssuable as issuable } from '../issuable_list/mock_data';
+import { mockIssuable as issuable } from 'jest/vue_shared/issuable/list/mock_data';
export const mockIssuable = {
...issuable,
diff --git a/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
index c872925cca2..788ba70ddc0 100644
--- a/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/sidebar/components/issuable_sidebar_root_spec.js
@@ -2,8 +2,8 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import IssuableSidebarRoot from '~/issuable_sidebar/components/issuable_sidebar_root.vue';
-import { USER_COLLAPSED_GUTTER_COOKIE } from '~/issuable_sidebar/constants';
+import IssuableSidebarRoot from '~/vue_shared/issuable/sidebar/components/issuable_sidebar_root.vue';
+import { USER_COLLAPSED_GUTTER_COOKIE } from '~/vue_shared/issuable/sidebar/constants';
const MOCK_LAYOUT_PAGE_CLASS = 'layout-page';
diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js
index cdaeec78e47..2b1513bb0f8 100644
--- a/spec/frontend/vue_shared/security_reports/mock_data.js
+++ b/spec/frontend/vue_shared/security_reports/mock_data.js
@@ -341,12 +341,15 @@ export const securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse = {
export const securityReportMergeRequestDownloadPathsQueryResponse = {
project: {
+ id: '1',
mergeRequest: {
+ id: 'mr-1',
headPipeline: {
id: 'gid://gitlab/Ci::Pipeline/176',
jobs: {
nodes: [
{
+ id: 'job-1',
name: 'secret_detection',
artifacts: {
nodes: [
@@ -368,6 +371,7 @@ export const securityReportMergeRequestDownloadPathsQueryResponse = {
__typename: 'CiJob',
},
{
+ id: 'job-2',
name: 'bandit-sast',
artifacts: {
nodes: [
@@ -389,6 +393,7 @@ export const securityReportMergeRequestDownloadPathsQueryResponse = {
__typename: 'CiJob',
},
{
+ id: 'job-3',
name: 'eslint-sast',
artifacts: {
nodes: [
@@ -410,6 +415,7 @@ export const securityReportMergeRequestDownloadPathsQueryResponse = {
__typename: 'CiJob',
},
{
+ id: 'job-4',
name: 'all_artifacts',
artifacts: {
nodes: [
@@ -449,11 +455,13 @@ export const securityReportMergeRequestDownloadPathsQueryResponse = {
export const securityReportPipelineDownloadPathsQueryResponse = {
project: {
+ id: 'project-1',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/176',
jobs: {
nodes: [
{
+ id: 'job-1',
name: 'secret_detection',
artifacts: {
nodes: [
@@ -475,6 +483,7 @@ export const securityReportPipelineDownloadPathsQueryResponse = {
__typename: 'CiJob',
},
{
+ id: 'job-2',
name: 'bandit-sast',
artifacts: {
nodes: [
@@ -496,6 +505,7 @@ export const securityReportPipelineDownloadPathsQueryResponse = {
__typename: 'CiJob',
},
{
+ id: 'job-3',
name: 'eslint-sast',
artifacts: {
nodes: [
@@ -517,6 +527,7 @@ export const securityReportPipelineDownloadPathsQueryResponse = {
__typename: 'CiJob',
},
{
+ id: 'job-4',
name: 'all_artifacts',
artifacts: {
nodes: [
diff --git a/spec/frontend/vue_shared/translate_spec.js b/spec/frontend/vue_shared/translate_spec.js
index 42aa28a6309..30417161968 100644
--- a/spec/frontend/vue_shared/translate_spec.js
+++ b/spec/frontend/vue_shared/translate_spec.js
@@ -1,9 +1,9 @@
-import { mount, createLocalVue } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
+import Vue from 'vue';
import locale from '~/locale';
import Translate from '~/vue_shared/translate';
-const localVue = createLocalVue();
-localVue.use(Translate);
+Vue.use(Translate);
describe('Vue translate filter', () => {
const createTranslationMock = (key, ...translations) => {
@@ -26,16 +26,13 @@ describe('Vue translate filter', () => {
const translation = 'singular_translated';
createTranslationMock(key, translation);
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ __('${key}') }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe(translation);
});
@@ -45,16 +42,13 @@ describe('Vue translate filter', () => {
const translationPlural = 'plural_multiple translation';
createTranslationMock(key, 'plural_singular translation', translationPlural);
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ n__('${key}', 'plurals', 2) }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe(translationPlural);
});
@@ -67,31 +61,25 @@ describe('Vue translate filter', () => {
});
it('and n === 1', () => {
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ n__('${key}', '%d days', 1) }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe('1 singular translated');
});
it('and n > 1', () => {
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ n__('${key}', '%d days', 2) }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe('2 plural translated');
});
@@ -107,31 +95,25 @@ describe('Vue translate filter', () => {
});
it('and using two parameters', () => {
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ s__('Context', 'Foobar') }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe(expectation);
});
it('and using the pipe syntax', () => {
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ s__('${key}') }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe(expectation);
});
@@ -141,9 +123,8 @@ describe('Vue translate filter', () => {
const translation = 'multiline string translated';
createTranslationMock('multiline string', translation);
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ __(\`
multiline
@@ -151,9 +132,7 @@ describe('Vue translate filter', () => {
\`) }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe(translation);
});
@@ -163,9 +142,8 @@ describe('Vue translate filter', () => {
createTranslationMock('multiline string', 'multiline string singular', translation);
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ n__(
\`
@@ -180,9 +158,7 @@ describe('Vue translate filter', () => {
) }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe(translation);
});
@@ -192,9 +168,8 @@ describe('Vue translate filter', () => {
createTranslationMock('Context| multiline string', translation);
- const wrapper = mount(
- {
- template: `
+ const wrapper = mount({
+ template: `
<span>
{{ s__(
\`
@@ -205,9 +180,7 @@ describe('Vue translate filter', () => {
) }}
</span>
`,
- },
- { localVue },
- );
+ });
expect(wrapper.text()).toBe(translation);
});
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
new file mode 100644
index 00000000000..0f6e7091c59
--- /dev/null
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -0,0 +1,56 @@
+import { shallowMount } from '@vue/test-utils';
+import { escape } from 'lodash';
+import ItemTitle from '~/work_items/components/item_title.vue';
+
+jest.mock('lodash/escape', () => jest.fn((fn) => fn));
+
+const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) =>
+ shallowMount(ItemTitle, {
+ propsData: {
+ initialTitle,
+ disabled,
+ },
+ });
+
+describe('ItemTitle', () => {
+ let wrapper;
+ const mockUpdatedTitle = 'Updated title';
+ const findInputEl = () => wrapper.find('span#item-title');
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders title contents', () => {
+ expect(findInputEl().attributes()).toMatchObject({
+ 'data-placeholder': 'Add a title...',
+ contenteditable: 'true',
+ });
+ expect(findInputEl().text()).toBe('Sample title');
+ });
+
+ it('renders title contents with editing disabled', () => {
+ wrapper = createComponent({
+ disabled: true,
+ });
+
+ expect(wrapper.classes()).toContain('gl-cursor-not-allowed');
+ expect(findInputEl().attributes('contenteditable')).toBe('false');
+ });
+
+ it.each`
+ eventName | sourceEvent
+ ${'title-changed'} | ${'blur'}
+ ${'title-input'} | ${'keyup'}
+ `('emits "$eventName" event on input $sourceEvent', async ({ eventName, sourceEvent }) => {
+ findInputEl().element.innerText = mockUpdatedTitle;
+ await findInputEl().trigger(sourceEvent);
+
+ expect(wrapper.emitted(eventName)).toBeTruthy();
+ expect(escape).toHaveBeenCalledWith(mockUpdatedTitle);
+ });
+});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index efb4aa2feb2..9741a193258 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -1,13 +1,13 @@
export const workItemQueryResponse = {
workItem: {
- __typename: 'WorkItem',
+ __typename: 'LocalWorkItem',
id: '1',
type: 'FEATURE',
widgets: {
- __typename: 'WorkItemWidgetConnection',
+ __typename: 'LocalWorkItemWidgetConnection',
nodes: [
{
- __typename: 'TitleWidget',
+ __typename: 'LocalTitleWidget',
type: 'TITLE',
contentText: 'Test',
},
@@ -15,3 +15,22 @@ export const workItemQueryResponse = {
},
},
};
+
+export const updateWorkItemMutationResponse = {
+ __typename: 'LocalUpdateWorkItemPayload',
+ workItem: {
+ __typename: 'LocalWorkItem',
+ id: '1',
+ widgets: {
+ __typename: 'LocalWorkItemWidgetConnection',
+ nodes: [
+ {
+ __typename: 'LocalTitleWidget',
+ type: 'TITLE',
+ enabled: true,
+ contentText: 'Updated title',
+ },
+ ],
+ },
+ },
+};
diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js
new file mode 100644
index 00000000000..71e153d30c3
--- /dev/null
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -0,0 +1,94 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
+import ItemTitle from '~/work_items/components/item_title.vue';
+import { resolvers } from '~/work_items/graphql/resolvers';
+
+Vue.use(VueApollo);
+
+describe('Create work item component', () => {
+ let wrapper;
+ let fakeApollo;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findTitleInput = () => wrapper.findComponent(ItemTitle);
+ const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
+ const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
+
+ const createComponent = ({ data = {} } = {}) => {
+ fakeApollo = createMockApollo([], resolvers);
+ wrapper = shallowMount(CreateWorkItem, {
+ apolloProvider: fakeApollo,
+ data() {
+ return {
+ ...data,
+ };
+ },
+ mocks: {
+ $router: {
+ go: jest.fn(),
+ push: jest.fn(),
+ },
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ it('does not render error by default', () => {
+ createComponent();
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('renders a disabled Create button when title input is empty', () => {
+ createComponent();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('redirects to the previous page on Cancel button click', () => {
+ createComponent();
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.vm.$router.go).toHaveBeenCalledWith(-1);
+ });
+
+ it('hides the alert on dismissing the error', async () => {
+ createComponent({ data: { error: true } });
+ expect(findAlert().exists()).toBe(true);
+
+ findAlert().vm.$emit('dismiss');
+ await nextTick();
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ describe('when title input field has a text', () => {
+ beforeEach(async () => {
+ const mockTitle = 'Test title';
+ createComponent();
+ await findTitleInput().vm.$emit('title-input', mockTitle);
+ });
+
+ it('renders a non-disabled Create button', () => {
+ expect(findCreateButton().props('disabled')).toBe(false);
+ });
+
+ it('redirects to the work item page on successful mutation', async () => {
+ wrapper.find('form').trigger('submit');
+ await waitForPromises();
+
+ expect(wrapper.vm.$router.push).toHaveBeenCalled();
+ });
+
+ // TODO: write a proper test here when we have a backend implementation
+ it.todo('shows an alert on mutation error');
+ });
+});
diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js
index 64d02baed36..02795751f33 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -1,12 +1,16 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vue from 'vue';
+import { 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 workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
+import ItemTitle from '~/work_items/components/item_title.vue';
+import { resolvers } from '~/work_items/graphql/resolvers';
import { workItemQueryResponse } from '../mock_data';
-const localVue = createLocalVue();
-localVue.use(VueApollo);
+Vue.use(VueApollo);
const WORK_ITEM_ID = '1';
@@ -14,10 +18,10 @@ describe('Work items root component', () => {
let wrapper;
let fakeApollo;
- const findTitle = () => wrapper.find('[data-testid="title"]');
+ const findTitle = () => wrapper.findComponent(ItemTitle);
const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => {
- fakeApollo = createMockApollo();
+ fakeApollo = createMockApollo([], resolvers);
fakeApollo.clients.defaultClient.cache.writeQuery({
query: workItemQuery,
variables: {
@@ -30,7 +34,6 @@ describe('Work items root component', () => {
propsData: {
id: WORK_ITEM_ID,
},
- localVue,
apolloProvider: fakeApollo,
});
};
@@ -44,7 +47,28 @@ describe('Work items root component', () => {
createComponent();
expect(findTitle().exists()).toBe(true);
- expect(findTitle().text()).toBe('Test');
+ expect(findTitle().props('initialTitle')).toBe('Test');
+ });
+
+ it('updates the title when it is edited', async () => {
+ createComponent();
+ jest.spyOn(wrapper.vm.$apollo, 'mutate');
+ const mockUpdatedTitle = 'Updated title';
+
+ await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: WORK_ITEM_ID,
+ title: mockUpdatedTitle,
+ },
+ },
+ });
+
+ await waitForPromises();
+ expect(findTitle().props('initialTitle')).toBe(mockUpdatedTitle);
});
it('does not render the title if title is not in the widgets list', () => {
diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js
index 0a57eab753f..6017c9d9dbb 100644
--- a/spec/frontend/work_items/router_spec.js
+++ b/spec/frontend/work_items/router_spec.js
@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils';
import App from '~/work_items/components/app.vue';
+import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
import { createRouter } from '~/work_items/router';
@@ -27,4 +28,10 @@ describe('Work items router', () => {
expect(wrapper.find(WorkItemsRoot).exists()).toBe(true);
});
+
+ it('renders create work item page on `/new` route', async () => {
+ await createComponent('/new');
+
+ expect(wrapper.findComponent(CreateWorkItem).exists()).toBe(true);
+ });
});