summaryrefslogtreecommitdiff
path: root/spec/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/__helpers__/init_vue_mr_page_helper.js4
-rw-r--r--spec/frontend/__helpers__/matchers/index.js1
-rw-r--r--spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js60
-rw-r--r--spec/frontend/__helpers__/mock_user_callout_dismisser.js15
-rw-r--r--spec/frontend/__helpers__/performance.js8
-rw-r--r--spec/frontend/__helpers__/set_window_location_helper.js2
-rw-r--r--spec/frontend/__helpers__/shared_test_setup.js1
-rw-r--r--spec/frontend/__helpers__/web_worker_fake.js3
-rw-r--r--spec/frontend/access_tokens/components/access_token_table_app_spec.js15
-rw-r--r--spec/frontend/access_tokens/components/projects_field_spec.js8
-rw-r--r--spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap2
-rw-r--r--spec/frontend/admin/analytics/devops_score/components/devops_score_spec.js2
-rw-r--r--spec/frontend/admin/deploy_keys/components/table_spec.js29
-rw-r--r--spec/frontend/admin/signup_restrictions/components/signup_form_spec.js8
-rw-r--r--spec/frontend/admin/signup_restrictions/mock_data.js8
-rw-r--r--spec/frontend/admin/signup_restrictions/utils_spec.js4
-rw-r--r--spec/frontend/admin/statistics_panel/components/app_spec.js2
-rw-r--r--spec/frontend/admin/users/components/user_actions_spec.js6
-rw-r--r--spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap6
-rw-r--r--spec/frontend/alerts_settings/components/alerts_settings_form_spec.js2
-rw-r--r--spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap1
-rw-r--r--spec/frontend/batch_comments/components/draft_note_spec.js11
-rw-r--r--spec/frontend/batch_comments/components/preview_dropdown_spec.js26
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js12
-rw-r--r--spec/frontend/behaviors/shortcuts/keybindings_spec.js4
-rw-r--r--spec/frontend/blob/viewer/index_spec.js189
-rw-r--r--spec/frontend/boards/components/board_card_spec.js38
-rw-r--r--spec/frontend/boards/components/config_toggle_spec.js59
-rw-r--r--spec/frontend/boards/components/toggle_focus_spec.js47
-rw-r--r--spec/frontend/boards/mock_data.js2
-rw-r--r--spec/frontend/boards/project_select_spec.js8
-rw-r--r--spec/frontend/ci_lint/mock_data.js11
-rw-r--r--spec/frontend/ci_secure_files/components/secure_files_list_spec.js41
-rw-r--r--spec/frontend/clusters_list/components/agent_table_spec.js8
-rw-r--r--spec/frontend/clusters_list/components/clusters_spec.js4
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/code_block_spec.js25
-rw-r--r--spec/frontend/content_editor/components/bubble_menus/formatting_spec.js24
-rw-r--r--spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js35
-rw-r--r--spec/frontend/content_editor/components/top_toolbar_spec.js10
-rw-r--r--spec/frontend/content_editor/extensions/html_nodes_spec.js42
-rw-r--r--spec/frontend/content_editor/extensions/paste_markdown_spec.js2
-rw-r--r--spec/frontend/content_editor/markdown_snapshot_spec.js10
-rw-r--r--spec/frontend/content_editor/markdown_snapshot_spec_helper.js102
-rw-r--r--spec/frontend/content_editor/remark_markdown_processing_spec.js619
-rw-r--r--spec/frontend/content_editor/render_html_and_json_for_all_examples.js115
-rw-r--r--spec/frontend/content_editor/services/markdown_serializer_spec.js28
-rw-r--r--spec/frontend/content_editor/services/markdown_sourcemap_spec.js2
-rw-r--r--spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js2
-rw-r--r--spec/frontend/cycle_analytics/path_navigation_spec.js2
-rw-r--r--spec/frontend/cycle_analytics/value_stream_metrics_spec.js3
-rw-r--r--spec/frontend/design_management/components/design_sidebar_spec.js61
-rw-r--r--spec/frontend/design_management/components/image_spec.js5
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap4
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap67
-rw-r--r--spec/frontend/design_management/pages/index_spec.js78
-rw-r--r--spec/frontend/diffs/components/app_spec.js2
-rw-r--r--spec/frontend/diffs/components/collapsed_files_warning_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_code_quality_spec.js66
-rw-r--r--spec/frontend/diffs/components/diff_content_spec.js6
-rw-r--r--spec/frontend/diffs/components/diff_expansion_cell_spec.js177
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js4
-rw-r--r--spec/frontend/diffs/components/diff_line_note_form_spec.js289
-rw-r--r--spec/frontend/diffs/components/diff_row_spec.js3
-rw-r--r--spec/frontend/diffs/components/diff_stats_spec.js3
-rw-r--r--spec/frontend/diffs/components/diff_view_spec.js32
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js9
-rw-r--r--spec/frontend/diffs/components/tree_list_spec.js32
-rw-r--r--spec/frontend/diffs/mock_data/diff_code_quality.js62
-rw-r--r--spec/frontend/diffs/mock_data/diff_file.js4
-rw-r--r--spec/frontend/diffs/store/actions_spec.js4
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js5
-rw-r--r--spec/frontend/diffs/store/utils_spec.js6
-rw-r--r--spec/frontend/diffs/utils/diff_file_spec.js6
-rw-r--r--spec/frontend/dirty_submit/dirty_submit_form_spec.js1
-rw-r--r--spec/frontend/emoji/awards_app/store/actions_spec.js12
-rw-r--r--spec/frontend/environment.js6
-rw-r--r--spec/frontend/environments/canary_update_modal_spec.js2
-rw-r--r--spec/frontend/environments/confirm_rollback_modal_spec.js15
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js8
-rw-r--r--spec/frontend/environments/environment_item_spec.js4
-rw-r--r--spec/frontend/environments/environment_table_spec.js3
-rw-r--r--spec/frontend/environments/environments_app_spec.js4
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js24
-rw-r--r--spec/frontend/fixtures/api_deploy_keys.rb1
-rw-r--r--spec/frontend/fixtures/blob.rb1
-rw-r--r--spec/frontend/fixtures/deploy_keys.rb6
-rw-r--r--spec/frontend/fixtures/jobs.rb98
-rw-r--r--spec/frontend/fixtures/runner.rb16
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js11
-rw-r--r--spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js102
-rw-r--r--spec/frontend/google_cloud/components/app_spec.js77
-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/google_cloud_menu_spec.js40
-rw-r--r--spec/frontend/google_cloud/components/home_spec.js66
-rw-r--r--spec/frontend/google_cloud/components/incubation_banner_spec.js21
-rw-r--r--spec/frontend/google_cloud/components/revoke_oauth_spec.js2
-rw-r--r--spec/frontend/google_cloud/configuration/panel_spec.js65
-rw-r--r--spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js103
-rw-r--r--spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js65
-rw-r--r--spec/frontend/google_cloud/databases/panel_spec.js36
-rw-r--r--spec/frontend/google_cloud/databases/service_table_spec.js44
-rw-r--r--spec/frontend/google_cloud/deployments/panel_spec.js46
-rw-r--r--spec/frontend/google_cloud/deployments/service_table_spec.js (renamed from spec/frontend/google_cloud/components/deployments_service_table_spec.js)4
-rw-r--r--spec/frontend/google_cloud/gcp_regions/form_spec.js (renamed from spec/frontend/google_cloud/components/gcp_regions_form_spec.js)4
-rw-r--r--spec/frontend/google_cloud/gcp_regions/list_spec.js (renamed from spec/frontend/google_cloud/components/gcp_regions_list_spec.js)4
-rw-r--r--spec/frontend/google_cloud/service_accounts/form_spec.js (renamed from spec/frontend/google_cloud/components/service_accounts_form_spec.js)4
-rw-r--r--spec/frontend/google_cloud/service_accounts/list_spec.js (renamed from spec/frontend/google_cloud/components/service_accounts_list_spec.js)4
-rw-r--r--spec/frontend/google_tag_manager/index_spec.js11
-rw-r--r--spec/frontend/groups/components/group_folder_spec.js79
-rw-r--r--spec/frontend/groups/components/group_item_spec.js101
-rw-r--r--spec/frontend/groups/components/group_name_and_path_spec.js17
-rw-r--r--spec/frontend/groups/components/groups_spec.js72
-rw-r--r--spec/frontend/groups/mock_data.js21
-rw-r--r--spec/frontend/header_search/components/app_spec.js272
-rw-r--r--spec/frontend/header_search/components/header_search_scoped_items_spec.js47
-rw-r--r--spec/frontend/header_search/init_spec.js74
-rw-r--r--spec/frontend/header_search/mock_data.js75
-rw-r--r--spec/frontend/header_search/store/getters_spec.js8
-rw-r--r--spec/frontend/header_spec.js24
-rw-r--r--spec/frontend/ide/components/commit_sidebar/empty_state_spec.js26
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_spec.js56
-rw-r--r--spec/frontend/ide/components/commit_sidebar/success_message_spec.js30
-rw-r--r--spec/frontend/ide/components/ide_spec.js3
-rw-r--r--spec/frontend/ide/components/ide_tree_list_spec.js78
-rw-r--r--spec/frontend/ide/components/nav_dropdown_button_spec.js73
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js470
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js12
-rw-r--r--spec/frontend/ide/ide_router_spec.js3
-rw-r--r--spec/frontend/ide/stores/actions/file_spec.js3
-rw-r--r--spec/frontend/ide/stores/actions/merge_request_spec.js3
-rw-r--r--spec/frontend/ide/stores/actions/tree_spec.js3
-rw-r--r--spec/frontend/ide/stores/actions_spec.js3
-rw-r--r--spec/frontend/import_entities/import_groups/components/import_table_spec.js32
-rw-r--r--spec/frontend/integrations/edit/components/active_checkbox_spec.js14
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js57
-rw-r--r--spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js3
-rw-r--r--spec/frontend/invite_members/components/import_project_members_modal_spec.js (renamed from spec/frontend/invite_members/components/import_a_project_modal_spec.js)44
-rw-r--r--spec/frontend/invite_members/components/import_project_members_trigger_spec.js49
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js173
-rw-r--r--spec/frontend/invite_members/components/invite_modal_base_spec.js2
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js5
-rw-r--r--spec/frontend/invite_members/mock_data/member_modal.js10
-rw-r--r--spec/frontend/invite_members/utils/member_utils_spec.js12
-rw-r--r--spec/frontend/invite_members/utils/response_message_parser_spec.js33
-rw-r--r--spec/frontend/issuable/issuable_form_spec.js19
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js61
-rw-r--r--spec/frontend/issues/list/mock_data.js1
-rw-r--r--spec/frontend/issues/list/utils_spec.js79
-rw-r--r--spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap3
-rw-r--r--spec/frontend/issues/show/components/description_spec.js45
-rw-r--r--spec/frontend/issues/show/components/edited_spec.js83
-rw-r--r--spec/frontend/issues/show/components/incidents/highlight_bar_spec.js2
-rw-r--r--spec/frontend/issues/show/components/incidents/mock_data.js33
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js181
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js61
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js129
-rw-r--r--spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js78
-rw-r--r--spec/frontend/issues/show/components/incidents/utils_spec.js31
-rw-r--r--spec/frontend/jobs/bridge/app_spec.js146
-rw-r--r--spec/frontend/jobs/bridge/components/empty_state_spec.js58
-rw-r--r--spec/frontend/jobs/bridge/components/sidebar_spec.js99
-rw-r--r--spec/frontend/jobs/bridge/mock_data.js102
-rw-r--r--spec/frontend/jobs/components/job_app_spec.js7
-rw-r--r--spec/frontend/jobs/components/job_log_controllers_spec.js61
-rw-r--r--spec/frontend/jobs/components/job_sidebar_details_container_spec.js4
-rw-r--r--spec/frontend/jobs/components/jobs_container_spec.js2
-rw-r--r--spec/frontend/jobs/components/log/collapsible_section_spec.js9
-rw-r--r--spec/frontend/jobs/components/log/line_spec.js42
-rw-r--r--spec/frontend/jobs/components/log/log_spec.js91
-rw-r--r--spec/frontend/jobs/components/log/mock_data.js74
-rw-r--r--spec/frontend/jobs/components/table/cells/actions_cell_spec.js41
-rw-r--r--spec/frontend/jobs/components/table/cells/job_cell_spec.js32
-rw-r--r--spec/frontend/jobs/components/table/job_table_app_spec.js43
-rw-r--r--spec/frontend/jobs/components/table/jobs_table_spec.js10
-rw-r--r--spec/frontend/jobs/mock_data.js772
-rw-r--r--spec/frontend/jobs/store/mutations_spec.js94
-rw-r--r--spec/frontend/jobs/store/utils_spec.js111
-rw-r--r--spec/frontend/lib/dompurify_spec.js23
-rw-r--r--spec/frontend/lib/gfm/index_spec.js71
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js22
-rw-r--r--spec/frontend/lib/utils/navigation_utility_spec.js2
-rw-r--r--spec/frontend/lib/utils/rails_ujs_spec.js2
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js13
-rw-r--r--spec/frontend/logs/components/environment_logs_spec.js370
-rw-r--r--spec/frontend/logs/components/log_advanced_filters_spec.js175
-rw-r--r--spec/frontend/logs/components/log_control_buttons_spec.js88
-rw-r--r--spec/frontend/logs/components/log_simple_filters_spec.js134
-rw-r--r--spec/frontend/logs/components/tokens/token_with_loading_state_spec.js71
-rw-r--r--spec/frontend/logs/mock_data.js71
-rw-r--r--spec/frontend/logs/stores/actions_spec.js521
-rw-r--r--spec/frontend/logs/stores/getters_spec.js75
-rw-r--r--spec/frontend/logs/stores/mutations_spec.js257
-rw-r--r--spec/frontend/merge_request_tabs_spec.js19
-rw-r--r--spec/frontend/milestones/components/delete_milestone_modal_spec.js137
-rw-r--r--spec/frontend/milestones/components/milestone_combobox_spec.js8
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap2
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js98
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js37
-rw-r--r--spec/frontend/monitoring/store/mutations_spec.js12
-rw-r--r--spec/frontend/new_branch_spec.js23
-rw-r--r--spec/frontend/notebook/cells/code_spec.js56
-rw-r--r--spec/frontend/notebook/cells/markdown_spec.js2
-rw-r--r--spec/frontend/notebook/cells/output/index_spec.js56
-rw-r--r--spec/frontend/notebook/cells/prompt_spec.js42
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js122
-rw-r--r--spec/frontend/notes/components/note_signed_out_widget_spec.js37
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js6
-rw-r--r--spec/frontend/notes/components/noteable_note_spec.js23
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js49
-rw-r--r--spec/frontend/notes/components/toggle_replies_widget_spec.js83
-rw-r--r--spec/frontend/notes/stores/actions_spec.js17
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js6
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js12
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js44
-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.js5
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/app_spec.js27
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js25
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js21
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js1
-rw-r--r--spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js21
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap3
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js7
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js9
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js267
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js81
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js30
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/mock_data.js30
-rw-r--r--spec/frontend/packages_and_registries/shared/components/settings_block_spec.js43
-rw-r--r--spec/frontend/pages/dashboard/todos/index/todos_spec.js25
-rw-r--r--spec/frontend/pages/projects/forks/new/components/fork_form_spec.js15
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap244
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js57
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js6
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_trial_card_spec.js12
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js8
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js6
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_content_spec.js8
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_form_spec.js35
-rw-r--r--spec/frontend/pdf/index_spec.js39
-rw-r--r--spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js22
-rw-r--r--spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js128
-rw-r--r--spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js43
-rw-r--r--spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js23
-rw-r--r--spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js259
-rw-r--r--spec/frontend/pipeline_editor/mock_data.js2
-rw-r--r--spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js110
-rw-r--r--spec/frontend/pipeline_wizard/pipeline_wizard_spec.js8
-rw-r--r--spec/frontend/pipeline_wizard/templates/pages_spec.js89
-rw-r--r--spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js6
-rw-r--r--spec/frontend/pipelines/components/jobs/jobs_app_spec.js9
-rw-r--r--spec/frontend/pipelines/graph/graph_component_wrapper_spec.js17
-rw-r--r--spec/frontend/pipelines/graph/graph_view_selector_spec.js60
-rw-r--r--spec/frontend/pipelines/graph/job_group_dropdown_spec.js2
-rw-r--r--spec/frontend/pipelines/graph/linked_pipeline_spec.js2
-rw-r--r--spec/frontend/pipelines/graph/mock_data.js242
-rw-r--r--spec/frontend/pipelines/header_component_spec.js4
-rw-r--r--spec/frontend/pipelines/performance_insights_modal_spec.js122
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js1
-rw-r--r--spec/frontend/pipelines/test_reports/stores/mutations_spec.js2
-rw-r--r--spec/frontend/pipelines/test_reports/test_case_details_spec.js6
-rw-r--r--spec/frontend/pipelines/test_reports/test_reports_spec.js26
-rw-r--r--spec/frontend/pipelines/test_reports/test_suite_table_spec.js30
-rw-r--r--spec/frontend/pipelines/test_reports/test_summary_table_spec.js16
-rw-r--r--spec/frontend/pipelines/utils_spec.js44
-rw-r--r--spec/frontend/projects/new/components/new_project_url_select_spec.js25
-rw-r--r--spec/frontend/projects/pipelines/charts/components/app_spec.js80
-rw-r--r--spec/frontend/projects/settings/components/new_access_dropdown_spec.js23
-rw-r--r--spec/frontend/protected_branches/protected_branch_edit_spec.js36
-rw-r--r--spec/frontend/ref/components/ref_selector_spec.js6
-rw-r--r--spec/frontend/releases/__snapshots__/util_spec.js.snap12
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js62
-rw-r--r--spec/frontend/releases/components/app_index_spec.js31
-rw-r--r--spec/frontend/releases/components/confirm_delete_modal_spec.js89
-rw-r--r--spec/frontend/releases/components/release_block_footer_spec.js26
-rw-r--r--spec/frontend/releases/components/release_block_spec.js5
-rw-r--r--spec/frontend/releases/components/tag_field_spec.js8
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js158
-rw-r--r--spec/frontend/releases/stores/modules/detail/getters_spec.js36
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js23
-rw-r--r--spec/frontend/reports/components/grouped_issues_list_spec.js7
-rw-r--r--spec/frontend/reports/mock_data/new_failures_report.json2
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap2
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js11
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js290
-rw-r--r--spec/frontend/repository/components/table/row_spec.js9
-rw-r--r--spec/frontend/repository/log_tree_spec.js12
-rw-r--r--spec/frontend/repository/utils/commit_spec.js2
-rw-r--r--spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js29
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js227
-rw-r--r--spec/frontend/runner/components/cells/runner_actions_cell_spec.js4
-rw-r--r--spec/frontend/runner/components/runner_delete_button_spec.js4
-rw-r--r--spec/frontend/runner/components/runner_details_spec.js32
-rw-r--r--spec/frontend/runner/components/runner_filtered_search_bar_spec.js10
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js4
-rw-r--r--spec/frontend/runner/components/runner_pause_button_spec.js24
-rw-r--r--spec/frontend/runner/components/runner_type_tabs_spec.js137
-rw-r--r--spec/frontend/runner/components/search_tokens/tag_token_spec.js3
-rw-r--r--spec/frontend/runner/components/stat/runner_count_spec.js148
-rw-r--r--spec/frontend/runner/components/stat/runner_stats_spec.js61
-rw-r--r--spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js213
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js211
-rw-r--r--spec/frontend/runner/mock_data.js212
-rw-r--r--spec/frontend/runner/runner_search_utils_spec.js212
-rw-r--r--spec/frontend/security_configuration/components/app_spec.js173
-rw-r--r--spec/frontend/security_configuration/components/training_provider_list_spec.js77
-rw-r--r--spec/frontend/security_configuration/mock_data.js11
-rw-r--r--spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap2
-rw-r--r--spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js22
-rw-r--r--spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js14
-rw-r--r--spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js7
-rw-r--r--spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js41
-rw-r--r--spec/frontend/sidebar/lock/issuable_lock_form_spec.js45
-rw-r--r--spec/frontend/sidebar/mock_data.js13
-rw-r--r--spec/frontend/snippets/components/edit_spec.js3
-rw-r--r--spec/frontend/snippets/components/show_spec.js5
-rw-r--r--spec/frontend/surveys/merge_request_performance/app_spec.js143
-rw-r--r--spec/frontend/tabs/index_spec.js105
-rw-r--r--spec/frontend/test_setup.js2
-rw-r--r--spec/frontend/tracking/tracking_spec.js2
-rw-r--r--spec/frontend/user_lists/store/edit/mutations_spec.js4
-rw-r--r--spec/frontend/user_lists/store/new/mutations_spec.js2
-rw-r--r--spec/frontend/user_popovers_spec.js26
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js5
-rw-r--r--spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js71
-rw-r--r--spec/frontend/vue_mr_widget/extensions/test_report/utils_spec.js242
-rw-r--r--spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js6
-rw-r--r--spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js169
-rw-r--r--spec/frontend/vue_mr_widget/test_extensions.js33
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js18
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js52
-rw-r--r--spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js50
-rw-r--r--spec/frontend/vue_shared/components/deployment_instance/mock_data.js1
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js143
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js146
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js283
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js282
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap23
-rw-r--r--spec/frontend/vue_shared/components/notes/placeholder_note_spec.js12
-rw-r--r--spec/frontend/vue_shared/components/page_size_selector_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap4
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js14
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js2
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js31
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js73
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js23
-rw-r--r--spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js2
-rw-r--r--spec/frontend/work_items/components/item_title_spec.js2
-rw-r--r--spec/frontend/work_items/components/work_item_assignees_spec.js407
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js10
-rw-r--r--spec/frontend/work_items/components/work_item_information_spec.js48
-rw-r--r--spec/frontend/work_items/components/work_item_labels_spec.js171
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js65
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js141
-rw-r--r--spec/frontend/work_items/components/work_item_links/work_item_links_spec.js30
-rw-r--r--spec/frontend/work_items/components/work_item_weight_spec.js154
-rw-r--r--spec/frontend/work_items/mock_data.js292
-rw-r--r--spec/frontend/work_items/pages/create_work_item_spec.js52
-rw-r--r--spec/frontend/work_items/pages/work_item_detail_spec.js135
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js1
371 files changed, 12134 insertions, 7661 deletions
diff --git a/spec/frontend/__helpers__/init_vue_mr_page_helper.js b/spec/frontend/__helpers__/init_vue_mr_page_helper.js
index 6b719a32480..83ed0a869dc 100644
--- a/spec/frontend/__helpers__/init_vue_mr_page_helper.js
+++ b/spec/frontend/__helpers__/init_vue_mr_page_helper.js
@@ -1,7 +1,7 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import initMRPage from '~/mr_notes';
-import diffFileMockData from '../diffs/mock_data/diff_file';
+import { getDiffFileMock } from '../diffs/mock_data/diff_file';
import { userDataMock, notesDataMock, noteableDataMock } from '../notes/mock_data';
export default function initVueMRPage() {
@@ -39,7 +39,7 @@ export default function initVueMRPage() {
const mock = new MockAdapter(axios);
mock.onGet(diffsAppEndpoint).reply(200, {
branch_name: 'foo',
- diff_files: [diffFileMockData],
+ diff_files: [getDiffFileMock()],
});
initMRPage();
diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js
index 9b83ced10e1..5da6676cdc1 100644
--- a/spec/frontend/__helpers__/matchers/index.js
+++ b/spec/frontend/__helpers__/matchers/index.js
@@ -2,3 +2,4 @@ export * from './to_have_sprite_icon';
export * from './to_have_tracking_attributes';
export * from './to_match_interpolated_text';
export * from './to_validate_json_schema';
+export * from './to_match_expected_for_markdown';
diff --git a/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js b/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js
new file mode 100644
index 00000000000..829f6ba9770
--- /dev/null
+++ b/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js
@@ -0,0 +1,60 @@
+export function toMatchExpectedForMarkdown(
+ received,
+ deserializationTarget,
+ name,
+ markdown,
+ errMsg,
+ expected,
+) {
+ const options = {
+ comment: `Markdown deserialization to ${deserializationTarget}`,
+ isNot: this.isNot,
+ promise: this.promise,
+ };
+
+ const EXPECTED_LABEL = 'Expected';
+ const RECEIVED_LABEL = 'Received';
+ const isExpand = (expand) => expand !== false;
+ const forMarkdownName = `for Markdown example '${name}':\n${markdown}`;
+ const matcherName = `toMatchExpected${
+ deserializationTarget === 'HTML' ? 'Html' : 'Json'
+ }ForMarkdown`;
+
+ let pass;
+
+ // If both expected and received are deserialization errors, force pass = true,
+ // because the actual error messages can vary across environments and cause
+ // false failures (e.g. due to jest '--coverage' being passed in CI).
+ const errMsgRegExp = new RegExp(errMsg);
+ const errMsgRegExp2 = new RegExp(errMsg);
+
+ if (errMsgRegExp.test(expected) && errMsgRegExp2.test(received)) {
+ pass = true;
+ } else {
+ pass = received === expected;
+ }
+
+ const message = pass
+ ? () =>
+ // eslint-disable-next-line prefer-template
+ this.utils.matcherHint(matcherName, undefined, undefined, options) +
+ '\n\n' +
+ `Expected HTML to NOT match:\n${expected}\n\n${forMarkdownName}`
+ : () => {
+ return (
+ // eslint-disable-next-line prefer-template
+ this.utils.matcherHint(matcherName, undefined, undefined, options) +
+ '\n\n' +
+ this.utils.printDiffOrStringify(
+ expected,
+ received,
+ EXPECTED_LABEL,
+ RECEIVED_LABEL,
+ isExpand(this.expand),
+ ) +
+ `\n\n${forMarkdownName}`
+ );
+ };
+
+ return { actual: received, expected, message, name: matcherName, pass };
+}
diff --git a/spec/frontend/__helpers__/mock_user_callout_dismisser.js b/spec/frontend/__helpers__/mock_user_callout_dismisser.js
index 652f36028dc..f115e2289af 100644
--- a/spec/frontend/__helpers__/mock_user_callout_dismisser.js
+++ b/spec/frontend/__helpers__/mock_user_callout_dismisser.js
@@ -1,3 +1,5 @@
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+
/**
* Mock factory for the UserCalloutDismisser component.
* @param {slotProps} The slot props to pass to the default slot content.
@@ -6,11 +8,24 @@
export const makeMockUserCalloutDismisser = ({
dismiss = () => {},
shouldShowCallout = true,
+ isLoadingQuery = false,
} = {}) => ({
+ props: UserCalloutDismisser.props,
+ data() {
+ return {
+ isLoadingQuery,
+ shouldShowCallout,
+ dismiss,
+ };
+ },
+ mounted() {
+ this.$emit('queryResult', { shouldShowCallout });
+ },
render() {
return this.$scopedSlots.default({
dismiss,
shouldShowCallout,
+ isLoadingQuery,
});
},
});
diff --git a/spec/frontend/__helpers__/performance.js b/spec/frontend/__helpers__/performance.js
new file mode 100644
index 00000000000..3bdf163c22b
--- /dev/null
+++ b/spec/frontend/__helpers__/performance.js
@@ -0,0 +1,8 @@
+// FIXME(vslobodin): Remove this stub once we have migrated to Jest 28.
+// NOTE: Do not try to optimize these stubs as Jest 27 overwrites
+// the "global.performance" object in every suite where fake timers are enabled.
+export const stubPerformanceWebAPI = () => {
+ global.performance.getEntriesByName = () => [];
+ global.performance.mark = () => {};
+ global.performance.measure = () => {};
+};
diff --git a/spec/frontend/__helpers__/set_window_location_helper.js b/spec/frontend/__helpers__/set_window_location_helper.js
index 573a089f111..d81c502b337 100644
--- a/spec/frontend/__helpers__/set_window_location_helper.js
+++ b/spec/frontend/__helpers__/set_window_location_helper.js
@@ -30,7 +30,7 @@
* // window.location.href is now 'http://test.host/a/b/foo.html?bar=1#qux
*
* Both approaches also automatically update the rest of the properties on
- * `window.locaton`. For instance:
+ * `window.location`. For instance:
*
* setWindowLocation('http://test.host/a/b/foo.html?bar=1#qux');
* // window.location.origin is now 'http://test.host'
diff --git a/spec/frontend/__helpers__/shared_test_setup.js b/spec/frontend/__helpers__/shared_test_setup.js
index 011e1142c76..4d6486544ca 100644
--- a/spec/frontend/__helpers__/shared_test_setup.js
+++ b/spec/frontend/__helpers__/shared_test_setup.js
@@ -15,6 +15,7 @@ import '~/commons/bootstrap';
// This module has some fairly decent visual test coverage in it's own repository.
jest.mock('@gitlab/favicon-overlay');
+jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils'));
process.on('unhandledRejection', global.promiseRejectionHandler);
diff --git a/spec/frontend/__helpers__/web_worker_fake.js b/spec/frontend/__helpers__/web_worker_fake.js
index 041a9bd8540..fb37e41a853 100644
--- a/spec/frontend/__helpers__/web_worker_fake.js
+++ b/spec/frontend/__helpers__/web_worker_fake.js
@@ -14,8 +14,7 @@ const createRelativeRequire = (filename) => {
const rel = path.relative(__dirname, path.dirname(filename));
const base = path.resolve(__dirname, rel);
- // reason: Dynamic require should be fine here since the code is dynamically evaluated anyways.
- // eslint-disable-next-line import/no-dynamic-require, global-require
+ // eslint-disable-next-line global-require
return (pathArg) => require(transformRequirePath(base, pathArg));
};
diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js
index b45abe418e4..6013fa3ec39 100644
--- a/spec/frontend/access_tokens/components/access_token_table_app_spec.js
+++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js
@@ -1,4 +1,4 @@
-import { GlPagination, GlTable } from '@gitlab/ui';
+import { GlButton, GlPagination, GlTable } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue';
@@ -164,8 +164,8 @@ describe('~/access_tokens/components/access_token_table_app', () => {
expect(cells.at(3).text()).toBe(__('Never'));
expect(cells.at(4).text()).toBe(__('Never'));
expect(cells.at(5).text()).toBe('Maintainer');
- let anchor = cells.at(6).find('a');
- expect(anchor.attributes()).toMatchObject({
+ let button = cells.at(6).findComponent(GlButton);
+ expect(button.attributes()).toMatchObject({
'aria-label': __('Revoke'),
'data-qa-selector': __('revoke_button'),
href: '/-/profile/personal_access_tokens/1/revoke',
@@ -176,8 +176,7 @@ describe('~/access_tokens/components/access_token_table_app', () => {
{ accessTokenType },
),
});
-
- expect(anchor.classes()).toContain('btn-danger-secondary');
+ expect(button.props('category')).toBe('tertiary');
// Second row
expect(cells.at(7).text()).toBe('b');
@@ -186,9 +185,9 @@ describe('~/access_tokens/components/access_token_table_app', () => {
expect(cells.at(10).text()).not.toBe(__('Never'));
expect(cells.at(11).text()).toBe(__('Expired'));
expect(cells.at(12).text()).toBe('Maintainer');
- anchor = cells.at(13).find('a');
- expect(anchor.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke');
- expect(anchor.classes()).toEqual(['btn', 'btn-danger', 'btn-md', 'gl-button', 'btn-icon']);
+ button = cells.at(13).findComponent(GlButton);
+ expect(button.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke');
+ expect(button.props('category')).toBe('tertiary');
});
it('sorts rows alphabetically', async () => {
diff --git a/spec/frontend/access_tokens/components/projects_field_spec.js b/spec/frontend/access_tokens/components/projects_field_spec.js
index a9e0799d114..1c4fe7bb168 100644
--- a/spec/frontend/access_tokens/components/projects_field_spec.js
+++ b/spec/frontend/access_tokens/components/projects_field_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { within, fireEvent } from '@testing-library/dom';
import { mount } from '@vue/test-utils';
import ProjectsField from '~/access_tokens/components/projects_field.vue';
@@ -118,11 +119,10 @@ describe('ProjectsField', () => {
});
describe('when radio is changed back to "All projects"', () => {
- beforeEach(() => {
- fireEvent.click(findAllProjectsRadio());
- });
+ it('removes the hidden input value', async () => {
+ fireEvent.change(findAllProjectsRadio());
+ await nextTick();
- it('removes the hidden input value', () => {
expect(findHiddenInput().attributes('value')).toBe('');
});
});
diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
index 3fdbacb6efa..2c2151bfb41 100644
--- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
+++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap
@@ -25,7 +25,7 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = `
>
<div
- class="mt-2"
+ class="gl-mt-3"
>
<gl-search-box-by-type-stub
clearbuttontitle="Clear"
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 14f94e671a4..d6c5c5f963a 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
@@ -52,7 +52,7 @@ describe('DevopsScore', () => {
it('contains a link to the feature documentation', () => {
expect(findDocsLink().exists()).toBe(true);
expect(findDocsLink().attributes('href')).toBe(
- '/help/user/admin_area/analytics/dev_ops_report',
+ '/help/user/admin_area/analytics/dev_ops_reports',
);
});
});
diff --git a/spec/frontend/admin/deploy_keys/components/table_spec.js b/spec/frontend/admin/deploy_keys/components/table_spec.js
index 49bda7100fb..a18506c0916 100644
--- a/spec/frontend/admin/deploy_keys/components/table_spec.js
+++ b/spec/frontend/admin/deploy_keys/components/table_spec.js
@@ -27,6 +27,7 @@ describe('DeployKeysTable', () => {
const deployKey = responseBody[0];
const deployKey2 = responseBody[1];
+ const deployKeyWithoutMd5Fingerprint = responseBody[2];
const createComponent = (provide = {}) => {
wrapper = mountExtended(DeployKeysTable, {
@@ -57,9 +58,10 @@ describe('DeployKeysTable', () => {
const timeAgoTooltip = findTimeAgoTooltip(expectedRowIndex);
expect(wrapper.findByText(expectedDeployKey.title).exists()).toBe(true);
- expect(wrapper.findByText(expectedDeployKey.fingerprint, { selector: 'code' }).exists()).toBe(
- true,
- );
+
+ expect(
+ wrapper.findByText(expectedDeployKey.fingerprint_sha256, { selector: 'span' }).exists(),
+ ).toBe(true);
expect(timeAgoTooltip.exists()).toBe(true);
expect(timeAgoTooltip.props('time')).toBe(expectedDeployKey.created_at);
expect(editButton.exists()).toBe(true);
@@ -67,6 +69,13 @@ describe('DeployKeysTable', () => {
expect(findRemoveButton(expectedRowIndex).exists()).toBe(true);
};
+ const expectDeployKeyWithFingerprintIsRendered = (expectedDeployKey, expectedRowIndex) => {
+ expect(wrapper.findByText(expectedDeployKey.fingerprint, { selector: 'span' }).exists()).toBe(
+ true,
+ );
+ expectDeployKeyIsRendered(expectedDeployKey, expectedRowIndex);
+ };
+
const itRendersTheEmptyState = () => {
it('renders empty state', () => {
const emptyState = wrapper.findComponent(GlEmptyState);
@@ -127,8 +136,12 @@ describe('DeployKeysTable', () => {
});
it('renders deploy keys in table', () => {
- expectDeployKeyIsRendered(deployKey, 0);
- expectDeployKeyIsRendered(deployKey2, 1);
+ expectDeployKeyWithFingerprintIsRendered(deployKey, 0);
+ expectDeployKeyWithFingerprintIsRendered(deployKey2, 1);
+ });
+
+ it('renders deploy keys that do not have an MD5 fingerprint', () => {
+ expectDeployKeyIsRendered(deployKeyWithoutMd5Fingerprint, 2);
});
describe('when delete button is clicked', () => {
@@ -157,7 +170,7 @@ describe('DeployKeysTable', () => {
beforeEach(() => {
Api.deployKeys.mockResolvedValueOnce({
data: [deployKey],
- headers: { 'x-total': '2' },
+ headers: { 'x-total': '3' },
});
createComponent();
@@ -179,7 +192,7 @@ describe('DeployKeysTable', () => {
describe('when pagination is changed', () => {
it('calls API with `page` parameter', async () => {
const pagination = findPagination();
- expectDeployKeyIsRendered(deployKey, 0);
+ expectDeployKeyWithFingerprintIsRendered(deployKey, 0);
Api.deployKeys.mockResolvedValue({
data: [deployKey2],
@@ -199,7 +212,7 @@ describe('DeployKeysTable', () => {
page: 2,
public: true,
});
- expectDeployKeyIsRendered(deployKey2, 0);
+ expectDeployKeyWithFingerprintIsRendered(deployKey2, 0);
});
});
});
diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
index 5b4f954b672..31a0c2b07e4 100644
--- a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
+++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js
@@ -1,6 +1,6 @@
import { GlButton, GlModal } from '@gitlab/ui';
-import { within, fireEvent } from '@testing-library/dom';
-import { shallowMount, mount } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { shallowMount, mount, createWrapper } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import SignupForm from '~/pages/admin/application_settings/general/components/signup_form.vue';
@@ -121,7 +121,7 @@ describe('Signup Form', () => {
describe('when user clicks on file radio', () => {
beforeEach(() => {
- fireEvent.click(findDenyListFileRadio());
+ createWrapper(findDenyListFileRadio()).setChecked(true);
});
it('has raw list not selected', () => {
@@ -165,7 +165,7 @@ describe('Signup Form', () => {
describe('when user clicks on raw list radio', () => {
beforeEach(() => {
- fireEvent.click(findDenyListRawRadio());
+ createWrapper(findDenyListRawRadio()).setChecked(true);
});
it('has raw list selected', () => {
diff --git a/spec/frontend/admin/signup_restrictions/mock_data.js b/spec/frontend/admin/signup_restrictions/mock_data.js
index 135fc8caae0..9e001e122a4 100644
--- a/spec/frontend/admin/signup_restrictions/mock_data.js
+++ b/spec/frontend/admin/signup_restrictions/mock_data.js
@@ -18,6 +18,10 @@ export const rawMockData = {
emailRestrictions: 'user1@domain.com, user2@domain.com',
afterSignUpText: 'Congratulations on your successful sign-up!',
pendingUserCount: '0',
+ passwordNumberRequired: 'true',
+ passwordLowercaseRequired: 'true',
+ passwordUppercaseRequired: 'true',
+ passwordSymbolRequired: 'true',
};
export const mockData = {
@@ -40,4 +44,8 @@ export const mockData = {
emailRestrictions: 'user1@domain.com, user2@domain.com',
afterSignUpText: 'Congratulations on your successful sign-up!',
pendingUserCount: '0',
+ passwordNumberRequired: true,
+ passwordLowercaseRequired: true,
+ passwordUppercaseRequired: true,
+ passwordSymbolRequired: true,
};
diff --git a/spec/frontend/admin/signup_restrictions/utils_spec.js b/spec/frontend/admin/signup_restrictions/utils_spec.js
index fd5c4c3317b..f07e14430f9 100644
--- a/spec/frontend/admin/signup_restrictions/utils_spec.js
+++ b/spec/frontend/admin/signup_restrictions/utils_spec.js
@@ -14,6 +14,10 @@ describe('utils', () => {
'domainDenylistEnabled',
'denylistTypeRawSelected',
'emailRestrictionsEnabled',
+ 'passwordNumberRequired',
+ 'passwordLowercaseRequired',
+ 'passwordUppercaseRequired',
+ 'passwordSymbolRequired',
],
}),
).toEqual(mockData);
diff --git a/spec/frontend/admin/statistics_panel/components/app_spec.js b/spec/frontend/admin/statistics_panel/components/app_spec.js
index 3cfb6feeb86..bac542e72fb 100644
--- a/spec/frontend/admin/statistics_panel/components/app_spec.js
+++ b/spec/frontend/admin/statistics_panel/components/app_spec.js
@@ -64,7 +64,7 @@ describe('Admin statistics app', () => {
createComponent();
expect(findStats(index).text()).toContain(label);
- expect(findStats(index).text()).toContain(count);
+ expect(findStats(index).text()).toContain(count.toString());
});
});
});
diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js
index b90a30b5b89..e04c43ae3f2 100644
--- a/spec/frontend/admin/users/components/user_actions_spec.js
+++ b/spec/frontend/admin/users/components/user_actions_spec.js
@@ -77,12 +77,6 @@ describe('AdminUserActions component', () => {
expect(findActionsDropdown().exists()).toBe(true);
});
- it('renders the tooltip', () => {
- const tooltip = getBinding(findActionsDropdown().element, 'gl-tooltip');
-
- expect(tooltip.value).toBe(I18N_USER_ACTIONS.userAdministration);
- });
-
describe('when there are actions that require confirmation', () => {
beforeEach(() => {
initComponent({ actions: CONFIRMATION_ACTIONS });
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 ec5b6a5597b..4693d5a47e4 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
@@ -17,6 +17,7 @@ exports[`Alert integration settings form default state should match the default
<gl-form-checkbox-stub
checked="true"
data-qa-selector="create_issue_checkbox"
+ id="2"
>
<span>
Create an incident. Incidents are created for each alert triggered.
@@ -87,7 +88,9 @@ exports[`Alert integration settings form default state should match the default
labeldescription=""
optionaltext="(optional)"
>
- <gl-form-checkbox-stub>
+ <gl-form-checkbox-stub
+ id="3"
+ >
<span>
Send a single email notification to Owners and Maintainers for new alerts.
</span>
@@ -101,6 +104,7 @@ exports[`Alert integration settings form default state should match the default
>
<gl-form-checkbox-stub
checked="true"
+ id="4"
>
<span>
Automatically close associated incident when a recovery alert notification resolves an alert
diff --git a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
index 018303fcae7..7d9d2875cf8 100644
--- a/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_settings_form_spec.js
@@ -265,7 +265,6 @@ describe('AlertsSettingsForm', () => {
});
it('should not allow a user to test invalid JSON', async () => {
- jest.useFakeTimers();
await findJsonTextArea().setValue('Invalid JSON');
jest.runAllTimers();
@@ -278,7 +277,6 @@ describe('AlertsSettingsForm', () => {
});
it('should allow for the form to be automatically saved if the test payload is successfully submitted', async () => {
- jest.useFakeTimers();
await findJsonTextArea().setValue('{ "value": "value" }');
jest.runAllTimers();
diff --git a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
index 2691e11e616..ba8215f4e00 100644
--- a/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
+++ b/spec/frontend/artifacts_settings/components/__snapshots__/keep_latest_artifact_checkbox_spec.js.snap
@@ -7,6 +7,7 @@ exports[`Keep latest artifact checkbox when application keep latest artifact set
<b-form-checkbox-stub
checked="true"
class="gl-form-checkbox"
+ id="4"
value="true"
>
<strong
diff --git a/spec/frontend/batch_comments/components/draft_note_spec.js b/spec/frontend/batch_comments/components/draft_note_spec.js
index 6a997ebaaa8..ccca4a2c3e9 100644
--- a/spec/frontend/batch_comments/components/draft_note_spec.js
+++ b/spec/frontend/batch_comments/components/draft_note_spec.js
@@ -33,13 +33,16 @@ describe('Batch comments draft note component', () => {
const findSubmitReviewButton = () => wrapper.findComponent(PublishButton);
const findAddCommentButton = () => wrapper.findComponent(GlButton);
- const createComponent = (propsData = { draft }) => {
+ const createComponent = (propsData = { draft }, glFeatures = {}) => {
wrapper = shallowMount(DraftNote, {
store,
propsData,
stubs: {
NoteableNote: NoteableNoteStub,
},
+ provide: {
+ glFeatures,
+ },
});
jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation();
@@ -96,6 +99,12 @@ describe('Batch comments draft note component', () => {
expect(publishNowButton.props().disabled).toBe(true);
expect(publishNowButton.props().loading).toBe(false);
});
+
+ it('hides button when mr_review_submit_comment is enabled', () => {
+ createComponent({ draft }, { mrReviewSubmitComment: true });
+
+ expect(findAddCommentButton().exists()).toBe(false);
+ });
});
describe('submit review', () => {
diff --git a/spec/frontend/batch_comments/components/preview_dropdown_spec.js b/spec/frontend/batch_comments/components/preview_dropdown_spec.js
index bf3bbf4de26..079b64225e4 100644
--- a/spec/frontend/batch_comments/components/preview_dropdown_spec.js
+++ b/spec/frontend/batch_comments/components/preview_dropdown_spec.js
@@ -1,8 +1,15 @@
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+import { visitUrl } from '~/lib/utils/url_utility';
import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue';
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ setUrlParams: jest.requireActual('~/lib/utils/url_utility').setUrlParams,
+}));
+
Vue.use(Vuex);
let wrapper;
@@ -27,6 +34,11 @@ function factory({ viewDiffsFileByFile = false, draftsCount = 1, sortedDrafts =
actions: { scrollToDraft },
getters: { draftsCount: () => draftsCount, sortedDrafts: () => sortedDrafts },
},
+ notes: {
+ getters: {
+ getNoteableData: () => ({ diff_head_sha: '123' }),
+ },
+ },
},
});
@@ -67,5 +79,19 @@ describe('Batch comments preview dropdown', () => {
expect(scrollToDraft).toHaveBeenCalledWith(expect.anything(), { id: 1 });
});
+
+ it('changes window location to navigate to commit', async () => {
+ factory({
+ viewDiffsFileByFile: false,
+ sortedDrafts: [{ id: 1, position: { head_sha: '1234' } }],
+ });
+
+ wrapper.findByTestId('preview-item').vm.$emit('click');
+
+ await nextTick();
+
+ expect(scrollToDraft).not.toHaveBeenCalled();
+ expect(visitUrl).toHaveBeenCalledWith(`${TEST_HOST}/?commit_id=1234#note_1`);
+ });
});
});
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
index 172b510645d..9f50b12bac2 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
@@ -298,14 +298,18 @@ describe('Batch comments store actions', () => {
const draft = {
discussion_id: '1',
id: '2',
+ file_path: 'lib/example.js',
};
actions.scrollToDraft({ dispatch, rootGetters }, draft);
- expect(dispatch.mock.calls[0]).toEqual([
- 'expandDiscussion',
- { discussionId: '1' },
- { root: true },
+ expect(dispatch.mock.calls).toEqual([
+ [
+ 'diffs/setFileCollapsedAutomatically',
+ { filePath: draft.file_path, collapsed: false },
+ { root: true },
+ ],
+ ['expandDiscussion', { discussionId: '1' }, { root: true }],
]);
expect(window.mrTabs.tabShown).toHaveBeenCalledWith('diffs');
diff --git a/spec/frontend/behaviors/shortcuts/keybindings_spec.js b/spec/frontend/behaviors/shortcuts/keybindings_spec.js
index 3ad44a16ae1..1f7e1b24e78 100644
--- a/spec/frontend/behaviors/shortcuts/keybindings_spec.js
+++ b/spec/frontend/behaviors/shortcuts/keybindings_spec.js
@@ -11,9 +11,7 @@ import {
} from '~/behaviors/shortcuts/keybindings';
describe('~/behaviors/shortcuts/keybindings', () => {
- beforeAll(() => {
- useLocalStorageSpy();
- });
+ useLocalStorageSpy();
const setupCustomizations = (customizationsAsString) => {
localStorage.clear();
diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js
deleted file mode 100644
index b2559af182b..00000000000
--- a/spec/frontend/blob/viewer/index_spec.js
+++ /dev/null
@@ -1,189 +0,0 @@
-/* eslint-disable no-new */
-
-import MockAdapter from 'axios-mock-adapter';
-import $ from 'jquery';
-import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import { setTestTimeout } from 'helpers/timeout';
-import { BlobViewer } from '~/blob/viewer/index';
-import axios from '~/lib/utils/axios_utils';
-
-const execImmediately = (callback) => {
- callback();
-};
-
-describe('Blob viewer', () => {
- let blob;
- let mock;
-
- const jQueryMock = {
- tooltip: jest.fn(),
- };
-
- setTestTimeout(2000);
-
- beforeEach(() => {
- window.gon.features = { refactorBlobViewer: false }; // This file is based on the old (non-refactored) blob viewer
- jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
- $.fn.extend(jQueryMock);
- mock = new MockAdapter(axios);
-
- loadHTMLFixture('blob/show_readme.html');
- $('#modal-upload-blob').remove();
-
- mock.onGet(/blob\/.+\/README\.md/).reply(200, {
- html: '<div>testing</div>',
- });
-
- blob = new BlobViewer();
- });
-
- afterEach(() => {
- mock.restore();
- window.location.hash = '';
-
- resetHTMLFixture();
- });
-
- it('loads source file after switching views', async () => {
- document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
-
- await axios.waitForAll();
-
- expect(
- document
- .querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]')
- .classList.contains('hidden'),
- ).toBeFalsy();
- });
-
- it('loads source file when line number is in hash', async () => {
- window.location.hash = '#L1';
-
- new BlobViewer();
-
- await axios.waitForAll();
-
- expect(
- document
- .querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]')
- .classList.contains('hidden'),
- ).toBeFalsy();
- });
-
- it('doesnt reload file if already loaded', () => {
- const asyncClick = async () => {
- document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
-
- await axios.waitForAll();
- };
-
- return asyncClick()
- .then(() => asyncClick())
- .then(() => {
- expect(document.querySelector('.blob-viewer[data-type="simple"]').dataset.loaded).toBe(
- 'true',
- );
- });
- });
-
- describe('copy blob button', () => {
- let copyButton;
- let copyButtonTooltip;
-
- beforeEach(() => {
- copyButton = document.querySelector('.js-copy-blob-source-btn');
- copyButtonTooltip = document.querySelector('.js-copy-blob-source-btn-tooltip');
- });
-
- it('disabled on load', () => {
- expect(copyButton.classList.contains('disabled')).toBeTruthy();
- });
-
- it('has tooltip when disabled', () => {
- expect(copyButtonTooltip.getAttribute('title')).toBe(
- 'Switch to the source to copy the file contents',
- );
- });
-
- it('is blurred when clicked and disabled', () => {
- jest.spyOn(copyButton, 'blur').mockImplementation(() => {});
-
- copyButton.click();
-
- expect(copyButton.blur).toHaveBeenCalled();
- });
-
- it('is not blurred when clicked and not disabled', () => {
- jest.spyOn(copyButton, 'blur').mockImplementation(() => {});
-
- copyButton.classList.remove('disabled');
- copyButton.click();
-
- expect(copyButton.blur).not.toHaveBeenCalled();
- });
-
- it('enables after switching to simple view', async () => {
- document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
-
- await axios.waitForAll();
-
- expect(copyButton.classList.contains('disabled')).toBeFalsy();
- });
-
- it('updates tooltip after switching to simple view', async () => {
- document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click();
-
- await axios.waitForAll();
-
- expect(copyButtonTooltip.getAttribute('title')).toBe('Copy file contents');
- });
- });
-
- describe('switchToViewer', () => {
- it('removes active class from old viewer button', () => {
- blob.switchToViewer('simple');
-
- expect(
- document.querySelector('.js-blob-viewer-switch-btn.active[data-viewer="rich"]'),
- ).toBeNull();
- });
-
- it('adds active class to new viewer button', () => {
- const simpleBtn = document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]');
-
- jest.spyOn(simpleBtn, 'blur').mockImplementation(() => {});
-
- blob.switchToViewer('simple');
-
- expect(simpleBtn.classList.contains('selected')).toBeTruthy();
-
- expect(simpleBtn.blur).toHaveBeenCalled();
- });
-
- it('makes request for initial view', () => {
- expect(mock.history).toMatchObject({
- get: [{ url: expect.stringMatching(/README\.md\?.*viewer=rich/) }],
- });
- });
-
- describe.each`
- views
- ${['simple']}
- ${['simple', 'rich']}
- `('when view switches to $views', ({ views }) => {
- beforeEach(async () => {
- views.forEach((view) => blob.switchToViewer(view));
- await axios.waitForAll();
- });
-
- it('sends 1 AJAX request for new view', async () => {
- expect(mock.history).toMatchObject({
- get: [
- { url: expect.stringMatching(/README\.md\?.*viewer=rich/) },
- { url: expect.stringMatching(/README\.md\?.*viewer=simple/) },
- ],
- });
- });
- });
- });
-});
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index aad89cf8261..17a5383a31e 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -6,7 +6,7 @@ import Vuex from 'vuex';
import BoardCard from '~/boards/components/board_card.vue';
import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { inactiveId } from '~/boards/constants';
-import { mockLabelList, mockIssue } from '../mock_data';
+import { mockLabelList, mockIssue, DEFAULT_COLOR } from '../mock_data';
describe('Board card', () => {
let wrapper;
@@ -180,4 +180,40 @@ describe('Board card', () => {
expect(wrapper.classes()).toContain('gl-cursor-grab');
});
});
+
+ describe('when Epic colors are enabled', () => {
+ it('applies the correct color', () => {
+ window.gon.features = { epicColorHighlight: true };
+ createStore();
+ mountComponent({
+ item: {
+ ...mockIssue,
+ color: DEFAULT_COLOR,
+ },
+ });
+
+ expect(wrapper.classes()).toEqual(
+ expect.arrayContaining(['gl-pl-4', 'gl-border-l-solid', 'gl-border-4']),
+ );
+ expect(wrapper.attributes('style')).toContain(`border-color: ${DEFAULT_COLOR}`);
+ });
+ });
+
+ describe('when Epic colors are not enabled', () => {
+ it('applies the correct color', () => {
+ window.gon.features = { epicColorHighlight: false };
+ createStore();
+ mountComponent({
+ item: {
+ ...mockIssue,
+ color: DEFAULT_COLOR,
+ },
+ });
+
+ expect(wrapper.classes()).not.toEqual(
+ expect.arrayContaining(['gl-pl-4', 'gl-border-l-solid', 'gl-border-4']),
+ );
+ expect(wrapper.attributes('style')).toBeUndefined();
+ });
+ });
});
diff --git a/spec/frontend/boards/components/config_toggle_spec.js b/spec/frontend/boards/components/config_toggle_spec.js
new file mode 100644
index 00000000000..47d4692453d
--- /dev/null
+++ b/spec/frontend/boards/components/config_toggle_spec.js
@@ -0,0 +1,59 @@
+import Vuex from 'vuex';
+import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
+import ConfigToggle from '~/boards/components/config_toggle.vue';
+import eventHub from '~/boards/eventhub';
+import store from '~/boards/stores';
+import { mockTracking } from 'helpers/tracking_helper';
+
+describe('ConfigToggle', () => {
+ let wrapper;
+
+ Vue.use(Vuex);
+
+ const createComponent = (provide = {}) =>
+ shallowMount(ConfigToggle, {
+ store,
+ provide: {
+ canAdminList: true,
+ ...provide,
+ },
+ });
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a button with label `View scope` when `canAdminList` is `false`', () => {
+ wrapper = createComponent({ canAdminList: false });
+ expect(findButton().text()).toBe('View scope');
+ });
+
+ it('renders a button with label `Edit board` when `canAdminList` is `true`', () => {
+ wrapper = createComponent();
+ expect(findButton().text()).toBe('Edit board');
+ });
+
+ it('emits `showBoardModal` when button is clicked', () => {
+ const eventHubSpy = jest.spyOn(eventHub, '$emit');
+ wrapper = createComponent();
+
+ findButton().vm.$emit('click', { preventDefault: () => {} });
+
+ expect(eventHubSpy).toHaveBeenCalledWith('showBoardModal', 'edit');
+ });
+
+ it('tracks clicking the button', () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ wrapper = createComponent();
+
+ findButton().vm.$emit('click', { preventDefault: () => {} });
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'edit_board',
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/toggle_focus_spec.js b/spec/frontend/boards/components/toggle_focus_spec.js
new file mode 100644
index 00000000000..3cbaac91f8d
--- /dev/null
+++ b/spec/frontend/boards/components/toggle_focus_spec.js
@@ -0,0 +1,47 @@
+import { GlButton } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import ToggleFocus from '~/boards/components/toggle_focus.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('ToggleFocus', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ToggleFocus, {
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ attachTo: document.body,
+ });
+ };
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a button with `maximize` icon', () => {
+ createComponent();
+
+ expect(findButton().props('icon')).toBe('maximize');
+ expect(findButton().attributes('aria-label')).toBe(ToggleFocus.i18n.toggleFocusMode);
+ });
+
+ it('contains a tooltip with title', () => {
+ createComponent();
+ const tooltip = getBinding(findButton().element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ expect(findButton().attributes('title')).toBe(ToggleFocus.i18n.toggleFocusMode);
+ });
+
+ it('toggles the icon when the button is clicked', async () => {
+ createComponent();
+ findButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findButton().props('icon')).toBe('minimize');
+ });
+});
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 6ec39be5d29..1ee05d81f37 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -790,3 +790,5 @@ export const epicBoardListQueryResponse = (totalWeight = 5) => ({
},
},
});
+
+export const DEFAULT_COLOR = '#1068bf';
diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js
index bd79060c54f..c45cd545155 100644
--- a/spec/frontend/boards/project_select_spec.js
+++ b/spec/frontend/boards/project_select_spec.js
@@ -1,16 +1,16 @@
import {
GlDropdown,
GlDropdownItem,
+ GlFormInput,
GlSearchBoxByType,
GlLoadingIcon,
- GlFormInput,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-import waitForPromises from 'helpers/wait_for_promises';
import ProjectSelect from '~/boards/components/project_select.vue';
import defaultState from '~/boards/stores/state';
+import waitForPromises from 'helpers/wait_for_promises';
import { mockList, mockActiveGroupProjects } from './mock_data';
@@ -21,7 +21,7 @@ describe('ProjectSelect component', () => {
let store;
const findLabel = () => wrapper.find("[data-testid='header-label']");
- const findGlDropdown = () => wrapper.find(GlDropdown);
+ const findGlDropdown = () => wrapper.findComponent(GlDropdown);
const findGlDropdownLoadingIcon = () =>
findGlDropdown().find('button:first-child').find(GlLoadingIcon);
const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
@@ -137,7 +137,7 @@ describe('ProjectSelect component', () => {
await nextTick();
const searchInput = findGlDropdown().findComponent(GlFormInput).element;
- expect(document.activeElement).toEqual(searchInput);
+ expect(document.activeElement).toBe(searchInput);
});
});
diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci_lint/mock_data.js
index 28ea0f55bf8..660b2ad6e8b 100644
--- a/spec/frontend/ci_lint/mock_data.js
+++ b/spec/frontend/ci_lint/mock_data.js
@@ -1,5 +1,16 @@
import { mockJobs } from 'jest/pipeline_editor/mock_data';
+export const mockLintDataError = {
+ data: {
+ lintCI: {
+ errors: ['Error message'],
+ warnings: ['Warning message'],
+ valid: false,
+ jobs: mockJobs,
+ },
+ },
+};
+
export const mockLintDataValid = {
data: {
lintCI: {
diff --git a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
index ad5f8a56ced..04d38a3281a 100644
--- a/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
+++ b/spec/frontend/ci_secure_files/components/secure_files_list_spec.js
@@ -1,10 +1,12 @@
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { mount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import SecureFilesList from '~/ci_secure_files/components/secure_files_list.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import Api from '~/api';
import { secureFiles } from '../mock_data';
@@ -22,15 +24,18 @@ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProj
describe('SecureFilesList', () => {
let wrapper;
let mock;
+ let trackingSpy;
beforeEach(() => {
originalGon = window.gon;
+ trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
window.gon = { ...dummyGon };
});
afterEach(() => {
wrapper.destroy();
mock.restore();
+ unmockTracking();
window.gon = originalGon;
});
@@ -52,7 +57,9 @@ describe('SecureFilesList', () => {
const findPagination = () => wrapper.findAll('ul.pagination');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findUploadButton = () => wrapper.findAll('span.gl-button-text');
- const findDeleteButton = () => wrapper.findAll('tbody tr td button.btn-danger');
+ const findDeleteModal = () => wrapper.findComponent(GlModal);
+ const findUploadInput = () => wrapper.findAll('input[type="file"]').at(0);
+ const findDeleteButton = () => wrapper.findAll('[data-testid="delete-button"]');
describe('when secure files exist in a project', () => {
beforeEach(async () => {
@@ -64,7 +71,7 @@ describe('SecureFilesList', () => {
});
it('displays a table with expected headers', () => {
- const headers = ['Filename', 'Uploaded'];
+ const headers = ['File name', 'Uploaded date'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
@@ -78,6 +85,30 @@ describe('SecureFilesList', () => {
expect(findCell(0, 0).text()).toBe(secureFile.name);
expect(findCell(0, 1).find(TimeAgoTooltip).props('time')).toBe(secureFile.created_at);
});
+
+ describe('event tracking', () => {
+ it('sends tracking information on list load', () => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'render_secure_files_list', {});
+ });
+
+ it('sends tracking information on file upload', async () => {
+ Api.uploadProjectSecureFile = jest.fn().mockResolvedValue();
+ Object.defineProperty(findUploadInput().element, 'files', { value: [{}] });
+ findUploadInput().trigger('change');
+
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'upload_secure_file', {});
+ });
+
+ it('sends tracking information on file deletion', async () => {
+ Api.deleteProjectSecureFile = jest.fn().mockResolvedValue();
+ findDeleteModal().vm.$emit('ok');
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'delete_secure_file', {});
+ });
+ });
});
describe('when no secure files exist in a project', () => {
@@ -90,14 +121,14 @@ describe('SecureFilesList', () => {
});
it('displays a table with expected headers', () => {
- const headers = ['Filename', 'Uploaded'];
+ const headers = ['File name', 'Uploaded date'];
headers.forEach((header, i) => {
expect(findHeaderAt(i).text()).toBe(header);
});
});
it('displays a table with a no records message', () => {
- expect(findCell(0, 0).text()).toBe('There are no records to show');
+ expect(findCell(0, 0).text()).toBe('There are no secure files yet.');
});
});
diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js
index 2a43b45a2f5..b78f0a3686c 100644
--- a/spec/frontend/clusters_list/components/agent_table_spec.js
+++ b/spec/frontend/clusters_list/components/agent_table_spec.js
@@ -70,10 +70,10 @@ describe('AgentTable', () => {
});
it.each`
- status | iconName | lineNumber
- ${'Never connected'} | ${'status-neutral'} | ${0}
- ${'Connected'} | ${'status-success'} | ${1}
- ${'Not connected'} | ${'severity-critical'} | ${2}
+ status | iconName | lineNumber
+ ${'Never connected'} | ${'status-neutral'} | ${0}
+ ${'Connected'} | ${'status-success'} | ${1}
+ ${'Not connected'} | ${'status-alert'} | ${2}
`(
'displays agent connection status as "$status" at line $lineNumber',
({ status, iconName, lineNumber }) => {
diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js
index c150a7f05d0..5c7635c1617 100644
--- a/spec/frontend/clusters_list/components/clusters_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_spec.js
@@ -103,11 +103,9 @@ describe('Clusters', () => {
});
describe('when is loaded as a child component', () => {
- beforeEach(() => {
+ it("shouldn't render pagination buttons", () => {
createWrapper({ limit: 6 });
- });
- it("shouldn't render pagination buttons", () => {
expect(findPaginatedButtons().exists()).toBe(false);
});
});
diff --git a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js
index 646d068e795..154035a46ed 100644
--- a/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/code_block_spec.js
@@ -44,6 +44,12 @@ describe('content_editor/components/bubble_menus/code_block', () => {
});
};
+ const preTag = ({ language, content = 'test' } = {}) => {
+ const languageAttr = language ? ` lang="${language}"` : '';
+
+ return `<pre class="js-syntax-highlight"${languageAttr}>${content}</pre>`;
+ };
+
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findDropdownItemsData = () =>
findDropdownItems().wrappers.map((x) => ({
@@ -62,7 +68,7 @@ describe('content_editor/components/bubble_menus/code_block', () => {
});
it('renders bubble menu component', async () => {
- tiptapEditor.commands.insertContent('<pre>test</pre>');
+ tiptapEditor.commands.insertContent(preTag());
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
@@ -72,7 +78,7 @@ describe('content_editor/components/bubble_menus/code_block', () => {
});
it('selects plaintext language by default', async () => {
- tiptapEditor.commands.insertContent('<pre>test</pre>');
+ tiptapEditor.commands.insertContent(preTag());
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
@@ -81,7 +87,7 @@ describe('content_editor/components/bubble_menus/code_block', () => {
});
it('selects appropriate language based on the code block', async () => {
- tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
+ tiptapEditor.commands.insertContent(preTag({ language: 'javascript' }));
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
@@ -90,7 +96,7 @@ describe('content_editor/components/bubble_menus/code_block', () => {
});
it('selects diagram sytnax for mermaid', async () => {
- tiptapEditor.commands.insertContent('<pre lang="mermaid">test</pre>');
+ tiptapEditor.commands.insertContent(preTag({ language: 'mermaid' }));
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
@@ -99,7 +105,7 @@ describe('content_editor/components/bubble_menus/code_block', () => {
});
it("selects Custom (syntax) if the language doesn't exist in the list", async () => {
- tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>');
+ tiptapEditor.commands.insertContent(preTag({ language: 'nomnoml' }));
bubbleMenu = wrapper.findComponent(BubbleMenu);
await emitEditorEvent({ event: 'transaction', tiptapEditor });
@@ -109,19 +115,20 @@ describe('content_editor/components/bubble_menus/code_block', () => {
describe('copy button', () => {
it('copies the text of the code block', async () => {
+ const content = 'var a = Math.PI / 2;';
jest.spyOn(navigator.clipboard, 'writeText');
- tiptapEditor.commands.insertContent('<pre lang="javascript">var a = Math.PI / 2;</pre>');
+ tiptapEditor.commands.insertContent(preTag({ language: 'javascript', content }));
await wrapper.findByTestId('copy-code-block').vm.$emit('click');
- expect(navigator.clipboard.writeText).toHaveBeenCalledWith('var a = Math.PI / 2;');
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(content);
});
});
describe('delete button', () => {
it('deletes the code block', async () => {
- tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
+ tiptapEditor.commands.insertContent(preTag({ language: 'javascript' }));
await wrapper.findByTestId('delete-code-block').vm.$emit('click');
@@ -164,7 +171,7 @@ describe('content_editor/components/bubble_menus/code_block', () => {
describe('when opened and search is changed', () => {
beforeEach(async () => {
- tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
+ tiptapEditor.commands.insertContent(preTag({ language: 'javascript' }));
wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js');
diff --git a/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js
index 6479c0ba008..1e2f58d9e40 100644
--- a/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js
+++ b/spec/frontend/content_editor/components/bubble_menus/formatting_spec.js
@@ -46,12 +46,14 @@ describe('content_editor/components/bubble_menus/formatting', () => {
});
describe.each`
- testId | controlProps
- ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold', size: 'medium', category: 'tertiary' }}
- ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic', size: 'medium', category: 'tertiary' }}
- ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike', size: 'medium', category: 'tertiary' }}
- ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode', size: 'medium', category: 'tertiary' }}
- ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' }, size: 'medium', category: 'tertiary' }}
+ testId | controlProps
+ ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
+ ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }}
+ ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
+ ${'superscript'} | ${{ contentType: 'superscript', iconName: 'superscript', label: 'Superscript', editorCommand: 'toggleSuperscript' }}
+ ${'subscript'} | ${{ contentType: 'subscript', iconName: 'subscript', label: 'Subscript', editorCommand: 'toggleSubscript' }}
+ ${'link'} | ${{ contentType: 'link', iconName: 'link', label: 'Insert link', editorCommand: 'toggleLink', editorCommandParams: { href: '' } }}
`('given a $testId toolbar control', ({ testId, controlProps }) => {
beforeEach(() => {
buildWrapper();
@@ -60,9 +62,13 @@ describe('content_editor/components/bubble_menus/formatting', () => {
it('renders the toolbar control with the provided properties', () => {
expect(wrapper.findByTestId(testId).exists()).toBe(true);
- Object.keys(controlProps).forEach((propName) => {
- expect(wrapper.findByTestId(testId).props(propName)).toEqual(controlProps[propName]);
- });
+ expect(wrapper.findByTestId(testId).props()).toEqual(
+ expect.objectContaining({
+ ...controlProps,
+ size: 'medium',
+ category: 'tertiary',
+ }),
+ );
});
it('tracks the execution of toolbar controls', () => {
diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
index 0334a18c9a1..351fd967719 100644
--- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
@@ -2,22 +2,26 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarMoreDropdown from '~/content_editor/components/toolbar_more_dropdown.vue';
import Diagram from '~/content_editor/extensions/diagram';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
-import { createTestEditor, mockChainedCommands } from '../test_utils';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_more_dropdown', () => {
let wrapper;
let tiptapEditor;
+ let eventHub;
const buildEditor = () => {
tiptapEditor = createTestEditor({
extensions: [Diagram, HorizontalRule],
});
+ eventHub = eventHubFactory();
};
const buildWrapper = (propsData = {}) => {
wrapper = mountExtended(ToolbarMoreDropdown, {
provide: {
tiptapEditor,
+ eventHub,
},
propsData,
});
@@ -33,19 +37,30 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
});
describe.each`
- label | contentType | data
- ${'Mermaid diagram'} | ${'diagram'} | ${{ language: 'mermaid' }}
- ${'PlantUML diagram'} | ${'diagram'} | ${{ language: 'plantuml' }}
- ${'Horizontal rule'} | ${'horizontalRule'} | ${undefined}
- `('when option $label is clicked', ({ label, contentType, data }) => {
- it(`inserts a ${contentType}`, async () => {
- const commands = mockChainedCommands(tiptapEditor, ['setNode', 'focus', 'run']);
+ name | contentType | command | params
+ ${'Code block'} | ${'codeBlock'} | ${'setNode'} | ${['codeBlock']}
+ ${'Details block'} | ${'details'} | ${'toggleList'} | ${['details', 'detailsContent']}
+ ${'Bullet list'} | ${'bulletList'} | ${'toggleList'} | ${['bulletList', 'listItem']}
+ ${'Ordered list'} | ${'orderedList'} | ${'toggleList'} | ${['orderedList', 'listItem']}
+ ${'Task list'} | ${'taskList'} | ${'toggleList'} | ${['taskList', 'taskItem']}
+ ${'Mermaid diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'mermaid' }]}
+ ${'PlantUML diagram'} | ${'diagram'} | ${'setNode'} | ${['diagram', { language: 'plantuml' }]}
+ ${'Horizontal rule'} | ${'horizontalRule'} | ${'setHorizontalRule'} | ${[]}
+ `('when option $label is clicked', ({ name, command, contentType, params }) => {
+ let commands;
+ let btn;
+
+ beforeEach(async () => {
+ commands = mockChainedCommands(tiptapEditor, [command, 'focus', 'run']);
+ btn = wrapper.findByRole('menuitem', { name });
+ });
- const btn = wrapper.findByRole('menuitem', { name: label });
+ it(`inserts a ${contentType}`, async () => {
await btn.trigger('click');
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
expect(commands.focus).toHaveBeenCalled();
- expect(commands.setNode).toHaveBeenCalledWith(contentType, data);
+ expect(commands[command]).toHaveBeenCalledWith(...params);
expect(commands.run).toHaveBeenCalled();
expect(wrapper.emitted('execute')).toEqual([[{ contentType }]]);
diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js
index d98a9a52aff..2acb6e14ce0 100644
--- a/spec/frontend/content_editor/components/top_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/top_toolbar_spec.js
@@ -24,17 +24,15 @@ describe('content_editor/components/top_toolbar', () => {
describe.each`
testId | controlProps
+ ${'text-styles'} | ${{}}
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
- ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }}
- ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
+ ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
+ ${'link'} | ${{}}
${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }}
- ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }}
- ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }}
- ${'text-styles'} | ${{}}
- ${'link'} | ${{}}
+ ${'task-list'} | ${{ contentType: 'taskList', iconName: 'list-task', label: 'Add a task list', editorCommand: 'toggleTaskList' }}
${'image'} | ${{}}
${'table'} | ${{}}
${'more'} | ${{}}
diff --git a/spec/frontend/content_editor/extensions/html_nodes_spec.js b/spec/frontend/content_editor/extensions/html_nodes_spec.js
new file mode 100644
index 00000000000..24c68239025
--- /dev/null
+++ b/spec/frontend/content_editor/extensions/html_nodes_spec.js
@@ -0,0 +1,42 @@
+import HTMLNodes from '~/content_editor/extensions/html_nodes';
+import { createTestEditor, createDocBuilder } from '../test_utils';
+
+describe('content_editor/extensions/html_nodes', () => {
+ let tiptapEditor;
+ let doc;
+ let div;
+ let pre;
+ let p;
+
+ beforeEach(() => {
+ tiptapEditor = createTestEditor({ extensions: [...HTMLNodes] });
+
+ ({
+ builders: { doc, p, pre, div },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ ...HTMLNodes.reduce(
+ (builders, htmlNode) => ({
+ ...builders,
+ [htmlNode.name]: { nodeType: htmlNode.name },
+ }),
+ {},
+ ),
+ },
+ }));
+ });
+
+ it.each`
+ input | insertedNodes
+ ${'<div><p>foo</p></div>'} | ${() => div(p('foo'))}
+ ${'<pre><p>foo</p></pre>'} | ${() => pre(p('foo'))}
+ `('parses and creates nodes for $input', ({ input, insertedNodes }) => {
+ const expectedDoc = doc(insertedNodes());
+
+ tiptapEditor.commands.setContent(input);
+
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ expect(tiptapEditor.getHTML()).toEqual(input);
+ });
+});
diff --git a/spec/frontend/content_editor/extensions/paste_markdown_spec.js b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
index 5d46c2c0650..53efda6aee2 100644
--- a/spec/frontend/content_editor/extensions/paste_markdown_spec.js
+++ b/spec/frontend/content_editor/extensions/paste_markdown_spec.js
@@ -14,7 +14,7 @@ import {
import waitForPromises from 'helpers/wait_for_promises';
import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils';
-const CODE_BLOCK_HTML = '<pre lang="javascript">var a = 2;</pre>';
+const CODE_BLOCK_HTML = '<pre class="js-syntax-highlight" lang="javascript">var a = 2;</pre>';
const DIAGRAM_HTML =
'<img data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,WzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybl0=">';
const FRONTMATTER_HTML = '<pre lang="yaml" data-lang-params="frontmatter">key: value</pre>';
diff --git a/spec/frontend/content_editor/markdown_snapshot_spec.js b/spec/frontend/content_editor/markdown_snapshot_spec.js
new file mode 100644
index 00000000000..63ca66172e6
--- /dev/null
+++ b/spec/frontend/content_editor/markdown_snapshot_spec.js
@@ -0,0 +1,10 @@
+import path from 'path';
+import { describeMarkdownSnapshots } from 'jest/content_editor/markdown_snapshot_spec_helper';
+
+jest.mock('~/emoji');
+
+const glfmSpecificationDir = path.join(__dirname, '..', '..', '..', 'glfm_specification');
+
+// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing
+// for documentation on this spec.
+describeMarkdownSnapshots('CE markdown snapshots in ContentEditor', glfmSpecificationDir);
diff --git a/spec/frontend/content_editor/markdown_snapshot_spec_helper.js b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js
new file mode 100644
index 00000000000..05fa8e6a6b2
--- /dev/null
+++ b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js
@@ -0,0 +1,102 @@
+// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing
+// for documentation on this spec.
+
+import fs from 'fs';
+import path from 'path';
+import jsYaml from 'js-yaml';
+import { pick } from 'lodash';
+import {
+ IMPLEMENTATION_ERROR_MSG,
+ renderHtmlAndJsonForAllExamples,
+} from './render_html_and_json_for_all_examples';
+
+const filterExamples = (examples) => {
+ const focusedMarkdownExamples = process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || [];
+ if (!focusedMarkdownExamples.length) {
+ return examples;
+ }
+ return pick(examples, focusedMarkdownExamples);
+};
+
+const loadExamples = (dir, fileName) => {
+ const yaml = fs.readFileSync(path.join(dir, fileName));
+ const examples = jsYaml.safeLoad(yaml, {});
+ return filterExamples(examples);
+};
+
+// eslint-disable-next-line jest/no-export
+export const describeMarkdownSnapshots = (description, glfmSpecificationDir) => {
+ let actualHtmlAndJsonExamples;
+ let skipRunningSnapshotWysiwygHtmlTests;
+ let skipRunningSnapshotProsemirrorJsonTests;
+
+ const exampleStatuses = loadExamples(
+ path.join(glfmSpecificationDir, 'input', 'gitlab_flavored_markdown'),
+ 'glfm_example_status.yml',
+ );
+ const glfmExampleSnapshotsDir = path.join(glfmSpecificationDir, 'example_snapshots');
+ const markdownExamples = loadExamples(glfmExampleSnapshotsDir, 'markdown.yml');
+ const expectedHtmlExamples = loadExamples(glfmExampleSnapshotsDir, 'html.yml');
+ const expectedProseMirrorJsonExamples = loadExamples(
+ glfmExampleSnapshotsDir,
+ 'prosemirror_json.yml',
+ );
+
+ beforeAll(async () => {
+ return renderHtmlAndJsonForAllExamples(markdownExamples).then((examples) => {
+ actualHtmlAndJsonExamples = examples;
+ });
+ });
+
+ describe(description, () => {
+ const exampleNames = Object.keys(markdownExamples);
+
+ describe.each(exampleNames)('%s', (name) => {
+ const exampleNamePrefix = 'verifies conversion of GLFM to';
+ skipRunningSnapshotWysiwygHtmlTests =
+ exampleStatuses[name]?.skip_running_snapshot_wysiwyg_html_tests;
+ skipRunningSnapshotProsemirrorJsonTests =
+ exampleStatuses[name]?.skip_running_snapshot_prosemirror_json_tests;
+
+ const markdown = markdownExamples[name];
+
+ if (skipRunningSnapshotWysiwygHtmlTests) {
+ it.todo(`${exampleNamePrefix} HTML: ${skipRunningSnapshotWysiwygHtmlTests}`);
+ } else {
+ it(`${exampleNamePrefix} HTML`, async () => {
+ const expectedHtml = expectedHtmlExamples[name].wysiwyg;
+ const { html: actualHtml } = actualHtmlAndJsonExamples[name];
+
+ // noinspection JSUnresolvedFunction (required to avoid RubyMine type inspection warning, because custom matchers auto-imported via Jest test setup are not automatically resolved - see https://youtrack.jetbrains.com/issue/WEB-42350/matcher-for-jest-is-not-recognized-but-it-is-runable)
+ expect(actualHtml).toMatchExpectedForMarkdown(
+ 'HTML',
+ name,
+ markdown,
+ IMPLEMENTATION_ERROR_MSG,
+ expectedHtml,
+ );
+ });
+ }
+
+ if (skipRunningSnapshotProsemirrorJsonTests) {
+ it.todo(
+ `${exampleNamePrefix} ProseMirror JSON: ${skipRunningSnapshotProsemirrorJsonTests}`,
+ );
+ } else {
+ it(`${exampleNamePrefix} ProseMirror JSON`, async () => {
+ const expectedJson = expectedProseMirrorJsonExamples[name];
+ const { json: actualJson } = actualHtmlAndJsonExamples[name];
+
+ // noinspection JSUnresolvedFunction
+ expect(actualJson).toMatchExpectedForMarkdown(
+ 'JSON',
+ name,
+ markdown,
+ IMPLEMENTATION_ERROR_MSG,
+ expectedJson,
+ );
+ });
+ }
+ });
+ });
+};
diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js
index 60dc540e192..48adceaab58 100644
--- a/spec/frontend/content_editor/remark_markdown_processing_spec.js
+++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js
@@ -6,6 +6,7 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight
import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
import FootnoteReference from '~/content_editor/extensions/footnote_reference';
import HardBreak from '~/content_editor/extensions/hard_break';
+import HTMLNodes from '~/content_editor/extensions/html_nodes';
import Heading from '~/content_editor/extensions/heading';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
import Image from '~/content_editor/extensions/image';
@@ -52,6 +53,7 @@ const tiptapEditor = createTestEditor({
TableCell,
TaskList,
TaskItem,
+ ...HTMLNodes,
],
});
@@ -64,6 +66,7 @@ const {
bulletList,
code,
codeBlock,
+ div,
footnoteDefinition,
footnoteReference,
hardBreak,
@@ -74,6 +77,7 @@ const {
link,
listItem,
orderedList,
+ pre,
strike,
table,
tableRow,
@@ -108,14 +112,21 @@ const {
tableRow: { nodeType: TableRow.name },
taskItem: { nodeType: TaskItem.name },
taskList: { nodeType: TaskList.name },
+ ...HTMLNodes.reduce(
+ (builders, htmlNode) => ({
+ ...builders,
+ [htmlNode.name]: { nodeType: htmlNode.name },
+ }),
+ {},
+ ),
},
});
describe('Client side Markdown processing', () => {
- const deserialize = async (content) => {
+ const deserialize = async (markdown) => {
const { document } = await remarkMarkdownDeserializer().deserialize({
schema: tiptapEditor.schema,
- content,
+ markdown,
});
return document;
@@ -127,8 +138,8 @@ describe('Client side Markdown processing', () => {
pristineDoc: document,
});
- const sourceAttrs = (sourceMapKey, sourceMarkdown) => ({
- sourceMapKey,
+ const source = (sourceMarkdown) => ({
+ sourceMapKey: expect.any(String),
sourceMarkdown,
});
@@ -136,63 +147,48 @@ describe('Client side Markdown processing', () => {
{
markdown: '__bold text__',
expectedDoc: doc(
- paragraph(
- sourceAttrs('0:13', '__bold text__'),
- bold(sourceAttrs('0:13', '__bold text__'), 'bold text'),
- ),
+ paragraph(source('__bold text__'), bold(source('__bold text__'), 'bold text')),
),
},
{
markdown: '**bold text**',
expectedDoc: doc(
- paragraph(
- sourceAttrs('0:13', '**bold text**'),
- bold(sourceAttrs('0:13', '**bold text**'), 'bold text'),
- ),
+ paragraph(source('**bold text**'), bold(source('**bold text**'), 'bold text')),
),
},
{
markdown: '<strong>bold text</strong>',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:26', '<strong>bold text</strong>'),
- bold(sourceAttrs('0:26', '<strong>bold text</strong>'), 'bold text'),
+ source('<strong>bold text</strong>'),
+ bold(source('<strong>bold text</strong>'), 'bold text'),
),
),
},
{
markdown: '<b>bold text</b>',
expectedDoc: doc(
- paragraph(
- sourceAttrs('0:16', '<b>bold text</b>'),
- bold(sourceAttrs('0:16', '<b>bold text</b>'), 'bold text'),
- ),
+ paragraph(source('<b>bold text</b>'), bold(source('<b>bold text</b>'), 'bold text')),
),
},
{
markdown: '_italic text_',
expectedDoc: doc(
- paragraph(
- sourceAttrs('0:13', '_italic text_'),
- italic(sourceAttrs('0:13', '_italic text_'), 'italic text'),
- ),
+ paragraph(source('_italic text_'), italic(source('_italic text_'), 'italic text')),
),
},
{
markdown: '*italic text*',
expectedDoc: doc(
- paragraph(
- sourceAttrs('0:13', '*italic text*'),
- italic(sourceAttrs('0:13', '*italic text*'), 'italic text'),
- ),
+ paragraph(source('*italic text*'), italic(source('*italic text*'), 'italic text')),
),
},
{
markdown: '<em>italic text</em>',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:20', '<em>italic text</em>'),
- italic(sourceAttrs('0:20', '<em>italic text</em>'), 'italic text'),
+ source('<em>italic text</em>'),
+ italic(source('<em>italic text</em>'), 'italic text'),
),
),
},
@@ -200,28 +196,25 @@ describe('Client side Markdown processing', () => {
markdown: '<i>italic text</i>',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:18', '<i>italic text</i>'),
- italic(sourceAttrs('0:18', '<i>italic text</i>'), 'italic text'),
+ source('<i>italic text</i>'),
+ italic(source('<i>italic text</i>'), 'italic text'),
),
),
},
{
markdown: '`inline code`',
expectedDoc: doc(
- paragraph(
- sourceAttrs('0:13', '`inline code`'),
- code(sourceAttrs('0:13', '`inline code`'), 'inline code'),
- ),
+ paragraph(source('`inline code`'), code(source('`inline code`'), 'inline code')),
),
},
{
markdown: '**`inline code bold`**',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:22', '**`inline code bold`**'),
+ source('**`inline code bold`**'),
bold(
- sourceAttrs('0:22', '**`inline code bold`**'),
- code(sourceAttrs('2:20', '`inline code bold`'), 'inline code bold'),
+ source('**`inline code bold`**'),
+ code(source('`inline code bold`'), 'inline code bold'),
),
),
),
@@ -230,10 +223,10 @@ describe('Client side Markdown processing', () => {
markdown: '_`inline code italics`_',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:23', '_`inline code italics`_'),
+ source('_`inline code italics`_'),
italic(
- sourceAttrs('0:23', '_`inline code italics`_'),
- code(sourceAttrs('1:22', '`inline code italics`'), 'inline code italics'),
+ source('_`inline code italics`_'),
+ code(source('`inline code italics`'), 'inline code italics'),
),
),
),
@@ -246,8 +239,8 @@ describe('Client side Markdown processing', () => {
`,
expectedDoc: doc(
paragraph(
- sourceAttrs('0:28', '<i class="foo">\n *bar*\n</i>'),
- italic(sourceAttrs('0:28', '<i class="foo">\n *bar*\n</i>'), '\n *bar*\n'),
+ source('<i class="foo">\n *bar*\n</i>'),
+ italic(source('<i class="foo">\n *bar*\n</i>'), '\n *bar*\n'),
),
),
},
@@ -259,8 +252,8 @@ describe('Client side Markdown processing', () => {
`,
expectedDoc: doc(
paragraph(
- sourceAttrs('0:27', '<img src="bar" alt="foo" />'),
- image({ ...sourceAttrs('0:27', '<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }),
+ source('<img src="bar" alt="foo" />'),
+ image({ ...source('<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }),
),
),
},
@@ -273,15 +266,12 @@ describe('Client side Markdown processing', () => {
`,
expectedDoc: doc(
bulletList(
- sourceAttrs('0:13', '- List item 1'),
- listItem(
- sourceAttrs('0:13', '- List item 1'),
- paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'),
- ),
+ source('- List item 1'),
+ listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')),
),
paragraph(
- sourceAttrs('15:42', '<img src="bar" alt="foo" />'),
- image({ ...sourceAttrs('15:42', '<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }),
+ source('<img src="bar" alt="foo" />'),
+ image({ ...source('<img src="bar" alt="foo" />'), alt: 'foo', src: 'bar' }),
),
),
},
@@ -289,10 +279,10 @@ describe('Client side Markdown processing', () => {
markdown: '[GitLab](https://gitlab.com "Go to GitLab")',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:43', '[GitLab](https://gitlab.com "Go to GitLab")'),
+ source('[GitLab](https://gitlab.com "Go to GitLab")'),
link(
{
- ...sourceAttrs('0:43', '[GitLab](https://gitlab.com "Go to GitLab")'),
+ ...source('[GitLab](https://gitlab.com "Go to GitLab")'),
href: 'https://gitlab.com',
title: 'Go to GitLab',
},
@@ -305,12 +295,12 @@ describe('Client side Markdown processing', () => {
markdown: '**[GitLab](https://gitlab.com "Go to GitLab")**',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:47', '**[GitLab](https://gitlab.com "Go to GitLab")**'),
+ source('**[GitLab](https://gitlab.com "Go to GitLab")**'),
bold(
- sourceAttrs('0:47', '**[GitLab](https://gitlab.com "Go to GitLab")**'),
+ source('**[GitLab](https://gitlab.com "Go to GitLab")**'),
link(
{
- ...sourceAttrs('2:45', '[GitLab](https://gitlab.com "Go to GitLab")'),
+ ...source('[GitLab](https://gitlab.com "Go to GitLab")'),
href: 'https://gitlab.com',
title: 'Go to GitLab',
},
@@ -324,10 +314,10 @@ describe('Client side Markdown processing', () => {
markdown: 'www.commonmark.org',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:18', 'www.commonmark.org'),
+ source('www.commonmark.org'),
link(
{
- ...sourceAttrs('0:18', 'www.commonmark.org'),
+ ...source('www.commonmark.org'),
href: 'http://www.commonmark.org',
},
'www.commonmark.org',
@@ -339,11 +329,11 @@ describe('Client side Markdown processing', () => {
markdown: 'Visit www.commonmark.org/help for more information.',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:51', 'Visit www.commonmark.org/help for more information.'),
+ source('Visit www.commonmark.org/help for more information.'),
'Visit ',
link(
{
- ...sourceAttrs('6:29', 'www.commonmark.org/help'),
+ ...source('www.commonmark.org/help'),
href: 'http://www.commonmark.org/help',
},
'www.commonmark.org/help',
@@ -356,11 +346,11 @@ describe('Client side Markdown processing', () => {
markdown: 'hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:66', 'hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.'),
+ source('hello@mail+xyz.example isn’t valid, but hello+xyz@mail.example is.'),
'hello@mail+xyz.example isn’t valid, but ',
link(
{
- ...sourceAttrs('40:62', 'hello+xyz@mail.example'),
+ ...source('hello+xyz@mail.example'),
href: 'mailto:hello+xyz@mail.example',
},
'hello+xyz@mail.example',
@@ -373,11 +363,12 @@ describe('Client side Markdown processing', () => {
markdown: '[https://gitlab.com>',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:20', '[https://gitlab.com>'),
+ source('[https://gitlab.com>'),
'[',
link(
{
- ...sourceAttrs(),
+ sourceMapKey: null,
+ sourceMarkdown: null,
href: 'https://gitlab.com',
},
'https://gitlab.com',
@@ -392,9 +383,9 @@ This is a paragraph with a\\
hard line break`,
expectedDoc: doc(
paragraph(
- sourceAttrs('0:43', 'This is a paragraph with a\\\nhard line break'),
+ source('This is a paragraph with a\\\nhard line break'),
'This is a paragraph with a',
- hardBreak(sourceAttrs('26:28', '\\\n')),
+ hardBreak(source('\\\n')),
'\nhard line break',
),
),
@@ -403,9 +394,9 @@ hard line break`,
markdown: '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:57', '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'),
+ source('![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'),
image({
- ...sourceAttrs('0:57', '![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'),
+ ...source('![GitLab Logo](https://gitlab.com/logo.png "GitLab Logo")'),
alt: 'GitLab Logo',
src: 'https://gitlab.com/logo.png',
title: 'GitLab Logo',
@@ -415,49 +406,43 @@ hard line break`,
},
{
markdown: '---',
- expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '---'))),
+ expectedDoc: doc(horizontalRule(source('---'))),
},
{
markdown: '***',
- expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '***'))),
+ expectedDoc: doc(horizontalRule(source('***'))),
},
{
markdown: '___',
- expectedDoc: doc(horizontalRule(sourceAttrs('0:3', '___'))),
+ expectedDoc: doc(horizontalRule(source('___'))),
},
{
markdown: '<hr>',
- expectedDoc: doc(horizontalRule(sourceAttrs('0:4', '<hr>'))),
+ expectedDoc: doc(horizontalRule(source('<hr>'))),
},
{
markdown: '# Heading 1',
- expectedDoc: doc(heading({ ...sourceAttrs('0:11', '# Heading 1'), level: 1 }, 'Heading 1')),
+ expectedDoc: doc(heading({ ...source('# Heading 1'), level: 1 }, 'Heading 1')),
},
{
markdown: '## Heading 2',
- expectedDoc: doc(heading({ ...sourceAttrs('0:12', '## Heading 2'), level: 2 }, 'Heading 2')),
+ expectedDoc: doc(heading({ ...source('## Heading 2'), level: 2 }, 'Heading 2')),
},
{
markdown: '### Heading 3',
- expectedDoc: doc(heading({ ...sourceAttrs('0:13', '### Heading 3'), level: 3 }, 'Heading 3')),
+ expectedDoc: doc(heading({ ...source('### Heading 3'), level: 3 }, 'Heading 3')),
},
{
markdown: '#### Heading 4',
- expectedDoc: doc(
- heading({ ...sourceAttrs('0:14', '#### Heading 4'), level: 4 }, 'Heading 4'),
- ),
+ expectedDoc: doc(heading({ ...source('#### Heading 4'), level: 4 }, 'Heading 4')),
},
{
markdown: '##### Heading 5',
- expectedDoc: doc(
- heading({ ...sourceAttrs('0:15', '##### Heading 5'), level: 5 }, 'Heading 5'),
- ),
+ expectedDoc: doc(heading({ ...source('##### Heading 5'), level: 5 }, 'Heading 5')),
},
{
markdown: '###### Heading 6',
- expectedDoc: doc(
- heading({ ...sourceAttrs('0:16', '###### Heading 6'), level: 6 }, 'Heading 6'),
- ),
+ expectedDoc: doc(heading({ ...source('###### Heading 6'), level: 6 }, 'Heading 6')),
},
{
markdown: `
@@ -465,9 +450,7 @@ Heading
one
======
`,
- expectedDoc: doc(
- heading({ ...sourceAttrs('0:18', 'Heading\none\n======'), level: 1 }, 'Heading\none'),
- ),
+ expectedDoc: doc(heading({ ...source('Heading\none\n======'), level: 1 }, 'Heading\none')),
},
{
markdown: `
@@ -475,9 +458,7 @@ Heading
two
-------
`,
- expectedDoc: doc(
- heading({ ...sourceAttrs('0:19', 'Heading\ntwo\n-------'), level: 2 }, 'Heading\ntwo'),
- ),
+ expectedDoc: doc(heading({ ...source('Heading\ntwo\n-------'), level: 2 }, 'Heading\ntwo')),
},
{
markdown: `
@@ -486,15 +467,9 @@ two
`,
expectedDoc: doc(
bulletList(
- sourceAttrs('0:27', '- List item 1\n- List item 2'),
- listItem(
- sourceAttrs('0:13', '- List item 1'),
- paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'),
- ),
- listItem(
- sourceAttrs('14:27', '- List item 2'),
- paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'),
- ),
+ source('- List item 1\n- List item 2'),
+ listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')),
+ listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')),
),
),
},
@@ -505,15 +480,9 @@ two
`,
expectedDoc: doc(
bulletList(
- sourceAttrs('0:27', '* List item 1\n* List item 2'),
- listItem(
- sourceAttrs('0:13', '* List item 1'),
- paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'),
- ),
- listItem(
- sourceAttrs('14:27', '* List item 2'),
- paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'),
- ),
+ source('* List item 1\n* List item 2'),
+ listItem(source('* List item 1'), paragraph(source('List item 1'), 'List item 1')),
+ listItem(source('* List item 2'), paragraph(source('List item 2'), 'List item 2')),
),
),
},
@@ -524,15 +493,9 @@ two
`,
expectedDoc: doc(
bulletList(
- sourceAttrs('0:27', '+ List item 1\n+ List item 2'),
- listItem(
- sourceAttrs('0:13', '+ List item 1'),
- paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'),
- ),
- listItem(
- sourceAttrs('14:27', '+ List item 2'),
- paragraph(sourceAttrs('16:27', 'List item 2'), 'List item 2'),
- ),
+ source('+ List item 1\n+ List item 2'),
+ listItem(source('+ List item 1'), paragraph(source('List item 1'), 'List item 1')),
+ listItem(source('+ List item 2'), paragraph(source('List item 2'), 'List item 2')),
),
),
},
@@ -543,15 +506,9 @@ two
`,
expectedDoc: doc(
orderedList(
- sourceAttrs('0:29', '1. List item 1\n1. List item 2'),
- listItem(
- sourceAttrs('0:14', '1. List item 1'),
- paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'),
- ),
- listItem(
- sourceAttrs('15:29', '1. List item 2'),
- paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'),
- ),
+ source('1. List item 1\n1. List item 2'),
+ listItem(source('1. List item 1'), paragraph(source('List item 1'), 'List item 1')),
+ listItem(source('1. List item 2'), paragraph(source('List item 2'), 'List item 2')),
),
),
},
@@ -562,15 +519,9 @@ two
`,
expectedDoc: doc(
orderedList(
- sourceAttrs('0:29', '1. List item 1\n2. List item 2'),
- listItem(
- sourceAttrs('0:14', '1. List item 1'),
- paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'),
- ),
- listItem(
- sourceAttrs('15:29', '2. List item 2'),
- paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'),
- ),
+ source('1. List item 1\n2. List item 2'),
+ listItem(source('1. List item 1'), paragraph(source('List item 1'), 'List item 1')),
+ listItem(source('2. List item 2'), paragraph(source('List item 2'), 'List item 2')),
),
),
},
@@ -581,15 +532,9 @@ two
`,
expectedDoc: doc(
orderedList(
- sourceAttrs('0:29', '1) List item 1\n2) List item 2'),
- listItem(
- sourceAttrs('0:14', '1) List item 1'),
- paragraph(sourceAttrs('3:14', 'List item 1'), 'List item 1'),
- ),
- listItem(
- sourceAttrs('15:29', '2) List item 2'),
- paragraph(sourceAttrs('18:29', 'List item 2'), 'List item 2'),
- ),
+ source('1) List item 1\n2) List item 2'),
+ listItem(source('1) List item 1'), paragraph(source('List item 1'), 'List item 1')),
+ listItem(source('2) List item 2'), paragraph(source('List item 2'), 'List item 2')),
),
),
},
@@ -600,15 +545,15 @@ two
`,
expectedDoc: doc(
bulletList(
- sourceAttrs('0:33', '- List item 1\n - Sub list item 1'),
+ source('- List item 1\n - Sub list item 1'),
listItem(
- sourceAttrs('0:33', '- List item 1\n - Sub list item 1'),
- paragraph(sourceAttrs('2:13', 'List item 1'), 'List item 1'),
+ source('- List item 1\n - Sub list item 1'),
+ paragraph(source('List item 1'), 'List item 1'),
bulletList(
- sourceAttrs('16:33', '- Sub list item 1'),
+ source('- Sub list item 1'),
listItem(
- sourceAttrs('16:33', '- Sub list item 1'),
- paragraph(sourceAttrs('18:33', 'Sub list item 1'), 'Sub list item 1'),
+ source('- Sub list item 1'),
+ paragraph(source('Sub list item 1'), 'Sub list item 1'),
),
),
),
@@ -624,19 +569,13 @@ two
`,
expectedDoc: doc(
bulletList(
- sourceAttrs(
- '0:66',
- '- List item 1 paragraph 1\n\n List item 1 paragraph 2\n- List item 2',
- ),
- listItem(
- sourceAttrs('0:52', '- List item 1 paragraph 1\n\n List item 1 paragraph 2'),
- paragraph(sourceAttrs('2:25', 'List item 1 paragraph 1'), 'List item 1 paragraph 1'),
- paragraph(sourceAttrs('29:52', 'List item 1 paragraph 2'), 'List item 1 paragraph 2'),
- ),
+ source('- List item 1 paragraph 1\n\n List item 1 paragraph 2\n- List item 2'),
listItem(
- sourceAttrs('53:66', '- List item 2'),
- paragraph(sourceAttrs('55:66', 'List item 2'), 'List item 2'),
+ source('- List item 1 paragraph 1\n\n List item 1 paragraph 2'),
+ paragraph(source('List item 1 paragraph 1'), 'List item 1 paragraph 1'),
+ paragraph(source('List item 1 paragraph 2'), 'List item 1 paragraph 2'),
),
+ listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')),
),
),
},
@@ -646,13 +585,13 @@ two
`,
expectedDoc: doc(
bulletList(
- sourceAttrs('0:41', '- List item with an image ![bar](foo.png)'),
+ source('- List item with an image ![bar](foo.png)'),
listItem(
- sourceAttrs('0:41', '- List item with an image ![bar](foo.png)'),
+ source('- List item with an image ![bar](foo.png)'),
paragraph(
- sourceAttrs('2:41', 'List item with an image ![bar](foo.png)'),
+ source('List item with an image ![bar](foo.png)'),
'List item with an image',
- image({ ...sourceAttrs('26:41', '![bar](foo.png)'), alt: 'bar', src: 'foo.png' }),
+ image({ ...source('![bar](foo.png)'), alt: 'bar', src: 'foo.png' }),
),
),
),
@@ -664,8 +603,8 @@ two
`,
expectedDoc: doc(
blockquote(
- sourceAttrs('0:22', '> This is a blockquote'),
- paragraph(sourceAttrs('2:22', 'This is a blockquote'), 'This is a blockquote'),
+ source('> This is a blockquote'),
+ paragraph(source('This is a blockquote'), 'This is a blockquote'),
),
),
},
@@ -676,17 +615,11 @@ two
`,
expectedDoc: doc(
blockquote(
- sourceAttrs('0:31', '> - List item 1\n> - List item 2'),
+ source('> - List item 1\n> - List item 2'),
bulletList(
- sourceAttrs('2:31', '- List item 1\n> - List item 2'),
- listItem(
- sourceAttrs('2:15', '- List item 1'),
- paragraph(sourceAttrs('4:15', 'List item 1'), 'List item 1'),
- ),
- listItem(
- sourceAttrs('18:31', '- List item 2'),
- paragraph(sourceAttrs('20:31', 'List item 2'), 'List item 2'),
- ),
+ source('- List item 1\n> - List item 2'),
+ listItem(source('- List item 1'), paragraph(source('List item 1'), 'List item 1')),
+ listItem(source('- List item 2'), paragraph(source('List item 2'), 'List item 2')),
),
),
),
@@ -699,10 +632,10 @@ code block
`,
expectedDoc: doc(
- paragraph(sourceAttrs('0:10', 'code block'), 'code block'),
+ paragraph(source('code block'), 'code block'),
codeBlock(
{
- ...sourceAttrs('12:42', " const fn = () => 'GitLab';"),
+ ...source(" const fn = () => 'GitLab';"),
class: 'code highlight',
language: null,
},
@@ -719,7 +652,7 @@ const fn = () => 'GitLab';
expectedDoc: doc(
codeBlock(
{
- ...sourceAttrs('0:44', "```javascript\nconst fn = () => 'GitLab';\n```"),
+ ...source("```javascript\nconst fn = () => 'GitLab';\n```"),
class: 'code highlight',
language: 'javascript',
},
@@ -736,7 +669,7 @@ const fn = () => 'GitLab';
expectedDoc: doc(
codeBlock(
{
- ...sourceAttrs('0:44', "~~~javascript\nconst fn = () => 'GitLab';\n~~~"),
+ ...source("~~~javascript\nconst fn = () => 'GitLab';\n~~~"),
class: 'code highlight',
language: 'javascript',
},
@@ -752,7 +685,7 @@ const fn = () => 'GitLab';
expectedDoc: doc(
codeBlock(
{
- ...sourceAttrs('0:7', '```\n```'),
+ ...source('```\n```'),
class: 'code highlight',
language: null,
},
@@ -770,7 +703,7 @@ const fn = () => 'GitLab';
expectedDoc: doc(
codeBlock(
{
- ...sourceAttrs('0:45', "```javascript\nconst fn = () => 'GitLab';\n\n```"),
+ ...source("```javascript\nconst fn = () => 'GitLab';\n\n```"),
class: 'code highlight',
language: 'javascript',
},
@@ -782,8 +715,8 @@ const fn = () => 'GitLab';
markdown: '~~Strikedthrough text~~',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:23', '~~Strikedthrough text~~'),
- strike(sourceAttrs('0:23', '~~Strikedthrough text~~'), 'Strikedthrough text'),
+ source('~~Strikedthrough text~~'),
+ strike(source('~~Strikedthrough text~~'), 'Strikedthrough text'),
),
),
},
@@ -791,8 +724,8 @@ const fn = () => 'GitLab';
markdown: '<del>Strikedthrough text</del>',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:30', '<del>Strikedthrough text</del>'),
- strike(sourceAttrs('0:30', '<del>Strikedthrough text</del>'), 'Strikedthrough text'),
+ source('<del>Strikedthrough text</del>'),
+ strike(source('<del>Strikedthrough text</del>'), 'Strikedthrough text'),
),
),
},
@@ -800,11 +733,8 @@ const fn = () => 'GitLab';
markdown: '<strike>Strikedthrough text</strike>',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:36', '<strike>Strikedthrough text</strike>'),
- strike(
- sourceAttrs('0:36', '<strike>Strikedthrough text</strike>'),
- 'Strikedthrough text',
- ),
+ source('<strike>Strikedthrough text</strike>'),
+ strike(source('<strike>Strikedthrough text</strike>'), 'Strikedthrough text'),
),
),
},
@@ -812,8 +742,8 @@ const fn = () => 'GitLab';
markdown: '<s>Strikedthrough text</s>',
expectedDoc: doc(
paragraph(
- sourceAttrs('0:26', '<s>Strikedthrough text</s>'),
- strike(sourceAttrs('0:26', '<s>Strikedthrough text</s>'), 'Strikedthrough text'),
+ source('<s>Strikedthrough text</s>'),
+ strike(source('<s>Strikedthrough text</s>'), 'Strikedthrough text'),
),
),
},
@@ -826,21 +756,21 @@ const fn = () => 'GitLab';
taskList(
{
numeric: false,
- ...sourceAttrs('0:45', '- [ ] task list item 1\n- [ ] task list item 2'),
+ ...source('- [ ] task list item 1\n- [ ] task list item 2'),
},
taskItem(
{
checked: false,
- ...sourceAttrs('0:22', '- [ ] task list item 1'),
+ ...source('- [ ] task list item 1'),
},
- paragraph(sourceAttrs('6:22', 'task list item 1'), 'task list item 1'),
+ paragraph(source('task list item 1'), 'task list item 1'),
),
taskItem(
{
checked: false,
- ...sourceAttrs('23:45', '- [ ] task list item 2'),
+ ...source('- [ ] task list item 2'),
},
- paragraph(sourceAttrs('29:45', 'task list item 2'), 'task list item 2'),
+ paragraph(source('task list item 2'), 'task list item 2'),
),
),
),
@@ -854,21 +784,21 @@ const fn = () => 'GitLab';
taskList(
{
numeric: false,
- ...sourceAttrs('0:45', '- [x] task list item 1\n- [x] task list item 2'),
+ ...source('- [x] task list item 1\n- [x] task list item 2'),
},
taskItem(
{
checked: true,
- ...sourceAttrs('0:22', '- [x] task list item 1'),
+ ...source('- [x] task list item 1'),
},
- paragraph(sourceAttrs('6:22', 'task list item 1'), 'task list item 1'),
+ paragraph(source('task list item 1'), 'task list item 1'),
),
taskItem(
{
checked: true,
- ...sourceAttrs('23:45', '- [x] task list item 2'),
+ ...source('- [x] task list item 2'),
},
- paragraph(sourceAttrs('29:45', 'task list item 2'), 'task list item 2'),
+ paragraph(source('task list item 2'), 'task list item 2'),
),
),
),
@@ -882,21 +812,21 @@ const fn = () => 'GitLab';
taskList(
{
numeric: true,
- ...sourceAttrs('0:47', '1. [ ] task list item 1\n2. [ ] task list item 2'),
+ ...source('1. [ ] task list item 1\n2. [ ] task list item 2'),
},
taskItem(
{
checked: false,
- ...sourceAttrs('0:23', '1. [ ] task list item 1'),
+ ...source('1. [ ] task list item 1'),
},
- paragraph(sourceAttrs('7:23', 'task list item 1'), 'task list item 1'),
+ paragraph(source('task list item 1'), 'task list item 1'),
),
taskItem(
{
checked: false,
- ...sourceAttrs('24:47', '2. [ ] task list item 2'),
+ ...source('2. [ ] task list item 2'),
},
- paragraph(sourceAttrs('31:47', 'task list item 2'), 'task list item 2'),
+ paragraph(source('task list item 2'), 'task list item 2'),
),
),
),
@@ -909,16 +839,16 @@ const fn = () => 'GitLab';
`,
expectedDoc: doc(
table(
- sourceAttrs('0:29', '| a | b |\n|---|---|\n| c | d |'),
+ source('| a | b |\n|---|---|\n| c | d |'),
tableRow(
- sourceAttrs('0:9', '| a | b |'),
- tableHeader(sourceAttrs('0:5', '| a |'), paragraph(sourceAttrs('2:3', 'a'), 'a')),
- tableHeader(sourceAttrs('5:9', ' b |'), paragraph(sourceAttrs('6:7', 'b'), 'b')),
+ source('| a | b |'),
+ tableHeader(source('| a |'), paragraph(source('a'), 'a')),
+ tableHeader(source(' b |'), paragraph(source('b'), 'b')),
),
tableRow(
- sourceAttrs('20:29', '| c | d |'),
- tableCell(sourceAttrs('20:25', '| c |'), paragraph(sourceAttrs('22:23', 'c'), 'c')),
- tableCell(sourceAttrs('25:29', ' d |'), paragraph(sourceAttrs('26:27', 'd'), 'd')),
+ source('| c | d |'),
+ tableCell(source('| c |'), paragraph(source('c'), 'c')),
+ tableCell(source(' d |'), paragraph(source('d'), 'd')),
),
),
),
@@ -936,30 +866,29 @@ const fn = () => 'GitLab';
`,
expectedDoc: doc(
table(
- sourceAttrs(
- '0:132',
+ source(
'<table>\n <tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>\n <tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>\n</table>',
),
tableRow(
- sourceAttrs('10:66', '<tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>'),
+ source('<tr>\n <th colspan="2" rowspan="5">Header</th>\n </tr>'),
tableHeader(
{
- ...sourceAttrs('19:58', '<th colspan="2" rowspan="5">Header</th>'),
+ ...source('<th colspan="2" rowspan="5">Header</th>'),
colspan: 2,
rowspan: 5,
},
- paragraph(sourceAttrs('47:53', 'Header'), 'Header'),
+ paragraph(source('Header'), 'Header'),
),
),
tableRow(
- sourceAttrs('69:123', '<tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>'),
+ source('<tr>\n <td colspan="2" rowspan="5">Body</td>\n </tr>'),
tableCell(
{
- ...sourceAttrs('78:115', '<td colspan="2" rowspan="5">Body</td>'),
+ ...source('<td colspan="2" rowspan="5">Body</td>'),
colspan: 2,
rowspan: 5,
},
- paragraph(sourceAttrs('106:110', 'Body'), 'Body'),
+ paragraph(source('Body'), 'Body'),
),
),
),
@@ -977,24 +906,177 @@ Paragraph
`,
expectedDoc: doc(
paragraph(
- sourceAttrs('0:30', 'This is a footnote [^footnote]'),
+ source('This is a footnote [^footnote]'),
'This is a footnote ',
footnoteReference({
- ...sourceAttrs('19:30', '[^footnote]'),
+ ...source('[^footnote]'),
identifier: 'footnote',
label: 'footnote',
}),
),
- paragraph(sourceAttrs('32:41', 'Paragraph'), 'Paragraph'),
+ paragraph(source('Paragraph'), 'Paragraph'),
footnoteDefinition(
{
- ...sourceAttrs('43:75', '[^footnote]: Footnote definition'),
+ ...source('[^footnote]: Footnote definition'),
identifier: 'footnote',
label: 'footnote',
},
- paragraph(sourceAttrs('56:75', 'Footnote definition'), 'Footnote definition'),
+ paragraph(source('Footnote definition'), 'Footnote definition'),
+ ),
+ paragraph(source('Paragraph'), 'Paragraph'),
+ ),
+ },
+ {
+ markdown: `
+<div>div</div>
+`,
+ expectedDoc: doc(div(source('<div>div</div>'), paragraph(source('div'), 'div'))),
+ },
+ {
+ markdown: `
+[![moon](moon.jpg)](/uri)
+`,
+ expectedDoc: doc(
+ paragraph(
+ source('[![moon](moon.jpg)](/uri)'),
+ link(
+ { ...source('[![moon](moon.jpg)](/uri)'), href: '/uri' },
+ image({ ...source('![moon](moon.jpg)'), src: 'moon.jpg', alt: 'moon' }),
+ ),
+ ),
+ ),
+ },
+ {
+ markdown: `
+<del>
+
+*foo*
+
+</del>
+`,
+ expectedDoc: doc(
+ paragraph(
+ source('*foo*'),
+ strike(source('<del>\n\n*foo*\n\n</del>'), italic(source('*foo*'), 'foo')),
+ ),
+ ),
+ expectedMarkdown: '*foo*',
+ },
+ {
+ markdown: `
+~[moon](moon.jpg) and [sun](sun.jpg)~
+`,
+ expectedDoc: doc(
+ paragraph(
+ source('~[moon](moon.jpg) and [sun](sun.jpg)~'),
+ strike(
+ source('~[moon](moon.jpg) and [sun](sun.jpg)~'),
+ link({ ...source('[moon](moon.jpg)'), href: 'moon.jpg' }, 'moon'),
+ ),
+ strike(source('~[moon](moon.jpg) and [sun](sun.jpg)~'), ' and '),
+ strike(
+ source('~[moon](moon.jpg) and [sun](sun.jpg)~'),
+ link({ ...source('[sun](sun.jpg)'), href: 'sun.jpg' }, 'sun'),
+ ),
+ ),
+ ),
+ },
+ {
+ markdown: `
+<del>
+
+**Paragraph 1**
+
+_Paragraph 2_
+
+</del>
+ `,
+ expectedDoc: doc(
+ paragraph(
+ source('**Paragraph 1**'),
+ strike(
+ source('<del>\n\n**Paragraph 1**\n\n_Paragraph 2_\n\n</del>'),
+ bold(source('**Paragraph 1**'), 'Paragraph 1'),
+ ),
+ ),
+ paragraph(
+ source('_Paragraph 2_'),
+ strike(
+ source('<del>\n\n**Paragraph 1**\n\n_Paragraph 2_\n\n</del>'),
+ italic(source('_Paragraph 2_'), 'Paragraph 2'),
+ ),
+ ),
+ ),
+ expectedMarkdown: `**Paragraph 1**
+
+_Paragraph 2_`,
+ },
+ /* TODO
+ * Implement proper editing support for HTML comments in the Content Editor
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/342173
+ */
+ {
+ markdown: '<!-- HTML comment -->',
+ expectedDoc: doc(paragraph()),
+ expectedMarkdown: '',
+ },
+ {
+ markdown: `
+<![CDATA[
+function matchwo(a,b)
+{
+ if (a < b && a < 0) then {
+ return 1;
+
+ } else {
+
+ return 0;
+ }
+}
+]]>
+ `,
+ expectedDoc: doc(paragraph()),
+ expectedMarkdown: '',
+ },
+ {
+ markdown: `
+<!-- foo -->*bar*
+*baz*
+ `,
+ expectedDoc: doc(
+ paragraph(source('*bar*'), '*bar*\n'),
+ paragraph(source('*baz*'), italic(source('*baz*'), 'baz')),
+ ),
+ expectedMarkdown: `*bar*
+
+*baz*`,
+ },
+ {
+ markdown: `
+<table><tr><td>
+<pre>
+**Hello**,
+
+_world_.
+</pre>
+</td></tr></table>
+`,
+ expectedDoc: doc(
+ table(
+ source('<table><tr><td>\n<pre>\n**Hello**,\n\n_world_.\n</pre>\n</td></tr></table>'),
+ tableRow(
+ source('<tr><td>\n<pre>\n**Hello**,\n\n_world_.\n</pre>\n</td></tr>'),
+ tableCell(
+ source('<td>\n<pre>\n**Hello**,\n\n_world_.\n</pre>\n</td>'),
+ pre(
+ source('<pre>\n**Hello**,\n\n_world_.\n</pre>'),
+ paragraph(source('**Hello**,'), '**Hello**,\n'),
+ paragraph(source('_world_.\n'), italic(source('_world_'), 'world'), '.\n'),
+ ),
+ paragraph(),
+ ),
+ ),
),
- paragraph(sourceAttrs('77:86', 'Paragraph'), 'Paragraph'),
),
},
];
@@ -1002,12 +1084,75 @@ Paragraph
const runOnly = examples.find((example) => example.only === true);
const runExamples = runOnly ? [runOnly] : examples;
- it.each(runExamples)('processes %s correctly', async ({ markdown, expectedDoc }) => {
- const trimmed = markdown.trim();
- const document = await deserialize(trimmed);
+ it.each(runExamples)(
+ 'processes %s correctly',
+ async ({ markdown, expectedDoc, expectedMarkdown }) => {
+ const trimmed = markdown.trim();
+ const document = await deserialize(trimmed);
- expect(expectedDoc).not.toBeFalsy();
- expect(document.toJSON()).toEqual(expectedDoc.toJSON());
- expect(serialize(document)).toEqual(trimmed);
- });
+ expect(expectedDoc).not.toBeFalsy();
+ expect(document.toJSON()).toEqual(expectedDoc.toJSON());
+ expect(serialize(document)).toEqual(expectedMarkdown ?? trimmed);
+ },
+ );
+
+ /**
+ * DISCLAIMER: THIS IS A SECURITY ORIENTED TEST THAT ENSURES
+ * THE CLIENT-SIDE PARSER IGNORES DANGEROUS TAGS THAT ARE NOT
+ * EXPLICITELY SUPPORTED.
+ *
+ * PLEASE CONSIDER THIS INFORMATION WHILE MODIFYING THESE TESTS
+ */
+ it.each([
+ {
+ markdown: `
+<script>
+alert("Hello world")
+</script>
+ `,
+ expectedHtml: '<p></p>',
+ },
+ {
+ markdown: `
+<foo>Hello</foo>
+ `,
+ expectedHtml: '<p></p>',
+ },
+ {
+ markdown: `
+<h1 class="heading-with-class">Header</h1>
+ `,
+ expectedHtml: '<h1>Header</h1>',
+ },
+ {
+ markdown: `
+<a id="link-id">Header</a> and other text
+ `,
+ expectedHtml:
+ '<p><a target="_blank" rel="noopener noreferrer nofollow">Header</a> and other text</p>',
+ },
+ {
+ markdown: `
+<style>
+body {
+ display: none;
+}
+</style>
+ `,
+ expectedHtml: '<p></p>',
+ },
+ {
+ markdown: '<div style="transform">div</div>',
+ expectedHtml: '<div><p>div</p></div>',
+ },
+ ])(
+ 'removes unknown tags and unsupported attributes from HTML output',
+ async ({ markdown, expectedHtml }) => {
+ const document = await deserialize(markdown);
+
+ tiptapEditor.commands.setContent(document.toJSON());
+
+ expect(tiptapEditor.getHTML()).toEqual(expectedHtml);
+ },
+ );
});
diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
new file mode 100644
index 00000000000..116a26cf7d5
--- /dev/null
+++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
@@ -0,0 +1,115 @@
+import { DOMSerializer } from 'prosemirror-model';
+// TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js
+// See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan
+import Blockquote from '~/content_editor/extensions/blockquote';
+import Bold from '~/content_editor/extensions/bold';
+import BulletList from '~/content_editor/extensions/bullet_list';
+import Code from '~/content_editor/extensions/code';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import DescriptionItem from '~/content_editor/extensions/description_item';
+import DescriptionList from '~/content_editor/extensions/description_list';
+import Details from '~/content_editor/extensions/details';
+import DetailsContent from '~/content_editor/extensions/details_content';
+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';
+import HTMLNodes from '~/content_editor/extensions/html_nodes';
+import Image from '~/content_editor/extensions/image';
+import InlineDiff from '~/content_editor/extensions/inline_diff';
+import Italic from '~/content_editor/extensions/italic';
+import Link from '~/content_editor/extensions/link';
+import ListItem from '~/content_editor/extensions/list_item';
+import OrderedList from '~/content_editor/extensions/ordered_list';
+import Strike from '~/content_editor/extensions/strike';
+import Table from '~/content_editor/extensions/table';
+import TableCell from '~/content_editor/extensions/table_cell';
+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 createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
+import { createTestEditor } from 'jest/content_editor/test_utils';
+
+const tiptapEditor = createTestEditor({
+ extensions: [
+ Blockquote,
+ Bold,
+ BulletList,
+ Code,
+ CodeBlockHighlight,
+ DescriptionItem,
+ DescriptionList,
+ Details,
+ DetailsContent,
+ Emoji,
+ FootnoteDefinition,
+ FootnoteReference,
+ FootnotesSection,
+ Figure,
+ FigureCaption,
+ HardBreak,
+ Heading,
+ HorizontalRule,
+ ...HTMLNodes,
+ Image,
+ InlineDiff,
+ Italic,
+ Link,
+ ListItem,
+ OrderedList,
+ Strike,
+ Table,
+ TableCell,
+ TableHeader,
+ TableRow,
+ TaskItem,
+ TaskList,
+ ],
+});
+
+export const IMPLEMENTATION_ERROR_MSG = 'Error - check implementation';
+
+async function renderMarkdownToHTMLAndJSON(markdown, schema, deserializer) {
+ let prosemirrorDocument;
+ try {
+ const { document } = await deserializer.deserialize({ schema, markdown });
+ prosemirrorDocument = document;
+ } catch (e) {
+ const errorMsg = `${IMPLEMENTATION_ERROR_MSG}:\n${e.message}`;
+ return {
+ html: errorMsg,
+ json: errorMsg,
+ };
+ }
+
+ const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment(
+ prosemirrorDocument.content,
+ );
+ const htmlString = Array.from(documentFragment.children)
+ .map((el) => el.outerHTML)
+ .join('\n');
+
+ const json = prosemirrorDocument.toJSON();
+ const jsonString = JSON.stringify(json, null, 2);
+ return { html: htmlString, json: jsonString };
+}
+
+export function renderHtmlAndJsonForAllExamples(markdownExamples) {
+ const { schema } = tiptapEditor;
+ const deserializer = createMarkdownDeserializer();
+ const exampleNames = Object.keys(markdownExamples);
+
+ return exampleNames.reduce(async (promisedExamples, exampleName) => {
+ const markdown = markdownExamples[exampleName];
+ const htmlAndJson = await renderMarkdownToHTMLAndJSON(markdown, schema, deserializer);
+ const examples = await promisedExamples;
+ examples[exampleName] = htmlAndJson;
+ return examples;
+ }, Promise.resolve({}));
+}
diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js
index 13e9efaea59..509cda3046c 100644
--- a/spec/frontend/content_editor/services/markdown_serializer_spec.js
+++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js
@@ -7,7 +7,6 @@ import DescriptionItem from '~/content_editor/extensions/description_item';
import DescriptionList from '~/content_editor/extensions/description_list';
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
-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';
@@ -16,6 +15,8 @@ import FootnoteReference from '~/content_editor/extensions/footnote_reference';
import HardBreak from '~/content_editor/extensions/hard_break';
import Heading from '~/content_editor/extensions/heading';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
+import HTMLMarks from '~/content_editor/extensions/html_marks';
+import HTMLNodes from '~/content_editor/extensions/html_nodes';
import Image from '~/content_editor/extensions/image';
import InlineDiff from '~/content_editor/extensions/inline_diff';
import Italic from '~/content_editor/extensions/italic';
@@ -48,7 +49,6 @@ const tiptapEditor = createTestEditor({
DescriptionList,
Details,
DetailsContent,
- Division,
Emoji,
FootnoteDefinition,
FootnoteReference,
@@ -71,6 +71,8 @@ const tiptapEditor = createTestEditor({
TableRow,
TaskItem,
TaskList,
+ ...HTMLMarks,
+ ...HTMLNodes,
],
});
@@ -84,7 +86,7 @@ const {
codeBlock,
details,
detailsContent,
- division,
+ div,
descriptionItem,
descriptionList,
emoji,
@@ -120,7 +122,6 @@ const {
codeBlock: { nodeType: CodeBlockHighlight.name },
details: { nodeType: Details.name },
detailsContent: { nodeType: DetailsContent.name },
- division: { nodeType: Division.name },
descriptionItem: { nodeType: DescriptionItem.name },
descriptionList: { nodeType: DescriptionList.name },
emoji: { markType: Emoji.name },
@@ -145,6 +146,13 @@ const {
tableRow: { nodeType: TableRow.name },
taskItem: { nodeType: TaskItem.name },
taskList: { nodeType: TaskList.name },
+ ...HTMLNodes.reduce(
+ (builders, htmlNode) => ({
+ ...builders,
+ [htmlNode.name]: { nodeType: htmlNode.name },
+ }),
+ {},
+ ),
},
});
@@ -725,8 +733,8 @@ _inception_
it('correctly renders div', () => {
expect(
serialize(
- division(paragraph('just a paragraph in a div')),
- division(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')),
+ div(paragraph('just a paragraph in a div')),
+ div(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')),
),
).toBe(
'<div>just a paragraph in a div</div>\n<div>\n\njust some **styled** _content_ in a div\n\n</div>',
@@ -1169,7 +1177,7 @@ Oranges are orange [^1]
};
it.each`
- mark | content | modifiedContent | editAction
+ mark | markdown | modifiedMarkdown | editAction
${'bold'} | ${'**bold**'} | ${'**bold modified**'} | ${defaultEditAction}
${'bold'} | ${'__bold__'} | ${'__bold modified__'} | ${defaultEditAction}
${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} | ${defaultEditAction}
@@ -1205,10 +1213,10 @@ Oranges are orange [^1]
${'taskList'} | ${'2) [x] task list item'} | ${'2) [x] task list item modified'} | ${defaultEditAction}
`(
'preserves original $mark syntax when sourceMarkdown is available for $content',
- async ({ content, modifiedContent, editAction }) => {
+ async ({ markdown, modifiedMarkdown, editAction }) => {
const { document } = await remarkMarkdownDeserializer().deserialize({
schema: tiptapEditor.schema,
- content,
+ markdown,
});
editAction(document);
@@ -1218,7 +1226,7 @@ Oranges are orange [^1]
doc: tiptapEditor.state.doc,
});
- expect(serialized).toEqual(modifiedContent);
+ expect(serialized).toEqual(modifiedMarkdown);
},
);
});
diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
index 8a304c73163..2efc73ddef8 100644
--- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
+++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js
@@ -77,7 +77,7 @@ describe('content_editor/services/markdown_sourcemap', () => {
render: () => BULLET_LIST_HTML,
}).deserialize({
schema: tiptapEditor.schema,
- content: BULLET_LIST_MARKDOWN,
+ markdown: BULLET_LIST_MARKDOWN,
});
const expected = doc(
diff --git a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
index 2001f5c1441..bd4ed950f9d 100644
--- a/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
+++ b/spec/frontend/custom_metrics/components/custom_metrics_form_fields_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
@@ -148,6 +149,7 @@ describe('custom metrics form fields component', () => {
it('expect loading message to display', async () => {
const queryInput = wrapper.find(`input[name="${queryInputName}"]`);
queryInput.setValue('query');
+ await nextTick();
expect(wrapper.text()).toContain('Validating query');
});
diff --git a/spec/frontend/cycle_analytics/path_navigation_spec.js b/spec/frontend/cycle_analytics/path_navigation_spec.js
index e8c4ebd3a38..fa9eadbd071 100644
--- a/spec/frontend/cycle_analytics/path_navigation_spec.js
+++ b/spec/frontend/cycle_analytics/path_navigation_spec.js
@@ -85,7 +85,7 @@ describe('Project PathNavigation', () => {
const result = findPathNavigationTitles();
transformedProjectStagePathData.forEach(({ title, metric }, index) => {
expect(result[index]).toContain(title);
- expect(result[index]).toContain(metric);
+ expect(result[index]).toContain(metric.toString());
});
});
diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
index df86b10cba3..23e41f35b00 100644
--- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
+++ b/spec/frontend/cycle_analytics/value_stream_metrics_spec.js
@@ -55,10 +55,10 @@ describe('ValueStreamMetrics', () => {
describe('with successful requests', () => {
beforeEach(() => {
mockGetValueStreamSummaryMetrics = jest.fn().mockResolvedValue({ data: metricsData });
- wrapper = createComponent();
});
it('will display a loader with pending requests', async () => {
+ wrapper = createComponent();
await nextTick();
expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true);
@@ -66,6 +66,7 @@ describe('ValueStreamMetrics', () => {
describe('with data loaded', () => {
beforeEach(async () => {
+ wrapper = createComponent();
await waitForPromises();
});
diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js
index 40968d9204a..f13796138bd 100644
--- a/spec/frontend/design_management/components/design_sidebar_spec.js
+++ b/spec/frontend/design_management/components/design_sidebar_spec.js
@@ -1,7 +1,6 @@
-import { GlCollapse, GlPopover } from '@gitlab/ui';
+import { GlAccordionItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import Cookies from '~/lib/utils/cookies';
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue';
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
@@ -27,8 +26,6 @@ const $route = {
},
};
-const cookieKey = 'hide_design_resolved_comments_popover';
-
const mutate = jest.fn().mockResolvedValue();
describe('Design management design sidebar component', () => {
@@ -40,9 +37,7 @@ describe('Design management design sidebar component', () => {
const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]');
const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]');
const findParticipants = () => wrapper.find(Participants);
- const findCollapsible = () => wrapper.find(GlCollapse);
- const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]');
- const findPopover = () => wrapper.find(GlPopover);
+ const findResolvedCommentsToggle = () => wrapper.find(GlAccordionItem);
const findNewDiscussionDisclaimer = () =>
wrapper.find('[data-testid="new-discussion-disclaimer"]');
@@ -61,7 +56,6 @@ describe('Design management design sidebar component', () => {
mutate,
},
},
- stubs: { GlPopover },
provide: {
registerPath: '/users/sign_up?redirect_to_referer=yes',
signInPath: '/users/sign_in?redirect_to_referer=yes',
@@ -119,7 +113,6 @@ describe('Design management design sidebar component', () => {
describe('when has discussions', () => {
beforeEach(() => {
- Cookies.set(cookieKey, true);
createComponent();
});
@@ -131,26 +124,23 @@ describe('Design management design sidebar component', () => {
expect(findResolvedDiscussions()).toHaveLength(1);
});
- it('has resolved comments collapsible collapsed', () => {
- expect(findCollapsible().attributes('visible')).toBeUndefined();
+ it('has resolved comments accordion item collapsed', () => {
+ expect(findResolvedCommentsToggle().props('visible')).toBe(false);
});
- it('emits toggleResolveComments event on resolve comments button click', () => {
- findToggleResolvedCommentsButton().vm.$emit('click');
+ it('emits toggleResolveComments event on resolve comments button click', async () => {
+ findResolvedCommentsToggle().vm.$emit('input', true);
+ await nextTick();
expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1);
});
- it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', async () => {
- expect(findCollapsible().attributes('visible')).toBeUndefined();
+ it('opens the accordion item when resolvedDiscussionsExpanded prop changes to true', async () => {
+ expect(findResolvedCommentsToggle().props('visible')).toBe(false);
wrapper.setProps({
resolvedDiscussionsExpanded: true,
});
await nextTick();
- expect(findCollapsible().attributes('visible')).toBe('true');
- });
-
- it('does not popover about resolved comments', () => {
- expect(findPopover().exists()).toBe(false);
+ expect(findResolvedCommentsToggle().props('visible')).toBe(true);
});
it('sends a mutation to set an active discussion when clicking on a discussion', () => {
@@ -232,36 +222,6 @@ describe('Design management design sidebar component', () => {
});
});
- describe('when showing resolved discussions for the first time', () => {
- beforeEach(() => {
- Cookies.set(cookieKey, false);
- createComponent();
- });
-
- it('renders a popover if we show resolved comments collapsible for the first time', () => {
- expect(findPopover().exists()).toBe(true);
- });
-
- it('scrolls to resolved threads link', () => {
- expect(scrollIntoViewMock).toHaveBeenCalled();
- });
-
- it('dismisses a popover on the outside click', async () => {
- wrapper.trigger('click');
- await nextTick();
- expect(findPopover().exists()).toBe(false);
- });
-
- it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => {
- jest.spyOn(Cookies, 'set');
- wrapper.trigger('click');
- expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', {
- expires: 365 * 10,
- secure: false,
- });
- });
- });
-
describe('when user is not logged in', () => {
const findDesignNoteSignedOut = () => wrapper.findComponent(DesignNoteSignedOut);
@@ -292,7 +252,6 @@ describe('Design management design sidebar component', () => {
describe('design has discussions', () => {
beforeEach(() => {
- Cookies.set(cookieKey, true);
createComponent();
});
diff --git a/spec/frontend/design_management/components/image_spec.js b/spec/frontend/design_management/components/image_spec.js
index e27b2bc9fa5..65ee0ae6238 100644
--- a/spec/frontend/design_management/components/image_spec.js
+++ b/spec/frontend/design_management/components/image_spec.js
@@ -1,6 +1,7 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import DesignImage from '~/design_management/components/image.vue';
describe('Design management large image component', () => {
@@ -15,6 +16,10 @@ describe('Design management large image component', () => {
wrapper.setData(data);
}
+ beforeEach(() => {
+ stubPerformanceWebAPI();
+ });
+
afterEach(() => {
wrapper.destroy();
});
diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
index be736184e60..9997f02cd01 100644
--- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -7,6 +7,8 @@ exports[`Design management index page designs renders error 1`] = `
>
<!---->
+ <!---->
+
<div
class="gl-mt-6"
>
@@ -39,6 +41,8 @@ exports[`Design management index page designs renders loading icon 1`] = `
>
<!---->
+ <!---->
+
<div
class="gl-mt-6"
>
diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
index 0f2857821ea..3177a5e016c 100644
--- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -88,57 +88,26 @@ exports[`Design management design index page renders design index 1`] = `
signinpath=""
/>
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
- data-testid="resolved-comments"
- icon="chevron-right"
- id="resolved-comments"
- size="medium"
- variant="link"
+ <gl-accordion-stub
+ class="gl-mb-5"
+ headerlevel="3"
>
- Resolved Comments (1)
-
- </gl-button-stub>
-
- <gl-popover-stub
- container="popovercontainer"
- cssclasses=""
- placement="top"
- show="true"
- target="resolved-comments"
- title="Resolved Comments"
- >
- <p>
-
- Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below
-
- </p>
-
- <a
- href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
- rel="noopener noreferrer"
- target="_blank"
+ <gl-accordion-item-stub
+ headerclass="gl-mb-5!"
+ title="Resolved Comments (1)"
>
- Learn more about resolving comments
- </a>
- </gl-popover-stub>
-
- <gl-collapse-stub
- class="gl-mt-3"
- >
- <design-discussion-stub
- data-testid="resolved-discussion"
- designid="gid::/gitlab/Design/1"
- discussion="[object Object]"
- discussionwithopenform=""
- markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
- noteableid="gid::/gitlab/Design/1"
- registerpath=""
- signinpath=""
- />
- </gl-collapse-stub>
+ <design-discussion-stub
+ data-testid="resolved-discussion"
+ designid="gid::/gitlab/Design/1"
+ discussion="[object Object]"
+ discussionwithopenform=""
+ markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
+ noteableid="gid::/gitlab/Design/1"
+ registerpath=""
+ signinpath=""
+ />
+ </gl-accordion-item-stub>
+ </gl-accordion-stub>
</div>
</div>
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 087655d10f7..21be7bd148b 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -1,5 +1,4 @@
import { GlEmptyState } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo, { ApolloMutation } from 'vue-apollo';
@@ -9,6 +8,7 @@ import VueDraggable from 'vuedraggable';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import DeleteButton from '~/design_management/components/delete_button.vue';
@@ -23,6 +23,7 @@ import * as utils from '~/design_management/utils/design_management_utils';
import {
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
+ UPLOAD_DESIGN_ERROR,
} from '~/design_management/utils/error_messages';
import {
DESIGN_TRACKING_PAGE_NAME,
@@ -101,20 +102,20 @@ describe('Design management index page', () => {
let moveDesignHandler;
const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox');
- const findSelectAllButton = () => wrapper.find('[data-testid="select-all-designs-button"');
- const findToolbar = () => wrapper.find('.qa-selector-toolbar');
- const findDesignCollectionIsCopying = () =>
- wrapper.find('[data-testid="design-collection-is-copying"');
- const findDeleteButton = () => wrapper.find(DeleteButton);
- const findDropzone = () => wrapper.findAll(DesignDropzone).at(0);
+ const findSelectAllButton = () => wrapper.findByTestId('select-all-designs-button');
+ const findToolbar = () => wrapper.findByTestId('design-selector-toolbar');
+ const findDesignCollectionIsCopying = () => wrapper.findByTestId('design-collection-is-copying');
+ const findDeleteButton = () => wrapper.findComponent(DeleteButton);
+ const findDropzone = () => wrapper.findAllComponents(DesignDropzone).at(0);
const dropzoneClasses = () => findDropzone().classes();
- const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]');
- const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1);
- const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]');
+ const findDropzoneWrapper = () => wrapper.findByTestId('design-dropzone-wrapper');
+ const findFirstDropzoneWithDesign = () => wrapper.findAllComponents(DesignDropzone).at(1);
+ const findDesignsWrapper = () => wrapper.findByTestId('designs-root');
const findDesigns = () => wrapper.findAll(Design);
const draggableAttributes = () => wrapper.find(VueDraggable).vm.$attrs;
- const findDesignUploadButton = () => wrapper.find('[data-testid="design-upload-button"]');
- const findDesignToolbarWrapper = () => wrapper.find('[data-testid="design-toolbar-wrapper"]');
+ const findDesignUploadButton = () => wrapper.findByTestId('design-upload-button');
+ const findDesignToolbarWrapper = () => wrapper.findByTestId('design-toolbar-wrapper');
+ const findDesignUpdateAlert = () => wrapper.findByTestId('design-update-alert');
async function moveDesigns(localWrapper) {
await waitForPromises();
@@ -149,7 +150,7 @@ describe('Design management index page', () => {
mutate,
};
- wrapper = shallowMount(Index, {
+ wrapper = shallowMountExtended(Index, {
data() {
return {
allVersions,
@@ -185,7 +186,7 @@ describe('Design management index page', () => {
];
fakeApollo = createMockApollo(requestHandlers, {}, { addTypename: true });
- wrapper = shallowMount(Index, {
+ wrapper = shallowMountExtended(Index, {
apolloProvider: fakeApollo,
router,
stubs: { VueDraggable },
@@ -412,7 +413,8 @@ describe('Design management index page', () => {
await nextTick();
expect(wrapper.vm.filesToBeSaved).toEqual([]);
expect(wrapper.vm.isSaving).toBeFalsy();
- expect(createFlash).toHaveBeenCalled();
+ expect(findDesignUpdateAlert().exists()).toBe(true);
+ expect(findDesignUpdateAlert().text()).toBe(UPLOAD_DESIGN_ERROR);
});
it('does not call mutation if createDesign is false', () => {
@@ -431,19 +433,23 @@ describe('Design management index page', () => {
wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0]));
- expect(createFlash).not.toHaveBeenCalled();
+ expect(findDesignUpdateAlert().exists()).toBe(false);
});
- it('warns when too many files are uploaded', () => {
+ it('warns when too many files are uploaded', async () => {
createComponent();
wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0]));
+ await nextTick();
- expect(createFlash).toHaveBeenCalled();
+ expect(findDesignUpdateAlert().exists()).toBe(true);
+ expect(findDesignUpdateAlert().text()).toBe(
+ 'The maximum number of designs allowed to be uploaded is 10. Please try again.',
+ );
});
});
- it('flashes warning if designs are skipped', async () => {
+ it('displays warning if designs are skipped', async () => {
createComponent({
mockMutate: () =>
Promise.resolve({
@@ -458,11 +464,8 @@ describe('Design management index page', () => {
]);
await uploadDesign;
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith({
- message: 'Upload skipped. test.jpg did not change.',
- types: 'warning',
- });
+ expect(findDesignUpdateAlert().exists()).toBe(true);
+ expect(findDesignUpdateAlert().text()).toBe('Upload skipped. test.jpg did not change.');
});
describe('dragging onto an existing design', () => {
@@ -495,13 +498,17 @@ describe('Design management index page', () => {
description | eventPayload | message
${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE}
${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE}
- `('calls createFlash when upload has $description', ({ eventPayload, message }) => {
- const designDropzone = findFirstDropzoneWithDesign();
- designDropzone.vm.$emit('change', eventPayload);
-
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith({ message });
- });
+ `(
+ 'displays GlAlert component when upload has $description',
+ async ({ eventPayload, message }) => {
+ expect(findDesignUpdateAlert().exists()).toBe(false);
+ const designDropzone = findFirstDropzoneWithDesign();
+ await designDropzone.vm.$emit('change', eventPayload);
+
+ expect(findDesignUpdateAlert().exists()).toBe(true);
+ expect(findDesignUpdateAlert().text()).toBe(message);
+ },
+ );
});
describe('tracking', () => {
@@ -804,7 +811,7 @@ describe('Design management index page', () => {
expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' });
});
- it('displays flash if mutation had a non-recoverable error', async () => {
+ it('displays alert if mutation had a non-recoverable error', async () => {
createComponentWithApollo({
moveHandler: jest.fn().mockRejectedValue('Error'),
});
@@ -812,9 +819,10 @@ describe('Design management index page', () => {
await moveDesigns(wrapper);
await waitForPromises();
- expect(createFlash).toHaveBeenCalledWith({
- message: 'Something went wrong when reordering designs. Please try again',
- });
+ expect(findDesignUpdateAlert().exists()).toBe(true);
+ expect(findDesignUpdateAlert().text()).toBe(
+ 'Something went wrong when reordering designs. Please try again',
+ );
});
});
});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 76e4a944d87..96f2ac1692c 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -18,6 +18,7 @@ import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
import axios from '~/lib/utils/axios_utils';
import * as urlUtils from '~/lib/utils/url_utility';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import createDiffsStore from '../create_diffs_store';
import diffsMockData from '../mock_data/merge_request_diffs';
@@ -79,6 +80,7 @@ describe('diffs/components/app', () => {
}
beforeEach(() => {
+ stubPerformanceWebAPI();
// setup globals (needed for component to mount :/)
window.mrTabs = {
resetViewContainer: jest.fn(),
diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
index 8cc342e45a7..cc4f13ab0cf 100644
--- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js
+++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
@@ -6,7 +6,7 @@ import { EVT_EXPAND_ALL_FILES } from '~/diffs/constants';
import eventHub from '~/diffs/event_hub';
import createStore from '~/diffs/store/modules';
-import file from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
const propsData = {
limited: true,
@@ -15,7 +15,7 @@ const propsData = {
};
async function files(store, count) {
- const copies = Array(count).fill(file);
+ const copies = Array(count).fill(getDiffFileMock());
store.state.diffs.diffFiles.push(...copies);
await nextTick();
diff --git a/spec/frontend/diffs/components/diff_code_quality_spec.js b/spec/frontend/diffs/components/diff_code_quality_spec.js
new file mode 100644
index 00000000000..81a817c47dc
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_code_quality_spec.js
@@ -0,0 +1,66 @@
+import { GlIcon } from '@gitlab/ui';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue';
+import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants';
+import { multipleFindingsArr } from '../mock_data/diff_code_quality';
+
+let wrapper;
+
+const findIcon = () => wrapper.findComponent(GlIcon);
+
+describe('DiffCodeQuality', () => {
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const createWrapper = (codeQuality, mountFunction = mountExtended) => {
+ return mountFunction(DiffCodeQuality, {
+ propsData: {
+ expandedLines: [],
+ line: 1,
+ codeQuality,
+ },
+ });
+ };
+
+ it('hides details and throws hideCodeQualityFindings event on close click', async () => {
+ wrapper = createWrapper(multipleFindingsArr);
+ expect(wrapper.findByTestId('diff-codequality').exists()).toBe(true);
+
+ await wrapper.findByTestId('diff-codequality-close').trigger('click');
+
+ expect(wrapper.emitted('hideCodeQualityFindings').length).toBe(1);
+ expect(wrapper.emitted().hideCodeQualityFindings[0][0]).toBe(wrapper.props('line'));
+ });
+
+ it('renders correct amount of list items for codequality array and their description', async () => {
+ wrapper = createWrapper(multipleFindingsArr);
+ const listItems = wrapper.findAll('li');
+
+ expect(wrapper.findAll('li').length).toBe(3);
+
+ listItems.wrappers.map((e, i) => {
+ return expect(e.text()).toEqual(multipleFindingsArr[i].description);
+ });
+ });
+
+ it.each`
+ severity
+ ${'info'}
+ ${'minor'}
+ ${'major'}
+ ${'critical'}
+ ${'blocker'}
+ ${'unknown'}
+ `('shows icon for $severity degradation', ({ severity }) => {
+ wrapper = createWrapper([{ severity }], shallowMountExtended);
+
+ expect(findIcon().exists()).toBe(true);
+
+ expect(findIcon().attributes()).toMatchObject({
+ class: `codequality-severity-icon ${SEVERITY_CLASSES[severity]}`,
+ name: SEVERITY_ICONS[severity],
+ size: '12',
+ });
+ });
+});
diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js
index 7d2afe105a5..6844e6e497a 100644
--- a/spec/frontend/diffs/components/diff_content_spec.js
+++ b/spec/frontend/diffs/components/diff_content_spec.js
@@ -10,7 +10,7 @@ import { diffViewerModes } from '~/ide/constants';
import NoteForm from '~/notes/components/note_form.vue';
import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue';
import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue';
-import diffFileMockData from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
Vue.use(Vuex);
@@ -28,7 +28,7 @@ describe('DiffContent', () => {
const getCommentFormForDiffFileGetterMock = jest.fn();
const defaultProps = {
- diffFile: JSON.parse(JSON.stringify(diffFileMockData)),
+ diffFile: getDiffFileMock(),
};
const createComponent = ({ props, state, provide } = {}) => {
@@ -70,7 +70,7 @@ describe('DiffContent', () => {
isInlineView: isInlineViewGetterMock,
isParallelView: isParallelViewGetterMock,
getCommentFormForDiffFile: getCommentFormForDiffFileGetterMock,
- diffLines: () => () => [...diffFileMockData.parallel_diff_lines],
+ diffLines: () => () => [...getDiffFileMock().parallel_diff_lines],
fileLineCodequality: () => () => [],
},
actions: {
diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
index 5ff0728b358..34bb73ccf26 100644
--- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js
+++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js
@@ -1,10 +1,9 @@
import { mount } from '@vue/test-utils';
-import { cloneDeep } from 'lodash';
import DiffExpansionCell from '~/diffs/components/diff_expansion_cell.vue';
import { INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants';
import { getPreviousLineIndex } from '~/diffs/store/utils';
import { createStore } from '~/mr_notes/stores';
-import diffFileMockData from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
const EXPAND_UP_CLASS = '.js-unfold';
const EXPAND_DOWN_CLASS = '.js-unfold-down';
@@ -26,7 +25,7 @@ function makeLoadMoreLinesPayload({
isExpandDown = false,
}) {
return {
- endpoint: diffFileMockData.context_lines_path,
+ endpoint: getDiffFileMock().context_lines_path,
params: {
since: sinceLine,
to: toLine,
@@ -57,7 +56,7 @@ describe('DiffExpansionCell', () => {
let store;
beforeEach(() => {
- mockFile = cloneDeep(diffFileMockData);
+ mockFile = getDiffFileMock();
mockLine = getLine(mockFile, INLINE_DIFF_VIEW_TYPE, 8);
store = createStore();
store.state.diffs.diffFiles = [mockFile];
@@ -117,102 +116,102 @@ describe('DiffExpansionCell', () => {
});
describe('any row', () => {
- [
- { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: cloneDeep(diffFileMockData) },
- ].forEach(({ diffViewType, file, lineIndex }) => {
- describe(`with diffViewType (${diffViewType})`, () => {
- beforeEach(() => {
- mockLine = getLine(mockFile, diffViewType, lineIndex);
- store.state.diffs.diffFiles = [{ ...mockFile, ...file }];
- store.state.diffs.diffViewType = diffViewType;
- });
-
- it('does not initially dispatch anything', () => {
- expect(store.dispatch).not.toHaveBeenCalled();
- });
-
- it('on expand all clicked, dispatch loadMoreLines', () => {
- const oldLineNumber = mockLine.meta_data.old_pos;
- const newLineNumber = mockLine.meta_data.new_pos;
- const previousIndex = getPreviousLineIndex(mockFile, {
- oldLineNumber,
- newLineNumber,
+ [{ diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: getDiffFileMock() }].forEach(
+ ({ diffViewType, file, lineIndex }) => {
+ describe(`with diffViewType (${diffViewType})`, () => {
+ beforeEach(() => {
+ mockLine = getLine(mockFile, diffViewType, lineIndex);
+ store.state.diffs.diffFiles = [{ ...mockFile, ...file }];
+ store.state.diffs.diffViewType = diffViewType;
});
- const wrapper = createComponent({ file, lineCountBetween: 10 });
-
- findExpandAll(wrapper).trigger('click');
+ it('does not initially dispatch anything', () => {
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
- expect(store.dispatch).toHaveBeenCalledWith(
- 'diffs/loadMoreLines',
- makeLoadMoreLinesPayload({
- fileHash: mockFile.file_hash,
- toLine: newLineNumber - 1,
- sinceLine: previousIndex,
+ it('on expand all clicked, dispatch loadMoreLines', () => {
+ const oldLineNumber = mockLine.meta_data.old_pos;
+ const newLineNumber = mockLine.meta_data.new_pos;
+ const previousIndex = getPreviousLineIndex(mockFile, {
oldLineNumber,
- }),
- );
- });
+ newLineNumber,
+ });
+
+ const wrapper = createComponent({ file, lineCountBetween: 10 });
+
+ findExpandAll(wrapper).trigger('click');
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'diffs/loadMoreLines',
+ makeLoadMoreLinesPayload({
+ fileHash: mockFile.file_hash,
+ toLine: newLineNumber - 1,
+ sinceLine: previousIndex,
+ oldLineNumber,
+ }),
+ );
+ });
- it('on expand up clicked, dispatch loadMoreLines', () => {
- mockLine.meta_data.old_pos = 200;
- mockLine.meta_data.new_pos = 200;
+ it('on expand up clicked, dispatch loadMoreLines', () => {
+ mockLine.meta_data.old_pos = 200;
+ mockLine.meta_data.new_pos = 200;
- const oldLineNumber = mockLine.meta_data.old_pos;
- const newLineNumber = mockLine.meta_data.new_pos;
+ const oldLineNumber = mockLine.meta_data.old_pos;
+ const newLineNumber = mockLine.meta_data.new_pos;
- const wrapper = createComponent({ file });
+ const wrapper = createComponent({ file });
- findExpandUp(wrapper).trigger('click');
+ findExpandUp(wrapper).trigger('click');
- expect(store.dispatch).toHaveBeenCalledWith(
- 'diffs/loadMoreLines',
- makeLoadMoreLinesPayload({
- fileHash: mockFile.file_hash,
- toLine: newLineNumber - 1,
- sinceLine: 179,
- oldLineNumber,
- diffViewType,
- unfold: true,
- }),
- );
- });
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'diffs/loadMoreLines',
+ makeLoadMoreLinesPayload({
+ fileHash: mockFile.file_hash,
+ toLine: newLineNumber - 1,
+ sinceLine: 179,
+ oldLineNumber,
+ diffViewType,
+ unfold: true,
+ }),
+ );
+ });
- it('on expand down clicked, dispatch loadMoreLines', () => {
- mockFile[lineSources[diffViewType]][lineIndex + 1] = cloneDeep(
- mockFile[lineSources[diffViewType]][lineIndex],
- );
- const nextLine = getLine(mockFile, diffViewType, lineIndex + 1);
-
- nextLine.meta_data.old_pos = 300;
- nextLine.meta_data.new_pos = 300;
- mockLine.meta_data.old_pos = 200;
- mockLine.meta_data.new_pos = 200;
-
- const wrapper = createComponent({ file });
-
- findExpandDown(wrapper).trigger('click');
-
- expect(store.dispatch).toHaveBeenCalledWith('diffs/loadMoreLines', {
- endpoint: diffFileMockData.context_lines_path,
- params: {
- since: 1,
- to: 21, // the load amount, plus 1 line
- offset: 0,
- unfold: true,
- bottom: true,
- },
- lineNumbers: {
- // when expanding down, these are based on the previous line, 0, in this case
- oldLineNumber: 0,
- newLineNumber: 0,
- },
- nextLineNumbers: { old_line: 200, new_line: 200 },
- fileHash: mockFile.file_hash,
- isExpandDown: true,
+ it('on expand down clicked, dispatch loadMoreLines', () => {
+ mockFile[lineSources[diffViewType]][lineIndex + 1] = getDiffFileMock()[
+ lineSources[diffViewType]
+ ][lineIndex];
+ const nextLine = getLine(mockFile, diffViewType, lineIndex + 1);
+
+ nextLine.meta_data.old_pos = 300;
+ nextLine.meta_data.new_pos = 300;
+ mockLine.meta_data.old_pos = 200;
+ mockLine.meta_data.new_pos = 200;
+
+ const wrapper = createComponent({ file });
+
+ findExpandDown(wrapper).trigger('click');
+
+ expect(store.dispatch).toHaveBeenCalledWith('diffs/loadMoreLines', {
+ endpoint: mockFile.context_lines_path,
+ params: {
+ since: 1,
+ to: 21, // the load amount, plus 1 line
+ offset: 0,
+ unfold: true,
+ bottom: true,
+ },
+ lineNumbers: {
+ // when expanding down, these are based on the previous line, 0, in this case
+ oldLineNumber: 0,
+ newLineNumber: 0,
+ },
+ nextLineNumbers: { old_line: 200, new_line: 200 },
+ fileHash: mockFile.file_hash,
+ isExpandDown: true,
+ });
});
});
- });
- });
+ },
+ );
});
});
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index a0aa4c784bf..9e8d9e1ca29 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -20,7 +20,7 @@ import axios from '~/lib/utils/axios_utils';
import { scrollToElement } from '~/lib/utils/common_utils';
import httpStatus from '~/lib/utils/http_status';
import createNotesStore from '~/notes/stores/modules';
-import diffFileMockDataReadable from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable';
jest.mock('~/lib/utils/common_utils');
@@ -106,7 +106,7 @@ const findLoader = (wrapper) => wrapper.find('[data-testid="loader-icon"]');
const findToggleButton = (wrapper) => wrapper.find('[data-testid="expand-button"]');
const toggleFile = (wrapper) => findDiffHeader(wrapper).vm.$emit('toggleFile');
-const getReadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataReadable));
+const getReadableFile = () => getDiffFileMock();
const getUnreadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataUnreadable));
const makeFileAutomaticallyCollapsed = (store, index = 0) =>
diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js
index b59043168b8..542d61c4680 100644
--- a/spec/frontend/diffs/components/diff_line_note_form_spec.js
+++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js
@@ -1,200 +1,207 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import Vuex from 'vuex';
+import Autosave from '~/autosave';
import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue';
-import { createStore } from '~/mr_notes/stores';
+import { createModules } from '~/mr_notes/stores';
import NoteForm from '~/notes/components/note_form.vue';
+import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { noteableDataMock } from 'jest/notes/mock_data';
-import diffFileMockData from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
-jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => {
- return {
- confirmAction: jest.fn(),
- };
-});
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+jest.mock('~/autosave');
describe('DiffLineNoteForm', () => {
let wrapper;
let diffFile;
let diffLines;
- const getDiffFileMock = () => ({ ...diffFileMockData });
+ let actions;
+ let store;
- const createComponent = (args = {}) => {
- diffFile = getDiffFileMock();
- diffLines = diffFile.highlighted_diff_lines;
- const store = createStore();
+ const getSelectedLine = () => {
+ const lineCode = diffLines[1].line_code;
+ return diffFile.highlighted_diff_lines.find((l) => l.line_code === lineCode);
+ };
+
+ const createStore = (state) => {
+ const modules = createModules();
+ modules.diffs.actions = {
+ ...modules.diffs.actions,
+ saveDiffDiscussion: jest.fn(() => Promise.resolve()),
+ };
+ modules.diffs.getters = {
+ ...modules.diffs.getters,
+ diffCompareDropdownTargetVersions: jest.fn(),
+ diffCompareDropdownSourceVersions: jest.fn(),
+ selectedSourceIndex: jest.fn(),
+ };
+ modules.notes.getters = {
+ ...modules.notes.getters,
+ noteableType: jest.fn(),
+ };
+ actions = modules.diffs.actions;
+
+ store = new Vuex.Store({ modules });
store.state.notes.userData.id = 1;
store.state.notes.noteableData = noteableDataMock;
+
+ store.replaceState({ ...store.state, ...state });
+ };
+
+ const createComponent = ({ props, state } = {}) => {
+ wrapper?.destroy();
+ diffFile = getDiffFileMock();
+ diffLines = diffFile.highlighted_diff_lines;
+
+ createStore(state);
store.state.diffs.diffFiles = [diffFile];
- store.replaceState({ ...store.state, ...args.state });
+ const propsData = {
+ diffFileHash: diffFile.file_hash,
+ diffLines,
+ line: diffLines[1],
+ range: { start: diffLines[0], end: diffLines[1] },
+ noteTargetLine: diffLines[1],
+ ...props,
+ };
- return shallowMount(DiffLineNoteForm, {
+ wrapper = shallowMount(DiffLineNoteForm, {
store,
- propsData: {
- ...{
- diffFileHash: diffFile.file_hash,
- diffLines,
- line: diffLines[1],
- range: { start: diffLines[0], end: diffLines[1] },
- noteTargetLine: diffLines[1],
- },
- ...(args.props || {}),
- },
+ propsData,
});
};
const findNoteForm = () => wrapper.findComponent(NoteForm);
+ const findCommentForm = () => wrapper.findComponent(MultilineCommentForm);
- describe('methods', () => {
- beforeEach(() => {
- wrapper = createComponent();
- });
-
- describe('handleCancelCommentForm', () => {
- afterEach(() => {
- confirmAction.mockReset();
- });
-
- it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => {
- confirmAction.mockResolvedValueOnce(false);
-
- findNoteForm().vm.$emit('cancelForm', true, true);
-
- expect(confirmAction).toHaveBeenCalled();
- });
+ beforeEach(() => {
+ Autosave.mockClear();
+ createComponent();
+ });
- it('should only ask for confirmation once', () => {
- // Never resolve so we can test what happens when triggered while "confirmAction" is loading
- confirmAction.mockImplementation(() => new Promise(() => {}));
+ it('shows note form', () => {
+ expect(wrapper.find(NoteForm).exists()).toBe(true);
+ });
- findNoteForm().vm.$emit('cancelForm', true, true);
- findNoteForm().vm.$emit('cancelForm', true, true);
+ it('passes the provided range of lines to comment form', () => {
+ expect(findCommentForm().props('lineRange')).toMatchObject({
+ start: diffLines[0],
+ end: diffLines[1],
+ });
+ });
- expect(confirmAction).toHaveBeenCalledTimes(1);
- });
+ it('respects empty range when passing a range of lines', () => {
+ createComponent({ props: { range: null } });
+ expect(findCommentForm().props('lineRange')).toMatchObject({
+ start: diffLines[1],
+ end: diffLines[1],
+ });
+ });
- it('should not ask for confirmation when one of the params false', () => {
- confirmAction.mockResolvedValueOnce(false);
+ it('should init autosave', () => {
+ expect(Autosave).toHaveBeenCalledWith({}, [
+ 'Note',
+ 'Issue',
+ 98,
+ undefined,
+ 'DiffNote',
+ undefined,
+ '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2',
+ ]);
+ });
- findNoteForm().vm.$emit('cancelForm', true, false);
+ describe('when cancelling form', () => {
+ afterEach(() => {
+ confirmAction.mockReset();
+ });
- expect(confirmAction).not.toHaveBeenCalled();
+ it('should only ask for confirmation once', () => {
+ let finalizePromise;
+ confirmAction.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finalizePromise = resolve;
+ }),
+ );
- findNoteForm().vm.$emit('cancelForm', false, true);
+ findNoteForm().vm.$emit('cancelForm', true, true);
+ findNoteForm().vm.$emit('cancelForm', true, true);
- expect(confirmAction).not.toHaveBeenCalled();
- });
+ expect(confirmAction).toHaveBeenCalledTimes(1);
+ finalizePromise();
+ });
- it('should call cancelCommentForm with lineCode', async () => {
+ describe('with confirmation', () => {
+ beforeEach(() => {
confirmAction.mockResolvedValueOnce(true);
- jest.spyOn(wrapper.vm, 'cancelCommentForm').mockImplementation(() => {});
- jest.spyOn(wrapper.vm, 'resetAutoSave').mockImplementation(() => {});
+ });
+ it('should ask form confirmation and hide form for a line', async () => {
findNoteForm().vm.$emit('cancelForm', true, true);
-
await nextTick();
-
expect(confirmAction).toHaveBeenCalled();
-
await nextTick();
- expect(wrapper.vm.cancelCommentForm).toHaveBeenCalledWith({
- lineCode: diffLines[1].line_code,
- fileHash: wrapper.vm.diffFileHash,
- });
- expect(wrapper.vm.resetAutoSave).toHaveBeenCalled();
+ expect(getSelectedLine().hasForm).toBe(false);
+ expect(Autosave.mock.instances[0].reset).toHaveBeenCalled();
});
});
- describe('saveNoteForm', () => {
- it('should call saveNote action with proper params', async () => {
- const saveDiffDiscussionSpy = jest
- .spyOn(wrapper.vm, 'saveDiffDiscussion')
- .mockReturnValue(Promise.resolve());
-
- const lineRange = {
- start: {
- line_code: wrapper.vm.commentLineStart.line_code,
- type: wrapper.vm.commentLineStart.type,
- new_line: 2,
- old_line: null,
- },
- end: {
- line_code: wrapper.vm.line.line_code,
- type: wrapper.vm.line.type,
- new_line: 2,
- old_line: null,
- },
- };
-
- const formData = {
- ...wrapper.vm.formData,
- lineRange,
- };
-
- await wrapper.vm.handleSaveNote('note body');
- expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({
- note: 'note body',
- formData,
- });
+ describe('without confirmation', () => {
+ beforeEach(() => {
+ confirmAction.mockResolvedValueOnce(false);
});
- });
- });
- describe('created', () => {
- it('should use the provided `range` of lines', () => {
- wrapper = createComponent();
+ it('should ask for confirmation when shouldConfirm and isDirty passed as truthy', () => {
+ findNoteForm().vm.$emit('cancelForm', true, true);
- expect(wrapper.vm.lines.start).toBe(diffLines[0]);
- expect(wrapper.vm.lines.end).toBe(diffLines[1]);
- });
+ expect(confirmAction).toHaveBeenCalled();
+ });
- it("should fill the internal `lines` data with the provided `line` if there's no provided `range", () => {
- wrapper = createComponent({ props: { range: null } });
+ it('should not ask for confirmation when one of the params false', () => {
+ findNoteForm().vm.$emit('cancelForm', true, false);
- expect(wrapper.vm.lines.start).toBe(diffLines[1]);
- expect(wrapper.vm.lines.end).toBe(diffLines[1]);
- });
- });
+ expect(confirmAction).not.toHaveBeenCalled();
- describe('mounted', () => {
- it('should init autosave', () => {
- const key = 'autosave/Note/Issue/98//DiffNote//1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2';
- wrapper = createComponent();
+ findNoteForm().vm.$emit('cancelForm', false, true);
- expect(wrapper.vm.autosave).toBeDefined();
- expect(wrapper.vm.autosave.key).toEqual(key);
+ expect(confirmAction).not.toHaveBeenCalled();
+ });
});
+ });
- it('should set selectedCommentPosition', () => {
- wrapper = createComponent();
- let startLineCode = wrapper.vm.commentLineStart.line_code;
- let lineCode = wrapper.vm.line.line_code;
-
- expect(startLineCode).toEqual(lineCode);
- wrapper.destroy();
-
- const state = {
- notes: {
- selectedCommentPosition: {
- start: {
- line_code: 'test',
- },
- },
+ describe('saving note', () => {
+ it('should save original line', async () => {
+ const lineRange = {
+ start: {
+ line_code: diffLines[1].line_code,
+ type: diffLines[1].type,
+ new_line: 2,
+ old_line: null,
+ },
+ end: {
+ line_code: diffLines[1].line_code,
+ type: diffLines[1].type,
+ new_line: 2,
+ old_line: null,
},
};
- wrapper = createComponent({ state });
- startLineCode = wrapper.vm.commentLineStart.line_code;
- lineCode = state.notes.selectedCommentPosition.start.line_code;
- expect(startLineCode).toEqual(lineCode);
+ await findNoteForm().vm.$emit('handleFormUpdate', 'note body');
+ expect(actions.saveDiffDiscussion.mock.calls[0][1].formData).toMatchObject({
+ lineRange,
+ });
});
- });
- describe('template', () => {
- it('should have note form', () => {
- wrapper = createComponent();
- expect(wrapper.find(NoteForm).exists()).toBe(true);
+ it('should save selected line from the store', async () => {
+ const lineCode = 'test';
+ store.state.notes.selectedCommentPosition = { start: { line_code: lineCode } };
+ createComponent({ state: store.state });
+ await findNoteForm().vm.$emit('handleFormUpdate', 'note body');
+ expect(actions.saveDiffDiscussion.mock.calls[0][1].formData.lineRange.start.line_code).toBe(
+ lineCode,
+ );
});
});
});
diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js
index 4c5ce429c9d..be81508213b 100644
--- a/spec/frontend/diffs/components/diff_row_spec.js
+++ b/spec/frontend/diffs/components/diff_row_spec.js
@@ -6,7 +6,7 @@ import DiffRow from '~/diffs/components/diff_row.vue';
import { mapParallel } from '~/diffs/components/diff_row_utils';
import diffsModule from '~/diffs/store/modules';
import { findInteropAttributes } from '../find_interop_attributes';
-import diffFileMockData from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
const showCommentForm = jest.fn();
const enterdragging = jest.fn();
@@ -210,6 +210,7 @@ describe('DiffRow', () => {
});
describe('sets coverage title and class', () => {
+ const diffFileMockData = getDiffFileMock();
const thisLine = diffFileMockData.parallel_diff_lines[2];
const rightLine = diffFileMockData.parallel_diff_lines[2].right;
diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js
index 4ef1ec55cb0..09fe69e97de 100644
--- a/spec/frontend/diffs/components/diff_stats_spec.js
+++ b/spec/frontend/diffs/components/diff_stats_spec.js
@@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DiffStats from '~/diffs/components/diff_stats.vue';
-import mockDiffFile from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
const TEST_ADDED_LINES = 100;
const TEST_REMOVED_LINES = 200;
@@ -48,6 +48,7 @@ describe('diff_stats', () => {
const getBytesContainer = () => wrapper.find('.diff-stats > div:first-child');
beforeEach(() => {
+ const mockDiffFile = getDiffFileMock();
file = {
...mockDiffFile,
viewer: {
diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js
index dfbe30e460b..15923a1c6de 100644
--- a/spec/frontend/diffs/components/diff_view_spec.js
+++ b/spec/frontend/diffs/components/diff_view_spec.js
@@ -1,7 +1,9 @@
import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import DiffView from '~/diffs/components/diff_view.vue';
+import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue';
+import { diffCodeQuality } from '../mock_data/diff_code_quality';
describe('DiffView', () => {
const DiffExpansionCell = { template: `<div/>` };
@@ -12,7 +14,7 @@ describe('DiffView', () => {
const setSelectedCommentPosition = jest.fn();
const getDiffRow = (wrapper) => wrapper.findComponent(DiffRow).vm;
- const createWrapper = (props) => {
+ const createWrapper = (props, provide = {}) => {
Vue.use(Vuex);
const batchComments = {
@@ -46,9 +48,33 @@ describe('DiffView', () => {
...props,
};
const stubs = { DiffExpansionCell, DiffRow, DiffCommentCell, DraftNote };
- return shallowMount(DiffView, { propsData, store, stubs });
+ return shallowMount(DiffView, { propsData, store, stubs, provide });
};
+ it('does not render a codeQuality diff view when there is no finding', () => {
+ const wrapper = createWrapper();
+ expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(false);
+ });
+
+ it('does render a codeQuality diff view with the correct props when there is a finding & refactorCodeQualityInlineFindings flag is true ', async () => {
+ const wrapper = createWrapper(diffCodeQuality, {
+ glFeatures: { refactorCodeQualityInlineFindings: true },
+ });
+ wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2);
+ await nextTick();
+ expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(true);
+ expect(wrapper.findComponent(DiffCodeQuality).props().codeQuality.length).not.toBe(0);
+ });
+
+ it('does not render a codeQuality diff view when there is a finding & refactorCodeQualityInlineFindings flag is false ', async () => {
+ const wrapper = createWrapper(diffCodeQuality, {
+ glFeatures: { refactorCodeQualityInlineFindings: false },
+ });
+ wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2);
+ await nextTick();
+ expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(false);
+ });
+
it.each`
type | side | container | sides | total
${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2}
diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js
index 693fc5bfd8f..2ec11ba86fd 100644
--- a/spec/frontend/diffs/components/settings_dropdown_spec.js
+++ b/spec/frontend/diffs/components/settings_dropdown_spec.js
@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import SettingsDropdown from '~/diffs/components/settings_dropdown.vue';
@@ -139,9 +138,7 @@ describe('Diff settings dropdown component', () => {
const checkbox = wrapper.findByTestId('show-whitespace');
const { checked } = checkbox.element;
- checkbox.trigger('click');
-
- await nextTick();
+ await checkbox.setChecked(false);
expect(store.dispatch).toHaveBeenCalledWith('diffs/setShowWhitespace', {
showWhitespace: !checked,
@@ -182,9 +179,7 @@ describe('Diff settings dropdown component', () => {
Object.assign(origStore.state.diffs, { viewDiffsFileByFile: start }),
});
- getFileByFileCheckbox(wrapper).trigger('click');
-
- await nextTick();
+ await getFileByFileCheckbox(wrapper).setChecked(setting);
expect(store.dispatch).toHaveBeenCalledWith('diffs/setFileByFile', {
fileByFile: setting,
diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js
index 963805f4792..931a9562d36 100644
--- a/spec/frontend/diffs/components/tree_list_spec.js
+++ b/spec/frontend/diffs/components/tree_list_spec.js
@@ -50,6 +50,19 @@ describe('Diffs tree list component', () => {
type: 'blob',
parentPath: 'app',
},
+ 'test.rb': {
+ addedLines: 0,
+ changed: true,
+ deleted: false,
+ fileHash: 'test',
+ key: 'test.rb',
+ name: 'test.rb',
+ path: 'app/test.rb',
+ removedLines: 0,
+ tempFile: true,
+ type: 'blob',
+ parentPath: 'app',
+ },
app: {
key: 'app',
path: 'app',
@@ -85,6 +98,23 @@ describe('Diffs tree list component', () => {
createComponent();
});
+ describe('search by file extension', () => {
+ it.each`
+ extension | itemSize
+ ${'*.md'} | ${0}
+ ${'*.js'} | ${1}
+ ${'index.js'} | ${1}
+ ${'app/*.js'} | ${1}
+ ${'*.js, *.rb'} | ${2}
+ `('it returns $itemSize item for $extension', async ({ extension, itemSize }) => {
+ wrapper.find('[data-testid="diff-tree-search"]').setValue(extension);
+
+ await nextTick();
+
+ expect(getFileRows()).toHaveLength(itemSize);
+ });
+ });
+
it('renders tree', () => {
expect(getFileRows()).toHaveLength(2);
expect(getFileRows().at(0).html()).toContain('index.js');
@@ -120,7 +150,7 @@ describe('Diffs tree list component', () => {
wrapper.vm.$store.state.diffs.renderTreeList = false;
await nextTick();
- expect(getFileRows()).toHaveLength(1);
+ expect(getFileRows()).toHaveLength(2);
});
it('renders file paths when renderTreeList is false', async () => {
diff --git a/spec/frontend/diffs/mock_data/diff_code_quality.js b/spec/frontend/diffs/mock_data/diff_code_quality.js
new file mode 100644
index 00000000000..2ca421a20b4
--- /dev/null
+++ b/spec/frontend/diffs/mock_data/diff_code_quality.js
@@ -0,0 +1,62 @@
+export const multipleFindingsArr = [
+ {
+ severity: 'minor',
+ description: 'Unexpected Debugger Statement.',
+ line: 2,
+ },
+ {
+ severity: 'major',
+ description:
+ 'Function `aVeryLongFunction` has 52 lines of code (exceeds 25 allowed). Consider refactoring.',
+ line: 3,
+ },
+ {
+ severity: 'minor',
+ description: 'Arrow function has too many statements (52). Maximum allowed is 30.',
+ line: 3,
+ },
+];
+
+export const multipleFindings = {
+ filePath: 'index.js',
+ codequality: multipleFindingsArr,
+};
+
+export const singularFinding = {
+ filePath: 'index.js',
+ codequality: [multipleFindingsArr[0]],
+};
+
+export const diffCodeQuality = {
+ diffFile: { file_hash: '123' },
+ diffLines: [
+ {
+ left: {
+ type: 'old',
+ old_line: 1,
+ new_line: null,
+ codequality: [],
+ lineDraft: {},
+ },
+ },
+ {
+ left: {
+ type: null,
+ old_line: 2,
+ new_line: 1,
+ codequality: [],
+ lineDraft: {},
+ },
+ },
+ {
+ left: {
+ type: 'new',
+ old_line: null,
+ new_line: 2,
+
+ codequality: [multipleFindingsArr[0]],
+ lineDraft: {},
+ },
+ },
+ ],
+};
diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js
index 9ebcd5ef26b..dd200b0248c 100644
--- a/spec/frontend/diffs/mock_data/diff_file.js
+++ b/spec/frontend/diffs/mock_data/diff_file.js
@@ -1,4 +1,4 @@
-export default {
+export const getDiffFileMock = () => ({
submodule: false,
submodule_link: null,
blob: {
@@ -305,4 +305,4 @@ export default {
],
discussions: [],
renderingLines: false,
-};
+});
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index cc595e58dda..346e43e5a72 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -3,7 +3,7 @@ import Cookies from '~/lib/utils/cookies';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
-import mockDiffFile from 'jest/diffs/mock_data/diff_file';
+import { getDiffFileMock } from 'jest/diffs/mock_data/diff_file';
import {
DIFF_VIEW_COOKIE_NAME,
INLINE_DIFF_VIEW_TYPE,
@@ -754,7 +754,7 @@ describe('DiffsStoreActions', () => {
it('dispatches actions', () => {
const commitId = 'something';
const formData = {
- diffFile: { ...mockDiffFile },
+ diffFile: getDiffFileMock(),
noteableData: {},
};
const note = {};
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index 57e623b843d..031e4fe2be2 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -3,7 +3,7 @@ import createState from '~/diffs/store/modules/diff_state';
import * as types from '~/diffs/store/mutation_types';
import mutations from '~/diffs/store/mutations';
import * as utils from '~/diffs/store/utils';
-import diffFileMockData from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
describe('DiffsStoreMutations', () => {
describe('SET_BASE_CONFIG', () => {
@@ -71,6 +71,7 @@ describe('DiffsStoreMutations', () => {
describe('SET_DIFF_METADATA', () => {
it('should overwrite state with the camelCased data that is passed in', () => {
+ const diffFileMockData = getDiffFileMock();
const state = {
diffFiles: [],
};
@@ -94,7 +95,7 @@ describe('DiffsStoreMutations', () => {
it('should set diff data batch type properly', () => {
const state = { diffFiles: [] };
const diffMock = {
- diff_files: [diffFileMockData],
+ diff_files: [getDiffFileMock()],
};
mutations[types.SET_DIFF_DATA_BATCH](state, diffMock);
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 6f55f76d7b5..8852c6c62c5 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -1,4 +1,3 @@
-import { clone } from 'lodash';
import {
LINE_POSITION_LEFT,
LINE_POSITION_RIGHT,
@@ -14,10 +13,9 @@ import {
import * as utils from '~/diffs/store/utils';
import { MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants';
import { noteableDataMock } from 'jest/notes/mock_data';
-import diffFileMockData from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
import { diffMetadata } from '../mock_data/diff_metadata';
-const getDiffFileMock = () => JSON.parse(JSON.stringify(diffFileMockData));
const getDiffMetadataMock = () => JSON.parse(JSON.stringify(diffMetadata));
describe('DiffsStoreUtils', () => {
@@ -47,7 +45,7 @@ describe('DiffsStoreUtils', () => {
let diffFile;
beforeEach(() => {
- diffFile = { ...clone(diffFileMockData) };
+ diffFile = getDiffFileMock();
});
it('should return the correct previous line number', () => {
diff --git a/spec/frontend/diffs/utils/diff_file_spec.js b/spec/frontend/diffs/utils/diff_file_spec.js
index 778897be3ba..b062a156216 100644
--- a/spec/frontend/diffs/utils/diff_file_spec.js
+++ b/spec/frontend/diffs/utils/diff_file_spec.js
@@ -6,7 +6,7 @@ import {
match,
} from '~/diffs/utils/diff_file';
import { diffViewerModes } from '~/ide/constants';
-import mockDiffFile from '../mock_data/diff_file';
+import { getDiffFileMock } from '../mock_data/diff_file';
function getDiffFiles() {
const loadFull = 'namespace/project/-/merge_requests/12345/diff_for_path?file_identifier=abc';
@@ -210,7 +210,7 @@ describe('diff_file utilities', () => {
];
const validFile = [
'computes the correct stats from a file',
- mockDiffFile,
+ getDiffFileMock(),
{
changed: 1024,
percent: 100,
@@ -223,7 +223,7 @@ describe('diff_file utilities', () => {
const negativeChange = [
'computed the correct states from a file with a negative size change',
{
- ...mockDiffFile,
+ ...getDiffFileMock(),
new_size: 0,
old_size: 1024,
},
diff --git a/spec/frontend/dirty_submit/dirty_submit_form_spec.js b/spec/frontend/dirty_submit/dirty_submit_form_spec.js
index bcbe824bd9f..f24bb7374a3 100644
--- a/spec/frontend/dirty_submit/dirty_submit_form_spec.js
+++ b/spec/frontend/dirty_submit/dirty_submit_form_spec.js
@@ -55,7 +55,6 @@ describe('DirtySubmitForm', () => {
describe('throttling tests', () => {
beforeEach(() => {
throttle.mockImplementation(lodash.throttle);
- jest.useFakeTimers();
});
afterEach(() => {
diff --git a/spec/frontend/emoji/awards_app/store/actions_spec.js b/spec/frontend/emoji/awards_app/store/actions_spec.js
index 0761256ed23..cd3dfab30d4 100644
--- a/spec/frontend/emoji/awards_app/store/actions_spec.js
+++ b/spec/frontend/emoji/awards_app/store/actions_spec.js
@@ -8,10 +8,6 @@ jest.mock('@sentry/browser');
jest.mock('~/vue_shared/plugins/global_toast');
describe('Awards app actions', () => {
- afterEach(() => {
- window.gon = {};
- });
-
describe('setInitialData', () => {
it('commits SET_INITIAL_DATA', async () => {
await testAction(
@@ -52,8 +48,6 @@ describe('Awards app actions', () => {
});
it('commits FETCH_AWARDS_SUCCESS', async () => {
- window.gon.current_user_id = 1;
-
await testAction(
actions.fetchAwards,
'1',
@@ -62,10 +56,6 @@ describe('Awards app actions', () => {
[{ type: 'fetchAwards', payload: '2' }],
);
});
-
- it('does not commit FETCH_AWARDS_SUCCESS when user signed out', async () => {
- await testAction(actions.fetchAwards, '1', { path: '/awards' }, [], []);
- });
});
});
@@ -75,8 +65,6 @@ describe('Awards app actions', () => {
});
it('calls Sentry.captureException', async () => {
- window.gon = { current_user_id: 1 };
-
await testAction(actions.fetchAwards, null, { path: '/awards' }, [], [], () => {
expect(Sentry.captureException).toHaveBeenCalled();
});
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
index 8465b57c660..dc1c1dfbe4a 100644
--- a/spec/frontend/environment.js
+++ b/spec/frontend/environment.js
@@ -67,12 +67,6 @@ class CustomEnvironment extends JSDOMEnvironment {
// Expose the jsdom (created in super class) to the global so that we can call reconfigure({ url: '' }) to properly set `window.location`
this.global.jsdom = this.dom;
- Object.assign(this.global.performance, {
- mark: () => null,
- measure: () => null,
- getEntriesByName: () => [],
- });
-
//
// Monaco-related environment variables
//
diff --git a/spec/frontend/environments/canary_update_modal_spec.js b/spec/frontend/environments/canary_update_modal_spec.js
index 22d13558a84..16792dcda1e 100644
--- a/spec/frontend/environments/canary_update_modal_spec.js
+++ b/spec/frontend/environments/canary_update_modal_spec.js
@@ -47,7 +47,7 @@ describe('/environments/components/canary_update_modal.vue', () => {
modalId: 'confirm-canary-change',
actionPrimary: {
text: 'Change ratio',
- attributes: [{ variant: 'info' }],
+ attributes: [{ variant: 'confirm' }],
},
actionCancel: { text: 'Cancel' },
});
diff --git a/spec/frontend/environments/confirm_rollback_modal_spec.js b/spec/frontend/environments/confirm_rollback_modal_spec.js
index b8dcb7c0d08..c4763933468 100644
--- a/spec/frontend/environments/confirm_rollback_modal_spec.js
+++ b/spec/frontend/environments/confirm_rollback_modal_spec.js
@@ -2,6 +2,7 @@ import { GlModal, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
+import { trimText } from 'helpers/text_helper';
import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import eventHub from '~/environments/event_hub';
@@ -76,9 +77,9 @@ describe('Confirm Rollback Modal Component', () => {
expect(modal.attributes('title')).toContain('Rollback');
expect(modal.attributes('title')).toContain('test');
- expect(modal.props('actionPrimary').text).toBe('Rollback');
+ expect(modal.props('actionPrimary').text).toBe('Rollback environment');
expect(modal.props('actionPrimary').attributes).toEqual(primaryPropsAttrs);
- expect(modal.text()).toContain('commit abc0123');
+ expect(trimText(modal.text())).toContain('commit abc0123');
expect(modal.text()).toContain('Are you sure you want to continue?');
});
@@ -95,8 +96,8 @@ describe('Confirm Rollback Modal Component', () => {
expect(modal.attributes('title')).toContain('Re-deploy');
expect(modal.attributes('title')).toContain('test');
- expect(modal.props('actionPrimary').text).toBe('Re-deploy');
- expect(modal.text()).toContain('commit abc0123');
+ expect(modal.props('actionPrimary').text).toBe('Re-deploy environment');
+ expect(trimText(modal.text())).toContain('commit abc0123');
expect(modal.text()).toContain('Are you sure you want to continue?');
});
@@ -156,7 +157,7 @@ describe('Confirm Rollback Modal Component', () => {
);
const modal = component.find(GlModal);
- expect(modal.text()).toContain('commit abc0123');
+ expect(trimText(modal.text())).toContain('commit abc0123');
expect(modal.text()).toContain('Are you sure you want to continue?');
});
@@ -180,7 +181,7 @@ describe('Confirm Rollback Modal Component', () => {
expect(modal.attributes('title')).toContain('Rollback');
expect(modal.attributes('title')).toContain('test');
- expect(modal.props('actionPrimary').text).toBe('Rollback');
+ expect(modal.props('actionPrimary').text).toBe('Rollback environment');
expect(modal.props('actionPrimary').attributes).toEqual(primaryPropsAttrs);
});
@@ -204,7 +205,7 @@ describe('Confirm Rollback Modal Component', () => {
expect(modal.attributes('title')).toContain('Re-deploy');
expect(modal.attributes('title')).toContain('test');
- expect(modal.props('actionPrimary').text).toBe('Re-deploy');
+ expect(modal.props('actionPrimary').text).toBe('Re-deploy environment');
});
it('should commit the "rollback" mutation when "ok" is clicked', async () => {
diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js
index 6bf87f7b07f..4d63648dd48 100644
--- a/spec/frontend/environments/deploy_board_component_spec.js
+++ b/spec/frontend/environments/deploy_board_component_spec.js
@@ -3,11 +3,9 @@ import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import CanaryIngress from '~/environments/components/canary_ingress.vue';
import DeployBoard from '~/environments/components/deploy_board.vue';
-import { deployBoardMockData, environment } from './mock_data';
+import { deployBoardMockData } from './mock_data';
import { rolloutStatus } from './graphql/mock_data';
-const logsPath = `gitlab-org/gitlab-test/-/logs?environment_name=${environment.name}`;
-
describe('Deploy Board', () => {
let wrapper;
@@ -17,7 +15,6 @@ describe('Deploy Board', () => {
deployBoardData: deployBoardMockData,
isLoading: false,
isEmpty: false,
- logsPath,
...props,
},
});
@@ -132,7 +129,6 @@ describe('Deploy Board', () => {
deployBoardData: {},
isLoading: false,
isEmpty: true,
- logsPath,
});
return nextTick();
});
@@ -151,7 +147,6 @@ describe('Deploy Board', () => {
deployBoardData: {},
isLoading: true,
isEmpty: false,
- logsPath,
});
return nextTick();
});
@@ -167,7 +162,6 @@ describe('Deploy Board', () => {
wrapper = createComponent({
isLoading: false,
isEmpty: false,
- logsPath: environment.log_path,
deployBoardData: deployBoardMockData,
});
({ statuses } = wrapper.vm);
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
index 0761d04229c..1c86a66d9b8 100644
--- a/spec/frontend/environments/environment_item_spec.js
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -68,7 +68,7 @@ describe('Environment item', () => {
describe('With deployment', () => {
it('should render deployment internal id', () => {
expect(wrapper.find('.deployment-column span').text()).toContain(
- environment.last_deployment.iid,
+ environment.last_deployment.iid.toString(),
);
expect(wrapper.find('.deployment-column span').text()).toContain('#');
@@ -400,7 +400,7 @@ describe('Environment item', () => {
});
it('should render the number of children in a badge', () => {
- expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size);
+ expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size.toString());
});
it('should not render the "Upcoming deployment" column', () => {
diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js
index 666e87c748e..aff6b1327f0 100644
--- a/spec/frontend/environments/environment_table_spec.js
+++ b/spec/frontend/environments/environment_table_spec.js
@@ -64,7 +64,6 @@ describe('Environment table', () => {
name: 'review',
size: 1,
environment_path: 'url',
- logs_path: 'url',
id: 1,
hasDeployBoard: true,
deployBoardData: deployBoardMockData,
@@ -92,7 +91,6 @@ describe('Environment table', () => {
name: 'review',
size: 1,
environment_path: 'url',
- logs_path: 'url',
id: 1,
isFolder: true,
isOpen: true,
@@ -161,7 +159,6 @@ describe('Environment table', () => {
name: 'review',
size: 1,
environment_path: 'url',
- logs_path: 'url',
id: 1,
hasDeployBoard: true,
deployBoardData: deployBoardMockData,
diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js
index 91b75c850bd..57f98c81124 100644
--- a/spec/frontend/environments/environments_app_spec.js
+++ b/spec/frontend/environments/environments_app_spec.js
@@ -204,9 +204,9 @@ describe('~/environments/components/environments_app.vue', () => {
const [available, stopped] = wrapper.findAllByRole('tab').wrappers;
expect(available.text()).toContain(__('Available'));
- expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount);
+ expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount.toString());
expect(stopped.text()).toContain(__('Stopped'));
- expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount);
+ expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount.toString());
});
it('should change the requested scope on tab change', async () => {
diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
index 5e0f0ca9bef..23d448f3964 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -398,6 +398,30 @@ describe('ErrorTrackingList', () => {
});
describe('When pagination is required', () => {
+ describe('and previous cursor is not available', () => {
+ beforeEach(async () => {
+ store.state.list.loading = false;
+ delete store.state.list.pagination.previous;
+ mountComponent();
+ });
+
+ it('disables Prev button in the pagination', async () => {
+ expect(findPagination().props('prevPage')).toBe(null);
+ expect(findPagination().props('nextPage')).not.toBe(null);
+ });
+ });
+ describe('and next cursor is not available', () => {
+ beforeEach(async () => {
+ store.state.list.loading = false;
+ delete store.state.list.pagination.next;
+ mountComponent();
+ });
+
+ it('disables Next button in the pagination', async () => {
+ expect(findPagination().props('prevPage')).not.toBe(null);
+ expect(findPagination().props('nextPage')).toBe(null);
+ });
+ });
describe('and the user is not on the first page', () => {
describe('and the previous button is clicked', () => {
beforeEach(async () => {
diff --git a/spec/frontend/fixtures/api_deploy_keys.rb b/spec/frontend/fixtures/api_deploy_keys.rb
index 7027b8c975b..5ffc726f086 100644
--- a/spec/frontend/fixtures/api_deploy_keys.rb
+++ b/spec/frontend/fixtures/api_deploy_keys.rb
@@ -11,6 +11,7 @@ RSpec.describe API::DeployKeys, '(JavaScript fixtures)', type: :request do
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_key_without_fingerprint) { create(:deploy_key, :without_md5_fingerprint, 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) }
diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb
index af548823886..b2bbdd2749e 100644
--- a/spec/frontend/fixtures/blob.rb
+++ b/spec/frontend/fixtures/blob.rb
@@ -12,7 +12,6 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control
render_views
before do
- stub_feature_flags(refactor_blob_viewer: false) # This fixture is only used by the legacy (non-refactored) blob viewer
sign_in(user)
allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon')
end
diff --git a/spec/frontend/fixtures/deploy_keys.rb b/spec/frontend/fixtures/deploy_keys.rb
index bed6c798793..154084e0181 100644
--- a/spec/frontend/fixtures/deploy_keys.rb
+++ b/spec/frontend/fixtures/deploy_keys.rb
@@ -27,9 +27,9 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c
render_views
it 'deploy_keys/keys.json' do
- create(:rsa_deploy_key_2048, public: true)
- project_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCdMHEHyhRjbhEZVddFn6lTWdgEy5Q6Bz4nwGB76xWZI5YT/1WJOMEW+sL5zYd31kk7sd3FJ5L9ft8zWMWrr/iWXQikC2cqZK24H1xy+ZUmrRuJD4qGAaIVoyyzBL+avL+lF8J5lg6YSw8gwJY/lX64/vnJHUlWw2n5BF8IFOWhiw== dummy@gitlab.com')
- internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com')
+ create(:rsa_deploy_key_5120, public: true)
+ project_key = create(:deploy_key)
+ internal_key = create(:deploy_key)
create(:deploy_keys_project, project: project, deploy_key: project_key)
create(:deploy_keys_project, project: project2, deploy_key: internal_key)
create(:deploy_keys_project, project: project3, deploy_key: project_key)
diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb
index 3cc87432655..2e15eefdce6 100644
--- a/spec/frontend/fixtures/jobs.rb
+++ b/spec/frontend/fixtures/jobs.rb
@@ -2,40 +2,94 @@
require 'spec_helper'
-RSpec.describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do
+RSpec.describe 'Jobs (JavaScript fixtures)' do
+ include ApiHelpers
include JavaScriptFixturesHelpers
+ include GraphqlHelpers
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, :repository, namespace: namespace, path: 'builds-project') }
let(:user) { project.first_owner }
let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id) }
- let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace_artifact, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) }
- let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') }
- let!(:pending_build) { create(:ci_build, :pending, pipeline: pipeline, stage: 'deploy') }
- let!(:delayed_job) do
- create(:ci_build, :scheduled,
- pipeline: pipeline,
- name: 'delayed job',
- stage: 'test')
+
+ after do
+ remove_repository(project)
end
- render_views
+ describe Projects::JobsController, type: :controller do
+ let!(:delayed) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'delayed job') }
- before do
- sign_in(user)
- end
+ before do
+ sign_in(user)
+ end
- after do
- remove_repository(project)
+ it 'jobs/delayed.json' do
+ get :show, params: {
+ namespace_id: project.namespace.to_param,
+ project_id: project,
+ id: delayed.to_param
+ }, format: :json
+
+ expect(response).to be_successful
+ end
end
- it 'jobs/delayed.json' do
- get :show, params: {
- namespace_id: project.namespace.to_param,
- project_id: project,
- id: delayed_job.to_param
- }, format: :json
+ describe GraphQL::Query, type: :request do
+ let(:artifact) { create(:ci_job_artifact, file_type: :archive, file_format: :zip) }
+
+ let!(:build) { create(:ci_build, :success, name: 'build', pipeline: pipeline) }
+ let!(:cancelable) { create(:ci_build, :cancelable, name: 'cancelable', pipeline: pipeline) }
+ let!(:created_by_tag) { create(:ci_build, :success, name: 'created_by_tag', tag: true, pipeline: pipeline) }
+ let!(:pending) { create(:ci_build, :pending, name: 'pending', pipeline: pipeline) }
+ let!(:playable) { create(:ci_build, :playable, name: 'playable', pipeline: pipeline) }
+ let!(:retryable) { create(:ci_build, :retryable, name: 'retryable', pipeline: pipeline) }
+ let!(:scheduled) { create(:ci_build, :scheduled, name: 'scheduled', pipeline: pipeline) }
+ let!(:with_artifact) { create(:ci_build, :success, name: 'with_artifact', job_artifacts: [artifact], pipeline: pipeline) }
+ let!(:with_coverage) { create(:ci_build, :success, name: 'with_coverage', coverage: 40.0, pipeline: pipeline) }
+
+ fixtures_path = 'graphql/jobs/'
+ get_jobs_query = 'get_jobs.query.graphql'
+ full_path = 'frontend-fixtures/builds-project'
+
+ let_it_be(:query) do
+ get_graphql_query_as_string("jobs/components/table/graphql/queries/#{get_jobs_query}")
+ end
+
+ it "#{fixtures_path}#{get_jobs_query}.json" do
+ post_graphql(query, current_user: user, variables: {
+ fullPath: full_path
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it "#{fixtures_path}#{get_jobs_query}.as_guest.json" do
+ guest = create(:user)
+ project.add_guest(guest)
+
+ post_graphql(query, current_user: guest, variables: {
+ fullPath: full_path
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it "#{fixtures_path}#{get_jobs_query}.paginated.json" do
+ post_graphql(query, current_user: user, variables: {
+ fullPath: full_path,
+ first: 2
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+
+ it "#{fixtures_path}#{get_jobs_query}.empty.json" do
+ post_graphql(query, current_user: user, variables: {
+ fullPath: full_path,
+ first: 0
+ })
- expect(response).to be_successful
+ expect_graphql_errors_to_be_empty
+ end
end
end
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index a79982fa647..36281af0219 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -29,7 +29,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
before do
allow(Gitlab::Ci::RunnerUpgradeCheck.instance)
.to receive(:check_runner_upgrade_status)
- .and_return(:not_available)
+ .and_return({ not_available: nil })
end
describe do
@@ -39,19 +39,19 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- admin_runners_query = 'list/admin_runners.query.graphql'
+ all_runners_query = 'list/all_runners.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{admin_runners_query}")
+ get_graphql_query_as_string("#{query_path}#{all_runners_query}")
end
- it "#{fixtures_path}#{admin_runners_query}.json" do
+ it "#{fixtures_path}#{all_runners_query}.json" do
post_graphql(query, current_user: admin, variables: {})
expect_graphql_errors_to_be_empty
end
- it "#{fixtures_path}#{admin_runners_query}.paginated.json" do
+ it "#{fixtures_path}#{all_runners_query}.paginated.json" do
post_graphql(query, current_user: admin, variables: { first: 2 })
expect_graphql_errors_to_be_empty
@@ -59,13 +59,13 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
end
describe GraphQL::Query, type: :request do
- admin_runners_count_query = 'list/admin_runners_count.query.graphql'
+ all_runners_count_query = 'list/all_runners_count.query.graphql'
let_it_be(:query) do
- get_graphql_query_as_string("#{query_path}#{admin_runners_count_query}")
+ get_graphql_query_as_string("#{query_path}#{all_runners_count_query}")
end
- it "#{fixtures_path}#{admin_runners_count_query}.json" do
+ it "#{fixtures_path}#{all_runners_count_query}.json" do
post_graphql(query, current_user: admin, variables: {})
expect_graphql_errors_to_be_empty
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 552377e3381..072cf34d0ef 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -738,7 +738,7 @@ describe('GfmAutoComplete', () => {
$textarea.trigger('focus').val(text).caret('pos', -1);
$textarea.trigger('keyup');
- return new Promise(window.requestAnimationFrame);
+ jest.runOnlyPendingTimers();
};
const getDropdownItems = () => {
@@ -747,10 +747,11 @@ describe('GfmAutoComplete', () => {
return [].map.call(items, (item) => item.textContent.trim());
};
- const expectLabels = ({ input, output }) =>
- triggerDropdown(input).then(() => {
- expect(getDropdownItems()).toEqual(output.map((label) => label.title));
- });
+ const expectLabels = ({ input, output }) => {
+ triggerDropdown(input);
+
+ expect(getDropdownItems()).toEqual(output.map((label) => label.title));
+ };
describe('with no labels assigned', () => {
beforeEach(() => {
diff --git a/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js b/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js
new file mode 100644
index 00000000000..685b5144a95
--- /dev/null
+++ b/spec/frontend/gitlab_pages/new/pages/pages_pipeline_wizard_spec.js
@@ -0,0 +1,102 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import PagesPipelineWizard, { i18n } from '~/gitlab_pages/components/pages_pipeline_wizard.vue';
+import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue';
+import pagesTemplate from '~/pipeline_wizard/templates/pages.yml';
+import pagesMarkOnboardingComplete from '~/gitlab_pages/queries/mark_onboarding_complete.graphql';
+import { redirectTo } from '~/lib/utils/url_utility';
+
+Vue.use(VueApollo);
+
+jest.mock('~/lib/utils/url_utility');
+
+describe('PagesPipelineWizard', () => {
+ const markOnboardingCompleteMutationHandler = jest.fn();
+ let wrapper;
+ const props = {
+ projectPath: '/user/repo',
+ defaultBranch: 'main',
+ redirectToWhenDone: './',
+ };
+
+ const findPipelineWizardWrapper = () => wrapper.findComponent(PipelineWizard);
+ const createMockApolloProvider = () => {
+ return createMockApollo([
+ [
+ pagesMarkOnboardingComplete,
+ markOnboardingCompleteMutationHandler.mockResolvedValue({
+ data: {
+ pagesMarkOnboardingComplete: {
+ onboardingComplete: true,
+ errors: [],
+ },
+ },
+ }),
+ ],
+ ]);
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(PagesPipelineWizard, {
+ apolloProvider: createMockApolloProvider(),
+ propsData: props,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows the pipeline wizard', () => {
+ expect(findPipelineWizardWrapper().exists()).toBe(true);
+ });
+
+ it('passes the appropriate props', () => {
+ const pipelineWizardWrapperProps = findPipelineWizardWrapper().props();
+
+ expect(pipelineWizardWrapperProps.template).toBe(pagesTemplate);
+ expect(pipelineWizardWrapperProps.projectPath).toBe(props.projectPath);
+ expect(pipelineWizardWrapperProps.defaultBranch).toBe(props.defaultBranch);
+ });
+
+ describe('after the steps are complete', () => {
+ const mockDone = () => findPipelineWizardWrapper().vm.$emit('done');
+
+ it('shows a loading screen during the update', async () => {
+ mockDone();
+
+ await nextTick();
+
+ const loadingScreenWrapper = wrapper.findByTestId('onboarding-mutation-loading');
+ expect(loadingScreenWrapper.exists()).toBe(true);
+ expect(loadingScreenWrapper.text()).toBe(i18n.loadingMessage);
+ });
+
+ it('calls pagesMarkOnboardingComplete mutation when done', async () => {
+ mockDone();
+
+ await waitForPromises();
+
+ expect(markOnboardingCompleteMutationHandler).toHaveBeenCalledWith({
+ input: {
+ projectPath: props.projectPath,
+ },
+ });
+ });
+
+ it('navigates to the path defined in redirectToWhenDone when done', async () => {
+ mockDone();
+
+ await waitForPromises();
+
+ expect(redirectTo).toHaveBeenCalledWith(props.redirectToWhenDone);
+ });
+ });
+});
diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js
deleted file mode 100644
index 0cafe6d3b9d..00000000000
--- a/spec/frontend/google_cloud/components/app_spec.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-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 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/feedback/-/issues/new';
-const SCREEN_COMPONENTS = {
- Home,
- ServiceAccountsForm,
- GcpError,
- NoGcpProjects,
-};
-const SERVICE_ACCOUNTS_FORM_PROPS = {
- gcpProjects: [1, 2, 3],
- refs: [4, 5, 6],
- cancelPath: '',
-};
-const HOME_PROPS = {
- serviceAccounts: [{}, {}],
- gcpRegions: [{}, {}],
- createServiceAccountUrl: '#url-create-service-account',
- configureGcpRegionsUrl: '#url-configure-gcp-regions',
- emptyIllustrationUrl: '#url-empty-illustration',
- enableCloudRunUrl: '#url-enable-cloud-run',
- enableCloudStorageUrl: '#enableCloudStorageUrl',
- revokeOauthUrl: '#revokeOauthUrl',
-};
-
-describe('google_cloud App component', () => {
- let wrapper;
-
- const findIncubationBanner = () => wrapper.findComponent(IncubationBanner);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- 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];
-
- beforeEach(() => {
- wrapper = shallowMount(App, { propsData: { screen, ...extraProps } });
- });
-
- it(`renders only ${componentName}`, () => {
- const existences = mapValues(SCREEN_COMPONENTS, (x) => wrapper.findComponent(x).exists());
-
- expect(existences).toEqual({
- ...mapValues(SCREEN_COMPONENTS, () => false),
- [componentName]: true,
- });
- });
-
- it(`renders the ${componentName} with props`, () => {
- expect(wrapper.findComponent(component).props()).toEqual(extraProps);
- });
-
- 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
deleted file mode 100644
index 4062a8b902a..00000000000
--- a/spec/frontend/google_cloud/components/errors/gcp_error_spec.js
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index e1e20377880..00000000000
--- a/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js
+++ /dev/null
@@ -1,33 +0,0 @@
-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/google_cloud_menu_spec.js b/spec/frontend/google_cloud/components/google_cloud_menu_spec.js
new file mode 100644
index 00000000000..4809ea37045
--- /dev/null
+++ b/spec/frontend/google_cloud/components/google_cloud_menu_spec.js
@@ -0,0 +1,40 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue';
+
+describe('google_cloud/components/google_cloud_menu', () => {
+ let wrapper;
+
+ const props = {
+ active: 'configuration',
+ configurationUrl: 'configuration-url',
+ deploymentsUrl: 'deployments-url',
+ databasesUrl: 'databases-url',
+ };
+
+ beforeEach(() => {
+ wrapper = mountExtended(GoogleCloudMenu, { propsData: props });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains active configuration link', () => {
+ const link = wrapper.findByTestId('configurationLink');
+ expect(link.text()).toBe(GoogleCloudMenu.i18n.configuration.title);
+ expect(link.attributes('href')).toBe(props.configurationUrl);
+ expect(link.element.classList.contains('gl-tab-nav-item-active')).toBe(true);
+ });
+
+ it('contains deployments link', () => {
+ const link = wrapper.findByTestId('deploymentsLink');
+ expect(link.text()).toBe(GoogleCloudMenu.i18n.deployments.title);
+ expect(link.attributes('href')).toBe(props.deploymentsUrl);
+ });
+
+ it('contains databases link', () => {
+ const link = wrapper.findByTestId('databasesLink');
+ expect(link.text()).toBe(GoogleCloudMenu.i18n.databases.title);
+ expect(link.attributes('href')).toBe(props.databasesUrl);
+ });
+});
diff --git a/spec/frontend/google_cloud/components/home_spec.js b/spec/frontend/google_cloud/components/home_spec.js
deleted file mode 100644
index 42e3d72577d..00000000000
--- a/spec/frontend/google_cloud/components/home_spec.js
+++ /dev/null
@@ -1,66 +0,0 @@
-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: [{}, {}],
- gcpRegions: [{}, {}],
- createServiceAccountUrl: '#url-create-service-account',
- configureGcpRegionsUrl: '#url-configure-gcp-regions',
- emptyIllustrationUrl: '#url-empty-illustration',
- enableCloudRunUrl: '#url-enable-cloud-run',
- enableCloudStorageUrl: '#enableCloudStorageUrl',
- revokeOauthUrl: '#revokeOauthUrl',
- };
-
- 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: undefined },
- { 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/incubation_banner_spec.js b/spec/frontend/google_cloud/components/incubation_banner_spec.js
index 89517be4ef1..09a4d92dca2 100644
--- a/spec/frontend/google_cloud/components/incubation_banner_spec.js
+++ b/spec/frontend/google_cloud/components/incubation_banner_spec.js
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { GlAlert, GlLink } from '@gitlab/ui';
import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
-describe('IncubationBanner component', () => {
+describe('google_cloud/components/incubation_banner', () => {
let wrapper;
const findAlert = () => wrapper.findComponent(GlAlert);
@@ -12,12 +12,7 @@ describe('IncubationBanner component', () => {
const findShareFeedbackLink = () => findLinks().at(2);
beforeEach(() => {
- const propsData = {
- shareFeedbackUrl: 'url_general_feedback',
- reportBugUrl: 'url_report_bug',
- featureRequestUrl: 'url_feature_request',
- };
- wrapper = mount(IncubationBanner, { propsData });
+ wrapper = mount(IncubationBanner);
});
afterEach(() => {
@@ -41,20 +36,26 @@ describe('IncubationBanner component', () => {
it('contains feature request link', () => {
const link = findFeatureRequestLink();
+ const expected =
+ 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=feature_request';
expect(link.text()).toBe('request a feature');
- expect(link.attributes('href')).toBe('url_feature_request');
+ expect(link.attributes('href')).toBe(expected);
});
it('contains report bug link', () => {
const link = findReportBugLink();
+ const expected =
+ 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=report_bug';
expect(link.text()).toBe('report a bug');
- expect(link.attributes('href')).toBe('url_report_bug');
+ expect(link.attributes('href')).toBe(expected);
});
it('contains share feedback link', () => {
const link = findShareFeedbackLink();
+ const expected =
+ 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=general_feedback';
expect(link.text()).toBe('share feedback');
- expect(link.attributes('href')).toBe('url_general_feedback');
+ expect(link.attributes('href')).toBe(expected);
});
});
});
diff --git a/spec/frontend/google_cloud/components/revoke_oauth_spec.js b/spec/frontend/google_cloud/components/revoke_oauth_spec.js
index 87580dbf6de..faaec07fc35 100644
--- a/spec/frontend/google_cloud/components/revoke_oauth_spec.js
+++ b/spec/frontend/google_cloud/components/revoke_oauth_spec.js
@@ -5,7 +5,7 @@ import RevokeOauth, {
GOOGLE_CLOUD_REVOKE_DESCRIPTION,
} from '~/google_cloud/components/revoke_oauth.vue';
-describe('RevokeOauth component', () => {
+describe('google_cloud/components/revoke_oauth', () => {
let wrapper;
const findTitle = () => wrapper.find('h2');
diff --git a/spec/frontend/google_cloud/configuration/panel_spec.js b/spec/frontend/google_cloud/configuration/panel_spec.js
new file mode 100644
index 00000000000..79eb4cb4918
--- /dev/null
+++ b/spec/frontend/google_cloud/configuration/panel_spec.js
@@ -0,0 +1,65 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Panel from '~/google_cloud/configuration/panel.vue';
+import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
+import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue';
+import ServiceAccountsList from '~/google_cloud/service_accounts/list.vue';
+import GcpRegionsList from '~/google_cloud/gcp_regions/list.vue';
+import RevokeOauth from '~/google_cloud/components/revoke_oauth.vue';
+
+describe('google_cloud/configuration/panel', () => {
+ let wrapper;
+
+ const props = {
+ configurationUrl: 'configuration-url',
+ deploymentsUrl: 'deployments-url',
+ databasesUrl: 'databases-url',
+ serviceAccounts: [],
+ createServiceAccountUrl: 'create-service-account-url',
+ emptyIllustrationUrl: 'empty-illustration-url',
+ gcpRegions: [],
+ configureGcpRegionsUrl: 'configure-gcp-regions-url',
+ revokeOauthUrl: 'revoke-oauth-url',
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMountExtended(Panel, { propsData: props });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains incubation banner', () => {
+ const target = wrapper.findComponent(IncubationBanner);
+ expect(target.exists()).toBe(true);
+ });
+
+ it('contains google cloud menu with `configuration` active', () => {
+ const target = wrapper.findComponent(GoogleCloudMenu);
+ expect(target.exists()).toBe(true);
+ expect(target.props('active')).toBe('configuration');
+ expect(target.props('configurationUrl')).toBe(props.configurationUrl);
+ expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl);
+ expect(target.props('databasesUrl')).toBe(props.databasesUrl);
+ });
+
+ it('contains service accounts list', () => {
+ const target = wrapper.findComponent(ServiceAccountsList);
+ expect(target.exists()).toBe(true);
+ expect(target.props('list')).toBe(props.serviceAccounts);
+ expect(target.props('createUrl')).toBe(props.createServiceAccountUrl);
+ expect(target.props('emptyIllustrationUrl')).toBe(props.emptyIllustrationUrl);
+ });
+
+ it('contains gcp regions list', () => {
+ const target = wrapper.findComponent(GcpRegionsList);
+ expect(target.props('list')).toBe(props.gcpRegions);
+ expect(target.props('createUrl')).toBe(props.configureGcpRegionsUrl);
+ expect(target.props('emptyIllustrationUrl')).toBe(props.emptyIllustrationUrl);
+ });
+
+ it('contains revoke oauth', () => {
+ const target = wrapper.findComponent(RevokeOauth);
+ expect(target.props('url')).toBe(props.revokeOauthUrl);
+ });
+});
diff --git a/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js b/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js
new file mode 100644
index 00000000000..48e4b0ca1ad
--- /dev/null
+++ b/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js
@@ -0,0 +1,103 @@
+import { GlFormCheckbox } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import InstanceForm from '~/google_cloud/databases/cloudsql/create_instance_form.vue';
+
+describe('google_cloud/databases/cloudsql/create_instance_form', () => {
+ let wrapper;
+
+ const findByTestId = (id) => wrapper.findByTestId(id);
+ const findCancelButton = () => findByTestId('cancel-button');
+ const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+ const findHeader = () => wrapper.find('header');
+ const findSubmitButton = () => findByTestId('submit-button');
+
+ const propsData = {
+ gcpProjects: [],
+ refs: [],
+ cancelPath: '#cancel-url',
+ formTitle: 'mock form title',
+ formDescription: 'mock form description',
+ databaseVersions: [],
+ tiers: [],
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMountExtended(InstanceForm, { propsData, stubs: { GlFormCheckbox } });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains header', () => {
+ expect(findHeader().exists()).toBe(true);
+ });
+
+ it('contains GCP project form group', () => {
+ const formGroup = findByTestId('form_group_gcp_project');
+ expect(formGroup.exists()).toBe(true);
+ expect(formGroup.attributes('label')).toBe(InstanceForm.i18n.gcpProjectLabel);
+ expect(formGroup.attributes('description')).toBe(InstanceForm.i18n.gcpProjectDescription);
+ });
+
+ it('contains GCP project dropdown', () => {
+ const select = findByTestId('select_gcp_project');
+ expect(select.exists()).toBe(true);
+ });
+
+ it('contains Environments form group', () => {
+ const formGroup = findByTestId('form_group_environments');
+ expect(formGroup.exists()).toBe(true);
+ expect(formGroup.attributes('label')).toBe(InstanceForm.i18n.refsLabel);
+ expect(formGroup.attributes('description')).toBe(InstanceForm.i18n.refsDescription);
+ });
+
+ it('contains Environments dropdown', () => {
+ const select = findByTestId('select_environments');
+ expect(select.exists()).toBe(true);
+ });
+
+ it('contains Tier form group', () => {
+ const formGroup = findByTestId('form_group_tier');
+ expect(formGroup.exists()).toBe(true);
+ expect(formGroup.attributes('label')).toBe(InstanceForm.i18n.tierLabel);
+ expect(formGroup.attributes('description')).toBe(InstanceForm.i18n.tierDescription);
+ });
+
+ it('contains Tier dropdown', () => {
+ const select = findByTestId('select_tier');
+ expect(select.exists()).toBe(true);
+ });
+
+ it('contains Database Version form group', () => {
+ const formGroup = findByTestId('form_group_database_version');
+ expect(formGroup.exists()).toBe(true);
+ expect(formGroup.attributes('label')).toBe(InstanceForm.i18n.databaseVersionLabel);
+ });
+
+ it('contains Database Version dropdown', () => {
+ const select = findByTestId('select_database_version');
+ expect(select.exists()).toBe(true);
+ });
+
+ it('contains Submit button', () => {
+ expect(findSubmitButton().exists()).toBe(true);
+ expect(findSubmitButton().text()).toBe(InstanceForm.i18n.submitLabel);
+ });
+
+ it('contains Cancel button', () => {
+ expect(findCancelButton().exists()).toBe(true);
+ expect(findCancelButton().text()).toBe(InstanceForm.i18n.cancelLabel);
+ expect(findCancelButton().attributes('href')).toBe('#cancel-url');
+ });
+
+ it('contains Confirmation checkbox', () => {
+ const checkbox = findCheckbox();
+ expect(checkbox.text()).toBe(InstanceForm.i18n.checkboxLabel);
+ });
+
+ it('checkbox must be required', () => {
+ const checkbox = findCheckbox();
+ expect(checkbox.attributes('required')).toBe('true');
+ });
+});
diff --git a/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js b/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js
new file mode 100644
index 00000000000..a5736d0a524
--- /dev/null
+++ b/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js
@@ -0,0 +1,65 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlEmptyState, GlTable } from '@gitlab/ui';
+import InstanceTable from '~/google_cloud/databases/cloudsql/instance_table.vue';
+
+describe('google_cloud/databases/cloudsql/instance_table', () => {
+ let wrapper;
+
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findTable = () => wrapper.findComponent(GlTable);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when there are no instances', () => {
+ beforeEach(() => {
+ const propsData = {
+ cloudsqlInstances: [],
+ emptyIllustrationUrl: '#empty-illustration-url',
+ };
+ wrapper = shallowMount(InstanceTable, { propsData });
+ });
+
+ it('should depict empty state', () => {
+ const emptyState = findEmptyState();
+ expect(emptyState.exists()).toBe(true);
+ expect(emptyState.attributes('title')).toBe(InstanceTable.i18n.noInstancesTitle);
+ expect(emptyState.attributes('description')).toBe(InstanceTable.i18n.noInstancesDescription);
+ });
+ });
+
+ describe('when there are three instances', () => {
+ beforeEach(() => {
+ const propsData = {
+ cloudsqlInstances: [
+ {
+ ref: '*',
+ gcp_project: 'test-gcp-project',
+ instance_name: 'postgres-14-instance',
+ version: 'POSTGRES_14',
+ },
+ {
+ ref: 'production',
+ gcp_project: 'prod-gcp-project',
+ instance_name: 'postgres-14-instance',
+ version: 'POSTGRES_14',
+ },
+ {
+ ref: 'staging',
+ gcp_project: 'test-gcp-project',
+ instance_name: 'postgres-14-instance',
+ version: 'POSTGRES_14',
+ },
+ ],
+ emptyIllustrationUrl: '#empty-illustration-url',
+ };
+ wrapper = shallowMount(InstanceTable, { propsData });
+ });
+
+ it('should contain a table', () => {
+ const table = findTable();
+ expect(table.exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/google_cloud/databases/panel_spec.js b/spec/frontend/google_cloud/databases/panel_spec.js
new file mode 100644
index 00000000000..490c0136651
--- /dev/null
+++ b/spec/frontend/google_cloud/databases/panel_spec.js
@@ -0,0 +1,36 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Panel from '~/google_cloud/databases/panel.vue';
+import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
+import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue';
+
+describe('google_cloud/databases/panel', () => {
+ let wrapper;
+
+ const props = {
+ configurationUrl: 'configuration-url',
+ deploymentsUrl: 'deployments-url',
+ databasesUrl: 'databases-url',
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMountExtended(Panel, { propsData: props });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains incubation banner', () => {
+ const target = wrapper.findComponent(IncubationBanner);
+ expect(target.exists()).toBe(true);
+ });
+
+ it('contains google cloud menu with `databases` active', () => {
+ const target = wrapper.findComponent(GoogleCloudMenu);
+ expect(target.exists()).toBe(true);
+ expect(target.props('active')).toBe('databases');
+ expect(target.props('configurationUrl')).toBe(props.configurationUrl);
+ expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl);
+ expect(target.props('databasesUrl')).toBe(props.databasesUrl);
+ });
+});
diff --git a/spec/frontend/google_cloud/databases/service_table_spec.js b/spec/frontend/google_cloud/databases/service_table_spec.js
new file mode 100644
index 00000000000..4a622e544e1
--- /dev/null
+++ b/spec/frontend/google_cloud/databases/service_table_spec.js
@@ -0,0 +1,44 @@
+import { GlTable } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import ServiceTable from '~/google_cloud/databases/service_table.vue';
+
+describe('google_cloud/databases/service_table', () => {
+ let wrapper;
+
+ const findTable = () => wrapper.findComponent(GlTable);
+
+ beforeEach(() => {
+ const propsData = {
+ cloudsqlPostgresUrl: '#url-cloudsql-postgres',
+ cloudsqlMysqlUrl: '#url-cloudsql-mysql',
+ cloudsqlSqlserverUrl: '#url-cloudsql-sqlserver',
+ alloydbPostgresUrl: '#url-alloydb-postgres',
+ memorystoreRedisUrl: '#url-memorystore-redis',
+ firestoreUrl: '#url-firestore',
+ };
+ wrapper = mountExtended(ServiceTable, { propsData });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should contain a table', () => {
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it.each`
+ name | testId | url
+ ${'cloudsql-postgres'} | ${'button-cloudsql-postgres'} | ${'#url-cloudsql-postgres'}
+ ${'cloudsql-mysql'} | ${'button-cloudsql-mysql'} | ${'#url-cloudsql-mysql'}
+ ${'cloudsql-sqlserver'} | ${'button-cloudsql-sqlserver'} | ${'#url-cloudsql-sqlserver'}
+ ${'alloydb-postgres'} | ${'button-alloydb-postgres'} | ${'#url-alloydb-postgres'}
+ ${'memorystore-redis'} | ${'button-memorystore-redis'} | ${'#url-memorystore-redis'}
+ ${'firestore'} | ${'button-firestore'} | ${'#url-firestore'}
+ `('renders $name button with correct url', ({ testId, url }) => {
+ const button = wrapper.findByTestId(testId);
+
+ expect(button.exists()).toBe(true);
+ expect(button.attributes('href')).toBe(url);
+ });
+});
diff --git a/spec/frontend/google_cloud/deployments/panel_spec.js b/spec/frontend/google_cloud/deployments/panel_spec.js
new file mode 100644
index 00000000000..729db1707a7
--- /dev/null
+++ b/spec/frontend/google_cloud/deployments/panel_spec.js
@@ -0,0 +1,46 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Panel from '~/google_cloud/deployments/panel.vue';
+import IncubationBanner from '~/google_cloud/components/incubation_banner.vue';
+import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue';
+import ServiceTable from '~/google_cloud/deployments/service_table.vue';
+
+describe('google_cloud/deployments/panel', () => {
+ let wrapper;
+
+ const props = {
+ configurationUrl: 'configuration-url',
+ deploymentsUrl: 'deployments-url',
+ databasesUrl: 'databases-url',
+ enableCloudRunUrl: 'cloud-run-url',
+ enableCloudStorageUrl: 'cloud-storage-url',
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMountExtended(Panel, { propsData: props });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains incubation banner', () => {
+ const target = wrapper.findComponent(IncubationBanner);
+ expect(target.exists()).toBe(true);
+ });
+
+ it('contains google cloud menu with `deployments` active', () => {
+ const target = wrapper.findComponent(GoogleCloudMenu);
+ expect(target.exists()).toBe(true);
+ expect(target.props('active')).toBe('deployments');
+ expect(target.props('configurationUrl')).toBe(props.configurationUrl);
+ expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl);
+ expect(target.props('databasesUrl')).toBe(props.databasesUrl);
+ });
+
+ it('contains service-table', () => {
+ const target = wrapper.findComponent(ServiceTable);
+ expect(target.exists()).toBe(true);
+ expect(target.props('cloudRunUrl')).toBe(props.enableCloudRunUrl);
+ expect(target.props('cloudStorageUrl')).toBe(props.enableCloudStorageUrl);
+ });
+});
diff --git a/spec/frontend/google_cloud/components/deployments_service_table_spec.js b/spec/frontend/google_cloud/deployments/service_table_spec.js
index 882376547c4..8faad64e313 100644
--- a/spec/frontend/google_cloud/components/deployments_service_table_spec.js
+++ b/spec/frontend/google_cloud/deployments/service_table_spec.js
@@ -1,8 +1,8 @@
import { mount } from '@vue/test-utils';
import { GlButton, GlTable } from '@gitlab/ui';
-import DeploymentsServiceTable from '~/google_cloud/components/deployments_service_table.vue';
+import DeploymentsServiceTable from '~/google_cloud/deployments/service_table.vue';
-describe('google_cloud DeploymentsServiceTable component', () => {
+describe('google_cloud/deployments/service_table', () => {
let wrapper;
const findTable = () => wrapper.findComponent(GlTable);
diff --git a/spec/frontend/google_cloud/components/gcp_regions_form_spec.js b/spec/frontend/google_cloud/gcp_regions/form_spec.js
index a8b7593e7c8..1030e9c8a18 100644
--- a/spec/frontend/google_cloud/components/gcp_regions_form_spec.js
+++ b/spec/frontend/google_cloud/gcp_regions/form_spec.js
@@ -1,8 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui';
-import GcpRegionsForm from '~/google_cloud/components/gcp_regions_form.vue';
+import GcpRegionsForm from '~/google_cloud/gcp_regions/form.vue';
-describe('GcpRegionsForm component', () => {
+describe('google_cloud/gcp_regions/form', () => {
let wrapper;
const findHeader = () => wrapper.find('header');
diff --git a/spec/frontend/google_cloud/components/gcp_regions_list_spec.js b/spec/frontend/google_cloud/gcp_regions/list_spec.js
index ab0c17451e8..6d8c389e5a1 100644
--- a/spec/frontend/google_cloud/components/gcp_regions_list_spec.js
+++ b/spec/frontend/google_cloud/gcp_regions/list_spec.js
@@ -1,8 +1,8 @@
import { mount } from '@vue/test-utils';
import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
-import GcpRegionsList from '~/google_cloud/components/gcp_regions_list.vue';
+import GcpRegionsList from '~/google_cloud/gcp_regions/list.vue';
-describe('GcpRegions component', () => {
+describe('google_cloud/gcp_regions/list', () => {
describe('when the project does not have any configured regions', () => {
let wrapper;
diff --git a/spec/frontend/google_cloud/components/service_accounts_form_spec.js b/spec/frontend/google_cloud/service_accounts/form_spec.js
index 38602d4e8cc..8be481774fa 100644
--- a/spec/frontend/google_cloud/components/service_accounts_form_spec.js
+++ b/spec/frontend/google_cloud/service_accounts/form_spec.js
@@ -1,8 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui';
-import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue';
+import ServiceAccountsForm from '~/google_cloud/service_accounts/form.vue';
-describe('ServiceAccountsForm component', () => {
+describe('google_cloud/service_accounts/form', () => {
let wrapper;
const findHeader = () => wrapper.find('header');
diff --git a/spec/frontend/google_cloud/components/service_accounts_list_spec.js b/spec/frontend/google_cloud/service_accounts/list_spec.js
index f7051c8a53d..7a76a893757 100644
--- a/spec/frontend/google_cloud/components/service_accounts_list_spec.js
+++ b/spec/frontend/google_cloud/service_accounts/list_spec.js
@@ -1,8 +1,8 @@
import { mount } from '@vue/test-utils';
import { GlAlert, GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
-import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue';
+import ServiceAccountsList from '~/google_cloud/service_accounts/list.vue';
-describe('ServiceAccounts component', () => {
+describe('google_cloud/service_accounts/list', () => {
describe('when the project does not have any service accounts', () => {
let wrapper;
diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js
index 50811f43fc3..6a7eb1fd9f1 100644
--- a/spec/frontend/google_tag_manager/index_spec.js
+++ b/spec/frontend/google_tag_manager/index_spec.js
@@ -10,6 +10,7 @@ import {
trackSaasTrialGroup,
trackSaasTrialProject,
trackSaasTrialGetStarted,
+ trackTrialAcceptTerms,
trackCheckout,
trackTransaction,
trackAddToCartUsageTab,
@@ -255,6 +256,16 @@ describe('~/google_tag_manager/index', () => {
expect(logError).not.toHaveBeenCalled();
});
+ it('when trackTrialAcceptTerms is invoked', () => {
+ expect(spy).not.toHaveBeenCalled();
+
+ trackTrialAcceptTerms();
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledWith({ event: 'saasTrialAcceptTerms' });
+ expect(logError).not.toHaveBeenCalled();
+ });
+
describe('when trackCheckout is invoked', () => {
it('with selectedPlan: 2c92a00d76f0d5060176f2fb0a5029ff', () => {
expect(spy).not.toHaveBeenCalled();
diff --git a/spec/frontend/groups/components/group_folder_spec.js b/spec/frontend/groups/components/group_folder_spec.js
index 98b7c2dd6c6..f223333360d 100644
--- a/spec/frontend/groups/components/group_folder_spec.js
+++ b/spec/frontend/groups/components/group_folder_spec.js
@@ -1,65 +1,50 @@
-import Vue, { nextTick } from 'vue';
-
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import GroupFolder from '~/groups/components/group_folder.vue';
+import GroupItem from '~/groups/components/group_item.vue';
+import { MAX_CHILDREN_COUNT } from '~/groups/constants';
import { mockGroups, mockParentGroupItem } from '../mock_data';
-const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
- const Component = Vue.extend(groupFolderComponent);
-
- return new Component({
- propsData: {
- groups,
- parentGroup,
- },
- });
-};
+describe('GroupFolder component', () => {
+ let wrapper;
-describe('GroupFolderComponent', () => {
- let vm;
+ Vue.component('GroupItem', GroupItem);
- beforeEach(async () => {
- Vue.component('GroupItem', groupItemComponent);
+ const findLink = () => wrapper.find('a');
- vm = createComponent();
- vm.$mount();
-
- await nextTick();
- });
+ const createComponent = ({ groups = mockGroups, parentGroup = mockParentGroupItem } = {}) =>
+ shallowMount(GroupFolder, {
+ propsData: {
+ groups,
+ parentGroup,
+ },
+ });
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
- describe('computed', () => {
- describe('hasMoreChildren', () => {
- it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
- expect(vm.hasMoreChildren).toBeFalsy();
- });
- });
+ it('does not render more children stats link when children count of group is under limit', () => {
+ wrapper = createComponent();
- describe('moreChildrenStats', () => {
- it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
- expect(vm.moreChildrenStats).toBe('3 more items');
- });
- });
+ expect(findLink().exists()).toBe(false);
});
- describe('template', () => {
- it('should render component template correctly', () => {
- expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
- expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
+ it('renders text of count of excess children when children count of group is over limit', () => {
+ const childrenCount = MAX_CHILDREN_COUNT + 1;
+ wrapper = createComponent({
+ parentGroup: {
+ ...mockParentGroupItem,
+ childrenCount,
+ },
});
- it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
- const parentGroup = { ...mockParentGroupItem };
- parentGroup.childrenCount = 21;
+ expect(findLink().text()).toBe(`${childrenCount} more items`);
+ });
- const newVm = createComponent(mockGroups, parentGroup);
- newVm.$mount();
+ it('renders group items', () => {
+ wrapper = createComponent();
- expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
- newVm.$destroy();
- });
+ expect(wrapper.findAllComponents(GroupItem)).toHaveLength(7);
});
});
diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js
index 8ea7e54aef4..0bc80df6535 100644
--- a/spec/frontend/groups/components/group_item_spec.js
+++ b/spec/frontend/groups/components/group_item_spec.js
@@ -1,4 +1,4 @@
-import { mount } from '@vue/test-utils';
+import { GlPopover } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import GroupFolder from '~/groups/components/group_folder.vue';
import GroupItem from '~/groups/components/group_item.vue';
@@ -6,14 +6,25 @@ import ItemActions from '~/groups/components/item_actions.vue';
import eventHub from '~/groups/event_hub';
import { getGroupItemMicrodata } from '~/groups/store/utils';
import * as urlUtilities from '~/lib/utils/url_utility';
+import {
+ ITEM_TYPE,
+ VISIBILITY_INTERNAL,
+ VISIBILITY_PRIVATE,
+ VISIBILITY_PUBLIC,
+} from '~/groups/constants';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { mockParentGroupItem, mockChildren } from '../mock_data';
const createComponent = (
propsData = { group: mockParentGroupItem, parentGroup: mockChildren[0] },
+ provide = {
+ currentGroupVisibility: VISIBILITY_PRIVATE,
+ },
) => {
- return mount(GroupItem, {
+ return mountExtended(GroupItem, {
propsData,
components: { GroupFolder },
+ provide,
});
};
@@ -276,4 +287,90 @@ describe('GroupItemComponent', () => {
});
});
});
+
+ describe('visibility warning popover', () => {
+ const findPopover = () => wrapper.findComponent(GlPopover);
+
+ const itDoesNotRenderVisibilityWarningPopover = () => {
+ it('does not render visibility warning popover', () => {
+ expect(findPopover().exists()).toBe(false);
+ });
+ };
+
+ describe('when showing groups', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ itDoesNotRenderVisibilityWarningPopover();
+ });
+
+ describe('when `action` prop is not `shared`', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ group: mockParentGroupItem,
+ parentGroup: mockChildren[0],
+ action: 'subgroups_and_projects',
+ });
+ });
+
+ itDoesNotRenderVisibilityWarningPopover();
+ });
+
+ describe('when showing projects', () => {
+ describe.each`
+ itemVisibility | currentGroupVisibility | isPopoverShown
+ ${VISIBILITY_PRIVATE} | ${VISIBILITY_PUBLIC} | ${false}
+ ${VISIBILITY_INTERNAL} | ${VISIBILITY_PUBLIC} | ${false}
+ ${VISIBILITY_PUBLIC} | ${VISIBILITY_PUBLIC} | ${false}
+ ${VISIBILITY_PRIVATE} | ${VISIBILITY_PRIVATE} | ${false}
+ ${VISIBILITY_INTERNAL} | ${VISIBILITY_PRIVATE} | ${true}
+ ${VISIBILITY_PUBLIC} | ${VISIBILITY_PRIVATE} | ${true}
+ `(
+ 'when item visibility is $itemVisibility and parent group visibility is $currentGroupVisibility',
+ ({ itemVisibility, currentGroupVisibility, isPopoverShown }) => {
+ beforeEach(() => {
+ wrapper = createComponent(
+ {
+ group: {
+ ...mockParentGroupItem,
+ visibility: itemVisibility,
+ type: ITEM_TYPE.PROJECT,
+ },
+ parentGroup: mockChildren[0],
+ action: 'shared',
+ },
+ {
+ currentGroupVisibility,
+ },
+ );
+ });
+
+ if (isPopoverShown) {
+ it('renders visibility warning popover', () => {
+ expect(findPopover().exists()).toBe(true);
+ });
+ } else {
+ itDoesNotRenderVisibilityWarningPopover();
+ }
+ },
+ );
+ });
+
+ it('sets up popover `target` prop correctly', () => {
+ wrapper = createComponent({
+ group: {
+ ...mockParentGroupItem,
+ visibility: VISIBILITY_PUBLIC,
+ type: ITEM_TYPE.PROJECT,
+ },
+ parentGroup: mockChildren[0],
+ action: 'shared',
+ });
+
+ expect(findPopover().props('target')()).toEqual(
+ wrapper.findByRole('button', { name: GroupItem.i18n.popoverTitle }).element,
+ );
+ });
+ });
});
diff --git a/spec/frontend/groups/components/group_name_and_path_spec.js b/spec/frontend/groups/components/group_name_and_path_spec.js
index eaa0801ab50..9c9bdead6fa 100644
--- a/spec/frontend/groups/components/group_name_and_path_spec.js
+++ b/spec/frontend/groups/components/group_name_and_path_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { merge } from 'lodash';
import { GlAlert } from '@gitlab/ui';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -50,17 +51,17 @@ describe('GroupNameAndPath', () => {
const findAlert = () => extendedWrapper(wrapper.findComponent(GlAlert));
const apiMockAvailablePath = () => {
- getGroupPathAvailability.mockResolvedValue({
+ getGroupPathAvailability.mockResolvedValueOnce({
data: { exists: false, suggests: [] },
});
};
const apiMockUnavailablePath = (suggests = [mockGroupUrlSuggested]) => {
- getGroupPathAvailability.mockResolvedValue({
+ getGroupPathAvailability.mockResolvedValueOnce({
data: { exists: true, suggests },
});
};
const apiMockLoading = () => {
- getGroupPathAvailability.mockImplementation(() => new Promise(() => {}));
+ getGroupPathAvailability.mockImplementationOnce(() => new Promise(() => {}));
};
const expectLoadingMessageExists = () => {
@@ -169,7 +170,7 @@ describe('GroupNameAndPath', () => {
describe('when API call fails', () => {
it('calls `createAlert`', async () => {
- getGroupPathAvailability.mockRejectedValue({});
+ getGroupPathAvailability.mockRejectedValueOnce({});
createComponent();
@@ -184,14 +185,20 @@ describe('GroupNameAndPath', () => {
describe('when multiple API calls are in-flight', () => {
it('aborts the first API call and resolves second API call', async () => {
- apiMockLoading();
+ getGroupPathAvailability.mockRejectedValueOnce({ __CANCEL__: true });
apiMockUnavailablePath();
+
const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
createComponent();
await findGroupNameField().setValue('Foo');
await findGroupNameField().setValue(mockGroupName);
+
+ // Wait for re-render to ensure loading message is still there
+ await nextTick();
+ expectLoadingMessageExists();
+
await waitForPromises();
expect(createAlert).not.toHaveBeenCalled();
diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js
index 590b4fb3d57..48a2319cf96 100644
--- a/spec/frontend/groups/components/groups_spec.js
+++ b/spec/frontend/groups/components/groups_spec.js
@@ -1,45 +1,55 @@
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import groupFolderComponent from '~/groups/components/group_folder.vue';
-import groupItemComponent from '~/groups/components/group_item.vue';
-import groupsComponent from '~/groups/components/groups.vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import GroupFolderComponent from '~/groups/components/group_folder.vue';
+import GroupItemComponent from '~/groups/components/group_item.vue';
+import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
+import GroupsComponent from '~/groups/components/groups.vue';
import eventHub from '~/groups/event_hub';
+import { VISIBILITY_PRIVATE } from '~/groups/constants';
import { mockGroups, mockPageInfo } from '../mock_data';
-const createComponent = (searchEmpty = false) => {
- const Component = Vue.extend(groupsComponent);
+describe('GroupsComponent', () => {
+ let wrapper;
- return mountComponent(Component, {
+ const defaultPropsData = {
groups: mockGroups,
pageInfo: mockPageInfo,
searchEmptyMessage: 'No matching results',
- searchEmpty,
- });
-};
+ searchEmpty: false,
+ };
-describe('GroupsComponent', () => {
- let vm;
-
- beforeEach(async () => {
- Vue.component('GroupFolder', groupFolderComponent);
- Vue.component('GroupItem', groupItemComponent);
+ const createComponent = ({ propsData } = {}) => {
+ wrapper = mountExtended(GroupsComponent, {
+ propsData: {
+ ...defaultPropsData,
+ ...propsData,
+ },
+ provide: {
+ currentGroupVisibility: VISIBILITY_PRIVATE,
+ },
+ });
+ };
- vm = createComponent();
+ const findPaginationLinks = () => wrapper.findComponent(PaginationLinks);
- await nextTick();
+ beforeEach(async () => {
+ Vue.component('GroupFolder', GroupFolderComponent);
+ Vue.component('GroupItem', GroupItemComponent);
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('methods', () => {
describe('change', () => {
it('should emit `fetchPage` event when page is changed via pagination', () => {
+ createComponent();
+
jest.spyOn(eventHub, '$emit').mockImplementation();
- vm.change(2);
+ findPaginationLinks().props('change')(2);
expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', {
page: 2,
@@ -52,18 +62,18 @@ describe('GroupsComponent', () => {
});
describe('template', () => {
- it('should render component template correctly', async () => {
- await nextTick();
- expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined();
- expect(vm.$el.querySelector('.group-list-tree')).toBeDefined();
- expect(vm.$el.querySelector('.gl-pagination')).toBeDefined();
- expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0);
+ it('should render component template correctly', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(GroupFolderComponent).exists()).toBe(true);
+ expect(findPaginationLinks().exists()).toBe(true);
+ expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(false);
});
- it('should render empty search message when `searchEmpty` is `true`', async () => {
- vm.searchEmpty = true;
- await nextTick();
- expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined();
+ it('should render empty search message when `searchEmpty` is `true`', () => {
+ createComponent({ propsData: { searchEmpty: true } });
+
+ expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js
index 603cb27deec..9a325776374 100644
--- a/spec/frontend/groups/mock_data.js
+++ b/spec/frontend/groups/mock_data.js
@@ -5,26 +5,6 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
-export const GROUP_VISIBILITY_TYPE = {
- public: 'Public - The group and any public projects can be viewed without any authentication.',
- internal:
- 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
- private: 'Private - The group and its projects can only be viewed by members.',
-};
-
-export const PROJECT_VISIBILITY_TYPE = {
- public: 'Public - The project can be accessed without any authentication.',
- internal: 'Internal - The project can be accessed by any logged in user except external users.',
- private:
- 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
-};
-
-export const VISIBILITY_TYPE_ICON = {
- public: 'earth',
- internal: 'shield',
- private: 'lock',
-};
-
export const mockParentGroupItem = {
id: 55,
name: 'hardware',
@@ -49,6 +29,7 @@ export const mockParentGroupItem = {
isChildrenLoading: false,
isBeingRemoved: false,
updatedAt: '2017-04-09T18:40:39.101Z',
+ lastActivityAt: '2017-04-09T18:40:39.101Z',
};
export const mockRawChildren = [
diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js
index f0de5b083ae..d89218f5542 100644
--- a/spec/frontend/header_search/components/app_spec.js
+++ b/spec/frontend/header_search/components/app_spec.js
@@ -1,22 +1,32 @@
-import { GlSearchBoxByType } from '@gitlab/ui';
+import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__, sprintf } from '~/locale';
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 { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION } from '~/header_search/constants';
+import {
+ SEARCH_INPUT_DESCRIPTION,
+ SEARCH_RESULTS_DESCRIPTION,
+ SEARCH_BOX_INDEX,
+ ICON_PROJECT,
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ SCOPE_TOKEN_MAX_LENGTH,
+} 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 { truncate } from '~/lib/utils/text_utility';
import {
MOCK_SEARCH,
MOCK_SEARCH_QUERY,
MOCK_USERNAME,
MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS,
- MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
+ MOCK_SEARCH_CONTEXT_FULL,
} from '../mock_data';
Vue.use(Vuex);
@@ -52,11 +62,27 @@ describe('HeaderSearchApp', () => {
});
};
+ const formatScopeName = (scopeName) => {
+ if (!scopeName) {
+ return false;
+ }
+ const searchResultsScope = s__('GlobalSearch|in %{scope}');
+ return truncate(
+ sprintf(searchResultsScope, {
+ scope: scopeName,
+ }),
+ SCOPE_TOKEN_MAX_LENGTH,
+ );
+ };
+
afterEach(() => {
wrapper.destroy();
});
+ const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form');
const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
+ const findScopeToken = () => wrapper.findComponent(GlToken);
+ const findHeaderSearchInputKBD = () => wrapper.find('.keyboard-shortcut-helper');
const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems);
const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
@@ -76,6 +102,14 @@ describe('HeaderSearchApp', () => {
expect(findHeaderSearchInput().exists()).toBe(true);
});
+ it('Header Search Input KBD hint', () => {
+ expect(findHeaderSearchInputKBD().exists()).toBe(true);
+ expect(findHeaderSearchInputKBD().text()).toContain('/');
+ expect(findHeaderSearchInputKBD().attributes('title')).toContain(
+ 'Use the shortcut key <kbd>/</kbd> to start a search',
+ );
+ });
+
it('Search Input Description', () => {
expect(findSearchInputDescription().exists()).toBe(true);
});
@@ -106,53 +140,38 @@ describe('HeaderSearchApp', () => {
});
describe.each`
- search | showDefault | showScoped | showAutocomplete | showDropdownNavigation
- ${null} | ${true} | ${false} | ${false} | ${true}
- ${''} | ${true} | ${false} | ${false} | ${true}
- ${'1'} | ${false} | ${false} | ${false} | ${false}
- ${')'} | ${false} | ${false} | ${false} | ${false}
- ${'t'} | ${false} | ${false} | ${true} | ${true}
- ${'te'} | ${false} | ${true} | ${true} | ${true}
- ${'tes'} | ${false} | ${true} | ${true} | ${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 },
- {
- autocompleteGroupedSearchOptions: () =>
- search.match(/^[A-Za-z]+$/g) ? MOCK_SORTED_AUTOCOMPLETE_OPTIONS : [],
- },
- );
- findHeaderSearchInput().vm.$emit('click');
- });
+ search | showDefault | showScoped | showAutocomplete
+ ${null} | ${true} | ${false} | ${false}
+ ${''} | ${true} | ${false} | ${false}
+ ${'t'} | ${false} | ${false} | ${true}
+ ${'te'} | ${false} | ${false} | ${true}
+ ${'tes'} | ${false} | ${true} | ${true}
+ ${MOCK_SEARCH} | ${false} | ${true} | ${true}
+ `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => {
+ 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${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${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${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 render the Dropdown Navigation Component`, () => {
+ expect(findDropdownKeyboardNavigation().exists()).toBe(true);
});
- },
- );
+ });
+ });
describe.each`
username | showDropdown | expectedDesc
@@ -185,12 +204,18 @@ describe('HeaderSearchApp', () => {
`(
'Search Results Description',
({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => {
- describe(`search is ${search}, loading is ${loading}, and showSearchDropdown is ${
- Boolean(username) && showDropdown
- }`, () => {
+ describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => {
beforeEach(() => {
window.gon.current_username = username;
- createComponent({ search, loading }, { searchOptions: () => searchOptions });
+ createComponent(
+ {
+ search,
+ loading,
+ },
+ {
+ searchOptions: () => searchOptions,
+ },
+ );
findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
});
@@ -200,6 +225,121 @@ describe('HeaderSearchApp', () => {
});
},
);
+
+ describe('input box', () => {
+ describe.each`
+ search | searchOptions | hasToken
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true}
+ ${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false}
+ ${'x'} | ${[]} | ${false}
+ `('token', ({ search, searchOptions, hasToken }) => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent(
+ { search },
+ {
+ searchOptions: () => searchOptions,
+ },
+ );
+ });
+
+ it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${
+ searchOptions[0]?.html_id
+ }"`, () => {
+ expect(findScopeToken().exists()).toBe(hasToken);
+ });
+
+ it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${
+ searchOptions[0]?.scope || searchOptions[0]?.description
+ }"`, () => {
+ expect(findScopeToken().exists() && findScopeToken().text()).toBe(
+ formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description),
+ );
+ });
+ });
+ });
+
+ describe('form wrapper', () => {
+ describe.each`
+ searchContext | search | searchOptions
+ ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]}
+ ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]}
+ ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS}
+ ${null} | ${null} | ${[]}
+ `('', ({ searchContext, search, searchOptions }) => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+
+ createComponent({ search, searchContext }, { searchOptions: () => searchOptions });
+
+ findHeaderSearchInput().vm.$emit('click');
+ });
+
+ const hasIcon = Boolean(searchContext?.group);
+ const isSearching = Boolean(search);
+ const isActive = Boolean(searchOptions.length > 0);
+
+ it(`${hasIcon ? 'with' : 'without'} search context classes contain "${
+ hasIcon ? 'has-icon' : 'has-no-icon'
+ }"`, () => {
+ const iconClassRegex = hasIcon ? 'has-icon' : 'has-no-icon';
+ expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
+ });
+
+ it(`${isSearching ? 'with' : 'without'} search string classes contain "${
+ isSearching ? 'is-searching' : 'is-not-searching'
+ }"`, () => {
+ const iconClassRegex = isSearching ? 'is-searching' : 'is-not-searching';
+ expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
+ });
+
+ it(`${isActive ? 'with' : 'without'} search results classes contain "${
+ isActive ? 'is-active' : 'is-not-active'
+ }"`, () => {
+ const iconClassRegex = isActive ? 'is-active' : 'is-not-active';
+ expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
+ });
+ });
+ });
+
+ describe.each`
+ search | searchOptions | hasIcon | iconName
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP}
+ ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false}
+ `('token', ({ search, searchOptions, hasIcon, iconName }) => {
+ beforeEach(() => {
+ window.gon.current_username = MOCK_USERNAME;
+ createComponent(
+ { search },
+ {
+ searchOptions: () => searchOptions,
+ },
+ );
+ });
+
+ it(`icon for data set type "${searchOptions[0]?.html_id}" ${
+ hasIcon ? 'is' : 'is NOT'
+ } rendered`, () => {
+ expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon);
+ });
+
+ it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${
+ searchOptions[0]?.html_id
+ }"`, () => {
+ expect(
+ findScopeToken().findComponent(GlIcon).exists() &&
+ findScopeToken().findComponent(GlIcon).attributes('name'),
+ ).toBe(iconName);
+ });
+ });
});
describe('events', () => {
@@ -285,18 +425,20 @@ describe('HeaderSearchApp', () => {
});
describe('computed', () => {
- describe('currentFocusedOption', () => {
- const MOCK_INDEX = 1;
-
+ describe.each`
+ MOCK_INDEX | search
+ ${1} | ${null}
+ ${SEARCH_BOX_INDEX} | ${'test'}
+ ${2} | ${'test1'}
+ `('currentFocusedOption', ({ MOCK_INDEX, search }) => {
beforeEach(() => {
- createComponent();
+ createComponent({ search });
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 () => {
+ it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => {
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
- await nextTick();
expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]);
});
});
@@ -308,15 +450,25 @@ describe('HeaderSearchApp', () => {
createComponent();
});
- it('onKey-enter submits a search', async () => {
+ it('onKey-enter submits a search', () => {
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
- await nextTick();
-
expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
});
});
+ describe('with less than min characters and no dropdown results', () => {
+ beforeEach(() => {
+ createComponent({ search: 'x' });
+ });
+
+ it('onKey-enter will NOT submit a search', () => {
+ findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+
+ expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
+ });
+ });
+
describe('with currentFocusedOption', () => {
const MOCK_INDEX = 1;
@@ -326,9 +478,9 @@ describe('HeaderSearchApp', () => {
findHeaderSearchInput().vm.$emit('click');
});
- it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => {
+ it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => {
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
- await 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_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
index 8788fb23458..2db9f71d702 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
@@ -1,9 +1,11 @@
-import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
+import { truncate } from '~/lib/utils/text_utility';
+import { MSG_IN_ALL_GITLAB, SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants';
import {
MOCK_SEARCH,
MOCK_SCOPED_SEARCH_OPTIONS,
@@ -41,9 +43,12 @@ describe('HeaderSearchScopedItems', () => {
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
- const findGlDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
+ const findScopeTokens = () => wrapper.findAllComponents(GlToken);
+ const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text()));
+ const findScopeTokensIcons = () =>
+ findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon));
const findDropdownItemAriaLabels = () =>
findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label')));
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
@@ -59,15 +64,31 @@ describe('HeaderSearchScopedItems', () => {
});
it('renders titles correctly', () => {
+ findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH));
+ });
+
+ it('renders scope names correctly', () => {
const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
- trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`),
+ truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH),
);
- expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
+
+ expect(findScopeTokensText()).toStrictEqual(expectedTitles);
+ });
+
+ it('renders scope icons correctly', () => {
+ findScopeTokensIcons().forEach((icon, i) => {
+ const w = icon.wrappers[0];
+ expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon);
+ });
+ });
+
+ it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => {
+ expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false);
});
it('renders aria-labels correctly', () => {
const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
- trimText(`${MOCK_SEARCH} ${o.description} ${o.scope || ''}`),
+ trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`),
);
expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels);
});
@@ -98,21 +119,5 @@ describe('HeaderSearchScopedItems', () => {
});
});
});
-
- describe.each`
- autosuggestResults | showDivider
- ${[]} | ${false}
- ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${true}
- `('scoped search items', ({ autosuggestResults, showDivider }) => {
- describe(`when when we have ${autosuggestResults.length} auto-sugest results`, () => {
- beforeEach(() => {
- createComponent({}, { autocompleteGroupedSearchOptions: () => autosuggestResults }, {});
- });
-
- it(`divider should${showDivider ? '' : ' not'} be shown`, () => {
- expect(findGlDropdownDivider().exists()).toBe(showDivider);
- });
- });
- });
});
});
diff --git a/spec/frontend/header_search/init_spec.js b/spec/frontend/header_search/init_spec.js
new file mode 100644
index 00000000000..9515ca8c812
--- /dev/null
+++ b/spec/frontend/header_search/init_spec.js
@@ -0,0 +1,74 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+
+import initHeaderSearch, { eventHandler, cleanEventListeners } from '~/header_search/init';
+
+describe('Header Search EventListener', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ jest.restoreAllMocks();
+ setHTMLFixture(`
+ <div class="js-header-content">
+ <div class="header-search" id="js-header-search" data-autocomplete-path="/search/autocomplete" data-issues-path="/dashboard/issues" data-mr-path="/dashboard/merge_requests" data-search-context="{}" data-search-path="/search">
+ <input autocomplete="off" class="form-control gl-form-input gl-search-box-by-type-input" data-qa-selector="search_box" id="search" name="search" placeholder="Search GitLab" type="text">
+ </div>
+ </div>`);
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ jest.clearAllMocks();
+ });
+
+ it('attached event listener', () => {
+ const searchInputBox = document?.querySelector('#search');
+ const addEventListenerSpy = jest.spyOn(searchInputBox, 'addEventListener');
+ initHeaderSearch();
+
+ expect(addEventListenerSpy).toBeCalledTimes(2);
+ });
+
+ it('removes event listener ', async () => {
+ const searchInputBox = document?.querySelector('#search');
+ const removeEventListenerSpy = jest.spyOn(searchInputBox, 'removeEventListener');
+ jest.mock('~/header_search', () => ({ initHeaderSearchApp: jest.fn() }));
+ await eventHandler.apply(
+ {
+ newHeaderSearchFeatureFlag: true,
+ searchInputBox: document.querySelector('#search'),
+ },
+ [cleanEventListeners],
+ );
+
+ expect(removeEventListenerSpy).toBeCalledTimes(2);
+ });
+
+ it('attaches new vue dropdown when feature flag is enabled', async () => {
+ const mockVueApp = jest.fn();
+ jest.mock('~/header_search', () => ({ initHeaderSearchApp: mockVueApp }));
+ await eventHandler.apply(
+ {
+ newHeaderSearchFeatureFlag: true,
+ searchInputBox: document.querySelector('#search'),
+ },
+ () => {},
+ );
+
+ expect(mockVueApp).toBeCalled();
+ });
+
+ it('attaches old vue dropdown when feature flag is disabled', async () => {
+ const mockLegacyApp = jest.fn(() => ({
+ onSearchInputFocus: jest.fn(),
+ }));
+ jest.mock('~/search_autocomplete', () => mockLegacyApp);
+ await eventHandler.apply(
+ {
+ newHeaderSearchFeatureFlag: false,
+ searchInputBox: document.querySelector('#search'),
+ },
+ () => {},
+ );
+
+ expect(mockLegacyApp).toBeCalled();
+ });
+});
diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js
index b6f0fdcc29d..8ccd7fb17e3 100644
--- a/spec/frontend/header_search/mock_data.js
+++ b/spec/frontend/header_search/mock_data.js
@@ -4,9 +4,12 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
- MSG_IN_PROJECT,
- MSG_IN_GROUP,
MSG_IN_ALL_GITLAB,
+ PROJECTS_CATEGORY,
+ ICON_PROJECT,
+ GROUPS_CATEGORY,
+ ICON_GROUP,
+ ICON_SUBGROUP,
} from '~/header_search/constants';
export const MOCK_USERNAME = 'anyone';
@@ -27,12 +30,24 @@ export const MOCK_PROJECT = {
path: '/mock-project',
};
+export const MOCK_PROJECT_LONG = {
+ id: 124,
+ name: 'Mock Project Name That Is Ridiculously Long And It Goes Forever',
+ path: '/mock-project-name-that-is-ridiculously-long-and-it-goes-forever',
+};
+
export const MOCK_GROUP = {
id: 321,
name: 'MockGroup',
path: '/mock-group',
};
+export const MOCK_SUBGROUP = {
+ id: 322,
+ name: 'MockSubGroup',
+ path: `${MOCK_GROUP}/mock-subgroup`,
+};
+
export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test';
export const MOCK_SEARCH = 'test';
@@ -44,6 +59,20 @@ export const MOCK_SEARCH_CONTEXT = {
group_metadata: {},
};
+export const MOCK_SEARCH_CONTEXT_FULL = {
+ group: {
+ id: 31,
+ name: 'testGroup',
+ full_name: 'testGroup',
+ },
+ group_metadata: {
+ group_path: 'testGroup',
+ name: 'testGroup',
+ issues_path: '/groups/testGroup/-/issues',
+ mr_path: '/groups/testGroup/-/merge_requests',
+ },
+};
+
export const MOCK_DEFAULT_SEARCH_OPTIONS = [
{
html_id: 'default-issues-assigned',
@@ -76,13 +105,51 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [
{
html_id: 'scoped-in-project',
scope: MOCK_PROJECT.name,
- description: MSG_IN_PROJECT,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ url: MOCK_PROJECT.path,
+ },
+ {
+ html_id: 'scoped-in-project-long',
+ scope: MOCK_PROJECT_LONG.name,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
+ url: MOCK_PROJECT_LONG.path,
+ },
+ {
+ html_id: 'scoped-in-group',
+ scope: MOCK_GROUP.name,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: ICON_GROUP,
+ url: MOCK_GROUP.path,
+ },
+ {
+ html_id: 'scoped-in-subgroup',
+ scope: MOCK_SUBGROUP.name,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: ICON_SUBGROUP,
+ url: MOCK_SUBGROUP.path,
+ },
+ {
+ html_id: 'scoped-in-all',
+ description: MSG_IN_ALL_GITLAB,
+ url: MOCK_ALL_PATH,
+ },
+];
+
+export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
+ {
+ html_id: 'scoped-in-project',
+ scope: MOCK_PROJECT.name,
+ scopeCategory: PROJECTS_CATEGORY,
+ icon: ICON_PROJECT,
url: MOCK_PROJECT.path,
},
{
html_id: 'scoped-in-group',
scope: MOCK_GROUP.name,
- description: MSG_IN_GROUP,
+ scopeCategory: GROUPS_CATEGORY,
+ icon: ICON_GROUP,
url: MOCK_GROUP.path,
},
{
diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js
index d3510de1439..c76be3c0360 100644
--- a/spec/frontend/header_search/store/getters_spec.js
+++ b/spec/frontend/header_search/store/getters_spec.js
@@ -9,6 +9,7 @@ import {
MOCK_SEARCH_CONTEXT,
MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS,
+ MOCK_SCOPED_SEARCH_OPTIONS_DEF,
MOCK_PROJECT,
MOCK_GROUP,
MOCK_ALL_PATH,
@@ -284,7 +285,7 @@ describe('Header Search Store Getters', () => {
it('returns the correct array', () => {
expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual(
- MOCK_SCOPED_SEARCH_OPTIONS,
+ MOCK_SCOPED_SEARCH_OPTIONS_DEF,
);
});
});
@@ -308,6 +309,11 @@ describe('Header Search Store Getters', () => {
${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)}
+ ${1} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
+ ${'('} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
+ ${'t'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
+ ${'te'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
+ ${'tes'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
`(
'searchOptions',
({
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 19849fba63c..4e2fb70a2cb 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import initTodoToggle, { initNavUserDropdownTracking } from '~/header';
import { loadHTMLFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
@@ -9,11 +8,17 @@ describe('Header', () => {
const fixtureTemplate = 'issues/open-issue.html';
function isTodosCountHidden() {
- return $(todosPendingCount).hasClass('hidden');
+ return document.querySelector(todosPendingCount).classList.contains('hidden');
}
function triggerToggle(newCount) {
- $(document).trigger('todo:toggle', newCount);
+ const event = new CustomEvent('todo:toggle', {
+ detail: {
+ count: newCount,
+ },
+ });
+
+ document.dispatchEvent(event);
}
beforeEach(() => {
@@ -28,7 +33,7 @@ describe('Header', () => {
it('should update todos-count after receiving the todo:toggle event', () => {
triggerToggle(5);
- expect($(todosPendingCount).text()).toEqual('5');
+ expect(document.querySelector(todosPendingCount).textContent).toEqual('5');
});
it('should hide todos-count when it is 0', () => {
@@ -53,7 +58,7 @@ describe('Header', () => {
});
it('should show 99+ for todos-count', () => {
- expect($(todosPendingCount).text()).toEqual('99+');
+ expect(document.querySelector(todosPendingCount).textContent).toEqual('99+');
});
});
});
@@ -67,7 +72,11 @@ describe('Header', () => {
<a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a>
</li>`);
- trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn);
+ trackingSpy = mockTracking(
+ '_category_',
+ document.querySelector('.js-nav-user-dropdown').element,
+ jest.spyOn,
+ );
document.body.dataset.page = 'some:page';
initNavUserDropdownTracking();
@@ -79,7 +88,8 @@ describe('Header', () => {
});
it('sends a tracking event when the dropdown is opened and contains Buy Pipeline minutes link', () => {
- $('.js-nav-user-dropdown').trigger('shown.bs.dropdown');
+ const event = new CustomEvent('shown.bs.dropdown');
+ document.querySelector('.js-nav-user-dropdown').dispatchEvent(event);
expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_buy_ci_minutes', {
label: 'free',
diff --git a/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js b/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js
index 4f81c0aa5d3..7c48c0e6f95 100644
--- a/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js
@@ -1,29 +1,21 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import emptyState from '~/ide/components/commit_sidebar/empty_state.vue';
+import { shallowMount } from '@vue/test-utils';
+import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue';
import { createStore } from '~/ide/stores';
-describe('IDE commit panel empty state', () => {
- let vm;
- let store;
+describe('IDE commit panel EmptyState component', () => {
+ let wrapper;
beforeEach(() => {
- store = createStore();
-
- const Component = Vue.extend(emptyState);
-
- Vue.set(store.state, 'noChangesStateSvgPath', 'no-changes');
-
- vm = createComponentWithStore(Component, store);
-
- vm.$mount();
+ const store = createStore();
+ store.state.noChangesStateSvgPath = 'no-changes';
+ wrapper = shallowMount(EmptyState, { store });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
it('renders no changes text when last commit message is empty', () => {
- expect(vm.$el.textContent).toContain('No changes');
+ expect(wrapper.find('h4').text()).toBe('No changes');
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js
index 1d42512c9ee..81c81fc0a9f 100644
--- a/spec/frontend/ide/components/commit_sidebar/list_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js
@@ -1,51 +1,47 @@
-import Vue, { nextTick } from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import commitSidebarList from '~/ide/components/commit_sidebar/list.vue';
-import { createStore } from '~/ide/stores';
+import { shallowMount } from '@vue/test-utils';
+import CommitSidebarList from '~/ide/components/commit_sidebar/list.vue';
+import ListItem from '~/ide/components/commit_sidebar/list_item.vue';
import { file } from '../../helpers';
describe('Multi-file editor commit sidebar list', () => {
- let store;
- let vm;
-
- beforeEach(() => {
- store = createStore();
-
- const Component = Vue.extend(commitSidebarList);
-
- vm = createComponentWithStore(Component, store, {
- title: 'Staged',
- fileList: [],
- action: 'stageAllChanges',
- actionBtnText: 'stage all',
- actionBtnIcon: 'history',
- activeFileKey: 'staged-testing',
- keyPrefix: 'staged',
+ let wrapper;
+
+ const mountComponent = ({ fileList }) =>
+ shallowMount(CommitSidebarList, {
+ propsData: {
+ title: 'Staged',
+ fileList,
+ action: 'stageAllChanges',
+ actionBtnText: 'stage all',
+ actionBtnIcon: 'history',
+ activeFileKey: 'staged-testing',
+ keyPrefix: 'staged',
+ },
});
- vm.$mount();
- });
-
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('with a list of files', () => {
beforeEach(async () => {
const f = file('file name');
f.changed = true;
- vm.fileList.push(f);
- await nextTick();
+ wrapper = mountComponent({ fileList: [f] });
});
it('renders list', () => {
- expect(vm.$el.querySelectorAll('.multi-file-commit-list > li').length).toBe(1);
+ expect(wrapper.findAllComponents(ListItem)).toHaveLength(1);
});
});
- describe('empty files array', () => {
- it('renders no changes text when empty', () => {
- expect(vm.$el.textContent).toContain('No changes');
+ describe('with empty files array', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({ fileList: [] });
+ });
+
+ it('renders no changes text ', () => {
+ expect(wrapper.text()).toContain('No changes');
});
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/success_message_spec.js b/spec/frontend/ide/components/commit_sidebar/success_message_spec.js
index 52e35bdbb73..63d51953915 100644
--- a/spec/frontend/ide/components/commit_sidebar/success_message_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/success_message_spec.js
@@ -1,32 +1,22 @@
-import Vue, { nextTick } from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import successMessage from '~/ide/components/commit_sidebar/success_message.vue';
+import { shallowMount } from '@vue/test-utils';
+import SuccessMessage from '~/ide/components/commit_sidebar/success_message.vue';
import { createStore } from '~/ide/stores';
describe('IDE commit panel successful commit state', () => {
- let vm;
- let store;
+ let wrapper;
beforeEach(() => {
- store = createStore();
-
- const Component = Vue.extend(successMessage);
-
- vm = createComponentWithStore(Component, store, {
- committedStateSvgPath: 'committed-state',
- });
-
- vm.$mount();
+ const store = createStore();
+ store.state.committedStateSvgPath = 'committed-state';
+ store.state.lastCommitMsg = 'testing commit message';
+ wrapper = shallowMount(SuccessMessage, { store });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
- it('renders last commit message when it exists', async () => {
- vm.$store.state.lastCommitMsg = 'testing commit message';
-
- await nextTick();
- expect(vm.$el.textContent).toContain('testing commit message');
+ it('renders last commit message when it exists', () => {
+ expect(wrapper.text()).toContain('testing commit message');
});
});
diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js
index 37b42001a80..9172c69b10e 100644
--- a/spec/frontend/ide/components/ide_spec.js
+++ b/spec/frontend/ide/components/ide_spec.js
@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import waitForPromises from 'helpers/wait_for_promises';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import CannotPushCodeAlert from '~/ide/components/cannot_push_code_alert.vue';
import ErrorMessage from '~/ide/components/error_message.vue';
import Ide from '~/ide/components/ide.vue';
@@ -40,6 +41,8 @@ describe('WebIDE', () => {
const findAlert = () => wrapper.findComponent(CannotPushCodeAlert);
beforeEach(() => {
+ stubPerformanceWebAPI();
+
store = createStore();
});
diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js
index a85c52f5e86..0f61aa80e53 100644
--- a/spec/frontend/ide/components/ide_tree_list_spec.js
+++ b/spec/frontend/ide/components/ide_tree_list_spec.js
@@ -1,82 +1,72 @@
-import Vue, { nextTick } from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import IdeTreeList from '~/ide/components/ide_tree_list.vue';
import { createStore } from '~/ide/stores';
+import FileTree from '~/vue_shared/components/file_tree.vue';
import { file } from '../helpers';
import { projectData } from '../mock_data';
-describe('IDE tree list', () => {
- const Component = Vue.extend(IdeTreeList);
- const normalBranchTree = [file('fileName')];
- const emptyBranchTree = [];
- let vm;
- let store;
+describe('IdeTreeList component', () => {
+ let wrapper;
- const bootstrapWithTree = (tree = normalBranchTree) => {
+ const mountComponent = ({ tree, loading = false } = {}) => {
+ const store = createStore();
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'main';
store.state.projects.abcproject = { ...projectData };
- Vue.set(store.state.trees, 'abcproject/main', {
- tree,
- loading: false,
- });
+ Vue.set(store.state.trees, 'abcproject/main', { tree, loading });
- vm = createComponentWithStore(Component, store, {
- viewerType: 'edit',
+ wrapper = shallowMount(IdeTreeList, {
+ propsData: {
+ viewerType: 'edit',
+ },
+ store,
});
};
- beforeEach(() => {
- store = createStore();
- });
-
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('normal branch', () => {
- beforeEach(() => {
- bootstrapWithTree();
-
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
-
- vm.$mount();
- });
+ const tree = [file('fileName')];
it('emits tree-ready event', () => {
- expect(vm.$emit).toHaveBeenCalledTimes(1);
- expect(vm.$emit).toHaveBeenCalledWith('tree-ready');
+ mountComponent({ tree });
+
+ expect(wrapper.emitted('tree-ready')).toEqual([[]]);
});
- it('renders loading indicator', async () => {
- store.state.trees['abcproject/main'].loading = true;
+ it('renders loading indicator', () => {
+ mountComponent({ tree, loading: true });
- await nextTick();
- expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
- expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+ expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(3);
});
it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
+ mountComponent({ tree });
+
+ expect(wrapper.findAllComponents(FileTree)).toHaveLength(1);
+ expect(wrapper.findComponent(FileTree).props('file')).toEqual(tree[0]);
});
});
describe('empty-branch state', () => {
beforeEach(() => {
- bootstrapWithTree(emptyBranchTree);
-
- jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ mountComponent({ tree: [] });
+ });
- vm.$mount();
+ it('emits tree-ready event', () => {
+ expect(wrapper.emitted('tree-ready')).toEqual([[]]);
});
- it('still emits tree-ready event', () => {
- expect(vm.$emit).toHaveBeenCalledWith('tree-ready');
+ it('does not render files', () => {
+ expect(wrapper.findAllComponents(FileTree)).toHaveLength(0);
});
- it('does not load files if the branch is empty', () => {
- expect(vm.$el.textContent).not.toContain('fileName');
- expect(vm.$el.textContent).toContain('No files');
+ it('renders empty state text', () => {
+ expect(wrapper.text()).toBe('No files');
});
});
});
diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js
index 1c14685df68..8eebcdd9e08 100644
--- a/spec/frontend/ide/components/nav_dropdown_button_spec.js
+++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js
@@ -1,81 +1,74 @@
-import Vue, { nextTick } from 'vue';
import { trimText } from 'helpers/text_helper';
-import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue';
import { createStore } from '~/ide/stores';
+import { __ } from '~/locale';
-describe('NavDropdown', () => {
+describe('NavDropdownButton component', () => {
const TEST_BRANCH_ID = 'lorem-ipsum-dolar';
const TEST_MR_ID = '12345';
- let store;
- let vm;
-
- beforeEach(() => {
- store = createStore();
- });
+ let wrapper;
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
- const createComponent = (props = {}) => {
- vm = mountComponentWithStore(Vue.extend(NavDropdownButton), { props, store });
- vm.$mount();
+ const createComponent = ({ props = {}, state = {} } = {}) => {
+ const store = createStore();
+ store.replaceState(state);
+ wrapper = mountExtended(NavDropdownButton, { propsData: props, store });
};
- const findIcon = (name) => vm.$el.querySelector(`[data-testid="${name}-icon"]`);
- const findMRIcon = () => findIcon('merge-request');
- const findBranchIcon = () => findIcon('branch');
+ const findMRIcon = () => wrapper.findByLabelText(__('Merge request'));
+ const findBranchIcon = () => wrapper.findByLabelText(__('Current Branch'));
describe('normal', () => {
- beforeEach(() => {
+ it('renders empty placeholders, if state is falsey', () => {
createComponent();
- });
- it('renders empty placeholders, if state is falsey', () => {
- expect(trimText(vm.$el.textContent)).toEqual('- -');
+ expect(trimText(wrapper.text())).toBe('- -');
});
- it('renders branch name, if state has currentBranchId', async () => {
- vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+ it('renders branch name, if state has currentBranchId', () => {
+ createComponent({ state: { currentBranchId: TEST_BRANCH_ID } });
- await nextTick();
- expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`);
+ expect(trimText(wrapper.text())).toBe(`${TEST_BRANCH_ID} -`);
});
- it('renders mr id, if state has currentMergeRequestId', async () => {
- vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+ it('renders mr id, if state has currentMergeRequestId', () => {
+ createComponent({ state: { currentMergeRequestId: TEST_MR_ID } });
- await nextTick();
- expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`);
+ expect(trimText(wrapper.text())).toBe(`- !${TEST_MR_ID}`);
});
- it('renders branch and mr, if state has both', async () => {
- vm.$store.state.currentBranchId = TEST_BRANCH_ID;
- vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+ it('renders branch and mr, if state has both', () => {
+ createComponent({
+ state: { currentBranchId: TEST_BRANCH_ID, currentMergeRequestId: TEST_MR_ID },
+ });
- await nextTick();
- expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`);
+ expect(trimText(wrapper.text())).toBe(`${TEST_BRANCH_ID} !${TEST_MR_ID}`);
});
it('shows icons', () => {
- expect(findBranchIcon()).toBeTruthy();
- expect(findMRIcon()).toBeTruthy();
+ createComponent();
+
+ expect(findBranchIcon().exists()).toBe(true);
+ expect(findMRIcon().exists()).toBe(true);
});
});
- describe('with showMergeRequests false', () => {
+ describe('when showMergeRequests=false', () => {
beforeEach(() => {
- createComponent({ showMergeRequests: false });
+ createComponent({ props: { showMergeRequests: false } });
});
it('shows single empty placeholder, if state is falsey', () => {
- expect(trimText(vm.$el.textContent)).toEqual('-');
+ expect(trimText(wrapper.text())).toBe('-');
});
it('shows only branch icon', () => {
- expect(findBranchIcon()).toBeTruthy();
- expect(findMRIcon()).toBe(null);
+ expect(findBranchIcon().exists()).toBe(true);
+ expect(findMRIcon().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js
index e8635444801..68cc08d2ebc 100644
--- a/spec/frontend/ide/components/new_dropdown/modal_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js
@@ -1,209 +1,419 @@
-import Vue, { nextTick } from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { GlButton, GlModal } from '@gitlab/ui';
+import { nextTick } from 'vue';
import createFlash from '~/flash';
-import modal from '~/ide/components/new_dropdown/modal.vue';
+import Modal from '~/ide/components/new_dropdown/modal.vue';
import { createStore } from '~/ide/stores';
+import { stubComponent } from 'helpers/stub_component';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { createEntriesFromPaths } from '../../helpers';
jest.mock('~/flash');
+const NEW_NAME = 'babar';
+
describe('new file modal component', () => {
- const Component = Vue.extend(modal);
- let vm;
+ const showModal = jest.fn();
+ const toggleModal = jest.fn();
+
+ let store;
+ let wrapper;
+
+ const findForm = () => wrapper.findByTestId('file-name-form');
+ const findGlModal = () => wrapper.findComponent(GlModal);
+ const findInput = () => wrapper.findByTestId('file-name-field');
+ const findTemplateButtons = () => wrapper.findAllComponents(GlButton);
+ const findTemplateButtonsModel = () =>
+ findTemplateButtons().wrappers.map((x) => ({
+ text: x.text(),
+ variant: x.props('variant'),
+ category: x.props('category'),
+ }));
+
+ const open = (type, path) => {
+ // TODO: This component can not be passed props
+ // We have to interact with the open() method?
+ wrapper.vm.open(type, path);
+ };
+ const triggerSubmitForm = () => {
+ findForm().trigger('submit');
+ };
+ const triggerSubmitModal = () => {
+ findGlModal().vm.$emit('primary');
+ };
+ const triggerCancel = () => {
+ findGlModal().vm.$emit('cancel');
+ };
+
+ const mountComponent = () => {
+ const GlModalStub = stubComponent(GlModal);
+ jest.spyOn(GlModalStub.methods, 'show').mockImplementation(showModal);
+ jest.spyOn(GlModalStub.methods, 'toggle').mockImplementation(toggleModal);
+
+ wrapper = shallowMountExtended(Modal, {
+ store,
+ stubs: {
+ GlModal: GlModalStub,
+ },
+ // We need to attach to document for "focus" to work
+ attachTo: document.body,
+ });
+ };
+
+ beforeEach(() => {
+ store = createStore();
+
+ Object.assign(
+ store.state.entries,
+ createEntriesFromPaths([
+ 'README.md',
+ 'src',
+ 'src/deleted.js',
+ 'src/parent_dir',
+ 'src/parent_dir/foo.js',
+ ]),
+ );
+ Object.assign(store.state.entries['src/deleted.js'], { deleted: true });
+
+ jest.spyOn(store, 'dispatch').mockImplementation();
+ });
afterEach(() => {
- vm.$destroy();
+ store = null;
+ wrapper.destroy();
+ document.body.innerHTML = '';
});
- describe.each`
- entryType | modalTitle | btnTitle | showsFileTemplates
- ${'tree'} | ${'Create new directory'} | ${'Create directory'} | ${false}
- ${'blob'} | ${'Create new file'} | ${'Create file'} | ${true}
- `('$entryType', ({ entryType, modalTitle, btnTitle, showsFileTemplates }) => {
+ describe('default', () => {
beforeEach(async () => {
- const store = createStore();
-
- vm = createComponentWithStore(Component, store).$mount();
- vm.open(entryType);
- vm.name = 'testing';
+ mountComponent();
+ // Not necessarily needed, but used to ensure that nothing extra is happening after the tick
await nextTick();
});
- afterEach(() => {
- vm.close();
+ it('renders modal', () => {
+ expect(findGlModal().props()).toMatchObject({
+ actionCancel: {
+ attributes: [{ variant: 'default' }],
+ text: 'Cancel',
+ },
+ actionPrimary: {
+ attributes: [{ variant: 'confirm' }],
+ text: 'Create file',
+ },
+ actionSecondary: null,
+ size: 'lg',
+ modalId: 'ide-new-entry',
+ title: 'Create new file',
+ });
});
- it(`sets modal title as ${entryType}`, () => {
- expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
+ it('renders name label', () => {
+ expect(wrapper.find('label').text()).toBe('Name');
});
- it(`sets button label as ${entryType}`, () => {
- expect(document.querySelector('.btn-confirm').textContent.trim()).toBe(btnTitle);
+ it('renders template buttons', () => {
+ const actual = findTemplateButtonsModel();
+
+ expect(actual.length).toBeGreaterThan(0);
+ expect(actual).toEqual(
+ store.getters['fileTemplates/templateTypes'].map((template) => ({
+ category: 'secondary',
+ text: template.name,
+ variant: 'dashed',
+ })),
+ );
});
- it(`sets form label as ${entryType}`, () => {
- expect(document.querySelector('.label-bold').textContent.trim()).toBe('Name');
+ // These negative ".not.toHaveBeenCalled" assertions complement the positive "toHaveBeenCalled"
+ // assertions that show up later in this spec. Without these, we're not guaranteed the "act"
+ // actually caused the change in behavior.
+ it('does not dispatch actions by default', () => {
+ expect(store.dispatch).not.toHaveBeenCalled();
});
- it(`shows file templates: ${showsFileTemplates}`, () => {
- const templateFilesEl = document.querySelector('.file-templates');
- expect(Boolean(templateFilesEl)).toBe(showsFileTemplates);
+ it('does not trigger modal by default', () => {
+ expect(showModal).not.toHaveBeenCalled();
+ expect(toggleModal).not.toHaveBeenCalled();
});
- });
- describe('rename entry', () => {
- beforeEach(() => {
- const store = createStore();
- store.state.entries = {
- 'test-path': {
- name: 'test',
- type: 'blob',
- path: 'test-path',
- },
- };
-
- vm = createComponentWithStore(Component, store).$mount();
+ it('does not focus input by default', () => {
+ expect(document.activeElement).toBe(document.body);
});
+ });
- it.each`
- entryType | modalTitle | btnTitle
- ${'tree'} | ${'Rename folder'} | ${'Rename folder'}
- ${'blob'} | ${'Rename file'} | ${'Rename file'}
- `(
- 'renders title and button for renaming $entryType',
- async ({ entryType, modalTitle, btnTitle }) => {
- vm.$store.state.entries['test-path'].type = entryType;
- vm.open('rename', 'test-path');
+ describe.each`
+ entryType | path | modalTitle | btnTitle | showsFileTemplates | inputValue | inputPlaceholder
+ ${'tree'} | ${''} | ${'Create new directory'} | ${'Create directory'} | ${false} | ${''} | ${'dir/'}
+ ${'blob'} | ${''} | ${'Create new file'} | ${'Create file'} | ${true} | ${''} | ${'dir/file_name'}
+ ${'blob'} | ${'foo/bar'} | ${'Create new file'} | ${'Create file'} | ${true} | ${'foo/bar/'} | ${'dir/file_name'}
+ `(
+ 'when opened as $entryType with path "$path"',
+ ({
+ entryType,
+ path,
+ modalTitle,
+ btnTitle,
+ showsFileTemplates,
+ inputValue,
+ inputPlaceholder,
+ }) => {
+ beforeEach(async () => {
+ mountComponent();
+
+ open(entryType, path);
await nextTick();
- expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
- expect(document.querySelector('.btn-confirm').textContent.trim()).toBe(btnTitle);
- },
- );
+ });
- describe('entryName', () => {
- it('returns entries name', () => {
- vm.open('rename', 'test-path');
+ it('sets modal props', () => {
+ expect(findGlModal().props()).toMatchObject({
+ title: modalTitle,
+ actionPrimary: {
+ attributes: [{ variant: 'confirm' }],
+ text: btnTitle,
+ },
+ });
+ });
- expect(vm.entryName).toBe('test-path');
+ it('sets input attributes', () => {
+ expect(findInput().element.value).toBe(inputValue);
+ expect(findInput().attributes('placeholder')).toBe(inputPlaceholder);
});
- it('does not reset entryName to its old value if empty', () => {
- vm.entryName = 'hello';
- vm.entryName = '';
+ it(`shows file templates: ${showsFileTemplates}`, () => {
+ const actual = findTemplateButtonsModel().length > 0;
- expect(vm.entryName).toBe('');
+ expect(actual).toBe(showsFileTemplates);
+ });
+
+ it('shows modal', () => {
+ expect(showModal).toHaveBeenCalled();
});
- });
- describe('open', () => {
- it('sets entryName to path provided if modalType is rename', () => {
- vm.open('rename', 'test-path');
+ it('focus on input', () => {
+ expect(document.activeElement).toBe(findInput().element);
+ });
+
+ it('resets when canceled', async () => {
+ triggerCancel();
+
+ await nextTick();
- expect(vm.entryName).toBe('test-path');
+ // Resets input value
+ expect(findInput().element.value).toBe('');
+ // Resets to blob mode
+ expect(findGlModal().props('title')).toBe('Create new file');
});
+ },
+ );
+
+ describe.each`
+ modalType | name | expectedName
+ ${'blob'} | ${'foo/bar.js'} | ${'foo/bar.js'}
+ ${'blob'} | ${'foo /bar.js'} | ${'foo/bar.js'}
+ ${'tree'} | ${'foo/dir'} | ${'foo/dir'}
+ ${'tree'} | ${'foo /dir'} | ${'foo/dir'}
+ `('when submitting as $modalType with "$name"', ({ modalType, name, expectedName }) => {
+ describe('when using the modal primary button', () => {
+ beforeEach(async () => {
+ mountComponent();
+
+ open(modalType, '');
+ await nextTick();
- it("appends '/' to the path if modalType isn't rename", () => {
- vm.open('blob', 'test-path');
+ findInput().setValue(name);
+ triggerSubmitModal();
+ });
- expect(vm.entryName).toBe('test-path/');
+ it('triggers createTempEntry action', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', {
+ name: expectedName,
+ type: modalType,
+ });
});
+ });
+
+ describe('when triggering form submit (pressing enter)', () => {
+ beforeEach(async () => {
+ mountComponent();
+
+ open(modalType, '');
+ await nextTick();
- it('leaves entryName blank if no path is provided', () => {
- vm.open('blob');
+ findInput().setValue(name);
+ triggerSubmitForm();
+ });
- expect(vm.entryName).toBe('');
+ it('triggers createTempEntry action', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', {
+ name: expectedName,
+ type: modalType,
+ });
});
});
});
- describe('createFromTemplate', () => {
- let store;
+ describe('when creating from template type', () => {
+ beforeEach(async () => {
+ mountComponent();
- beforeEach(() => {
- store = createStore();
- store.state.entries = {
- 'test-path/test': {
- name: 'test',
- deleted: false,
- },
- };
+ open('blob', 'some_dir');
- vm = createComponentWithStore(Component, store).$mount();
- vm.open('blob');
+ await nextTick();
- jest.spyOn(vm, 'createTempEntry').mockImplementation();
+ // Set input, then trigger button
+ findInput().setValue('some_dir/foo.js');
+ findTemplateButtons().at(1).vm.$emit('click');
});
- it.each`
- entryName | newFilePath
- ${''} | ${'.gitignore'}
- ${'README.md'} | ${'.gitignore'}
- ${'test-path/test/'} | ${'test-path/test/.gitignore'}
- ${'test-path/test'} | ${'test-path/.gitignore'}
- ${'test-path/test/abc.md'} | ${'test-path/test/.gitignore'}
- `(
- 'creates a new file with the given template name in appropriate directory for path: $path',
- ({ entryName, newFilePath }) => {
- vm.entryName = entryName;
+ it('triggers createTempEntry action', () => {
+ const { name: expectedName } = store.getters['fileTemplates/templateTypes'][1];
- vm.createFromTemplate({ name: '.gitignore' });
+ expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', {
+ name: `some_dir/${expectedName}`,
+ type: 'blob',
+ });
+ });
- expect(vm.createTempEntry).toHaveBeenCalledWith({
- name: newFilePath,
- type: 'blob',
- });
- },
- );
+ it('toggles modal', () => {
+ expect(toggleModal).toHaveBeenCalled();
+ });
});
- describe('submitForm', () => {
- let store;
+ describe.each`
+ origPath | title | inputValue | inputSelectionStart
+ ${'src/parent_dir'} | ${'Rename folder'} | ${'src/parent_dir'} | ${'src/'.length}
+ ${'README.md'} | ${'Rename file'} | ${'README.md'} | ${0}
+ `('when renaming for $origPath', ({ origPath, title, inputValue, inputSelectionStart }) => {
+ beforeEach(async () => {
+ mountComponent();
+
+ open('rename', origPath);
+
+ await nextTick();
+ });
- beforeEach(() => {
- store = createStore();
- store.state.entries = {
- 'test-path/test': {
- name: 'test',
- deleted: false,
+ it('sets modal props for renaming', () => {
+ expect(findGlModal().props()).toMatchObject({
+ title,
+ actionPrimary: {
+ attributes: [{ variant: 'confirm' }],
+ text: title,
},
- };
+ });
+ });
+
+ it('sets input value', () => {
+ expect(findInput().element.value).toBe(inputValue);
+ });
- vm = createComponentWithStore(Component, store).$mount();
+ it(`does not show file templates`, () => {
+ expect(findTemplateButtonsModel()).toHaveLength(0);
});
- it('throws an error when target entry exists', () => {
- vm.open('rename', 'test-path/test');
+ it('shows modal when renaming', () => {
+ expect(showModal).toHaveBeenCalled();
+ });
- expect(createFlash).not.toHaveBeenCalled();
+ it('focus on input when renaming', () => {
+ expect(document.activeElement).toBe(findInput().element);
+ });
+
+ it('selects name part of the input', () => {
+ expect(findInput().element.selectionStart).toBe(inputSelectionStart);
+ expect(findInput().element.selectionEnd).toBe(origPath.length);
+ });
+
+ describe('when renames is submitted successfully', () => {
+ describe('when using the modal primary button', () => {
+ beforeEach(() => {
+ findInput().setValue(NEW_NAME);
+ triggerSubmitModal();
+ });
+
+ it('dispatches renameEntry event', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('renameEntry', {
+ path: origPath,
+ parentPath: '',
+ name: NEW_NAME,
+ });
+ });
+
+ it('does not trigger flash', () => {
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+ });
- vm.submitForm();
+ describe('when triggering form submit (pressing enter)', () => {
+ beforeEach(() => {
+ findInput().setValue(NEW_NAME);
+ triggerSubmitForm();
+ });
+ it('dispatches renameEntry event', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('renameEntry', {
+ path: origPath,
+ parentPath: '',
+ name: NEW_NAME,
+ });
+ });
+
+ it('does not trigger flash', () => {
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('when renaming and file already exists', () => {
+ beforeEach(async () => {
+ mountComponent();
+
+ open('rename', 'src/parent_dir');
+
+ await nextTick();
+
+ // Set to something that already exists!
+ findInput().setValue('src');
+ triggerSubmitModal();
+ });
+
+ it('creates flash', () => {
expect(createFlash).toHaveBeenCalledWith({
- message: 'The name "test-path/test" is already taken in this directory.',
+ message: 'The name "src" is already taken in this directory.',
fadeTransition: false,
addBodyClass: true,
});
});
- it('does not throw error when target entry does not exist', () => {
- jest.spyOn(vm, 'renameEntry').mockImplementation();
+ it('does not dispatch event', () => {
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+ });
- vm.open('rename', 'test-path/test');
- vm.entryName = 'test-path/test2';
- vm.submitForm();
+ describe('when renaming and file has been deleted', () => {
+ beforeEach(async () => {
+ mountComponent();
- expect(createFlash).not.toHaveBeenCalled();
- });
+ open('rename', 'src/parent_dir/foo.js');
- it('removes leading/trailing found in the new name', () => {
- vm.open('rename', 'test-path/test');
+ await nextTick();
- vm.entryName = 'test-path /test';
+ findInput().setValue('src/deleted.js');
+ triggerSubmitModal();
+ });
- vm.submitForm();
+ it('does not create flash', () => {
+ expect(createFlash).not.toHaveBeenCalled();
+ });
- expect(vm.entryName).toBe('test-path/test');
+ it('dispatches event', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('renameEntry', {
+ path: 'src/parent_dir/foo.js',
+ name: 'deleted.js',
+ parentPath: 'src',
+ });
});
});
});
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index b44651481e9..7a0bcda1b7a 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -1,3 +1,4 @@
+import { GlTab } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { editor as monacoEditor, Range } from 'monaco-editor';
import Vue, { nextTick } from 'vue';
@@ -5,6 +6,7 @@ import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils';
import '~/behaviors/markdown/render_gfm';
import waitForPromises from 'helpers/wait_for_promises';
+import { stubPerformanceWebAPI } from 'helpers/performance';
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';
@@ -125,10 +127,12 @@ describe('RepoEditor', () => {
};
const findEditor = () => wrapper.find('[data-testid="editor-container"]');
- const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li');
+ const findTabs = () => wrapper.findAllComponents(GlTab);
const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]');
beforeEach(() => {
+ stubPerformanceWebAPI();
+
createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN);
createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN);
createModelSpy = jest.spyOn(monacoEditor, 'createModel');
@@ -201,12 +205,12 @@ describe('RepoEditor', () => {
const tabs = findTabs();
expect(tabs).toHaveLength(2);
- expect(tabs.at(0).text()).toBe('Edit');
- expect(tabs.at(1).text()).toBe('Preview Markdown');
+ expect(tabs.at(0).element.dataset.testid).toBe('edit-tab');
+ expect(tabs.at(1).element.dataset.testid).toBe('preview-tab');
});
it('renders markdown for tempFile', async () => {
- findPreviewTab().trigger('click');
+ findPreviewTab().vm.$emit('click');
await waitForPromises();
expect(wrapper.find(ContentViewer).html()).toContain(dummyFile.text.content);
});
diff --git a/spec/frontend/ide/ide_router_spec.js b/spec/frontend/ide/ide_router_spec.js
index cd10812f8ea..adbdba1b11e 100644
--- a/spec/frontend/ide/ide_router_spec.js
+++ b/spec/frontend/ide/ide_router_spec.js
@@ -1,4 +1,5 @@
import waitForPromises from 'helpers/wait_for_promises';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import { createRouter } from '~/ide/ide_router';
import { createStore } from '~/ide/stores';
@@ -12,6 +13,8 @@ describe('IDE router', () => {
let router;
beforeEach(() => {
+ stubPerformanceWebAPI();
+
window.history.replaceState({}, '', '/');
store = createStore();
router = createRouter(store, DEFAULT_BRANCH);
diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js
index 45d1beea3f8..6c1dee1e5ca 100644
--- a/spec/frontend/ide/stores/actions/file_spec.js
+++ b/spec/frontend/ide/stores/actions/file_spec.js
@@ -7,6 +7,7 @@ import { createStore } from '~/ide/stores';
import * as actions from '~/ide/stores/actions/file';
import * as types from '~/ide/stores/mutation_types';
import axios from '~/lib/utils/axios_utils';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import { file, createTriggerRenameAction, createTriggerUpdatePayload } from '../../helpers';
const ORIGINAL_CONTENT = 'original content';
@@ -19,6 +20,8 @@ describe('IDE store file actions', () => {
let router;
beforeEach(() => {
+ stubPerformanceWebAPI();
+
mock = new MockAdapter(axios);
originalGon = window.gon;
window.gon = {
diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js
index 5592e2664c4..abc3ba5b0a2 100644
--- a/spec/frontend/ide/stores/actions/merge_request_spec.js
+++ b/spec/frontend/ide/stores/actions/merge_request_spec.js
@@ -1,5 +1,6 @@
import MockAdapter from 'axios-mock-adapter';
import { range } from 'lodash';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
@@ -35,6 +36,8 @@ describe('IDE store merge request actions', () => {
let mock;
beforeEach(() => {
+ stubPerformanceWebAPI();
+
store = createStore();
mock = new MockAdapter(axios);
diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js
index fc44cbb21ae..d43393875eb 100644
--- a/spec/frontend/ide/stores/actions/tree_spec.js
+++ b/spec/frontend/ide/stores/actions/tree_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
import { createRouter } from '~/ide/ide_router';
@@ -24,6 +25,8 @@ describe('Multi-file store tree actions', () => {
};
beforeEach(() => {
+ stubPerformanceWebAPI();
+
store = createStore();
router = createRouter(store);
jest.spyOn(router, 'push').mockImplementation();
diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js
index 3889c4f11c3..f6d54491d77 100644
--- a/spec/frontend/ide/stores/actions_spec.js
+++ b/spec/frontend/ide/stores/actions_spec.js
@@ -1,4 +1,5 @@
import MockAdapter from 'axios-mock-adapter';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import testAction from 'helpers/vuex_action_helper';
import eventHub from '~/ide/eventhub';
import { createRouter } from '~/ide/ide_router';
@@ -34,6 +35,8 @@ describe('Multi-file store actions', () => {
let router;
beforeEach(() => {
+ stubPerformanceWebAPI();
+
store = createStore();
router = createRouter(store);
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 0279ad454d2..cdc508a0033 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
@@ -50,13 +50,13 @@ describe('import table', () => {
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 triggerSelectAllCheckbox = (checked = true) =>
+ wrapper.find('thead input[type=checkbox]').setChecked(checked);
const selectRow = (idx) =>
- wrapper.findAll('tbody td input[type=checkbox]').at(idx).trigger('click');
+ wrapper.findAll('tbody td input[type=checkbox]').at(idx).setChecked(true);
- const createComponent = ({ bulkImportSourceGroups, importGroups }) => {
+ const createComponent = ({ bulkImportSourceGroups, importGroups, defaultTargetNamespace }) => {
apolloProvider = createMockApollo([], {
Query: {
availableNamespaces: () => availableNamespacesFixture,
@@ -73,6 +73,7 @@ describe('import table', () => {
jobsPath: '/fake_job_path',
sourceUrl: SOURCE_URL,
historyPath: '/fake_history_path',
+ defaultTargetNamespace,
},
apolloProvider,
});
@@ -165,6 +166,27 @@ describe('import table', () => {
expect(targetNamespaceDropdownButton.text()).toBe('No parent');
});
+ it('respects default namespace if provided', async () => {
+ const targetNamespace = availableNamespacesFixture[1];
+
+ createComponent({
+ bulkImportSourceGroups: () => ({
+ nodes: FAKE_GROUPS,
+ pageInfo: FAKE_PAGE_INFO,
+ versionValidation: FAKE_VERSION_VALIDATION,
+ }),
+ defaultTargetNamespace: targetNamespace.id,
+ });
+
+ await waitForPromises();
+
+ const firstRow = wrapper.find('tbody tr');
+ const targetNamespaceDropdownButton = findTargetNamespaceDropdown(firstRow).find(
+ '[aria-haspopup]',
+ );
+ expect(targetNamespaceDropdownButton.text()).toBe(targetNamespace.fullPath);
+ });
+
it('does not render status string when result list is empty', async () => {
createComponent({
bulkImportSourceGroups: jest.fn().mockResolvedValue({
@@ -388,7 +410,7 @@ describe('import table', () => {
expect(findSelectionCount().text()).toMatchInterpolatedText('0 selected');
await triggerSelectAllCheckbox();
expect(findSelectionCount().text()).toMatchInterpolatedText('2 selected');
- await triggerSelectAllCheckbox();
+ await triggerSelectAllCheckbox(false);
expect(findSelectionCount().text()).toMatchInterpolatedText('0 selected');
});
diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
index 633389578a0..1f7a5f0dbc9 100644
--- a/spec/frontend/integrations/edit/components/active_checkbox_spec.js
+++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js
@@ -1,7 +1,6 @@
import { GlFormCheckbox } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import { createStore } from '~/integrations/edit/store';
@@ -74,22 +73,13 @@ describe('ActiveCheckbox', () => {
expect(findGlFormCheckbox().vm.$attrs.checked).toBe(true);
});
- describe('on checkbox click', () => {
- it('switches the form value', async () => {
- findInputInCheckbox().trigger('click');
-
- await 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);
+ it('emits `toggle-integration-active` event', async () => {
+ await findInputInCheckbox().setChecked(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 a2bdece821f..21e57a2e33c 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -596,37 +596,42 @@ describe('IntegrationForm', () => {
});
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,
+ scenario | replyStatus | errorMessage | serviceResponse | expectToast | expectSentry
+ ${'when "test settings" request fails'} | ${httpStatus.INTERNAL_SERVER_ERROR} | ${undefined} | ${undefined} | ${I18N_DEFAULT_ERROR_MESSAGE} | ${true}
+ ${'when "test settings" returns an error'} | ${httpStatus.OK} | ${'an error'} | ${undefined} | ${'an error'} | ${false}
+ ${'when "test settings" returns an error with details'} | ${httpStatus.OK} | ${'an error.'} | ${'extra info'} | ${'an error. extra info'} | ${false}
+ ${'when "test settings" succeeds'} | ${httpStatus.OK} | ${undefined} | ${undefined} | ${I18N_SUCCESSFUL_CONNECTION_MESSAGE} | ${false}
+ `(
+ '$scenario',
+ ({ replyStatus, errorMessage, serviceResponse, expectToast, expectSentry }) => {
+ beforeEach(async () => {
+ mockAxios.onPut(mockTestPath).replyOnce(replyStatus, {
+ error: Boolean(errorMessage),
+ message: errorMessage,
+ service_response: serviceResponse,
+ });
+
+ await findTestButton().vm.$emit('click', new Event('click'));
+ await waitForPromises();
});
- await findTestButton().vm.$emit('click', new Event('click'));
- await waitForPromises();
- });
-
- it(`calls toast with '${expectToast}'`, () => {
- expect(mockToastShow).toHaveBeenCalledWith(expectToast);
- });
+ 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 `loading` prop of test button to `false`', () => {
+ expect(findTestButton().props('loading')).toBe(false);
+ });
- it('sets save button `disabled` prop to `false`', () => {
- expect(findProjectSaveButton().props('disabled')).toBe(false);
- });
+ it('sets save button `disabled` prop to `false`', () => {
+ expect(findProjectSaveButton().props('disabled')).toBe(false);
+ });
- it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => {
- expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0);
- });
- });
+ 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_trigger_fields_spec.js b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
index 49fbebb9396..6011b3e6edc 100644
--- a/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/jira_trigger_fields_spec.js
@@ -115,9 +115,8 @@ describe('JiraTriggerFields', () => {
const checkbox = findIssueTransitionEnabled();
expect(checkbox.element.checked).toBe(false);
- checkbox.trigger('click');
+ await checkbox.setChecked(true);
- await nextTick();
const [radio1, radio2] = findIssueTransitionModeRadios().wrappers;
expect(radio1.element.checked).toBe(true);
expect(radio2.element.checked).toBe(false);
diff --git a/spec/frontend/invite_members/components/import_a_project_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
index 6db881d5c75..b4d42d90d99 100644
--- a/spec/frontend/invite_members/components/import_a_project_modal_spec.js
+++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
@@ -5,7 +5,7 @@ import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import * as ProjectsApi from '~/api/projects_api';
-import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
+import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue';
import ProjectSelect from '~/invite_members/components/project_select.vue';
import axios from '~/lib/utils/axios_utils';
@@ -20,7 +20,7 @@ const $toast = {
};
const createComponent = () => {
- wrapper = shallowMountExtended(ImportAProjectModal, {
+ wrapper = shallowMountExtended(ImportProjectMembersModal, {
propsData: {
projectId,
projectName,
@@ -51,12 +51,11 @@ afterEach(() => {
mock.restore();
});
-describe('ImportAProjectModal', () => {
+describe('ImportProjectMembersModal', () => {
+ const findGlModal = () => wrapper.findComponent(GlModal);
const findIntroText = () => wrapper.find({ ref: 'modalIntro' }).text();
- const findCancelButton = () => wrapper.findByTestId('cancel-button');
- const findImportButton = () => wrapper.findByTestId('import-button');
- const clickImportButton = () => findImportButton().vm.$emit('click');
- const clickCancelButton = () => findCancelButton().vm.$emit('click');
+ const clickImportButton = () => findGlModal().vm.$emit('primary', { preventDefault: jest.fn() });
+ const closeModal = () => findGlModal().vm.$emit('hidden', { preventDefault: jest.fn() });
const findFormGroup = () => wrapper.findByTestId('form-group');
const formGroupInvalidFeedback = () => findFormGroup().props('invalidFeedback');
const formGroupErrorState = () => findFormGroup().props('state');
@@ -68,37 +67,40 @@ describe('ImportAProjectModal', () => {
});
it('renders the modal with the correct title', () => {
- expect(wrapper.findComponent(GlModal).props('title')).toBe(
- 'Import members from another project',
- );
+ expect(findGlModal().props('title')).toBe('Import members from another project');
});
it('renders the Cancel button text correctly', () => {
- expect(findCancelButton().text()).toBe('Cancel');
+ expect(findGlModal().props('actionCancel')).toMatchObject({
+ text: 'Cancel',
+ });
});
it('renders the Import button text correctly', () => {
- expect(findImportButton().text()).toBe('Import project members');
+ expect(findGlModal().props('actionPrimary')).toMatchObject({
+ text: 'Import project members',
+ attributes: {
+ variant: 'confirm',
+ disabled: true,
+ loading: false,
+ },
+ });
});
it('renders the modal intro text correctly', () => {
expect(findIntroText()).toBe("You're importing members to the test name project.");
});
- it('renders the Import button modal without isLoading', () => {
- expect(findImportButton().props('loading')).toBe(false);
- });
-
it('sets isLoading to true when the Invite button is clicked', async () => {
clickImportButton();
await nextTick();
- expect(findImportButton().props('loading')).toBe(true);
+ expect(findGlModal().props('actionPrimary').attributes.loading).toBe(true);
});
});
- describe('submitting the import form', () => {
+ describe('submitting the import', () => {
describe('when the import is successful', () => {
beforeEach(() => {
createComponent();
@@ -125,7 +127,7 @@ describe('ImportAProjectModal', () => {
});
it('sets isLoading to false after success', () => {
- expect(findImportButton().props('loading')).toBe(false);
+ expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false);
});
});
@@ -149,14 +151,14 @@ describe('ImportAProjectModal', () => {
});
it('sets isLoading to false after error', () => {
- expect(findImportButton().props('loading')).toBe(false);
+ expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false);
});
it('clears the error when the modal is closed with an error', async () => {
expect(formGroupInvalidFeedback()).toBe('Unable to import project members');
expect(formGroupErrorState()).toBe(false);
- clickCancelButton();
+ closeModal();
await nextTick();
diff --git a/spec/frontend/invite_members/components/import_project_members_trigger_spec.js b/spec/frontend/invite_members/components/import_project_members_trigger_spec.js
new file mode 100644
index 00000000000..b6375fcfa22
--- /dev/null
+++ b/spec/frontend/invite_members/components/import_project_members_trigger_spec.js
@@ -0,0 +1,49 @@
+import { GlButton } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import ImportProjectMembersTrigger from '~/invite_members/components/import_project_members_trigger.vue';
+import eventHub from '~/invite_members/event_hub';
+
+const displayText = 'Import Project Members';
+
+const createComponent = (props = {}) => {
+ return mount(ImportProjectMembersTrigger, {
+ propsData: {
+ displayText,
+ ...props,
+ },
+ });
+};
+
+describe('ImportProjectMembersTrigger', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ describe('displayText', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('includes the correct displayText for the link', () => {
+ expect(findButton().text()).toBe(displayText);
+ });
+ });
+
+ describe('when button is clicked', () => {
+ beforeEach(() => {
+ eventHub.$emit = jest.fn();
+
+ wrapper = createComponent();
+
+ findButton().trigger('click');
+ });
+
+ it('emits event that triggers opening the modal', () => {
+ expect(eventHub.$emit).toHaveBeenLastCalledWith('openProjectMembersModal');
+ });
+ });
+});
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 13985ce7d74..045a454e63a 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -35,6 +35,7 @@ import {
user2,
user3,
user4,
+ user5,
GlEmoji,
} from '../mock_data/member_modal';
@@ -93,6 +94,11 @@ describe('InviteMembersModal', () => {
const findModal = () => wrapper.findComponent(GlModal);
const findBase = () => wrapper.findComponent(InviteModalBase);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
+ const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error');
+ const findMemberErrorMessage = (element) =>
+ `${Object.keys(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[element]}: ${
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[element]
+ }`;
const emitEventFromModal = (eventName) => () =>
findModal().vm.$emit(eventName, { preventDefault: jest.fn() });
const clickInviteButton = emitEventFromModal('primary');
@@ -123,6 +129,10 @@ describe('InviteMembersModal', () => {
findBase().vm.$emit('access-level', val);
await nextTick();
};
+ const removeMembersToken = async (val) => {
+ findMembersSelect().vm.$emit('token-remove', val);
+ await nextTick();
+ };
describe('rendering the tasks to be done', () => {
const setupComponent = async (props = {}, urlParameter = ['invite_members_for_task']) => {
@@ -431,17 +441,20 @@ describe('InviteMembersModal', () => {
});
it('clears the error when the list of members to invite is cleared', async () => {
- expect(membersFormGroupInvalidFeedback()).toBe(
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(
Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0],
);
- expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
findMembersSelect().vm.$emit('clear');
await nextTick();
+ expect(findMemberErrorAlert().exists()).toBe(false);
expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('validationState')).not.toBe(false);
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('clears the error when the cancel button is clicked', async () => {
@@ -450,7 +463,7 @@ describe('InviteMembersModal', () => {
await nextTick();
expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('validationState')).not.toBe(false);
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('clears the error when the modal is hidden', async () => {
@@ -458,33 +471,12 @@ describe('InviteMembersModal', () => {
await nextTick();
+ expect(findMemberErrorAlert().exists()).toBe(false);
expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('validationState')).not.toBe(false);
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
});
- it('clears the invalid state and message once the list of members to invite is cleared', async () => {
- mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(membersFormGroupInvalidFeedback()).toBe(
- Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0],
- );
- expect(findMembersSelect().props('validationState')).toBe(false);
- expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
-
- findMembersSelect().vm.$emit('clear');
-
- await waitForPromises();
-
- expect(membersFormGroupInvalidFeedback()).toBe('');
- expect(findMembersSelect().props('validationState')).toBe(null);
- expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
- });
-
it('displays the generic error for http server error', async () => {
mockInvitationsApi(
httpStatus.INTERNAL_SERVER_ERROR,
@@ -496,6 +488,7 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong');
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
});
it('displays the restricted user api message for response with bad request', async () => {
@@ -505,20 +498,31 @@ describe('InviteMembersModal', () => {
await waitForPromises();
- expect(membersFormGroupInvalidFeedback()).toBe(expectedEmailRestrictedError);
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError);
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
- it('displays the first part of the error when multiple existing users are restricted by email', async () => {
+ it('displays all errors when there are multiple existing users that are restricted by email', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
await waitForPromises();
- expect(membersFormGroupInvalidFeedback()).toBe(
- "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.",
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0],
+ );
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1],
);
- expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2],
+ );
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
});
});
@@ -573,10 +577,30 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
+ it('clears the error when the modal is hidden', async () => {
+ mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
+
+ clickInviteButton();
+
+ await waitForPromises();
+
+ expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
+
+ findModal().vm.$emit('hidden');
+
+ await nextTick();
+
+ expect(findMemberErrorAlert().exists()).toBe(false);
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
+ });
+
it('displays the restricted email error when restricted email is invited', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
@@ -584,20 +608,32 @@ describe('InviteMembersModal', () => {
await waitForPromises();
- expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
- expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(expectedEmailRestrictedError);
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
- it('displays the first error message when multiple emails return a restricted error message', async () => {
+ it('displays all errors when there are multiple emails that return a restricted error message', async () => {
mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
await waitForPromises();
- expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
- expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[0],
+ );
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[1],
+ );
+ expect(findMemberErrorAlert().text()).toContain(
+ Object.values(invitationsApiResponse.MULTIPLE_RESTRICTED.message)[2],
+ );
+ expect(membersFormGroupInvalidFeedback()).toBe('');
+ expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
it('displays the invalid syntax error for bad request', async () => {
@@ -608,7 +644,7 @@ describe('InviteMembersModal', () => {
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
});
});
@@ -617,14 +653,51 @@ describe('InviteMembersModal', () => {
createInviteMembersToGroupWrapper();
await triggerMembersTokenSelect([user3, user4]);
- mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID);
+ mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID);
clickInviteButton();
await waitForPromises();
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- expect(findMembersSelect().props('validationState')).toBe(false);
+ expect(findMembersSelect().props('exceptionState')).toBe(false);
+ });
+
+ it('displays errors for multiple and allows clearing', async () => {
+ createInviteMembersToGroupWrapper();
+
+ await triggerMembersTokenSelect([user3, user4, user5]);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
+
+ clickInviteButton();
+
+ await waitForPromises();
+
+ expect(findMemberErrorAlert().exists()).toBe(true);
+ expect(findMemberErrorAlert().props('title')).toContain(
+ "The following 3 members couldn't be invited",
+ );
+ expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(0));
+ expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(1));
+ expect(findMemberErrorAlert().text()).toContain(findMemberErrorMessage(2));
+
+ await removeMembersToken(user3);
+
+ expect(findMemberErrorAlert().props('title')).toContain(
+ "The following 2 members couldn't be invited",
+ );
+ expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(0));
+
+ await removeMembersToken(user4);
+
+ expect(findMemberErrorAlert().props('title')).toContain(
+ "The following member couldn't be invited",
+ );
+ expect(findMemberErrorAlert().text()).not.toContain(findMemberErrorMessage(1));
+
+ await removeMembersToken(user5);
+
+ expect(findMemberErrorAlert().exists()).toBe(false);
});
});
});
@@ -675,24 +748,6 @@ describe('InviteMembersModal', () => {
});
});
});
-
- describe('when any invite failed for any reason', () => {
- beforeEach(async () => {
- createInviteMembersToGroupWrapper();
-
- await triggerMembersTokenSelect([user1, user3]);
-
- mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
-
- clickInviteButton();
- });
-
- it('displays the first error message', async () => {
- await waitForPromises();
-
- expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
- });
- });
});
describe('tracking', () => {
diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js
index cc19e90a5fa..b55eeb72471 100644
--- a/spec/frontend/invite_members/components/invite_modal_base_spec.js
+++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js
@@ -254,7 +254,7 @@ describe('InviteModalBase', () => {
expect(wrapper.findComponent(GlModal).props('actionPrimary').attributes.loading).toBe(true);
});
- it('with invalidFeedbackMessage, set members form group validation state', () => {
+ it('with invalidFeedbackMessage, set members form group exception state', () => {
createComponent({
invalidFeedbackMessage: 'invalid message!',
});
diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js
index bf5564e4d63..6375d0f7e2e 100644
--- a/spec/frontend/invite_members/components/members_token_select_spec.js
+++ b/spec/frontend/invite_members/components/members_token_select_spec.js
@@ -16,6 +16,7 @@ const createComponent = (props) => {
return shallowMount(MembersTokenSelect, {
propsData: {
ariaLabelledby: label,
+ invalidMembers: {},
placeholder,
...props,
},
@@ -124,12 +125,14 @@ describe('MembersTokenSelect', () => {
findTokenSelector().vm.$emit('token-remove', [user1]);
expect(wrapper.emitted('clear')).toEqual([[]]);
+ expect(wrapper.emitted('token-remove')).toBeUndefined();
});
- it('does not emit `clear` event when there are still tokens selected', () => {
+ it('emits `token-remove` event with the token when there are still tokens selected', () => {
findTokenSelector().vm.$emit('input', [user1, user2]);
findTokenSelector().vm.$emit('token-remove', [user1]);
+ expect(wrapper.emitted('token-remove')).toEqual([[[user1]]]);
expect(wrapper.emitted('clear')).toBeUndefined();
});
});
diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js
index 474234cfacb..7d675b6206c 100644
--- a/spec/frontend/invite_members/mock_data/member_modal.js
+++ b/spec/frontend/invite_members/mock_data/member_modal.js
@@ -26,13 +26,17 @@ export const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '
export const user3 = {
id: 'user-defined-token',
name: 'email@example.com',
- username: 'one_2',
avatar_url: '',
};
export const user4 = {
- id: 'user-defined-token',
+ id: 'user-defined-token2',
name: 'email4@example.com',
- username: 'one_4',
+ avatar_url: '',
+};
+export const user5 = {
+ id: '3',
+ username: 'root',
+ name: 'root',
avatar_url: '',
};
diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js
new file mode 100644
index 00000000000..eb76c9845d4
--- /dev/null
+++ b/spec/frontend/invite_members/utils/member_utils_spec.js
@@ -0,0 +1,12 @@
+import { memberName } from '~/invite_members/utils/member_utils';
+
+describe('Member Name', () => {
+ it.each([
+ [{ username: '_username_', name: '_name_' }, '_username_'],
+ [{ username: '_username_' }, '_username_'],
+ [{ name: '_name_' }, '_name_'],
+ [{}, undefined],
+ ])(`returns name from supplied member token: %j`, (member, result) => {
+ expect(memberName(member)).toBe(result);
+ });
+});
diff --git a/spec/frontend/invite_members/utils/response_message_parser_spec.js b/spec/frontend/invite_members/utils/response_message_parser_spec.js
index 8b2064df374..92f38c54c99 100644
--- a/spec/frontend/invite_members/utils/response_message_parser_spec.js
+++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js
@@ -1,5 +1,5 @@
import {
- responseMessageFromSuccess,
+ responseFromSuccess,
responseMessageFromError,
} from '~/invite_members/utils/response_message_parser';
import { invitationsApiResponse } from '../mock_data/api_responses';
@@ -11,12 +11,12 @@ describe('Response message parser', () => {
const exampleKeyedMsg = { 'email@example.com': expectedMessage };
it.each([
- [{ data: { message: expectedMessage } }],
- [{ data: { error: expectedMessage } }],
- [{ data: { message: [expectedMessage] } }],
- [{ data: { message: exampleKeyedMsg } }],
- ])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => {
- expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage);
+ [{ data: { message: expectedMessage } }, { error: true, message: expectedMessage }],
+ [{ data: { error: expectedMessage } }, { error: true, message: expectedMessage }],
+ [{ data: { message: [expectedMessage] } }, { error: true, message: expectedMessage }],
+ [{ data: { message: exampleKeyedMsg } }, { error: true, message: { ...exampleKeyedMsg } }],
+ ])(`returns "${expectedMessage}" from success response: %j`, (successResponse, result) => {
+ expect(responseFromSuccess(successResponse)).toStrictEqual(result);
});
});
@@ -30,15 +30,18 @@ describe('Response message parser', () => {
});
});
- describe('displaying only the first error when a response has messages for multiple users', () => {
- const expected =
- "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.";
-
+ describe('displaying all errors when a response has messages for multiple users', () => {
it.each([
- [{ data: invitationsApiResponse.MULTIPLE_RESTRICTED }],
- [{ data: invitationsApiResponse.EMAIL_RESTRICTED }],
- ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => {
- expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected);
+ [
+ { data: invitationsApiResponse.MULTIPLE_RESTRICTED },
+ { error: true, message: { ...invitationsApiResponse.MULTIPLE_RESTRICTED.message } },
+ ],
+ [
+ { data: invitationsApiResponse.EMAIL_RESTRICTED },
+ { error: true, message: { ...invitationsApiResponse.EMAIL_RESTRICTED.message } },
+ ],
+ ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse, result) => {
+ expect(responseFromSuccess(restrictedResponse)).toStrictEqual(result);
});
});
});
diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js
index a1583076b41..d844f3394d5 100644
--- a/spec/frontend/issuable/issuable_form_spec.js
+++ b/spec/frontend/issuable/issuable_form_spec.js
@@ -47,6 +47,25 @@ describe('IssuableForm', () => {
});
});
+ describe('resetAutosave', () => {
+ it('resets autosave on elements with the .js-reset-autosave class', () => {
+ setHTMLFixture(`
+ <form>
+ <input name="[title]" />
+ <textarea name="[description]"></textarea>
+ <a class="js-reset-autosave">Cancel</a>
+ </form>
+ `);
+ const $form = $('form');
+ const resetAutosave = jest.spyOn(IssuableForm.prototype, 'resetAutosave');
+ createIssuable($form);
+
+ $form.find('.js-reset-autosave').trigger('click');
+
+ expect(resetAutosave).toHaveBeenCalled();
+ });
+ });
+
describe('removeWip', () => {
it.each`
prefix
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 3f2c3c3ec5f..3d3dbfa6853 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -29,6 +29,7 @@ import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_ro
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,
RELATIVE_POSITION,
@@ -98,6 +99,7 @@ describe('CE IssuesListApp component', () => {
};
let defaultQueryResponse = getIssuesQueryResponse;
+ let router;
if (IS_EE) {
defaultQueryResponse = cloneDeep(getIssuesQueryResponse);
defaultQueryResponse.data.project.issues.nodes[0].blockingCount = 1;
@@ -133,9 +135,11 @@ describe('CE IssuesListApp component', () => {
[setSortPreferenceMutation, sortPreferenceMutationResponse],
];
+ router = new VueRouter({ mode: 'history' });
+
return mountFn(IssuesListApp, {
apolloProvider: createMockApollo(requestHandlers),
- router: new VueRouter({ mode: 'history' }),
+ router,
provide: {
...defaultProvide,
...provide,
@@ -736,7 +740,7 @@ describe('CE IssuesListApp component', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
wrapper = mountComponent();
- jest.spyOn(wrapper.vm.$router, 'push');
+ router.push = jest.fn();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
@@ -746,16 +750,26 @@ describe('CE IssuesListApp component', () => {
});
it('updates url to the new tab', () => {
- expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
+ expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ state: IssuableStates.Closed }),
});
});
});
describe.each`
- event | params
- ${'next-page'} | ${{ page_after: 'endCursor', page_before: undefined, first_page_size: 20, last_page_size: undefined }}
- ${'previous-page'} | ${{ page_after: undefined, page_before: 'startCursor', first_page_size: undefined, last_page_size: 20 }}
+ event | params
+ ${'next-page'} | ${{
+ page_after: 'endCursor',
+ page_before: undefined,
+ first_page_size: 20,
+ last_page_size: undefined,
+}}
+ ${'previous-page'} | ${{
+ page_after: undefined,
+ page_before: 'startCursor',
+ first_page_size: undefined,
+ last_page_size: 20,
+}}
`('when "$event" event is emitted by IssuableList', ({ event, params }) => {
beforeEach(() => {
wrapper = mountComponent({
@@ -766,7 +780,7 @@ describe('CE IssuesListApp component', () => {
},
},
});
- jest.spyOn(wrapper.vm.$router, 'push');
+ router.push = jest.fn();
findIssuableList().vm.$emit(event);
});
@@ -776,7 +790,7 @@ describe('CE IssuesListApp component', () => {
});
it(`updates url`, () => {
- expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
+ expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining(params),
});
});
@@ -888,13 +902,13 @@ describe('CE IssuesListApp component', () => {
'updates to the new sort when payload is `%s`',
async (sortKey) => {
wrapper = mountComponent();
- jest.spyOn(wrapper.vm.$router, 'push');
+ router.push = jest.fn();
findIssuableList().vm.$emit('sort', sortKey);
jest.runOnlyPendingTimers();
await nextTick();
- expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
+ expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ sort: urlSortParams[sortKey] }),
});
},
@@ -907,13 +921,13 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
provide: { initialSort, isIssueRepositioningDisabled: true },
});
- jest.spyOn(wrapper.vm.$router, 'push');
+ router.push = jest.fn();
findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC);
});
it('does not update the sort to manual', () => {
- expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
+ expect(router.push).not.toHaveBeenCalled();
});
it('shows an alert to tell the user that manual reordering is disabled', () => {
@@ -978,12 +992,12 @@ describe('CE IssuesListApp component', () => {
describe('when "filter" event is emitted by IssuableList', () => {
it('updates IssuableList with url params', async () => {
wrapper = mountComponent();
- jest.spyOn(wrapper.vm.$router, 'push');
+ router.push = jest.fn();
findIssuableList().vm.$emit('filter', filteredTokens);
await nextTick();
- expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
+ expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining(urlParams),
});
});
@@ -993,13 +1007,13 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
});
- jest.spyOn(wrapper.vm.$router, 'push');
+ router.push = jest.fn();
findIssuableList().vm.$emit('filter', filteredTokens);
});
it('does not update url params', () => {
- expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
+ expect(router.push).not.toHaveBeenCalled();
});
it('shows an alert to tell the user they must be signed in to search', () => {
@@ -1030,4 +1044,19 @@ describe('CE IssuesListApp component', () => {
expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers }));
});
});
+
+ describe('when "page-size-change" event is emitted by IssuableList', () => {
+ it('updates url params with new page size', async () => {
+ wrapper = mountComponent();
+ router.push = jest.fn();
+
+ findIssuableList().vm.$emit('page-size-change', 50);
+ await nextTick();
+
+ expect(router.push).toHaveBeenCalledTimes(1);
+ expect(router.push).toHaveBeenCalledWith({
+ query: expect.objectContaining({ first_page_size: 50 }),
+ });
+ });
+ });
});
diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index 42f2d08082e..4347c580a4d 100644
--- a/spec/frontend/issues/list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
@@ -32,6 +32,7 @@ export const getIssuesQueryResponse = {
state: 'opened',
title: 'Issue title',
updatedAt: '2021-05-22T04:08:01Z',
+ closedAt: null,
upvotes: 3,
userDiscussionsCount: 4,
webPath: 'project/-/issues/789',
diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js
index e8ffba9bc80..3c6332d5728 100644
--- a/spec/frontend/issues/list/utils_spec.js
+++ b/spec/frontend/issues/list/utils_spec.js
@@ -10,12 +10,7 @@ import {
urlParams,
urlParamsWithSpecialValues,
} from 'jest/issues/list/mock_data';
-import {
- PAGE_SIZE,
- PAGE_SIZE_MANUAL,
- RELATIVE_POSITION_ASC,
- urlSortParams,
-} from '~/issues/list/constants';
+import { PAGE_SIZE, urlSortParams } from '~/issues/list/constants';
import {
convertToApiParams,
convertToSearchQuery,
@@ -29,52 +24,30 @@ import {
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
describe('getInitialPageParams', () => {
- it.each(Object.keys(urlSortParams))(
- 'returns the correct page params for sort key %s',
- (sortKey) => {
- const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
+ it('returns page params with a default page size when no arguments are given', () => {
+ expect(getInitialPageParams()).toEqual({ firstPageSize: PAGE_SIZE });
+ });
- expect(getInitialPageParams(sortKey)).toEqual({ firstPageSize });
- },
- );
+ it('returns page params with the given page size', () => {
+ const pageSize = 100;
+ expect(getInitialPageParams(pageSize)).toEqual({ firstPageSize: pageSize });
+ });
- it.each(Object.keys(urlSortParams))(
- 'returns the correct page params for sort key %s with afterCursor',
- (sortKey) => {
- const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
- const lastPageSize = undefined;
- const afterCursor = 'randomCursorString';
- const beforeCursor = undefined;
- const pageParams = getInitialPageParams(
- sortKey,
- firstPageSize,
- lastPageSize,
- afterCursor,
- beforeCursor,
- );
-
- expect(pageParams).toEqual({ firstPageSize, afterCursor });
- },
- );
+ it('does not return firstPageSize when lastPageSize is provided', () => {
+ const firstPageSize = 100;
+ const lastPageSize = 50;
+ const afterCursor = undefined;
+ const beforeCursor = 'randomCursorString';
+ const pageParams = getInitialPageParams(
+ 100,
+ firstPageSize,
+ lastPageSize,
+ afterCursor,
+ beforeCursor,
+ );
- it.each(Object.keys(urlSortParams))(
- 'returns the correct page params for sort key %s with beforeCursor',
- (sortKey) => {
- const firstPageSize = undefined;
- const lastPageSize = PAGE_SIZE;
- const afterCursor = undefined;
- const beforeCursor = 'anotherRandomCursorString';
- const pageParams = getInitialPageParams(
- sortKey,
- firstPageSize,
- lastPageSize,
- afterCursor,
- beforeCursor,
- );
-
- expect(pageParams).toEqual({ lastPageSize, beforeCursor });
- },
- );
+ expect(pageParams).toEqual({ lastPageSize, beforeCursor });
+ });
});
describe('getSortKey', () => {
@@ -97,10 +70,10 @@ describe('isSortKey', () => {
describe('getSortOptions', () => {
describe.each`
hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking
- ${false} | ${false} | ${9} | ${false} | ${false}
- ${true} | ${false} | ${10} | ${true} | ${false}
- ${false} | ${true} | ${10} | ${false} | ${true}
- ${true} | ${true} | ${11} | ${true} | ${true}
+ ${false} | ${false} | ${10} | ${false} | ${false}
+ ${true} | ${false} | ${11} | ${true} | ${false}
+ ${false} | ${true} | ${11} | ${false} | ${true}
+ ${true} | ${true} | ${12} | ${true} | ${true}
`(
'when hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature',
({
diff --git a/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap
index 881dcda126f..1a199ed2ee9 100644
--- a/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap
+++ b/spec/frontend/issues/new/components/__snapshots__/type_popover_spec.js.snap
@@ -2,10 +2,11 @@
exports[`Issue type info popover renders 1`] = `
<span
+ class="gl-ml-2"
id="popovercontainer"
>
<gl-icon-stub
- class="gl-ml-5 gl-text-gray-500"
+ class="gl-text-blue-600"
id="issue-type-info"
name="question-o"
size="16"
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index 2cc27309e59..8ee57f97754 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -15,10 +15,15 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Description from '~/issues/show/components/description.vue';
import { updateHistory } from '~/lib/utils/url_utility';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import workItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
+import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
import TaskList from '~/task_list';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
-import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
+import {
+ projectWorkItemTypesQueryResponse,
+ createWorkItemFromTaskMutationResponse,
+} from 'jest/work_items/mock_data';
import {
descriptionProps as initialProps,
descriptionHtmlWithCheckboxes,
@@ -46,6 +51,10 @@ const workItemQueryResponse = {
};
const queryHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
+const workItemTypesQueryHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
+const createWorkItemFromTaskSuccessHandler = jest
+ .fn()
+ .mockResolvedValue(createWorkItemFromTaskMutationResponse);
describe('Description component', () => {
let wrapper;
@@ -60,18 +69,24 @@ describe('Description component', () => {
const findTooltips = () => wrapper.findAllComponents(GlTooltip);
const findModal = () => wrapper.findComponent(GlModal);
- const findCreateWorkItem = () => wrapper.findComponent(CreateWorkItem);
const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
- function createComponent({ props = {}, provide = {} } = {}) {
+ function createComponent({ props = {}, provide } = {}) {
wrapper = shallowMountExtended(Description, {
propsData: {
issueId: 1,
...initialProps,
...props,
},
- provide,
- apolloProvider: createMockApollo([[workItemQuery, queryHandler]]),
+ provide: {
+ fullPath: 'gitlab-org/gitlab-test',
+ ...provide,
+ },
+ apolloProvider: createMockApollo([
+ [workItemQuery, queryHandler],
+ [workItemTypesQuery, workItemTypesQueryHandler],
+ [createWorkItemFromTaskMutation, createWorkItemFromTaskSuccessHandler],
+ ]),
mocks: {
$toast,
},
@@ -299,24 +314,16 @@ describe('Description component', () => {
});
it('does not show a modal by default', () => {
- expect(findModal().props('visible')).toBe(false);
+ expect(findModal().exists()).toBe(false);
});
- it('opens a modal when a button is clicked and displays correct title', async () => {
- await findConvertToTaskButton().trigger('click');
- expect(findCreateWorkItem().props('initialTitle').trim()).toBe('todo 1');
- });
+ it('emits `updateDescription` after creating new work item', async () => {
+ const newDescription = `<p>New description</p>`;
- it('closes the modal on `closeCreateTaskModal` event', async () => {
await findConvertToTaskButton().trigger('click');
- findCreateWorkItem().vm.$emit('closeModal');
- expect(hideModal).toHaveBeenCalled();
- });
- it('emits `updateDescription` on `onCreate` event', () => {
- const newDescription = `<p>New description</p>`;
- findCreateWorkItem().vm.$emit('onCreate', newDescription);
- expect(hideModal).toHaveBeenCalled();
+ await waitForPromises();
+
expect(wrapper.emitted('updateDescription')).toEqual([[newDescription]]);
});
@@ -325,7 +332,7 @@ describe('Description component', () => {
findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc);
expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]);
- expect($toast.show).toHaveBeenCalledWith('Work item deleted');
+ expect($toast.show).toHaveBeenCalledWith('Task deleted');
});
});
diff --git a/spec/frontend/issues/show/components/edited_spec.js b/spec/frontend/issues/show/components/edited_spec.js
index 8a8fe23230a..8a240c38b5f 100644
--- a/spec/frontend/issues/show/components/edited_spec.js
+++ b/spec/frontend/issues/show/components/edited_spec.js
@@ -1,49 +1,50 @@
-import Vue from 'vue';
-import edited from '~/issues/show/components/edited.vue';
-
-function formatText(text) {
- return text.trim().replace(/\s\s+/g, ' ');
-}
-
-describe('edited', () => {
- const EditedComponent = Vue.extend(edited);
-
- it('should render an edited at+by string', () => {
- const editedComponent = new EditedComponent({
- propsData: {
- updatedAt: '2017-05-15T12:31:04.428Z',
- updatedByName: 'Some User',
- updatedByPath: '/some_user',
- },
- }).$mount();
-
- expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
- expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/);
- expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+import { shallowMount } from '@vue/test-utils';
+import Edited from '~/issues/show/components/edited.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+describe('Edited component', () => {
+ let wrapper;
+
+ const findAuthorLink = () => wrapper.find('a');
+ const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
+ const formatText = (text) => text.trim().replace(/\s\s+/g, ' ');
+
+ const mountComponent = (propsData) => shallowMount(Edited, { propsData });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders an edited at+by string', () => {
+ wrapper = mountComponent({
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ });
+
+ expect(formatText(wrapper.text())).toBe('Edited by Some User');
+ expect(findAuthorLink().attributes('href')).toBe('/some_user');
+ expect(findTimeAgoTooltip().exists()).toBe(true);
});
it('if no updatedAt is provided, no time element will be rendered', () => {
- const editedComponent = new EditedComponent({
- propsData: {
- updatedByName: 'Some User',
- updatedByPath: '/some_user',
- },
- }).$mount();
-
- expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/);
- expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/);
- expect(editedComponent.$el.querySelector('time')).toBeFalsy();
+ wrapper = mountComponent({
+ updatedByName: 'Some User',
+ updatedByPath: '/some_user',
+ });
+
+ expect(formatText(wrapper.text())).toBe('Edited by Some User');
+ expect(findAuthorLink().attributes('href')).toBe('/some_user');
+ expect(findTimeAgoTooltip().exists()).toBe(false);
});
it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
- const editedComponent = new EditedComponent({
- propsData: {
- updatedAt: '2017-05-15T12:31:04.428Z',
- },
- }).$mount();
-
- expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/);
- expect(editedComponent.$el.querySelector('.author-link')).toBeFalsy();
- expect(editedComponent.$el.querySelector('time')).toBeTruthy();
+ wrapper = mountComponent({
+ updatedAt: '2017-05-15T12:31:04.428Z',
+ });
+
+ expect(formatText(wrapper.text())).toBe('Edited');
+ expect(findAuthorLink().exists()).toBe(false);
+ expect(findTimeAgoTooltip().exists()).toBe(true);
});
});
diff --git a/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js
index a4910d63bb5..155ae703e48 100644
--- a/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js
+++ b/spec/frontend/issues/show/components/incidents/highlight_bar_spec.js
@@ -74,7 +74,7 @@ describe('Highlight Bar', () => {
});
it('renders a number of alert events', () => {
- expect(wrapper.text()).toContain(alert.eventCount);
+ expect(wrapper.text()).toContain(alert.eventCount.toString());
});
});
diff --git a/spec/frontend/issues/show/components/incidents/mock_data.js b/spec/frontend/issues/show/components/incidents/mock_data.js
index b5346a6089a..afc6099caf4 100644
--- a/spec/frontend/issues/show/components/incidents/mock_data.js
+++ b/spec/frontend/issues/show/components/incidents/mock_data.js
@@ -70,3 +70,36 @@ export const timelineEventsQueryEmptyResponse = {
},
},
};
+
+export const timelineEventsCreateEventResponse = {
+ timelineEvent: {
+ ...mockEvents[0],
+ },
+ errors: [],
+};
+
+export const timelineEventsCreateEventError = {
+ data: {
+ timelineEventCreate: {
+ timelineEvent: {
+ ...mockEvents[0],
+ },
+ errors: ['Create error'],
+ },
+ },
+};
+
+const timelineEventDeleteData = (errors = []) => {
+ return {
+ data: {
+ timelineEventDestroy: {
+ timelineEvent: { ...mockEvents[0] },
+ errors,
+ },
+ },
+ };
+};
+
+export const timelineEventsDeleteEventResponse = timelineEventDeleteData();
+
+export const timelineEventsDeleteEventError = timelineEventDeleteData(['Item does not exist']);
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js
new file mode 100644
index 00000000000..620cdfc53b0
--- /dev/null
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js
@@ -0,0 +1,181 @@
+import VueApollo from 'vue-apollo';
+import Vue, { nextTick } from 'vue';
+import { GlDatepicker } from '@gitlab/ui';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import IncidentTimelineEventForm from '~/issues/show/components/incidents/timeline_events_form.vue';
+import createTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createAlert } from '~/flash';
+import { useFakeDate } from 'helpers/fake_date';
+import { timelineEventsCreateEventResponse, timelineEventsCreateEventError } from './mock_data';
+
+Vue.use(VueApollo);
+
+jest.mock('~/flash');
+
+const addEventResponse = jest.fn().mockResolvedValue(timelineEventsCreateEventResponse);
+
+function createMockApolloProvider(response = addEventResponse) {
+ const requestHandlers = [[createTimelineEventMutation, response]];
+ return createMockApollo(requestHandlers);
+}
+
+describe('Timeline events form', () => {
+ // July 8 2020
+ useFakeDate(2020, 6, 8);
+ let wrapper;
+
+ const mountComponent = ({ mockApollo, mountMethod = shallowMountExtended, stubs }) => {
+ wrapper = mountMethod(IncidentTimelineEventForm, {
+ propsData: {
+ hasTimelineEvents: true,
+ },
+ provide: {
+ fullPath: 'group/project',
+ issuableId: '1',
+ },
+ apolloProvider: mockApollo,
+ stubs,
+ });
+ };
+
+ afterEach(() => {
+ addEventResponse.mockReset();
+ createAlert.mockReset();
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ const findSubmitButton = () => wrapper.findByText('Save');
+ const findSubmitAndAddButton = () => wrapper.findByText('Save and add another event');
+ const findCancelButton = () => wrapper.findByText('Cancel');
+ const findDatePicker = () => wrapper.findComponent(GlDatepicker);
+ const findDatePickerInput = () => wrapper.findByTestId('input-datepicker');
+ const findHourInput = () => wrapper.findByTestId('input-hours');
+ const findMinuteInput = () => wrapper.findByTestId('input-minutes');
+ const setDatetime = () => {
+ findDatePicker().vm.$emit('input', new Date('2021-08-12'));
+ findHourInput().vm.$emit('input', 5);
+ findMinuteInput().vm.$emit('input', 45);
+ };
+
+ const submitForm = async () => {
+ findSubmitButton().trigger('click');
+ await waitForPromises();
+ };
+ const submitFormAndAddAnother = async () => {
+ findSubmitAndAddButton().trigger('click');
+ await waitForPromises();
+ };
+ const cancelForm = async () => {
+ findCancelButton().trigger('click');
+ await waitForPromises();
+ };
+
+ describe('form button behaviour', () => {
+ const closeFormEvent = { 'hide-incident-timeline-event-form': [[]] };
+ beforeEach(() => {
+ mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended });
+ });
+
+ it('should close the form on submit', async () => {
+ await submitForm();
+ expect(wrapper.emitted()).toEqual(closeFormEvent);
+ });
+
+ it('should not close the form on "submit and add another"', async () => {
+ await submitFormAndAddAnother();
+ expect(wrapper.emitted()).toEqual({});
+ });
+
+ it('should close the form on cancel', async () => {
+ await cancelForm();
+ expect(wrapper.emitted()).toEqual(closeFormEvent);
+ });
+
+ it('should clear the form', async () => {
+ setDatetime();
+ await nextTick();
+
+ expect(findDatePickerInput().element.value).toBe('2021-08-12');
+ expect(findHourInput().element.value).toBe('5');
+ expect(findMinuteInput().element.value).toBe('45');
+
+ wrapper.vm.clear();
+ await nextTick();
+
+ expect(findDatePickerInput().element.value).toBe('2020-07-08');
+ expect(findHourInput().element.value).toBe('0');
+ expect(findMinuteInput().element.value).toBe('0');
+ });
+ });
+
+ describe('addTimelineEventQuery', () => {
+ const expectedData = {
+ input: {
+ incidentId: 'gid://gitlab/Issue/1',
+ note: '',
+ occurredAt: '2020-07-08T00:00:00.000Z',
+ },
+ };
+
+ let mockApollo;
+
+ beforeEach(() => {
+ mockApollo = createMockApolloProvider();
+ mountComponent({ mockApollo, mountMethod: mountExtended });
+ });
+
+ it('should call the mutation with the right variables', async () => {
+ await submitForm();
+
+ expect(addEventResponse).toHaveBeenCalledWith(expectedData);
+ });
+
+ it('should call the mutation with user selected variables', async () => {
+ const expectedUserSelectedData = {
+ input: {
+ ...expectedData.input,
+ occurredAt: '2021-08-12T05:45:00.000Z',
+ },
+ };
+
+ setDatetime();
+
+ await nextTick();
+ await submitForm();
+
+ expect(addEventResponse).toHaveBeenCalledWith(expectedUserSelectedData);
+ });
+ });
+
+ describe('error handling', () => {
+ it('should show an error when submission returns an error', async () => {
+ const expectedAlertArgs = {
+ message: 'Error creating incident timeline event: Create error',
+ };
+ addEventResponse.mockResolvedValueOnce(timelineEventsCreateEventError);
+ mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended });
+
+ await submitForm();
+
+ expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
+ });
+
+ it('should show an error when submission fails', async () => {
+ const expectedAlertArgs = {
+ captureError: true,
+ error: new Error(),
+ message: 'Something went wrong while creating the incident timeline event.',
+ };
+ addEventResponse.mockRejectedValueOnce();
+ mountComponent({ mockApollo: createMockApolloProvider(), mountMethod: mountExtended });
+
+ await submitForm();
+
+ expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
+ });
+ });
+});
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js
index 7e51219ffa7..e686f2eb4ec 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_item_spec.js
@@ -1,6 +1,6 @@
import timezoneMock from 'timezone-mock';
-import merge from 'lodash/merge';
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlDropdown } from '@gitlab/ui';
+import { nextTick } from 'vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import IncidentTimelineEventListItem from '~/issues/show/components/incidents/timeline_events_list_item.vue';
import { mockEvents } from './mock_data';
@@ -8,25 +8,28 @@ import { mockEvents } from './mock_data';
describe('IncidentTimelineEventList', () => {
let wrapper;
- const mountComponent = (propsData) => {
+ const mountComponent = ({ propsData, provide } = {}) => {
const { action, noteHtml, occurredAt } = mockEvents[0];
- wrapper = mountExtended(
- IncidentTimelineEventListItem,
- merge({
- propsData: {
- action,
- noteHtml,
- occurredAt,
- isLastItem: false,
- ...propsData,
- },
- }),
- );
+ wrapper = mountExtended(IncidentTimelineEventListItem, {
+ propsData: {
+ action,
+ noteHtml,
+ occurredAt,
+ isLastItem: false,
+ ...propsData,
+ },
+ provide: {
+ canUpdate: false,
+ ...provide,
+ },
+ });
};
const findCommentIcon = () => wrapper.findComponent(GlIcon);
const findTextContainer = () => wrapper.findByTestId('event-text-container');
const findEventTime = () => wrapper.findByTestId('event-time');
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDeleteButton = () => wrapper.findByText('Delete');
describe('template', () => {
it('shows comment icon', () => {
@@ -55,7 +58,7 @@ describe('IncidentTimelineEventList', () => {
});
it('does not show a bottom border when the last item', () => {
- mountComponent({ isLastItem: true });
+ mountComponent({ propsData: { isLastItem: true } });
expect(wrapper.classes()).not.toContain('gl-border-1');
});
@@ -83,5 +86,31 @@ describe('IncidentTimelineEventList', () => {
});
});
});
+
+ describe('action dropdown', () => {
+ it('does not show the action dropdown by default', () => {
+ mountComponent();
+
+ expect(findDropdown().exists()).toBe(false);
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+
+ it('shows dropdown and delete item when user has update permission', () => {
+ mountComponent({ provide: { canUpdate: true } });
+
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDeleteButton().exists()).toBe(true);
+ });
+
+ it('triggers a delete when the delete button is clicked', async () => {
+ mountComponent({ provide: { canUpdate: true } });
+
+ findDeleteButton().trigger('click');
+
+ await nextTick();
+
+ expect(wrapper.emitted().delete).toBeTruthy();
+ });
+ });
});
});
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
index 6610ea0b832..ae07237cf7d 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js
@@ -1,41 +1,81 @@
import timezoneMock from 'timezone-mock';
-import merge from 'lodash/merge';
-import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+import VueApollo from 'vue-apollo';
+import Vue from 'vue';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import IncidentTimelineEventList from '~/issues/show/components/incidents/timeline_events_list.vue';
-import { mockEvents } from './mock_data';
+import IncidentTimelineEventListItem from '~/issues/show/components/incidents/timeline_events_list_item.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import deleteTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/delete_timeline_event.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createAlert } from '~/flash';
+import {
+ mockEvents,
+ timelineEventsDeleteEventResponse,
+ timelineEventsDeleteEventError,
+} from './mock_data';
+
+Vue.use(VueApollo);
+
+jest.mock('~/flash');
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+
+const deleteEventResponse = jest.fn();
+
+function createMockApolloProvider() {
+ deleteEventResponse.mockResolvedValue(timelineEventsDeleteEventResponse);
+ const requestHandlers = [[deleteTimelineEventMutation, deleteEventResponse]];
+ return createMockApollo(requestHandlers);
+}
+
+const mockConfirmAction = ({ confirmed }) => {
+ confirmAction.mockResolvedValueOnce(confirmed);
+};
describe('IncidentTimelineEventList', () => {
let wrapper;
- const mountComponent = () => {
- wrapper = shallowMountExtended(
- IncidentTimelineEventList,
- merge({
- provide: {
- fullPath: 'group/project',
- issuableId: '1',
- },
- propsData: {
- timelineEvents: mockEvents,
- },
- }),
- );
+ const mountComponent = (mockApollo) => {
+ const apollo = mockApollo ? { apolloProvider: mockApollo } : {};
+
+ wrapper = shallowMountExtended(IncidentTimelineEventList, {
+ provide: {
+ fullPath: 'group/project',
+ issuableId: '1',
+ },
+ propsData: {
+ timelineEvents: mockEvents,
+ },
+ ...apollo,
+ });
};
- const findGroups = () => wrapper.findAllByTestId('timeline-group');
- const findItems = (base = wrapper) => base.findAllByTestId('timeline-event');
- const findFirstGroup = () => extendedWrapper(findGroups().at(0));
- const findSecondGroup = () => extendedWrapper(findGroups().at(1));
+ const findTimelineEventGroups = () => wrapper.findAllByTestId('timeline-group');
+ const findItems = (base = wrapper) => base.findAll(IncidentTimelineEventListItem);
+ const findFirstTimelineEventGroup = () => findTimelineEventGroups().at(0);
+ const findSecondTimelineEventGroup = () => findTimelineEventGroups().at(1);
const findDates = () => wrapper.findAllByTestId('event-date');
+ const clickFirstDeleteButton = async () => {
+ findItems()
+ .at(0)
+ .vm.$emit('delete', { ...mockEvents[0] });
+ await waitForPromises();
+ };
+
+ afterEach(() => {
+ confirmAction.mockReset();
+ deleteEventResponse.mockReset();
+ wrapper.destroy();
+ });
describe('template', () => {
it('groups items correctly', () => {
mountComponent();
- expect(findGroups()).toHaveLength(2);
+ expect(findTimelineEventGroups()).toHaveLength(2);
- expect(findItems(findFirstGroup())).toHaveLength(1);
- expect(findItems(findSecondGroup())).toHaveLength(2);
+ expect(findItems(findFirstTimelineEventGroup())).toHaveLength(1);
+ expect(findItems(findSecondTimelineEventGroup())).toHaveLength(2);
});
it('sets the isLastItem prop correctly', () => {
@@ -83,5 +123,48 @@ describe('IncidentTimelineEventList', () => {
});
});
});
+
+ describe('delete functionality', () => {
+ beforeEach(() => {
+ mockConfirmAction({ confirmed: true });
+ });
+
+ it('should delete when button is clicked', async () => {
+ const expectedVars = { input: { id: mockEvents[0].id } };
+
+ mountComponent(createMockApolloProvider());
+
+ await clickFirstDeleteButton();
+
+ expect(deleteEventResponse).toHaveBeenCalledWith(expectedVars);
+ });
+
+ it('should show an error when delete returns an error', async () => {
+ const expectedError = {
+ message: 'Error deleting incident timeline event: Item does not exist',
+ };
+
+ mountComponent(createMockApolloProvider());
+ deleteEventResponse.mockResolvedValue(timelineEventsDeleteEventError);
+
+ await clickFirstDeleteButton();
+
+ expect(createAlert).toHaveBeenCalledWith(expectedError);
+ });
+
+ it('should show an error when delete fails', async () => {
+ const expectedAlertArgs = {
+ captureError: true,
+ error: new Error(),
+ message: 'Something went wrong while deleting the incident timeline event.',
+ };
+ mountComponent(createMockApolloProvider());
+ deleteEventResponse.mockRejectedValueOnce();
+
+ await clickFirstDeleteButton();
+
+ expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs);
+ });
+ });
});
});
diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js
index cf81f4cdf66..2d87851a761 100644
--- a/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js
+++ b/spec/frontend/issues/show/components/incidents/timeline_events_tab_spec.js
@@ -1,13 +1,15 @@
-import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import Vue from 'vue';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import TimelineEventsTab from '~/issues/show/components/incidents/timeline_events_tab.vue';
import IncidentTimelineEventsList from '~/issues/show/components/incidents/timeline_events_list.vue';
+import IncidentTimelineEventForm from '~/issues/show/components/incidents/timeline_events_form.vue';
import timelineEventsQuery from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createAlert } from '~/flash';
+import { timelineTabI18n } from '~/issues/show/components/incidents/constants';
import { timelineEventsQueryListResponse, timelineEventsQueryEmptyResponse } from './mock_data';
Vue.use(VueApollo);
@@ -28,14 +30,17 @@ describe('TimelineEventsTab', () => {
let wrapper;
const mountComponent = (options = {}) => {
- const { mockApollo, mountMethod = shallowMountExtended } = options;
+ const { mockApollo, mountMethod = shallowMountExtended, stubs, provide } = options;
wrapper = mountMethod(TimelineEventsTab, {
provide: {
fullPath: 'group/project',
issuableId: '1',
+ canUpdate: true,
+ ...provide,
},
apolloProvider: mockApollo,
+ stubs,
});
};
@@ -48,6 +53,8 @@ describe('TimelineEventsTab', () => {
const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findTimelineEventsList = () => wrapper.findComponent(IncidentTimelineEventsList);
+ const findTimelineEventForm = () => wrapper.findComponent(IncidentTimelineEventForm);
+ const findAddEventButton = () => wrapper.findByText(timelineTabI18n.addEventButton);
describe('Timeline events tab', () => {
describe('empty state', () => {
@@ -82,24 +89,85 @@ describe('TimelineEventsTab', () => {
describe('timelineEventsQuery', () => {
let mockApollo;
- beforeEach(() => {
+ const setup = () => {
mockApollo = createMockApolloProvider();
mountComponent({ mockApollo });
- });
+ };
it('should request data', () => {
+ setup();
+
expect(listResponse).toHaveBeenCalled();
});
it('should show the loading state', () => {
+ setup();
+
expect(findEmptyState().exists()).toBe(false);
expect(findLoadingSpinner().exists()).toBe(true);
});
it('should render the list', async () => {
+ setup();
await waitForPromises();
+
expect(findEmptyState().exists()).toBe(false);
expect(findTimelineEventsList().props('timelineEvents')).toHaveLength(3);
});
});
+
+ describe('add new event form', () => {
+ beforeEach(async () => {
+ mountComponent({
+ mockApollo: createMockApolloProvider(emptyResponse),
+ mountMethod: mountExtended,
+ stubs: {
+ 'incident-timeline-events-list': true,
+ 'gl-tab': true,
+ },
+ });
+ await waitForPromises();
+ });
+
+ it('should show a button when user can update', () => {
+ expect(findAddEventButton().exists()).toBe(true);
+ });
+
+ it('should not show a button when user cannot update', () => {
+ mountComponent({
+ mockApollo: createMockApolloProvider(emptyResponse),
+ provide: { canUpdate: false },
+ });
+
+ expect(findAddEventButton().exists()).toBe(false);
+ });
+
+ it('should not show a form by default', () => {
+ expect(findTimelineEventForm().isVisible()).toBe(false);
+ });
+
+ it('should show a form when button is clicked', async () => {
+ await findAddEventButton().trigger('click');
+
+ expect(findTimelineEventForm().isVisible()).toBe(true);
+ });
+
+ it('should clear the form when button is clicked', async () => {
+ const mockClear = jest.fn();
+ wrapper.vm.$refs.eventForm.clear = mockClear;
+
+ await findAddEventButton().trigger('click');
+
+ expect(mockClear).toHaveBeenCalled();
+ });
+
+ it('should hide the form when the hide event is emitted', async () => {
+ // open the form
+ await findAddEventButton().trigger('click');
+
+ await findTimelineEventForm().vm.$emit('hide-incident-timeline-event-form');
+
+ expect(findTimelineEventForm().isVisible()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/issues/show/components/incidents/utils_spec.js b/spec/frontend/issues/show/components/incidents/utils_spec.js
index e6f7082d280..0da0114c654 100644
--- a/spec/frontend/issues/show/components/incidents/utils_spec.js
+++ b/spec/frontend/issues/show/components/incidents/utils_spec.js
@@ -1,4 +1,9 @@
-import { displayAndLogError, getEventIcon } from '~/issues/show/components/incidents/utils';
+import timezoneMock from 'timezone-mock';
+import {
+ displayAndLogError,
+ getEventIcon,
+ getUtcShiftedDateNow,
+} from '~/issues/show/components/incidents/utils';
import { createAlert } from '~/flash';
jest.mock('~/flash');
@@ -19,13 +24,31 @@ describe('incident utils', () => {
describe('get event icon', () => {
it('should display a matching event icon name', () => {
- const name = 'comment';
-
- expect(getEventIcon(name)).toBe(name);
+ ['comment', 'issues', 'status'].forEach((name) => {
+ expect(getEventIcon(name)).toBe(name);
+ });
});
it('should return a default icon name', () => {
expect(getEventIcon('non-existent-icon-name')).toBe('comment');
});
});
+
+ describe('getUtcShiftedDateNow', () => {
+ beforeEach(() => {
+ timezoneMock.register('US/Pacific');
+ });
+
+ afterEach(() => {
+ timezoneMock.unregister();
+ });
+
+ it('should shift the date by the timezone offset', () => {
+ const date = new Date();
+
+ const shiftedDate = getUtcShiftedDateNow();
+
+ expect(shiftedDate > date).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/jobs/bridge/app_spec.js b/spec/frontend/jobs/bridge/app_spec.js
deleted file mode 100644
index 210dcfa364b..00000000000
--- a/spec/frontend/jobs/bridge/app_spec.js
+++ /dev/null
@@ -1,146 +0,0 @@
-import Vue, { nextTick } from 'vue';
-import { shallowMount } from '@vue/test-utils';
-
-import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { GlLoadingIcon } from '@gitlab/ui';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import getPipelineQuery from '~/jobs/bridge/graphql/queries/pipeline.query.graphql';
-import waitForPromises from 'helpers/wait_for_promises';
-import BridgeApp from '~/jobs/bridge/app.vue';
-import BridgeEmptyState from '~/jobs/bridge/components/empty_state.vue';
-import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue';
-import CiHeader from '~/vue_shared/components/header_ci_component.vue';
-import {
- MOCK_BUILD_ID,
- MOCK_PIPELINE_IID,
- MOCK_PROJECT_FULL_PATH,
- mockPipelineQueryResponse,
-} from './mock_data';
-
-describe('Bridge Show Page', () => {
- let wrapper;
- let mockApollo;
- let mockPipelineQuery;
-
- const createComponent = (options) => {
- wrapper = shallowMount(BridgeApp, {
- provide: {
- buildId: MOCK_BUILD_ID,
- projectFullPath: MOCK_PROJECT_FULL_PATH,
- pipelineIid: MOCK_PIPELINE_IID,
- },
- mocks: {
- $apollo: {
- queries: {
- pipeline: {
- loading: true,
- },
- },
- },
- },
- ...options,
- });
- };
-
- const createComponentWithApollo = () => {
- const handlers = [[getPipelineQuery, mockPipelineQuery]];
- Vue.use(VueApollo);
- mockApollo = createMockApollo(handlers);
-
- createComponent({
- apolloProvider: mockApollo,
- mocks: {},
- });
- };
-
- const findCiHeader = () => wrapper.findComponent(CiHeader);
- const findEmptyState = () => wrapper.findComponent(BridgeEmptyState);
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findSidebar = () => wrapper.findComponent(BridgeSidebar);
-
- beforeEach(() => {
- mockPipelineQuery = jest.fn();
- });
-
- afterEach(() => {
- mockPipelineQuery.mockReset();
- wrapper.destroy();
- });
-
- describe('while pipeline query is loading', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders loading icon', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- });
- });
-
- describe('after pipeline query is loaded', () => {
- beforeEach(async () => {
- mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse);
- createComponentWithApollo();
- await waitForPromises();
- });
-
- it('query is called with correct variables', async () => {
- expect(mockPipelineQuery).toHaveBeenCalledTimes(1);
- expect(mockPipelineQuery).toHaveBeenCalledWith({
- fullPath: MOCK_PROJECT_FULL_PATH,
- iid: MOCK_PIPELINE_IID,
- });
- });
-
- it('renders CI header state', () => {
- expect(findCiHeader().exists()).toBe(true);
- });
-
- it('renders empty state', () => {
- expect(findEmptyState().exists()).toBe(true);
- });
-
- it('renders sidebar', () => {
- expect(findSidebar().exists()).toBe(true);
- });
- });
-
- describe('sidebar expansion', () => {
- beforeEach(async () => {
- mockPipelineQuery.mockResolvedValue(mockPipelineQueryResponse);
- createComponentWithApollo();
- await waitForPromises();
- });
-
- 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();
-
- expect(findSidebar().exists()).toBe(isSidebarExpanded);
- },
- );
- });
-
- it('toggles expansion on button click', async () => {
- expect(findSidebar().exists()).toBe(true);
-
- wrapper.vm.toggleSidebar();
- await nextTick();
-
- expect(findSidebar().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/jobs/bridge/components/empty_state_spec.js b/spec/frontend/jobs/bridge/components/empty_state_spec.js
deleted file mode 100644
index 38c55b296f0..00000000000
--- a/spec/frontend/jobs/bridge/components/empty_state_spec.js
+++ /dev/null
@@ -1,58 +0,0 @@
-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 = ({ downstreamPipelinePath }) => {
- wrapper = shallowMount(BridgeEmptyState, {
- provide: {
- emptyStateIllustrationPath: MOCK_EMPTY_ILLUSTRATION_PATH,
- },
- propsData: {
- downstreamPipelinePath,
- },
- });
- };
-
- const findSvg = () => wrapper.find('img');
- const findTitle = () => wrapper.find('h1');
- const findLinkBtn = () => wrapper.findComponent(GlButton);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('template', () => {
- beforeEach(() => {
- createComponent({ downstreamPipelinePath: MOCK_PATH_TO_DOWNSTREAM });
- });
-
- 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
deleted file mode 100644
index 5006d4f08a6..00000000000
--- a/spec/frontend/jobs/bridge/components/sidebar_spec.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import { GlButton, GlDropdown } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import BridgeSidebar from '~/jobs/bridge/components/sidebar.vue';
-import CommitBlock from '~/jobs/components/commit_block.vue';
-import { mockCommit, mockJob } from '../mock_data';
-
-describe('Bridge Sidebar', () => {
- let wrapper;
-
- const MockHeaderEl = {
- getBoundingClientRect() {
- return {
- bottom: '40',
- };
- },
- };
-
- const createComponent = ({ featureFlag } = {}) => {
- wrapper = shallowMount(BridgeSidebar, {
- provide: {
- glFeatures: {
- triggerJobRetryAction: featureFlag,
- },
- },
- propsData: {
- bridgeJob: mockJob,
- commit: mockCommit,
- },
- });
- };
-
- const findJobTitle = () => wrapper.find('h4');
- const findCommitBlock = () => wrapper.findComponent(CommitBlock);
- const findRetryDropdown = () => wrapper.find(GlDropdown);
- const findToggleBtn = () => wrapper.findComponent(GlButton);
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('template', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders job name', () => {
- expect(findJobTitle().text()).toBe(mockJob.name);
- });
-
- it('renders commit information', () => {
- expect(findCommitBlock().exists()).toBe(true);
- });
- });
-
- describe('styles', () => {
- beforeEach(async () => {
- jest.spyOn(document, 'querySelector').mockReturnValue(MockHeaderEl);
- createComponent();
- });
-
- it('calculates root styles correctly', () => {
- expect(wrapper.attributes('style')).toBe('width: 290px; top: 40px;');
- });
- });
-
- describe('sidebar expansion', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('emits toggle sidebar event on button click', async () => {
- expect(wrapper.emitted('toggleSidebar')).toBe(undefined);
-
- findToggleBtn().vm.$emit('click');
-
- expect(wrapper.emitted('toggleSidebar')).toHaveLength(1);
- });
- });
-
- describe('retry action', () => {
- describe('when feature flag is ON', () => {
- beforeEach(() => {
- createComponent({ featureFlag: true });
- });
-
- it('renders retry dropdown', () => {
- expect(findRetryDropdown().exists()).toBe(true);
- });
- });
-
- describe('when feature flag is OFF', () => {
- it('does not render retry dropdown', () => {
- createComponent({ featureFlag: false });
-
- expect(findRetryDropdown().exists()).toBe(false);
- });
- });
- });
-});
diff --git a/spec/frontend/jobs/bridge/mock_data.js b/spec/frontend/jobs/bridge/mock_data.js
deleted file mode 100644
index 4084bb54163..00000000000
--- a/spec/frontend/jobs/bridge/mock_data.js
+++ /dev/null
@@ -1,102 +0,0 @@
-export const MOCK_EMPTY_ILLUSTRATION_PATH = '/path/to/svg';
-export const MOCK_PATH_TO_DOWNSTREAM = '/path/to/downstream/pipeline';
-export const MOCK_BUILD_ID = '1331';
-export const MOCK_PIPELINE_IID = '174';
-export const MOCK_PROJECT_FULL_PATH = '/root/project/';
-export const MOCK_SHA = '38f3d89147765427a7ce58be28cd76d14efa682a';
-
-export const mockCommit = {
- id: `gid://gitlab/CommitPresenter/${MOCK_SHA}`,
- shortId: '38f3d891',
- title: 'Update .gitlab-ci.yml file',
- webPath: `/root/project/-/commit/${MOCK_SHA}`,
- __typename: 'Commit',
-};
-
-export const mockJob = {
- createdAt: '2021-12-10T09:05:45Z',
- id: 'gid://gitlab/Ci::Build/1331',
- name: 'triggerJobName',
- scheduledAt: null,
- startedAt: '2021-12-10T09:13:43Z',
- status: 'SUCCESS',
- triggered: null,
- detailedStatus: {
- id: '1',
- detailsPath: '/root/project/-/jobs/1331',
- icon: 'status_success',
- group: 'success',
- text: 'passed',
- tooltip: 'passed',
- __typename: 'DetailedStatus',
- },
- downstreamPipeline: {
- id: '1',
- path: '/root/project/-/pipelines/175',
- },
- stage: {
- id: '1',
- name: 'build',
- __typename: 'CiStage',
- },
- __typename: 'CiJob',
-};
-
-export const mockUser = {
- id: 'gid://gitlab/User/1',
- avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- name: 'Administrator',
- username: 'root',
- webPath: '/root',
- webUrl: 'http://gdk.test:3000/root',
- status: {
- message: 'making great things',
- __typename: 'UserStatus',
- },
- __typename: 'UserCore',
-};
-
-export const mockStage = {
- id: '1',
- name: 'build',
- jobs: {
- nodes: [mockJob],
- __typename: 'CiJobConnection',
- },
- __typename: 'CiStage',
-};
-
-export const mockPipelineQueryResponse = {
- data: {
- project: {
- id: '1',
- pipeline: {
- commit: mockCommit,
- id: 'gid://gitlab/Ci::Pipeline/174',
- iid: '88',
- path: '/root/project/-/pipelines/174',
- sha: MOCK_SHA,
- ref: 'main',
- refPath: 'path/to/ref',
- user: mockUser,
- detailedStatus: {
- id: '1',
- icon: 'status_failed',
- group: 'failed',
- __typename: 'DetailedStatus',
- },
- stages: {
- edges: [
- {
- node: mockStage,
- __typename: 'CiStageEdge',
- },
- ],
- __typename: 'CiStageConnection',
- },
- __typename: 'Pipeline',
- },
- __typename: 'Project',
- },
- },
-};
diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js
index fc308766ab9..b4b5bc4669d 100644
--- a/spec/frontend/jobs/components/job_app_spec.js
+++ b/spec/frontend/jobs/components/job_app_spec.js
@@ -22,7 +22,6 @@ describe('Job App', () => {
let store;
let wrapper;
let mock;
- let origGon;
const initSettings = {
endpoint: `${TEST_HOST}jobs/123.json`,
@@ -80,17 +79,11 @@ describe('Job App', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
store = createStore();
-
- origGon = window.gon;
-
- window.gon = { features: { infinitelyCollapsibleSections: false } }; // NOTE: All of this passes with the feature flag
});
afterEach(() => {
wrapper.destroy();
mock.restore();
-
- window.gon = origGon;
});
describe('while loading', () => {
diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js
index cd3ee734466..cc97d111c06 100644
--- a/spec/frontend/jobs/components/job_log_controllers_spec.js
+++ b/spec/frontend/jobs/components/job_log_controllers_spec.js
@@ -1,6 +1,11 @@
+import { GlSearchBoxByClick } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import JobLogControllers from '~/jobs/components/job_log_controllers.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import { mockJobLog } from '../mock_data';
+
+const mockToastShow = jest.fn();
describe('Job log controllers', () => {
let wrapper;
@@ -19,14 +24,30 @@ describe('Job log controllers', () => {
isScrollBottomDisabled: false,
isScrollingDown: true,
isJobLogSizeVisible: true,
+ jobLog: mockJobLog,
};
- const createWrapper = (props) => {
+ const createWrapper = (props, jobLogSearch = false) => {
wrapper = mount(JobLogControllers, {
propsData: {
...defaultProps,
...props,
},
+ provide: {
+ glFeatures: {
+ jobLogSearch,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '82',
+ };
+ },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
+ },
});
};
@@ -35,6 +56,8 @@ describe('Job log controllers', () => {
const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]');
const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]');
const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]');
+ const findJobLogSearch = () => wrapper.findComponent(GlSearchBoxByClick);
+ const findSearchHelp = () => wrapper.findComponent(HelpPopover);
describe('Truncate information', () => {
describe('with isJobLogSizeVisible', () => {
@@ -179,4 +202,40 @@ describe('Job log controllers', () => {
});
});
});
+
+ describe('Job log search', () => {
+ describe('with feature flag off', () => {
+ it('does not display job log search', () => {
+ createWrapper();
+
+ expect(findJobLogSearch().exists()).toBe(false);
+ expect(findSearchHelp().exists()).toBe(false);
+ });
+ });
+
+ describe('with feature flag on', () => {
+ beforeEach(() => {
+ createWrapper({}, { jobLogSearch: true });
+ });
+
+ it('displays job log search', () => {
+ expect(findJobLogSearch().exists()).toBe(true);
+ expect(findSearchHelp().exists()).toBe(true);
+ });
+
+ it('emits search results', () => {
+ const expectedSearchResults = [[[mockJobLog[6].lines[1], mockJobLog[6].lines[2]]]];
+
+ findJobLogSearch().vm.$emit('submit');
+
+ expect(wrapper.emitted('searchResults')).toEqual(expectedSearchResults);
+ });
+
+ it('clears search results', () => {
+ findJobLogSearch().vm.$emit('clear');
+
+ expect(wrapper.emitted('searchResults')).toEqual([[[]]]);
+ });
+ });
+ });
});
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 cc9a5e4ee25..4046f0269dd 100644
--- a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js
+++ b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js
@@ -42,7 +42,7 @@ describe('Job Sidebar Details Container', () => {
expect(wrapper.html()).toBe('');
});
- it.each(['duration', 'erased_at', 'finished_at', 'queued', 'runner', 'coverage'])(
+ it.each(['duration', 'erased_at', 'finished_at', 'queued_at', 'runner', 'coverage'])(
'should not render %s details when missing',
async (detail) => {
await store.dispatch('receiveJobSuccess', { [detail]: undefined });
@@ -59,7 +59,7 @@ describe('Job Sidebar Details Container', () => {
['duration', 'Elapsed time: 6 seconds'],
['erased_at', 'Erased: 3 weeks ago'],
['finished_at', 'Finished: 3 weeks ago'],
- ['queued', 'Queued: 9 seconds'],
+ ['queued_duration', 'Queued: 9 seconds'],
['runner', 'Runner: #1 (ABCDEFGH) local ci runner'],
['coverage', 'Coverage: 20%'],
])('uses %s to render job-%s', async (detail, value) => {
diff --git a/spec/frontend/jobs/components/jobs_container_spec.js b/spec/frontend/jobs/components/jobs_container_spec.js
index 1cde72682a2..127570b8184 100644
--- a/spec/frontend/jobs/components/jobs_container_spec.js
+++ b/spec/frontend/jobs/components/jobs_container_spec.js
@@ -106,7 +106,7 @@ describe('Jobs List block', () => {
});
expect(findJob().text()).toBe(job.name);
- expect(findJob().text()).not.toContain(job.id);
+ expect(findJob().text()).not.toContain(job.id.toString());
});
it('renders job id when job name is not available', () => {
diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js
index 2ab7f5fe22d..646935568b1 100644
--- a/spec/frontend/jobs/components/log/collapsible_section_spec.js
+++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js
@@ -5,7 +5,6 @@ import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data'
describe('Job Log Collapsible Section', () => {
let wrapper;
- let origGon;
const jobLogEndpoint = 'jobs/335';
@@ -20,16 +19,8 @@ describe('Job Log Collapsible Section', () => {
});
};
- beforeEach(() => {
- origGon = window.gon;
-
- window.gon = { features: { infinitelyCollapsibleSections: false } }; // NOTE: This also works with true
- });
-
afterEach(() => {
wrapper.destroy();
-
- window.gon = origGon;
});
describe('with closed section', () => {
diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js
index d184696cd1f..bf80d90e299 100644
--- a/spec/frontend/jobs/components/log/line_spec.js
+++ b/spec/frontend/jobs/components/log/line_spec.js
@@ -179,4 +179,46 @@ describe('Job Log Line', () => {
expect(findLink().exists()).toBe(false);
});
});
+
+ describe('job log search', () => {
+ const mockSearchResults = [
+ {
+ offset: 1533,
+ content: [{ text: '$ echo "82.71"', style: 'term-fg-l-green term-bold' }],
+ section: 'step-script',
+ lineNumber: 20,
+ },
+ { offset: 1560, content: [{ text: '82.71' }], section: 'step-script', lineNumber: 21 },
+ ];
+
+ it('applies highlight class to search result elements', () => {
+ createComponent({
+ line: {
+ offset: 1560,
+ content: [{ text: '82.71' }],
+ section: 'step-script',
+ lineNumber: 21,
+ },
+ path: '/root/ci-project/-/jobs/1089',
+ searchResults: mockSearchResults,
+ });
+
+ expect(wrapper.classes()).toContain('gl-bg-gray-500');
+ });
+
+ it('does not apply highlight class to search result elements', () => {
+ createComponent({
+ line: {
+ offset: 1560,
+ content: [{ text: 'docker' }],
+ section: 'step-script',
+ lineNumber: 29,
+ },
+ path: '/root/ci-project/-/jobs/1089',
+ searchResults: mockSearchResults,
+ });
+
+ expect(wrapper.classes()).not.toContain('gl-bg-gray-500');
+ });
+ });
});
diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js
index 9cc56cce9b3..c933ed5c3e1 100644
--- a/spec/frontend/jobs/components/log/log_spec.js
+++ b/spec/frontend/jobs/components/log/log_spec.js
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import Log from '~/jobs/components/log/log.vue';
-import { logLinesParserLegacy, logLinesParser } from '~/jobs/store/utils';
+import { logLinesParser } from '~/jobs/store/utils';
import { jobLog } from './mock_data';
describe('Job Log', () => {
@@ -10,7 +10,6 @@ describe('Job Log', () => {
let actions;
let state;
let store;
- let origGon;
Vue.use(Vuex);
@@ -25,12 +24,8 @@ describe('Job Log', () => {
toggleCollapsibleLine: () => {},
};
- origGon = window.gon;
-
- window.gon = { features: { infinitelyCollapsibleSections: false } };
-
state = {
- jobLog: logLinesParserLegacy(jobLog),
+ jobLog: logLinesParser(jobLog),
jobLogEndpoint: 'jobs/id',
};
@@ -44,88 +39,6 @@ describe('Job Log', () => {
afterEach(() => {
wrapper.destroy();
-
- window.gon = origGon;
- });
-
- const findCollapsibleLine = () => wrapper.find('.collapsible-line');
-
- describe('line numbers', () => {
- it('renders a line number for each open line', () => {
- expect(wrapper.find('#L1').text()).toBe('1');
- expect(wrapper.find('#L2').text()).toBe('2');
- expect(wrapper.find('#L3').text()).toBe('3');
- });
-
- it('links to the provided path and correct line number', () => {
- expect(wrapper.find('#L1').attributes('href')).toBe(`${state.jobLogEndpoint}#L1`);
- });
- });
-
- describe('collapsible sections', () => {
- it('renders a clickable header section', () => {
- expect(findCollapsibleLine().attributes('role')).toBe('button');
- });
-
- it('renders an icon with the open state', () => {
- expect(findCollapsibleLine().find('[data-testid="chevron-lg-down-icon"]').exists()).toBe(
- true,
- );
- });
-
- describe('on click header section', () => {
- it('calls toggleCollapsibleLine', () => {
- jest.spyOn(wrapper.vm, 'toggleCollapsibleLine');
-
- findCollapsibleLine().trigger('click');
-
- expect(wrapper.vm.toggleCollapsibleLine).toHaveBeenCalled();
- });
- });
- });
-});
-
-describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => {
- let wrapper;
- let actions;
- let state;
- let store;
- let origGon;
-
- Vue.use(Vuex);
-
- const createComponent = () => {
- wrapper = mount(Log, {
- store,
- });
- };
-
- beforeEach(() => {
- actions = {
- toggleCollapsibleLine: () => {},
- };
-
- origGon = window.gon;
-
- window.gon = { features: { infinitelyCollapsibleSections: true } };
-
- state = {
- jobLog: logLinesParser(jobLog).parsedLines,
- jobLogEndpoint: 'jobs/id',
- };
-
- store = new Vuex.Store({
- actions,
- state,
- });
-
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
-
- window.gon = origGon;
});
const findCollapsibleLine = () => wrapper.find('.collapsible-line');
diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js
index 3ff0bd73581..eb8c4fe8bc9 100644
--- a/spec/frontend/jobs/components/log/mock_data.js
+++ b/spec/frontend/jobs/components/log/mock_data.js
@@ -58,80 +58,6 @@ export const utilsMockData = [
},
];
-export const multipleCollapsibleSectionsMockData = [
- {
- offset: 1001,
- content: [{ text: ' on docker-auto-scale-com 8a6210b8' }],
- },
- {
- offset: 1002,
- content: [
- {
- text: 'Executing "step_script" stage of the job script',
- },
- ],
- section: 'step-script',
- section_header: true,
- },
- {
- offset: 1003,
- content: [{ text: 'sleep 60' }],
- section: 'step-script',
- },
- {
- offset: 1004,
- content: [
- {
- text:
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae',
- },
- ],
- section: 'step-script',
- },
- {
- offset: 1005,
- content: [{ text: 'executing...' }],
- section: 'step-script',
- },
- {
- offset: 1006,
- content: [{ text: '1st collapsible section' }],
- section: 'collapsible-1',
- section_header: true,
- },
- {
- offset: 1007,
- content: [
- {
- text:
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae',
- },
- ],
- section: 'collapsible-1',
- },
- {
- offset: 1008,
- content: [],
- section: 'collapsible-1',
- section_duration: '01:00',
- },
- {
- offset: 1009,
- content: [],
- section: 'step-script',
- section_duration: '10:00',
- },
-];
-
-export const backwardsCompatibilityTrace = [
- {
- offset: 2365,
- content: [],
- section: 'download-artifacts',
- section_duration: '00:01',
- },
-];
-
export const originalTrace = [
{
offset: 1,
diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
index 976b128532d..7cc008f332d 100644
--- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js
@@ -12,17 +12,12 @@ import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retr
import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql';
import JobCancelMutation from '~/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql';
import {
- playableJob,
- retryableJob,
- cancelableJob,
- scheduledJob,
- cannotRetryJob,
- cannotPlayJob,
- cannotPlayScheduledJob,
- retryMutationResponse,
+ mockJobsNodes,
+ mockJobsNodesAsGuest,
playMutationResponse,
- cancelMutationResponse,
+ retryMutationResponse,
unscheduleMutationResponse,
+ cancelMutationResponse,
} from '../../../mock_data';
jest.mock('~/lib/utils/url_utility');
@@ -32,6 +27,22 @@ Vue.use(VueApollo);
describe('Job actions cell', () => {
let wrapper;
+ const findMockJob = (jobName, nodes = mockJobsNodes) => {
+ const job = nodes.find(({ name }) => name === jobName);
+ expect(job).toBeDefined(); // ensure job is present
+ return job;
+ };
+
+ const mockJob = findMockJob('build');
+ const cancelableJob = findMockJob('cancelable');
+ const playableJob = findMockJob('playable');
+ const retryableJob = findMockJob('retryable');
+ const scheduledJob = findMockJob('scheduled');
+ const jobWithArtifact = findMockJob('with_artifact');
+ const cannotPlayJob = findMockJob('playable', mockJobsNodesAsGuest);
+ const cannotRetryJob = findMockJob('retryable', mockJobsNodesAsGuest);
+ const cannotPlayScheduledJob = findMockJob('scheduled', mockJobsNodesAsGuest);
+
const findRetryButton = () => wrapper.findByTestId('retry');
const findPlayButton = () => wrapper.findByTestId('play');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
@@ -55,10 +66,10 @@ describe('Job actions cell', () => {
return createMockApollo(requestHandlers);
};
- const createComponent = (jobType, requestHandlers, props = {}) => {
+ const createComponent = (job, requestHandlers, props = {}) => {
wrapper = shallowMountExtended(ActionsCell, {
propsData: {
- job: jobType,
+ job,
...props,
},
apolloProvider: createMockApolloProvider(requestHandlers),
@@ -73,15 +84,15 @@ describe('Job actions cell', () => {
});
it('displays the artifacts download button with correct link', () => {
- createComponent(playableJob);
+ createComponent(jobWithArtifact);
expect(findDownloadArtifactsButton().attributes('href')).toBe(
- playableJob.artifacts.nodes[0].downloadPath,
+ jobWithArtifact.artifacts.nodes[0].downloadPath,
);
});
it('does not display an artifacts download button', () => {
- createComponent(retryableJob);
+ createComponent(mockJob);
expect(findDownloadArtifactsButton().exists()).toBe(false);
});
@@ -101,7 +112,7 @@ describe('Job actions cell', () => {
button | action | jobType
${findPlayButton} | ${'play'} | ${playableJob}
${findRetryButton} | ${'retry'} | ${retryableJob}
- ${findDownloadArtifactsButton} | ${'download artifacts'} | ${playableJob}
+ ${findDownloadArtifactsButton} | ${'download artifacts'} | ${jobWithArtifact}
${findCancelButton} | ${'cancel'} | ${cancelableJob}
`('displays the $action button', ({ button, jobType }) => {
createComponent(jobType);
diff --git a/spec/frontend/jobs/components/table/cells/job_cell_spec.js b/spec/frontend/jobs/components/table/cells/job_cell_spec.js
index fc4e5586349..ddc196129a7 100644
--- a/spec/frontend/jobs/components/table/cells/job_cell_spec.js
+++ b/spec/frontend/jobs/components/table/cells/job_cell_spec.js
@@ -2,16 +2,22 @@ import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import JobCell from '~/jobs/components/table/cells/job_cell.vue';
-import { mockJobsInTable } from '../../../mock_data';
-
-const mockJob = mockJobsInTable[0];
-const mockJobCreatedByTag = mockJobsInTable[1];
-const mockJobLimitedAccess = mockJobsInTable[2];
-const mockStuckJob = mockJobsInTable[3];
+import { mockJobsNodes, mockJobsNodesAsGuest } from '../../../mock_data';
describe('Job Cell', () => {
let wrapper;
+ const findMockJob = (jobName, nodes = mockJobsNodes) => {
+ const job = nodes.find(({ name }) => name === jobName);
+ expect(job).toBeDefined(); // ensure job is present
+ return job;
+ };
+
+ const mockJob = findMockJob('build');
+ const jobCreatedByTag = findMockJob('created_by_tag');
+ const pendingJob = findMockJob('pending');
+ const jobAsGuest = findMockJob('build', mockJobsNodesAsGuest);
+
const findJobIdLink = () => wrapper.findByTestId('job-id-link');
const findJobIdNoLink = () => wrapper.findByTestId('job-id-limited-access');
const findJobRef = () => wrapper.findByTestId('job-ref');
@@ -23,11 +29,11 @@ describe('Job Cell', () => {
const findBadgeById = (id) => wrapper.findByTestId(id);
- const createComponent = (jobData = mockJob) => {
+ const createComponent = (job = mockJob) => {
wrapper = extendedWrapper(
shallowMount(JobCell, {
propsData: {
- job: jobData,
+ job,
},
}),
);
@@ -49,9 +55,9 @@ describe('Job Cell', () => {
});
it('display the job id with no link', () => {
- createComponent(mockJobLimitedAccess);
+ createComponent(jobAsGuest);
- const expectedJobId = `#${getIdFromGraphQLId(mockJobLimitedAccess.id)}`;
+ const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}`;
expect(findJobIdNoLink().text()).toBe(expectedJobId);
expect(findJobIdNoLink().exists()).toBe(true);
@@ -75,7 +81,7 @@ describe('Job Cell', () => {
});
it('displays label icon when job is created by a tag', () => {
- createComponent(mockJobCreatedByTag);
+ createComponent(jobCreatedByTag);
expect(findLabelIcon().exists()).toBe(true);
expect(findForkIcon().exists()).toBe(false);
@@ -130,8 +136,8 @@ describe('Job Cell', () => {
expect(findStuckIcon().exists()).toBe(false);
});
- it('stuck icon is shown if job is stuck', () => {
- createComponent(mockStuckJob);
+ it('stuck icon is shown if job is pending', () => {
+ createComponent(pendingJob);
expect(findStuckIcon().exists()).toBe(true);
expect(findStuckIcon().attributes('name')).toBe('warning');
diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js
index 986fba21fb9..374768c3ee4 100644
--- a/spec/frontend/jobs/components/table/job_table_app_spec.js
+++ b/spec/frontend/jobs/components/table/job_table_app_spec.js
@@ -6,7 +6,7 @@ import {
GlLoadingIcon,
} from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { s__ } from '~/locale';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -18,8 +18,8 @@ import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
import {
- mockJobsQueryResponse,
- mockJobsQueryEmptyResponse,
+ mockJobsResponsePaginated,
+ mockJobsResponseEmpty,
mockFailedSearchToken,
} from '../../mock_data';
@@ -30,11 +30,10 @@ jest.mock('~/flash');
describe('Job table app', () => {
let wrapper;
- let jobsTableVueSearch = true;
- const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse);
+ const successHandler = jest.fn().mockResolvedValue(mockJobsResponsePaginated);
const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
- const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse);
+ const emptyHandler = jest.fn().mockResolvedValue(mockJobsResponseEmpty);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
@@ -66,7 +65,6 @@ describe('Job table app', () => {
},
provide: {
fullPath: projectPath,
- glFeatures: { jobsTableVueSearch },
},
apolloProvider: createMockApolloProvider(handler),
});
@@ -77,17 +75,17 @@ describe('Job table app', () => {
});
describe('loading state', () => {
- beforeEach(() => {
+ it('should display skeleton loader when loading', () => {
createComponent();
- });
- it('should display skeleton loader when loading', () => {
expect(findSkeletonLoader().exists()).toBe(true);
expect(findTable().exists()).toBe(false);
expect(findLoadingSpinner().exists()).toBe(false);
});
it('when switching tabs only the skeleton loader should show', () => {
+ createComponent();
+
findTabs().vm.$emit('fetchJobsByStatus', null);
expect(findSkeletonLoader().exists()).toBe(true);
@@ -119,24 +117,29 @@ describe('Job table app', () => {
});
describe('when infinite scrolling is triggered', () => {
- beforeEach(() => {
+ it('does not display a skeleton loader', () => {
triggerInfiniteScroll();
- });
- it('does not display a skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(false);
});
it('handles infinite scrolling by calling fetch more', async () => {
+ triggerInfiniteScroll();
+
+ await nextTick();
+
+ const pageSize = 30;
+
expect(findLoadingSpinner().exists()).toBe(true);
await waitForPromises();
expect(findLoadingSpinner().exists()).toBe(false);
- expect(successHandler).toHaveBeenCalledWith({
- after: 'eyJpZCI6IjIzMTcifQ',
- fullPath: 'gitlab-org/gitlab',
+ expect(successHandler).toHaveBeenLastCalledWith({
+ first: pageSize,
+ fullPath: projectPath,
+ after: mockJobsResponsePaginated.data.project.jobs.pageInfo.endCursor,
});
});
});
@@ -227,13 +230,5 @@ describe('Job table app', () => {
expect(createFlash).toHaveBeenCalledWith(expectedWarning);
expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
});
-
- it('should not display filtered search', () => {
- jobsTableVueSearch = false;
-
- createComponent();
-
- expect(findFilteredSearch().exists()).toBe(false);
- });
});
});
diff --git a/spec/frontend/jobs/components/table/jobs_table_spec.js b/spec/frontend/jobs/components/table/jobs_table_spec.js
index ac8bef675f8..803df3df37f 100644
--- a/spec/frontend/jobs/components/table/jobs_table_spec.js
+++ b/spec/frontend/jobs/components/table/jobs_table_spec.js
@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
-import { mockJobsInTable } from '../../mock_data';
+import { mockJobsNodes } from '../../mock_data';
describe('Jobs Table', () => {
let wrapper;
@@ -19,7 +19,7 @@ describe('Jobs Table', () => {
wrapper = extendedWrapper(
mount(JobsTable, {
propsData: {
- jobs: mockJobsInTable,
+ jobs: mockJobsNodes,
...props,
},
}),
@@ -39,7 +39,7 @@ describe('Jobs Table', () => {
});
it('displays correct number of job rows', () => {
- expect(findTableRows()).toHaveLength(mockJobsInTable.length);
+ expect(findTableRows()).toHaveLength(mockJobsNodes.length);
});
it('displays job status', () => {
@@ -47,14 +47,14 @@ describe('Jobs Table', () => {
});
it('displays the job stage and name', () => {
- const firstJob = mockJobsInTable[0];
+ const firstJob = mockJobsNodes[0];
expect(findJobStage().text()).toBe(firstJob.stage.name);
expect(findJobName().text()).toBe(firstJob.name);
});
it('displays the coverage for only jobs that have coverage', () => {
- const jobsThatHaveCoverage = mockJobsInTable.filter((job) => job.coverage !== null);
+ const jobsThatHaveCoverage = mockJobsNodes.filter((job) => job.coverage !== null);
jobsThatHaveCoverage.forEach((job, index) => {
expect(findAllCoverageJobs().at(index).text()).toBe(`${job.coverage}%`);
diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js
index 4676635cce0..bf238b2e39a 100644
--- a/spec/frontend/jobs/mock_data.js
+++ b/spec/frontend/jobs/mock_data.js
@@ -1,8 +1,18 @@
+import mockJobsEmpty from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.empty.json';
+import mockJobsPaginated from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.paginated.json';
+import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json';
+import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json';
import { TEST_HOST } from 'spec/test_constants';
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
+// Fixtures generated at spec/frontend/fixtures/jobs.rb
+export const mockJobsResponsePaginated = mockJobsPaginated;
+export const mockJobsResponseEmpty = mockJobsEmpty;
+export const mockJobsNodes = mockJobs.data.project.jobs.nodes;
+export const mockJobsNodesAsGuest = mockJobsAsGuest.data.project.jobs.nodes;
+
export const stages = [
{
name: 'build',
@@ -924,7 +934,7 @@ export default {
created_at: threeWeeksAgo.toISOString(),
updated_at: threeWeeksAgo.toISOString(),
finished_at: threeWeeksAgo.toISOString(),
- queued: 9.54,
+ queued_duration: 9.54,
status: {
icon: 'status_success',
text: 'passed',
@@ -1283,602 +1293,6 @@ export const mockPipelineDetached = {
},
};
-export const mockJobsInTable = [
- {
- detailedStatus: {
- icon: 'status_manual',
- label: 'manual play action',
- text: 'manual',
- tooltip: 'manual action',
- action: {
- buttonTitle: 'Trigger this manual action',
- icon: 'play',
- method: 'post',
- path: '/root/ci-project/-/jobs/2004/play',
- title: 'Play',
- __typename: 'StatusAction',
- },
- detailsPath: '/root/ci-project/-/jobs/2004',
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/2004',
- refName: 'main',
- refPath: '/root/ci-project/-/commits/main',
- tags: [],
- shortSha: '2d5d8323',
- commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
- pipeline: {
- id: 'gid://gitlab/Ci::Pipeline/423',
- path: '/root/ci-project/-/pipelines/423',
- user: {
- webPath: '/root',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- __typename: 'User',
- },
- __typename: 'Pipeline',
- },
- stage: { name: 'test', __typename: 'CiStage' },
- name: 'test_manual_job',
- duration: null,
- finishedAt: null,
- coverage: null,
- createdByTag: false,
- retryable: false,
- playable: true,
- cancelable: false,
- active: false,
- stuck: false,
- userPermissions: { readBuild: true, __typename: 'JobPermissions' },
- __typename: 'CiJob',
- },
- {
- detailedStatus: {
- icon: 'status_skipped',
- label: 'skipped',
- text: 'skipped',
- tooltip: 'skipped',
- action: null,
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/2021',
- refName: 'main',
- refPath: '/root/ci-project/-/commits/main',
- tags: [],
- shortSha: '2d5d8323',
- commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
- pipeline: {
- id: 'gid://gitlab/Ci::Pipeline/425',
- path: '/root/ci-project/-/pipelines/425',
- user: {
- webPath: '/root',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- __typename: 'User',
- },
- __typename: 'Pipeline',
- },
- stage: { name: 'test', __typename: 'CiStage' },
- name: 'coverage_job',
- duration: null,
- finishedAt: null,
- coverage: null,
- createdByTag: true,
- retryable: false,
- playable: false,
- cancelable: false,
- active: false,
- stuck: false,
- userPermissions: { readBuild: true, __typename: 'JobPermissions' },
- __typename: 'CiJob',
- },
- {
- detailedStatus: {
- icon: 'status_success',
- label: 'passed',
- text: 'passed',
- tooltip: 'passed',
- action: {
- buttonTitle: 'Retry this job',
- icon: 'retry',
- method: 'post',
- path: '/root/ci-project/-/jobs/2015/retry',
- title: 'Retry',
- __typename: 'StatusAction',
- },
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/2015',
- refName: 'main',
- refPath: '/root/ci-project/-/commits/main',
- tags: [],
- shortSha: '2d5d8323',
- commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
- pipeline: {
- id: 'gid://gitlab/Ci::Pipeline/424',
- path: '/root/ci-project/-/pipelines/424',
- user: {
- webPath: '/root',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- __typename: 'User',
- },
- __typename: 'Pipeline',
- },
- stage: { name: 'deploy', __typename: 'CiStage' },
- name: 'artifact_job',
- duration: 2,
- finishedAt: '2021-04-01T17:36:18Z',
- coverage: 82.71,
- createdByTag: false,
- retryable: true,
- playable: false,
- cancelable: false,
- active: false,
- stuck: false,
- userPermissions: { readBuild: false, __typename: 'JobPermissions' },
- __typename: 'CiJob',
- },
- {
- artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' },
- allowFailure: false,
- status: 'PENDING',
- scheduledAt: null,
- manualJob: false,
- triggered: null,
- createdByTag: false,
- detailedStatus: {
- detailsPath: '/root/ci-project/-/jobs/2391',
- group: 'pending',
- icon: 'status_pending',
- label: 'pending',
- text: 'pending',
- tooltip: 'pending',
- action: {
- buttonTitle: 'Cancel this job',
- icon: 'cancel',
- method: 'post',
- path: '/root/ci-project/-/jobs/2391/cancel',
- title: 'Cancel',
- __typename: 'StatusAction',
- },
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/2391',
- refName: 'master',
- refPath: '/root/ci-project/-/commits/master',
- tags: [],
- shortSha: '916330b4',
- commitPath: '/root/ci-project/-/commit/916330b4fda5dae226524ceb51c756c0ed26679d',
- pipeline: {
- id: 'gid://gitlab/Ci::Pipeline/482',
- path: '/root/ci-project/-/pipelines/482',
- user: {
- webPath: '/root',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- __typename: 'UserCore',
- },
- __typename: 'Pipeline',
- },
- stage: { name: 'build', __typename: 'CiStage' },
- name: 'build_job',
- duration: null,
- finishedAt: null,
- coverage: null,
- retryable: false,
- playable: false,
- cancelable: true,
- active: true,
- stuck: true,
- userPermissions: { readBuild: true, __typename: 'JobPermissions' },
- __typename: 'CiJob',
- },
-];
-
-export const mockJobsQueryResponse = {
- data: {
- project: {
- id: '1',
- jobs: {
- count: 1,
- pageInfo: {
- endCursor: 'eyJpZCI6IjIzMTcifQ',
- hasNextPage: true,
- hasPreviousPage: false,
- startCursor: 'eyJpZCI6IjIzMzYifQ',
- __typename: 'PageInfo',
- },
- nodes: [
- {
- artifacts: {
- 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',
- },
- ],
- __typename: 'CiJobArtifactConnection',
- },
- allowFailure: false,
- status: 'SUCCESS',
- scheduledAt: null,
- manualJob: false,
- triggered: null,
- createdByTag: false,
- detailedStatus: {
- id: 'status-1',
- detailsPath: '/root/ci-project/-/jobs/2336',
- group: 'success',
- icon: 'status_success',
- label: 'passed',
- text: 'passed',
- tooltip: 'passed',
- action: {
- id: 'action-1',
- buttonTitle: 'Retry this job',
- icon: 'retry',
- method: 'post',
- path: '/root/ci-project/-/jobs/2336/retry',
- title: 'Retry',
- __typename: 'StatusAction',
- },
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/2336',
- refName: 'main',
- refPath: '/root/ci-project/-/commits/main',
- tags: [],
- shortSha: '4408fa2a',
- commitPath: '/root/ci-project/-/commit/4408fa2a27aaadfdf42d8dda3d6a9c01ce6cad78',
- pipeline: {
- 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',
- __typename: 'UserCore',
- },
- __typename: 'Pipeline',
- },
- stage: {
- id: 'stage-1',
- name: 'deploy',
- __typename: 'CiStage',
- },
- name: 'artifact_job',
- duration: 3,
- finishedAt: '2021-04-29T14:19:50Z',
- coverage: null,
- retryable: true,
- playable: false,
- cancelable: false,
- active: false,
- stuck: false,
- userPermissions: {
- readBuild: true,
- readJobArtifacts: true,
- updateBuild: true,
- __typename: 'JobPermissions',
- },
- __typename: 'CiJob',
- },
- ],
- __typename: 'CiJobConnection',
- },
- __typename: 'Project',
- },
- },
-};
-
-export const mockJobsQueryEmptyResponse = {
- data: {
- project: {
- id: '1',
- jobs: [],
- },
- },
-};
-
-export const retryableJob = {
- 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,
- manualJob: false,
- triggered: null,
- createdByTag: false,
- detailedStatus: {
- detailsPath: '/root/test-job-artifacts/-/jobs/1981',
- group: 'success',
- icon: 'status_success',
- label: 'passed',
- text: 'passed',
- tooltip: 'passed',
- action: {
- buttonTitle: 'Retry this job',
- icon: 'retry',
- method: 'post',
- path: '/root/test-job-artifacts/-/jobs/1981/retry',
- title: 'Retry',
- __typename: 'StatusAction',
- },
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/1981',
- refName: 'main',
- refPath: '/root/test-job-artifacts/-/commits/main',
- tags: [],
- shortSha: '75daf01b',
- commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
- pipeline: {
- id: 'gid://gitlab/Ci::Pipeline/288',
- path: '/root/test-job-artifacts/-/pipelines/288',
- user: {
- webPath: '/root',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- __typename: 'UserCore',
- },
- __typename: 'Pipeline',
- },
- stage: { name: 'test', __typename: 'CiStage' },
- name: 'hello_world',
- duration: 7,
- finishedAt: '2021-08-30T20:33:56Z',
- coverage: null,
- retryable: true,
- playable: false,
- cancelable: false,
- active: false,
- stuck: false,
- userPermissions: { readBuild: true, updateBuild: true, __typename: 'JobPermissions' },
- __typename: 'CiJob',
-};
-
-export const cancelableJob = {
- artifacts: {
- nodes: [],
- __typename: 'CiJobArtifactConnection',
- },
- allowFailure: false,
- status: 'PENDING',
- scheduledAt: null,
- manualJob: false,
- triggered: null,
- createdByTag: false,
- detailedStatus: {
- id: 'pending-1305-1305',
- detailsPath: '/root/lots-of-jobs-project/-/jobs/1305',
- group: 'pending',
- icon: 'status_pending',
- label: 'pending',
- text: 'pending',
- tooltip: 'pending',
- action: {
- id: 'Ci::Build-pending-1305',
- buttonTitle: 'Cancel this job',
- icon: 'cancel',
- method: 'post',
- path: '/root/lots-of-jobs-project/-/jobs/1305/cancel',
- title: 'Cancel',
- __typename: 'StatusAction',
- },
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/1305',
- refName: 'main',
- refPath: '/root/lots-of-jobs-project/-/commits/main',
- tags: [],
- shortSha: '750605f2',
- commitPath: '/root/lots-of-jobs-project/-/commit/750605f29530778cf0912779eba6d073128962a5',
- stage: {
- id: 'gid://gitlab/Ci::Stage/181',
- name: 'deploy',
- __typename: 'CiStage',
- },
- name: 'job_212',
- duration: null,
- finishedAt: null,
- coverage: null,
- retryable: false,
- playable: false,
- cancelable: true,
- active: true,
- stuck: false,
- userPermissions: {
- readBuild: true,
- readJobArtifacts: 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/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',
- },
- ],
- __typename: 'CiJobArtifactConnection',
- },
- allowFailure: false,
- status: 'SUCCESS',
- scheduledAt: null,
- manualJob: true,
- triggered: null,
- createdByTag: false,
- detailedStatus: {
- detailsPath: '/root/test-job-artifacts/-/jobs/1982',
- group: 'success',
- icon: 'status_success',
- label: 'manual play action',
- text: 'passed',
- tooltip: 'passed',
- action: {
- buttonTitle: 'Trigger this manual action',
- icon: 'play',
- method: 'post',
- path: '/root/test-job-artifacts/-/jobs/1982/play',
- title: 'Play',
- __typename: 'StatusAction',
- },
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/1982',
- refName: 'main',
- refPath: '/root/test-job-artifacts/-/commits/main',
- tags: [],
- shortSha: '75daf01b',
- commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
- pipeline: {
- id: 'gid://gitlab/Ci::Pipeline/288',
- path: '/root/test-job-artifacts/-/pipelines/288',
- user: {
- webPath: '/root',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- __typename: 'UserCore',
- },
- __typename: 'Pipeline',
- },
- stage: { name: 'test', __typename: 'CiStage' },
- name: 'hello_world_delayed',
- duration: 6,
- finishedAt: '2021-08-30T20:36:12Z',
- coverage: null,
- retryable: true,
- playable: true,
- cancelable: false,
- active: false,
- stuck: false,
- 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,
- status: 'SCHEDULED',
- scheduledAt: '2021-08-31T22:36:05Z',
- manualJob: true,
- triggered: null,
- createdByTag: false,
- detailedStatus: {
- detailsPath: '/root/test-job-artifacts/-/jobs/1986',
- group: 'scheduled',
- icon: 'status_scheduled',
- label: 'unschedule action',
- text: 'delayed',
- tooltip: 'delayed manual action (%{remainingTime})',
- action: {
- buttonTitle: 'Unschedule job',
- icon: 'time-out',
- method: 'post',
- path: '/root/test-job-artifacts/-/jobs/1986/unschedule',
- title: 'Unschedule',
- __typename: 'StatusAction',
- },
- __typename: 'DetailedStatus',
- },
- id: 'gid://gitlab/Ci::Build/1986',
- refName: 'main',
- refPath: '/root/test-job-artifacts/-/commits/main',
- tags: [],
- shortSha: '75daf01b',
- commitPath: '/root/test-job-artifacts/-/commit/75daf01b465e7eab5a04a315e44660c9a17c8055',
- pipeline: {
- id: 'gid://gitlab/Ci::Pipeline/290',
- path: '/root/test-job-artifacts/-/pipelines/290',
- user: {
- webPath: '/root',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- __typename: 'UserCore',
- },
- __typename: 'Pipeline',
- },
- stage: { name: 'test', __typename: 'CiStage' },
- name: 'hello_world_delayed',
- duration: null,
- finishedAt: null,
- coverage: null,
- retryable: false,
- playable: true,
- cancelable: false,
- active: false,
- stuck: false,
- userPermissions: { readBuild: true, updateBuild: true, __typename: 'JobPermissions' },
- __typename: 'CiJob',
-};
-
-export const cannotPlayScheduledJob = {
- ...scheduledJob,
- userPermissions: {
- readBuild: true,
- readJobArtifacts: true,
- updateBuild: false,
- __typename: 'JobPermissions',
- },
-};
-
export const CIJobConnectionIncomingCache = {
__typename: 'CiJobConnection',
pageInfo: {
@@ -2000,3 +1414,167 @@ export const unscheduleMutationResponse = {
},
},
};
+
+export const mockJobLog = [
+ { offset: 0, content: [{ text: 'Running with gitlab-runner 15.0.0 (febb2a09)' }], lineNumber: 0 },
+ { offset: 54, content: [{ text: ' on colima-docker EwM9WzgD' }], lineNumber: 1 },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 91,
+ content: [{ text: 'Resolving secrets', style: 'term-fg-l-cyan term-bold' }],
+ section: 'resolve-secrets',
+ section_header: true,
+ lineNumber: 2,
+ section_duration: '00:00',
+ },
+ lines: [],
+ },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 218,
+ content: [{ text: 'Preparing the "docker" executor', style: 'term-fg-l-cyan term-bold' }],
+ section: 'prepare-executor',
+ section_header: true,
+ lineNumber: 4,
+ section_duration: '00:01',
+ },
+ lines: [
+ {
+ offset: 317,
+ content: [{ text: 'Using Docker executor with image ruby:2.7 ...' }],
+ section: 'prepare-executor',
+ lineNumber: 5,
+ },
+ {
+ offset: 372,
+ content: [{ text: 'Pulling docker image ruby:2.7 ...' }],
+ section: 'prepare-executor',
+ lineNumber: 6,
+ },
+ {
+ offset: 415,
+ content: [
+ {
+ text:
+ 'Using docker image sha256:55106bf6ba7f452c38d01ea760affc6ceb67d4b60068ffadab98d1b7b007668c for ruby:2.7 with digest ruby@sha256:23d08a4bae1a12ee3fce017f83204fcf9a02243443e4a516e65e5ff73810a449 ...',
+ },
+ ],
+ section: 'prepare-executor',
+ lineNumber: 7,
+ },
+ ],
+ },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 665,
+ content: [{ text: 'Preparing environment', style: 'term-fg-l-cyan term-bold' }],
+ section: 'prepare-script',
+ section_header: true,
+ lineNumber: 9,
+ section_duration: '00:01',
+ },
+ lines: [
+ {
+ offset: 752,
+ content: [
+ { text: 'Running on runner-ewm9wzgd-project-20-concurrent-0 via 8ea689ec6969...' },
+ ],
+ section: 'prepare-script',
+ lineNumber: 10,
+ },
+ ],
+ },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 865,
+ content: [{ text: 'Getting source from Git repository', style: 'term-fg-l-cyan term-bold' }],
+ section: 'get-sources',
+ section_header: true,
+ lineNumber: 12,
+ section_duration: '00:01',
+ },
+ lines: [
+ {
+ offset: 962,
+ content: [
+ {
+ text: 'Fetching changes with git depth set to 20...',
+ style: 'term-fg-l-green term-bold',
+ },
+ ],
+ section: 'get-sources',
+ lineNumber: 13,
+ },
+ {
+ offset: 1019,
+ content: [
+ { text: 'Reinitialized existing Git repository in /builds/root/ci-project/.git/' },
+ ],
+ section: 'get-sources',
+ lineNumber: 14,
+ },
+ {
+ offset: 1090,
+ content: [{ text: 'Checking out e0f63d76 as main...', style: 'term-fg-l-green term-bold' }],
+ section: 'get-sources',
+ lineNumber: 15,
+ },
+ {
+ offset: 1136,
+ content: [{ text: 'Skipping Git submodules setup', style: 'term-fg-l-green term-bold' }],
+ section: 'get-sources',
+ lineNumber: 16,
+ },
+ ],
+ },
+ {
+ isClosed: false,
+ isHeader: true,
+ line: {
+ offset: 1217,
+ content: [
+ {
+ text: 'Executing "step_script" stage of the job script',
+ style: 'term-fg-l-cyan term-bold',
+ },
+ ],
+ section: 'step-script',
+ section_header: true,
+ lineNumber: 18,
+ section_duration: '00:00',
+ },
+ lines: [
+ {
+ offset: 1327,
+ content: [
+ {
+ text:
+ 'Using docker image sha256:55106bf6ba7f452c38d01ea760affc6ceb67d4b60068ffadab98d1b7b007668c for ruby:2.7 with digest ruby@sha256:23d08a4bae1a12ee3fce017f83204fcf9a02243443e4a516e65e5ff73810a449 ...',
+ },
+ ],
+ section: 'step-script',
+ lineNumber: 19,
+ },
+ {
+ offset: 1533,
+ content: [{ text: '$ echo "82.71"', style: 'term-fg-l-green term-bold' }],
+ section: 'step-script',
+ lineNumber: 20,
+ },
+ { offset: 1560, content: [{ text: '82.71' }], section: 'step-script', lineNumber: 21 },
+ ],
+ },
+ {
+ offset: 1605,
+ content: [{ text: 'Job succeeded', style: 'term-fg-l-green term-bold' }],
+ lineNumber: 23,
+ },
+];
diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js
index b73aa8abf4e..ea1ec383d6e 100644
--- a/spec/frontend/jobs/store/mutations_spec.js
+++ b/spec/frontend/jobs/store/mutations_spec.js
@@ -4,21 +4,12 @@ import state from '~/jobs/store/state';
describe('Jobs Store Mutations', () => {
let stateCopy;
- let origGon;
const html =
'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I';
beforeEach(() => {
stateCopy = state();
-
- origGon = window.gon;
-
- window.gon = { features: { infinitelyCollapsibleSections: false } };
- });
-
- afterEach(() => {
- window.gon = origGon;
});
describe('SET_JOB_ENDPOINT', () => {
@@ -276,88 +267,3 @@ describe('Jobs Store Mutations', () => {
});
});
});
-
-describe('Job Store mutations, feature flag ON', () => {
- let stateCopy;
- let origGon;
-
- const html =
- 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I';
-
- beforeEach(() => {
- stateCopy = state();
-
- origGon = window.gon;
-
- window.gon = { features: { infinitelyCollapsibleSections: true } };
- });
-
- afterEach(() => {
- window.gon = origGon;
- });
-
- describe('RECEIVE_JOB_LOG_SUCCESS', () => {
- describe('with new job log', () => {
- describe('log.lines', () => {
- describe('when append is true', () => {
- it('sets the parsed log ', () => {
- mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, {
- append: true,
- size: 511846,
- complete: true,
- lines: [
- {
- offset: 1,
- content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }],
- },
- ],
- });
-
- expect(stateCopy.jobLog).toEqual([
- {
- offset: 1,
- content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }],
- lineNumber: 1,
- },
- ]);
- });
- });
-
- describe('when lines are defined', () => {
- it('sets the parsed log ', () => {
- mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, {
- append: false,
- size: 511846,
- complete: true,
- lines: [
- { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }] },
- ],
- });
-
- expect(stateCopy.jobLog).toEqual([
- {
- offset: 0,
- content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }],
- lineNumber: 1,
- },
- ]);
- });
- });
-
- describe('when lines are null', () => {
- it('sets the default value', () => {
- mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, {
- append: true,
- html,
- size: 511846,
- complete: false,
- lines: null,
- });
-
- expect(stateCopy.jobLog).toEqual([]);
- });
- });
- });
- });
- });
-});
diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js
index 92ac33c8792..9458c2184f5 100644
--- a/spec/frontend/jobs/store/utils_spec.js
+++ b/spec/frontend/jobs/store/utils_spec.js
@@ -1,6 +1,5 @@
import {
logLinesParser,
- logLinesParserLegacy,
updateIncrementalJobLog,
parseHeaderLine,
parseLine,
@@ -18,8 +17,6 @@ import {
headerTraceIncremental,
collapsibleTrace,
collapsibleTraceIncremental,
- multipleCollapsibleSectionsMockData,
- backwardsCompatibilityTrace,
} from '../components/log/mock_data';
describe('Jobs Store Utils', () => {
@@ -178,11 +175,11 @@ describe('Jobs Store Utils', () => {
expect(isCollapsibleSection()).toEqual(false);
});
});
- describe('logLinesParserLegacy', () => {
+ describe('logLinesParser', () => {
let result;
beforeEach(() => {
- result = logLinesParserLegacy(utilsMockData);
+ result = logLinesParser(utilsMockData);
});
describe('regular line', () => {
@@ -219,102 +216,6 @@ describe('Jobs Store Utils', () => {
});
});
- describe('logLinesParser', () => {
- let result;
-
- beforeEach(() => {
- result = logLinesParser(utilsMockData);
- });
-
- describe('regular line', () => {
- it('adds a lineNumber property with correct index', () => {
- expect(result.parsedLines[0].lineNumber).toEqual(1);
- expect(result.parsedLines[1].line.lineNumber).toEqual(2);
- });
- });
-
- describe('collapsible section', () => {
- it('adds a `isClosed` property', () => {
- expect(result.parsedLines[1].isClosed).toEqual(false);
- });
-
- it('adds a `isHeader` property', () => {
- expect(result.parsedLines[1].isHeader).toEqual(true);
- });
-
- it('creates a lines array property with the content of the collapsible section', () => {
- expect(result.parsedLines[1].lines.length).toEqual(2);
- expect(result.parsedLines[1].lines[0].content).toEqual(utilsMockData[2].content);
- expect(result.parsedLines[1].lines[1].content).toEqual(utilsMockData[3].content);
- });
- });
-
- describe('section duration', () => {
- it('adds the section information to the header section', () => {
- expect(result.parsedLines[1].line.section_duration).toEqual(
- utilsMockData[4].section_duration,
- );
- });
-
- it('does not add section duration as a line', () => {
- expect(result.parsedLines[1].lines.includes(utilsMockData[4])).toEqual(false);
- });
- });
-
- describe('multiple collapsible sections', () => {
- beforeEach(() => {
- result = logLinesParser(multipleCollapsibleSectionsMockData);
- });
-
- it('should contain a section inside another section', () => {
- const innerSection = [
- {
- isClosed: false,
- isHeader: true,
- line: {
- content: [{ text: '1st collapsible section' }],
- lineNumber: 6,
- offset: 1006,
- section: 'collapsible-1',
- section_duration: '01:00',
- section_header: true,
- },
- lines: [
- {
- content: [
- {
- text:
- 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae',
- },
- ],
- lineNumber: 7,
- offset: 1007,
- section: 'collapsible-1',
- },
- ],
- },
- ];
-
- expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection));
- });
- });
-
- describe('backwards compatibility', () => {
- beforeEach(() => {
- result = logLinesParser(backwardsCompatibilityTrace);
- });
-
- it('should return an object with a parsedLines prop', () => {
- expect(result).toEqual(
- expect.objectContaining({
- parsedLines: expect.any(Array),
- }),
- );
- expect(result.parsedLines).toHaveLength(1);
- });
- });
- });
-
describe('findOffsetAndRemove', () => {
describe('when last item is header', () => {
const existingLog = [
@@ -490,7 +391,7 @@ describe('Jobs Store Utils', () => {
describe('updateIncrementalJobLog', () => {
describe('without repeated section', () => {
it('concats and parses both arrays', () => {
- const oldLog = logLinesParserLegacy(originalTrace);
+ const oldLog = logLinesParser(originalTrace);
const result = updateIncrementalJobLog(regularIncremental, oldLog);
expect(result).toEqual([
@@ -518,7 +419,7 @@ describe('Jobs Store Utils', () => {
describe('with regular line repeated offset', () => {
it('updates the last line and formats with the incremental part', () => {
- const oldLog = logLinesParserLegacy(originalTrace);
+ const oldLog = logLinesParser(originalTrace);
const result = updateIncrementalJobLog(regularIncrementalRepeated, oldLog);
expect(result).toEqual([
@@ -537,7 +438,7 @@ describe('Jobs Store Utils', () => {
describe('with header line repeated', () => {
it('updates the header line and formats with the incremental part', () => {
- const oldLog = logLinesParserLegacy(headerTrace);
+ const oldLog = logLinesParser(headerTrace);
const result = updateIncrementalJobLog(headerTraceIncremental, oldLog);
expect(result).toEqual([
@@ -563,7 +464,7 @@ describe('Jobs Store Utils', () => {
describe('with collapsible line repeated', () => {
it('updates the collapsible line and formats with the incremental part', () => {
- const oldLog = logLinesParserLegacy(collapsibleTrace);
+ const oldLog = logLinesParser(collapsibleTrace);
const result = updateIncrementalJobLog(collapsibleTraceIncremental, oldLog);
expect(result).toEqual([
diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js
index 34325dad6a1..b585c69e911 100644
--- a/spec/frontend/lib/dompurify_spec.js
+++ b/spec/frontend/lib/dompurify_spec.js
@@ -34,6 +34,17 @@ const unsafeUrls = [
`${absoluteGon.sprite_file_icons}/../../https://evil.url`,
];
+/* eslint-disable no-script-url */
+const invalidProtocolUrls = [
+ 'javascript:alert(1)',
+ 'jAvascript:alert(1)',
+ 'data:text/html,<script>alert(1);</script>',
+ ' javascript:',
+ 'javascript :',
+];
+/* eslint-enable no-script-url */
+const validProtocolUrls = ['slack://open', 'x-devonthink-item://90909', 'x-devonthink-item:90909'];
+
const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method'];
const acceptedDataAttrs = ['data-random', 'data-custom'];
@@ -150,4 +161,16 @@ describe('~/lib/dompurify', () => {
expect(sanitize(htmlHref)).toBe(`<a ${attrWithValue}>hello</a>`);
});
});
+
+ describe('with non-http links', () => {
+ it.each(validProtocolUrls)('should allow %s', (url) => {
+ const html = `<a href="${url}">internal link</a>`;
+ expect(sanitize(html)).toBe(`<a href="${url}">internal link</a>`);
+ });
+
+ it.each(invalidProtocolUrls)('should not allow %s', (url) => {
+ const html = `<a href="${url}">internal link</a>`;
+ expect(sanitize(html)).toBe(`<a>internal link</a>`);
+ });
+ });
});
diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js
index 7aab0072364..b722315d63a 100644
--- a/spec/frontend/lib/gfm/index_spec.js
+++ b/spec/frontend/lib/gfm/index_spec.js
@@ -1,11 +1,12 @@
import { render } from '~/lib/gfm';
describe('gfm', () => {
- const markdownToAST = async (markdown) => {
+ const markdownToAST = async (markdown, skipRendering = []) => {
let result;
await render({
markdown,
+ skipRendering,
renderer: (tree) => {
result = tree;
},
@@ -58,36 +59,62 @@ describe('gfm', () => {
expect(result).toEqual(rendered);
});
- it('transforms footnotes into footnotedefinition and footnotereference tags', async () => {
- const result = await markdownToAST(
- `footnote reference [^footnote]
+ describe('when skipping the rendering of footnote reference and definition nodes', () => {
+ it('transforms footnotes into footnotedefinition and footnotereference tags', async () => {
+ const result = await markdownToAST(
+ `footnote reference [^footnote]
[^footnote]: Footnote definition`,
- );
+ ['footnoteReference', 'footnoteDefinition'],
+ );
- expectInRoot(
- result,
- expect.objectContaining({
- children: expect.arrayContaining([
- expect.objectContaining({
- type: 'element',
- tagName: 'footnotereference',
- properties: {
- identifier: 'footnote',
- label: 'footnote',
- },
- }),
- ]),
- }),
+ expectInRoot(
+ result,
+ expect.objectContaining({
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ type: 'element',
+ tagName: 'footnotereference',
+ properties: {
+ identifier: 'footnote',
+ label: 'footnote',
+ },
+ }),
+ ]),
+ }),
+ );
+
+ expectInRoot(
+ result,
+ expect.objectContaining({
+ tagName: 'footnotedefinition',
+ properties: {
+ identifier: 'footnote',
+ label: 'footnote',
+ },
+ }),
+ );
+ });
+ });
+ });
+
+ describe('when skipping the rendering of code blocks', () => {
+ it('transforms code nodes into codeblock html tags', async () => {
+ const result = await markdownToAST(
+ `
+\`\`\`javascript
+console.log('Hola');
+\`\`\`\
+ `,
+ ['code'],
);
expectInRoot(
result,
expect.objectContaining({
- tagName: 'footnotedefinition',
+ tagName: 'codeblock',
properties: {
- identifier: 'footnote',
- label: 'footnote',
+ language: 'javascript',
},
}),
);
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index 8e499844406..7cf101a5e59 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -88,6 +88,28 @@ describe('common_utils', () => {
expectGetElementIdToHaveBeenCalledWith('user-content-definição');
});
+ it(`does not scroll when ${commonUtils.NO_SCROLL_TO_HASH_CLASS} is set on target`, () => {
+ jest.spyOn(window, 'scrollBy');
+
+ document.body.innerHTML += `
+ <div id="parent">
+ <a href="#test">Link</a>
+ <div style="height: 2000px;"></div>
+ <div id="test" style="height: 2000px;" class="${commonUtils.NO_SCROLL_TO_HASH_CLASS}"></div>
+ </div>
+ `;
+
+ window.history.pushState({}, null, '#test');
+ commonUtils.handleLocationHash();
+ jest.runOnlyPendingTimers();
+
+ try {
+ expect(window.scrollBy).not.toHaveBeenCalled();
+ } finally {
+ document.getElementById('parent').remove();
+ }
+ });
+
it('scrolls element into view', () => {
document.body.innerHTML += `
<div id="parent">
diff --git a/spec/frontend/lib/utils/navigation_utility_spec.js b/spec/frontend/lib/utils/navigation_utility_spec.js
index 632a8904578..6d3a871eb33 100644
--- a/spec/frontend/lib/utils/navigation_utility_spec.js
+++ b/spec/frontend/lib/utils/navigation_utility_spec.js
@@ -81,8 +81,6 @@ describe('initPrefetchLinks', () => {
const mouseOverEvent = new Event('mouseover');
beforeEach(() => {
- jest.useFakeTimers();
-
jest.spyOn(global, 'setTimeout');
jest.spyOn(newLink, 'removeEventListener');
});
diff --git a/spec/frontend/lib/utils/rails_ujs_spec.js b/spec/frontend/lib/utils/rails_ujs_spec.js
index 00c29b72e73..c10301523c9 100644
--- a/spec/frontend/lib/utils/rails_ujs_spec.js
+++ b/spec/frontend/lib/utils/rails_ujs_spec.js
@@ -8,7 +8,7 @@ beforeAll(async () => {
// that jQuery isn't available *before* we import @rails/ujs.
delete global.jQuery;
- const { initRails } = await import('~/lib/utils/rails_ujs.js');
+ const { initRails } = await import('~/lib/utils/rails_ujs');
initRails();
});
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index 9570d2a831c..8e31fc792c5 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -384,4 +384,17 @@ describe('text_utility', () => {
);
});
});
+
+ describe('limitedCounterWithDelimiter', () => {
+ it('returns 1,000+ for count greater than 1000', () => {
+ const expectedOutput = '1,000+';
+
+ expect(textUtils.limitedCounterWithDelimiter(1001)).toBe(expectedOutput);
+ expect(textUtils.limitedCounterWithDelimiter(2300)).toBe(expectedOutput);
+ });
+
+ it('returns exact number for count less than 1000', () => {
+ expect(textUtils.limitedCounterWithDelimiter(120)).toBe(120);
+ });
+ });
});
diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js
deleted file mode 100644
index 84dc0bdf6cd..00000000000
--- a/spec/frontend/logs/components/environment_logs_spec.js
+++ /dev/null
@@ -1,370 +0,0 @@
-import { GlSprintf, GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { scrollDown } from '~/lib/utils/scroll_utils';
-import EnvironmentLogs from '~/logs/components/environment_logs.vue';
-
-import { createStore } from '~/logs/stores';
-import {
- mockEnvName,
- mockEnvironments,
- mockPods,
- mockLogsResult,
- mockTrace,
- mockEnvironmentsEndpoint,
- mockDocumentationPath,
-} from '../mock_data';
-
-jest.mock('~/lib/utils/scroll_utils');
-
-const module = 'environmentLogs';
-
-jest.mock('lodash/throttle', () =>
- jest.fn((func) => {
- return func;
- }),
-);
-
-describe('EnvironmentLogs', () => {
- let store;
- let dispatch;
- let wrapper;
- let state;
-
- const propsData = {
- environmentName: mockEnvName,
- environmentsPath: mockEnvironmentsEndpoint,
- clusterApplicationsDocumentationPath: mockDocumentationPath,
- clustersPath: '/gitlab-org',
- };
-
- const updateControlBtnsMock = jest.fn();
- const LogControlButtonsStub = {
- template: '<div/>',
- methods: {
- update: updateControlBtnsMock,
- },
- props: {
- scrollDownButtonDisabled: false,
- },
- };
-
- const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
-
- const findSimpleFilters = () => wrapper.find({ ref: 'log-simple-filters' });
- const findAdvancedFilters = () => wrapper.find({ ref: 'log-advanced-filters' });
- const findElasticsearchNotice = () => wrapper.find({ ref: 'elasticsearchNotice' });
- const findLogControlButtons = () => wrapper.find(LogControlButtonsStub);
-
- const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' });
- const findLogTrace = () => wrapper.find({ ref: 'logTrace' });
- const findLogFooter = () => wrapper.find({ ref: 'logFooter' });
- const getInfiniteScrollAttr = (attr) => parseInt(findInfiniteScroll().attributes(attr), 10);
-
- const mockSetInitData = () => {
- state.pods.options = mockPods;
- state.environments.current = mockEnvName;
- [state.pods.current] = state.pods.options;
-
- state.logs.lines = [];
- };
-
- const mockShowPodLogs = () => {
- state.pods.options = mockPods;
- [state.pods.current] = mockPods;
-
- state.logs.lines = mockLogsResult;
- };
-
- const mockFetchEnvs = () => {
- state.environments.options = mockEnvironments;
- };
-
- const initWrapper = () => {
- wrapper = shallowMount(EnvironmentLogs, {
- propsData,
- store,
- stubs: {
- LogControlButtons: LogControlButtonsStub,
- GlInfiniteScroll: {
- name: 'gl-infinite-scroll',
- template: `
- <div>
- <slot name="header"></slot>
- <slot name="items"></slot>
- <slot></slot>
- </div>
- `,
- },
- GlSprintf,
- },
- });
- };
-
- beforeEach(() => {
- store = createStore();
- state = store.state.environmentLogs;
-
- jest.spyOn(store, 'dispatch').mockResolvedValue();
-
- dispatch = store.dispatch;
- });
-
- afterEach(() => {
- store.dispatch.mockReset();
-
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
- it('displays UI elements', () => {
- initWrapper();
-
- expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true);
- expect(findSimpleFilters().exists()).toBe(true);
- expect(findLogControlButtons().exists()).toBe(true);
-
- expect(findInfiniteScroll().exists()).toBe(true);
- expect(findLogTrace().exists()).toBe(true);
- });
-
- it('mounted inits data', () => {
- initWrapper();
-
- expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, {
- timeRange: expect.objectContaining({
- default: true,
- }),
- environmentName: mockEnvName,
- podName: null,
- });
-
- expect(dispatch).toHaveBeenCalledWith(`${module}/fetchEnvironments`, mockEnvironmentsEndpoint);
- });
-
- describe('loading state', () => {
- beforeEach(() => {
- state.pods.options = [];
-
- state.logs.lines = [];
- state.logs.isLoading = true;
-
- state.environments = {
- options: [],
- isLoading: true,
- };
-
- initWrapper();
- });
-
- it('does not display an alert to upgrade to ES', () => {
- expect(findElasticsearchNotice().exists()).toBe(false);
- });
-
- it('displays a disabled environments dropdown', () => {
- expect(findEnvironmentsDropdown().attributes('disabled')).toBe('true');
- expect(findEnvironmentsDropdown().findAll(GlDropdownItem).length).toBe(0);
- });
-
- it('does not update buttons state', () => {
- expect(updateControlBtnsMock).not.toHaveBeenCalled();
- });
-
- it('shows an infinite scroll with no content', () => {
- expect(getInfiniteScrollAttr('fetched-items')).toBe(0);
- });
-
- it('shows an infinite scroll container with no set max-height ', () => {
- expect(findInfiniteScroll().attributes('max-list-height')).toBeUndefined();
- });
-
- it('shows a logs trace', () => {
- expect(findLogTrace().text()).toBe('');
- expect(findLogTrace().find('.js-build-loader-animation').isVisible()).toBe(true);
- });
- });
-
- describe('k8s environment', () => {
- beforeEach(() => {
- state.pods.options = [];
-
- state.logs.lines = [];
- state.logs.isLoading = false;
-
- state.environments = {
- options: mockEnvironments,
- current: 'staging',
- isLoading: false,
- };
-
- initWrapper();
- });
-
- it('displays an alert to upgrade to ES', () => {
- expect(findElasticsearchNotice().exists()).toBe(true);
- });
-
- it('displays simple filters for kubernetes logs API', () => {
- expect(findSimpleFilters().exists()).toBe(true);
- expect(findAdvancedFilters().exists()).toBe(false);
- });
- });
-
- describe('state with data', () => {
- beforeEach(() => {
- dispatch.mockImplementation((actionName) => {
- if (actionName === `${module}/setInitData`) {
- mockSetInitData();
- } else if (actionName === `${module}/showPodLogs`) {
- mockShowPodLogs();
- } else if (actionName === `${module}/fetchEnvironments`) {
- mockFetchEnvs();
- mockShowPodLogs();
- }
- });
-
- initWrapper();
- });
-
- afterEach(() => {
- scrollDown.mockReset();
- updateControlBtnsMock.mockReset();
- });
-
- it('does not display an alert to upgrade to ES', () => {
- expect(findElasticsearchNotice().exists()).toBe(false);
- });
-
- it('populates environments dropdown', () => {
- const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
- expect(findEnvironmentsDropdown().props('text')).toBe(mockEnvName);
- expect(items.length).toBe(mockEnvironments.length);
- mockEnvironments.forEach((env, i) => {
- const item = items.at(i);
- expect(item.text()).toBe(env.name);
- });
- });
-
- it('dropdown has one environment selected', () => {
- const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
- mockEnvironments.forEach((env, i) => {
- const item = items.at(i);
-
- if (item.text() !== mockEnvName) {
- expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy();
- } else {
- expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy();
- }
- });
- });
-
- it('displays advanced filters for elasticsearch logs API', () => {
- expect(findSimpleFilters().exists()).toBe(false);
- expect(findAdvancedFilters().exists()).toBe(true);
- });
-
- it('shows infinite scroll with content', () => {
- expect(getInfiniteScrollAttr('fetched-items')).toBe(mockTrace.length);
- });
-
- it('populates logs trace', () => {
- const trace = findLogTrace();
- expect(trace.text().split('\n').length).toBe(mockTrace.length);
- expect(trace.text().split('\n')).toEqual(mockTrace);
- });
-
- it('populates footer', () => {
- const footer = findLogFooter().text();
-
- expect(footer).toContain(`${mockLogsResult.length} results`);
- });
-
- describe('when user clicks', () => {
- it('environment name, trace is refreshed', () => {
- const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
- const index = 1; // any env
-
- expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything());
-
- items.at(index).vm.$emit('click');
-
- expect(dispatch).toHaveBeenCalledWith(
- `${module}/showEnvironment`,
- mockEnvironments[index].name,
- );
- });
-
- it('refresh button, trace is refreshed', () => {
- expect(dispatch).not.toHaveBeenCalledWith(`${module}/refreshPodLogs`, undefined);
-
- findLogControlButtons().vm.$emit('refresh');
-
- expect(dispatch).toHaveBeenCalledWith(`${module}/refreshPodLogs`, undefined);
- });
- });
- });
-
- describe('listeners', () => {
- beforeEach(() => {
- initWrapper();
- });
-
- it('attaches listeners in components', () => {
- expect(findInfiniteScroll().vm.$listeners).toEqual({
- topReached: expect.any(Function),
- scroll: expect.any(Function),
- });
- });
-
- it('`topReached` when not loading', () => {
- expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
-
- findInfiniteScroll().vm.$emit('topReached');
-
- expect(store.dispatch).toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
- });
-
- it('`topReached` does not fetches more logs when already loading', () => {
- state.logs.isLoading = true;
- findInfiniteScroll().vm.$emit('topReached');
-
- expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
- });
-
- it('`topReached` fetches more logs', () => {
- state.logs.isLoading = true;
- findInfiniteScroll().vm.$emit('topReached');
-
- expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
- });
-
- it('`scroll` on a scrollable target results in enabled scroll buttons', async () => {
- const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 21 };
-
- state.logs.isLoading = true;
- findInfiniteScroll().vm.$emit('scroll', { target });
-
- await nextTick();
- expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(false);
- });
-
- it('`scroll` on a non-scrollable target in disabled scroll buttons', async () => {
- const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 20 };
-
- state.logs.isLoading = true;
- findInfiniteScroll().vm.$emit('scroll', { target });
-
- await nextTick();
- expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
- });
-
- it('`scroll` on no target results in disabled scroll buttons', async () => {
- state.logs.isLoading = true;
- findInfiniteScroll().vm.$emit('scroll', { target: undefined });
-
- await nextTick();
- expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
- });
- });
-});
diff --git a/spec/frontend/logs/components/log_advanced_filters_spec.js b/spec/frontend/logs/components/log_advanced_filters_spec.js
deleted file mode 100644
index 4e4052eb4d8..00000000000
--- a/spec/frontend/logs/components/log_advanced_filters_spec.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import { GlFilteredSearch } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import LogAdvancedFilters from '~/logs/components/log_advanced_filters.vue';
-import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
-import { createStore } from '~/logs/stores';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
-import { defaultTimeRange } from '~/vue_shared/constants';
-import { mockPods, mockSearch } from '../mock_data';
-
-const module = 'environmentLogs';
-
-describe('LogAdvancedFilters', () => {
- let store;
- let dispatch;
- let wrapper;
- let state;
-
- const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
- const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
- const getSearchToken = (type) =>
- findFilteredSearch()
- .props('availableTokens')
- .filter((token) => token.type === type)[0];
-
- const mockStateLoading = () => {
- state.timeRange.selected = defaultTimeRange;
- state.timeRange.current = convertToFixedRange(defaultTimeRange);
- state.pods.options = [];
- state.pods.current = null;
- state.logs.isLoading = true;
- };
-
- const mockStateWithData = () => {
- state.timeRange.selected = defaultTimeRange;
- state.timeRange.current = convertToFixedRange(defaultTimeRange);
- state.pods.options = mockPods;
- state.pods.current = null;
- state.logs.isLoading = false;
- };
-
- const initWrapper = (propsData = {}) => {
- wrapper = shallowMount(LogAdvancedFilters, {
- propsData: {
- ...propsData,
- },
- store,
- });
- };
-
- beforeEach(() => {
- store = createStore();
- state = store.state.environmentLogs;
-
- jest.spyOn(store, 'dispatch').mockResolvedValue();
-
- dispatch = store.dispatch;
- });
-
- afterEach(() => {
- store.dispatch.mockReset();
-
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
- it('displays UI elements', () => {
- initWrapper();
-
- expect(findFilteredSearch().exists()).toBe(true);
- expect(findTimeRangePicker().exists()).toBe(true);
- });
-
- it('displays search tokens', () => {
- initWrapper();
-
- expect(getSearchToken(TOKEN_TYPE_POD_NAME)).toMatchObject({
- title: 'Pod name',
- unique: true,
- operators: OPERATOR_IS_ONLY,
- });
- });
-
- describe('disabled state', () => {
- beforeEach(() => {
- mockStateLoading();
- initWrapper({
- disabled: true,
- });
- });
-
- it('displays disabled filters', () => {
- expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
- expect(findTimeRangePicker().attributes('disabled')).toBeTruthy();
- });
- });
-
- describe('when the state is loading', () => {
- beforeEach(() => {
- mockStateLoading();
- initWrapper();
- });
-
- it('displays a disabled search', () => {
- expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
- });
-
- it('displays an enable date filter', () => {
- expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
- });
-
- it('displays no pod options when no pods are available, so suggestions can be displayed', () => {
- expect(getSearchToken(TOKEN_TYPE_POD_NAME).options).toBe(null);
- expect(getSearchToken(TOKEN_TYPE_POD_NAME).loading).toBe(true);
- });
- });
-
- describe('when the state has data', () => {
- beforeEach(() => {
- mockStateWithData();
- initWrapper();
- });
-
- it('displays a single token for pods', () => {
- initWrapper();
-
- const tokens = findFilteredSearch().props('availableTokens');
-
- expect(tokens).toHaveLength(1);
- expect(tokens[0].type).toBe(TOKEN_TYPE_POD_NAME);
- });
-
- it('displays a enabled filters', () => {
- expect(findFilteredSearch().attributes('disabled')).toBeFalsy();
- expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
- });
-
- it('displays options in the pods token', () => {
- const { options } = getSearchToken(TOKEN_TYPE_POD_NAME);
-
- expect(options).toHaveLength(mockPods.length);
- });
-
- it('displays options in date time picker', () => {
- const options = findTimeRangePicker().props('options');
-
- expect(options).toEqual(expect.any(Array));
- expect(options.length).toBeGreaterThan(0);
- });
-
- describe('when the user interacts', () => {
- it('clicks on the search button, showFilteredLogs is dispatched', () => {
- findFilteredSearch().vm.$emit('submit', null);
-
- expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, null);
- });
-
- it('clicks on the search button, showFilteredLogs is dispatched with null', () => {
- findFilteredSearch().vm.$emit('submit', [mockSearch]);
-
- expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, [mockSearch]);
- });
-
- it('selects a new time range', () => {
- expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
-
- const mockRange = { start: 'START_DATE', end: 'END_DATE' };
- findTimeRangePicker().vm.$emit('input', mockRange);
-
- expect(dispatch).toHaveBeenCalledWith(`${module}/setTimeRange`, mockRange);
- });
- });
- });
-});
diff --git a/spec/frontend/logs/components/log_control_buttons_spec.js b/spec/frontend/logs/components/log_control_buttons_spec.js
deleted file mode 100644
index e249272b87d..00000000000
--- a/spec/frontend/logs/components/log_control_buttons_spec.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import LogControlButtons from '~/logs/components/log_control_buttons.vue';
-
-describe('LogControlButtons', () => {
- let wrapper;
-
- const findScrollToTop = () => wrapper.find('.js-scroll-to-top');
- const findScrollToBottom = () => wrapper.find('.js-scroll-to-bottom');
- const findRefreshBtn = () => wrapper.find('.js-refresh-log');
-
- const initWrapper = (opts) => {
- wrapper = shallowMount(LogControlButtons, {
- listeners: {
- scrollUp: () => {},
- scrollDown: () => {},
- },
- ...opts,
- });
- };
-
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
- it('displays UI elements', () => {
- initWrapper();
-
- expect(findScrollToTop().is(GlButton)).toBe(true);
- expect(findScrollToBottom().is(GlButton)).toBe(true);
- expect(findRefreshBtn().is(GlButton)).toBe(true);
- });
-
- it('emits a `refresh` event on click on `refresh` button', async () => {
- initWrapper();
-
- // An `undefined` value means no event was emitted
- expect(wrapper.emitted('refresh')).toBe(undefined);
-
- findRefreshBtn().vm.$emit('click');
-
- await nextTick();
- expect(wrapper.emitted('refresh')).toHaveLength(1);
- });
-
- describe('when scrolling actions are enabled', () => {
- beforeEach(async () => {
- // mock scrolled to the middle of a long page
- initWrapper();
- await nextTick();
- });
-
- it('click on "scroll to top" scrolls up', () => {
- expect(findScrollToTop().attributes('disabled')).toBeUndefined();
-
- findScrollToTop().vm.$emit('click');
-
- expect(wrapper.emitted('scrollUp')).toHaveLength(1);
- });
-
- it('click on "scroll to bottom" scrolls down', () => {
- expect(findScrollToBottom().attributes('disabled')).toBeUndefined();
-
- findScrollToBottom().vm.$emit('click');
-
- expect(wrapper.emitted('scrollDown')).toHaveLength(1);
- });
- });
-
- describe('when scrolling actions are disabled', () => {
- beforeEach(async () => {
- initWrapper({ listeners: {} });
- await nextTick();
- });
-
- it('buttons are disabled', async () => {
- await nextTick();
- expect(findScrollToTop().exists()).toBe(false);
- expect(findScrollToBottom().exists()).toBe(false);
- // This should be enabled when gitlab-ui contains:
- // https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1149
- // expect(findScrollToBottom().is('[disabled]')).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/logs/components/log_simple_filters_spec.js b/spec/frontend/logs/components/log_simple_filters_spec.js
deleted file mode 100644
index 04ad2e03542..00000000000
--- a/spec/frontend/logs/components/log_simple_filters_spec.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import { GlDropdownItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import LogSimpleFilters from '~/logs/components/log_simple_filters.vue';
-import { createStore } from '~/logs/stores';
-import { mockPods, mockPodName } from '../mock_data';
-
-const module = 'environmentLogs';
-
-describe('LogSimpleFilters', () => {
- let store;
- let dispatch;
- let wrapper;
- let state;
-
- const findPodsDropdown = () => wrapper.find({ ref: 'podsDropdown' });
- const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' });
- const findPodsDropdownItems = () =>
- findPodsDropdown()
- .findAll(GlDropdownItem)
- .filter((item) => !('disabled' in item.attributes()));
-
- const mockPodsLoading = () => {
- state.pods.options = [];
- state.pods.current = null;
- };
-
- const mockPodsLoaded = () => {
- state.pods.options = mockPods;
- state.pods.current = mockPodName;
- };
-
- const initWrapper = (propsData = {}) => {
- wrapper = shallowMount(LogSimpleFilters, {
- propsData: {
- ...propsData,
- },
- store,
- });
- };
-
- beforeEach(() => {
- store = createStore();
- state = store.state.environmentLogs;
-
- jest.spyOn(store, 'dispatch').mockResolvedValue();
-
- dispatch = store.dispatch;
- });
-
- afterEach(() => {
- store.dispatch.mockReset();
-
- if (wrapper) {
- wrapper.destroy();
- }
- });
-
- it('displays UI elements', () => {
- initWrapper();
-
- expect(findPodsDropdown().exists()).toBe(true);
- });
-
- describe('disabled state', () => {
- beforeEach(() => {
- mockPodsLoading();
- initWrapper({
- disabled: true,
- });
- });
-
- it('displays a disabled pods dropdown', () => {
- expect(findPodsDropdown().props('text')).toBe('No pod selected');
- expect(findPodsDropdown().attributes('disabled')).toBeTruthy();
- });
- });
-
- describe('loading state', () => {
- beforeEach(() => {
- mockPodsLoading();
- initWrapper();
- });
-
- it('displays an enabled pods dropdown', () => {
- expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
- expect(findPodsDropdown().props('text')).toBe('No pod selected');
- });
-
- it('displays an empty pods dropdown', () => {
- expect(findPodsNoPodsText().exists()).toBe(true);
- expect(findPodsDropdownItems()).toHaveLength(0);
- });
- });
-
- describe('pods available state', () => {
- beforeEach(() => {
- mockPodsLoaded();
- initWrapper();
- });
-
- it('displays an enabled pods dropdown', () => {
- expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
- expect(findPodsDropdown().props('text')).toBe(mockPods[0]);
- });
-
- it('displays a pods dropdown with items', () => {
- expect(findPodsNoPodsText().exists()).toBe(false);
- expect(findPodsDropdownItems()).toHaveLength(mockPods.length);
- });
-
- it('dropdown has one pod selected', () => {
- const items = findPodsDropdownItems();
- mockPods.forEach((pod, i) => {
- const item = items.at(i);
- if (item.text() !== mockPodName) {
- expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy();
- } else {
- expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy();
- }
- });
- });
-
- it('when the user clicks on a pod, showPodLogs is dispatched', () => {
- const items = findPodsDropdownItems();
- const index = 2; // any pod
-
- expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
-
- items.at(index).vm.$emit('click');
-
- expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]);
- });
- });
-});
diff --git a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js b/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js
deleted file mode 100644
index f667a590a36..00000000000
--- a/spec/frontend/logs/components/tokens/token_with_loading_state_spec.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-
-import TokenWithLoadingState from '~/logs/components/tokens/token_with_loading_state.vue';
-
-describe('TokenWithLoadingState', () => {
- let wrapper;
-
- const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
- const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
-
- const initWrapper = (props = {}, options) => {
- wrapper = shallowMount(TokenWithLoadingState, {
- propsData: {
- cursorPosition: 'start',
- ...props,
- },
- ...options,
- });
- };
-
- beforeEach(() => {});
-
- it('passes entire config correctly', () => {
- const config = {
- icon: 'pod',
- type: 'pod',
- title: 'Pod name',
- unique: true,
- };
-
- initWrapper({ config });
-
- expect(findFilteredSearchToken().props('config')).toEqual(config);
- });
-
- describe('suggestions are replaced', () => {
- let mockNoOptsText;
- let config;
- let stubs;
-
- beforeEach(() => {
- mockNoOptsText = 'No suggestions available';
- config = {
- loading: false,
- noOptionsText: mockNoOptsText,
- };
- stubs = {
- GlFilteredSearchToken: {
- template: `<div><slot name="suggestions"></slot></div>`,
- },
- };
- });
-
- it('renders a loading icon', () => {
- config.loading = true;
-
- initWrapper({ config }, { stubs });
-
- expect(findLoadingIcon().exists()).toBe(true);
- expect(wrapper.text()).toBe('');
- });
-
- it('renders an empty results message', () => {
- initWrapper({ config }, { stubs });
-
- expect(findLoadingIcon().exists()).toBe(false);
- expect(wrapper.text()).toBe(mockNoOptsText);
- });
- });
-});
diff --git a/spec/frontend/logs/mock_data.js b/spec/frontend/logs/mock_data.js
deleted file mode 100644
index 14c8f7a2ba2..00000000000
--- a/spec/frontend/logs/mock_data.js
+++ /dev/null
@@ -1,71 +0,0 @@
-const mockProjectPath = 'root/autodevops-deploy';
-
-export const mockEnvName = 'production';
-export const mockEnvironmentsEndpoint = `${mockProjectPath}/environments.json`;
-export const mockEnvId = '99';
-export const mockDocumentationPath = '/documentation.md';
-export const mockLogsEndpoint = '/dummy_logs_path.json';
-export const mockCursor = 'MOCK_CURSOR';
-export const mockNextCursor = 'MOCK_NEXT_CURSOR';
-
-const makeMockEnvironment = (id, name, advancedQuerying) => ({
- id,
- project_path: mockProjectPath,
- name,
- logs_api_path: mockLogsEndpoint,
- enable_advanced_logs_querying: advancedQuerying,
-});
-
-export const mockEnvironment = makeMockEnvironment(mockEnvId, mockEnvName, true);
-export const mockEnvironments = [
- mockEnvironment,
- makeMockEnvironment(101, 'staging', false),
- makeMockEnvironment(102, 'review/a-feature', false),
-];
-
-export const mockPodName = 'production-764c58d697-aaaaa';
-export const mockPods = [
- mockPodName,
- 'production-764c58d697-bbbbb',
- 'production-764c58d697-ccccc',
- 'production-764c58d697-ddddd',
-];
-
-export const mockLogsResult = [
- {
- timestamp: '2019-12-13T13:43:18.2760123Z',
- message: 'log line 1',
- pod: 'foo',
- },
- {
- timestamp: '2019-12-13T13:43:18.2760123Z',
- message: 'log line A',
- pod: 'bar',
- },
- {
- timestamp: '2019-12-13T13:43:26.8420123Z',
- message: 'log line 2',
- pod: 'foo',
- },
- {
- timestamp: '2019-12-13T13:43:26.8420123Z',
- message: 'log line B',
- pod: 'bar',
- },
-];
-
-export const mockTrace = [
- 'Dec 13 13:43:18.276 | foo | log line 1',
- 'Dec 13 13:43:18.276 | bar | log line A',
- 'Dec 13 13:43:26.842 | foo | log line 2',
- 'Dec 13 13:43:26.842 | bar | log line B',
-];
-
-export const mockResponse = {
- pod_name: mockPodName,
- pods: mockPods,
- logs: mockLogsResult,
- cursor: mockNextCursor,
-};
-
-export const mockSearch = 'foo +bar';
diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js
deleted file mode 100644
index 46ef1500a20..00000000000
--- a/spec/frontend/logs/stores/actions_spec.js
+++ /dev/null
@@ -1,521 +0,0 @@
-import MockAdapter from 'axios-mock-adapter';
-import testAction from 'helpers/vuex_action_helper';
-import axios from '~/lib/utils/axios_utils';
-import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
-import {
- setInitData,
- showFilteredLogs,
- showPodLogs,
- fetchEnvironments,
- fetchLogs,
- fetchMoreLogsPrepend,
-} from '~/logs/stores/actions';
-import * as types from '~/logs/stores/mutation_types';
-import logsPageState from '~/logs/stores/state';
-import Tracking from '~/tracking';
-
-import { defaultTimeRange } from '~/vue_shared/constants';
-
-import {
- mockPodName,
- mockEnvironmentsEndpoint,
- mockEnvironments,
- mockPods,
- mockLogsResult,
- mockEnvName,
- mockSearch,
- mockLogsEndpoint,
- mockResponse,
- mockCursor,
- mockNextCursor,
-} from '../mock_data';
-
-jest.mock('~/lib/utils/datetime_range');
-jest.mock('~/logs/utils');
-
-const mockDefaultRange = {
- start: '2020-01-10T18:00:00.000Z',
- end: '2020-01-10T19:00:00.000Z',
-};
-const mockFixedRange = {
- start: '2020-01-09T18:06:20.000Z',
- end: '2020-01-09T18:36:20.000Z',
-};
-const mockRollingRange = {
- duration: 120,
-};
-const mockRollingRangeAsFixed = {
- start: '2020-01-10T18:00:00.000Z',
- end: '2020-01-10T17:58:00.000Z',
-};
-
-describe('Logs Store actions', () => {
- let state;
- let mock;
-
- const latestGetParams = () => mock.history.get[mock.history.get.length - 1].params;
-
- convertToFixedRange.mockImplementation((range) => {
- if (range === defaultTimeRange) {
- return { ...mockDefaultRange };
- }
- if (range === mockFixedRange) {
- return { ...mockFixedRange };
- }
- if (range === mockRollingRange) {
- return { ...mockRollingRangeAsFixed };
- }
- throw new Error('Invalid time range');
- });
-
- beforeEach(() => {
- state = logsPageState();
- });
-
- describe('setInitData', () => {
- it('should commit environment and pod name mutation', () =>
- testAction(
- setInitData,
- { timeRange: mockFixedRange, environmentName: mockEnvName, podName: mockPodName },
- state,
- [
- { type: types.SET_TIME_RANGE, payload: mockFixedRange },
- { type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName },
- { type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
- ],
- ));
- });
-
- describe('showFilteredLogs', () => {
- it('empty search should filter with defaults', () =>
- testAction(
- showFilteredLogs,
- undefined,
- state,
- [
- { type: types.SET_CURRENT_POD_NAME, payload: null },
- { type: types.SET_SEARCH, payload: '' },
- ],
- [{ type: 'fetchLogs', payload: 'used_search_bar' }],
- ));
-
- it('text search should filter with a search term', () =>
- testAction(
- showFilteredLogs,
- [mockSearch],
- state,
- [
- { type: types.SET_CURRENT_POD_NAME, payload: null },
- { type: types.SET_SEARCH, payload: mockSearch },
- ],
- [{ type: 'fetchLogs', payload: 'used_search_bar' }],
- ));
-
- it('pod search should filter with a search term', () =>
- testAction(
- showFilteredLogs,
- [{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }],
- state,
- [
- { type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
- { type: types.SET_SEARCH, payload: '' },
- ],
- [{ type: 'fetchLogs', payload: 'used_search_bar' }],
- ));
-
- it('pod search should filter with a pod selection and a search term', () =>
- testAction(
- showFilteredLogs,
- [{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, mockSearch],
- state,
- [
- { type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
- { type: types.SET_SEARCH, payload: mockSearch },
- ],
- [{ type: 'fetchLogs', payload: 'used_search_bar' }],
- ));
-
- it('pod search should filter with a pod selection and two search terms', () =>
- testAction(
- showFilteredLogs,
- ['term1', 'term2'],
- state,
- [
- { type: types.SET_CURRENT_POD_NAME, payload: null },
- { type: types.SET_SEARCH, payload: `term1 term2` },
- ],
- [{ type: 'fetchLogs', payload: 'used_search_bar' }],
- ));
-
- it('pod search should filter with a pod selection and a search terms before and after', () =>
- testAction(
- showFilteredLogs,
- [
- 'term1',
- { type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } },
- 'term2',
- ],
- state,
- [
- { type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
- { type: types.SET_SEARCH, payload: `term1 term2` },
- ],
- [{ type: 'fetchLogs', payload: 'used_search_bar' }],
- ));
- });
-
- describe('showPodLogs', () => {
- it('should commit pod name', () =>
- testAction(
- showPodLogs,
- mockPodName,
- state,
- [{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }],
- [{ type: 'fetchLogs', payload: 'pod_log_changed' }],
- ));
- });
-
- describe('fetchEnvironments', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- it('should commit RECEIVE_ENVIRONMENTS_DATA_SUCCESS mutation on correct data', () => {
- mock.onGet(mockEnvironmentsEndpoint).replyOnce(200, mockEnvironments);
- return testAction(
- fetchEnvironments,
- mockEnvironmentsEndpoint,
- state,
- [
- { type: types.REQUEST_ENVIRONMENTS_DATA },
- { type: types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, payload: mockEnvironments },
- ],
- [{ type: 'fetchLogs', payload: 'environment_selected' }],
- );
- });
-
- it('should commit RECEIVE_ENVIRONMENTS_DATA_ERROR on wrong data', () => {
- mock.onGet(mockEnvironmentsEndpoint).replyOnce(500);
- return testAction(
- fetchEnvironments,
- mockEnvironmentsEndpoint,
- state,
- [
- { type: types.REQUEST_ENVIRONMENTS_DATA },
- { type: types.RECEIVE_ENVIRONMENTS_DATA_ERROR },
- ],
- [],
- );
- });
- });
-
- describe('when the backend responds succesfully', () => {
- let expectedMutations;
- let expectedActions;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
- mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
-
- state.environments.options = mockEnvironments;
- state.environments.current = mockEnvName;
- });
-
- afterEach(() => {
- mock.reset();
- });
-
- describe('fetchLogs', () => {
- beforeEach(() => {
- expectedMutations = [
- { type: types.REQUEST_LOGS_DATA },
- {
- type: types.RECEIVE_LOGS_DATA_SUCCESS,
- payload: { logs: mockLogsResult, cursor: mockNextCursor },
- },
- { type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
- { type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
- ];
-
- expectedActions = [];
- });
-
- it('should commit logs and pod data when there is pod name defined', () => {
- state.pods.current = mockPodName;
- state.timeRange.current = mockFixedRange;
-
- return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
- expect(latestGetParams()).toMatchObject({
- pod_name: mockPodName,
- });
- });
- });
-
- it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
- state.pods.current = mockPodName;
- state.timeRange.current = mockFixedRange;
- state.logs.cursor = mockCursor;
-
- return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
- expect(latestGetParams()).toEqual({
- pod_name: mockPodName,
- start_time: mockFixedRange.start,
- end_time: mockFixedRange.end,
- cursor: mockCursor,
- });
- });
- });
-
- it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
- state.pods.current = mockPodName;
- state.search = mockSearch;
- state.timeRange.current = 'INVALID_TIME_RANGE';
-
- expectedMutations.splice(1, 0, {
- type: types.SHOW_TIME_RANGE_INVALID_WARNING,
- });
-
- return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
- expect(latestGetParams()).toEqual({
- pod_name: mockPodName,
- search: mockSearch,
- });
- });
- });
-
- it('should commit logs and pod data when no pod name defined', () => {
- state.timeRange.current = defaultTimeRange;
-
- return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
- expect(latestGetParams()).toEqual({
- start_time: expect.any(String),
- end_time: expect.any(String),
- });
- });
- });
- });
-
- describe('fetchMoreLogsPrepend', () => {
- beforeEach(() => {
- expectedMutations = [
- { type: types.REQUEST_LOGS_DATA_PREPEND },
- {
- type: types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS,
- payload: { logs: mockLogsResult, cursor: mockNextCursor },
- },
- ];
-
- expectedActions = [];
- });
-
- it('should commit logs and pod data when there is pod name defined', () => {
- state.pods.current = mockPodName;
- state.timeRange.current = mockFixedRange;
-
- expectedActions = [];
-
- return testAction(
- fetchMoreLogsPrepend,
- null,
- state,
- expectedMutations,
- expectedActions,
- () => {
- expect(latestGetParams()).toMatchObject({
- pod_name: mockPodName,
- });
- },
- );
- });
-
- it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
- state.pods.current = mockPodName;
- state.timeRange.current = mockFixedRange;
- state.logs.cursor = mockCursor;
-
- return testAction(
- fetchMoreLogsPrepend,
- null,
- state,
- expectedMutations,
- expectedActions,
- () => {
- expect(latestGetParams()).toEqual({
- pod_name: mockPodName,
- start_time: mockFixedRange.start,
- end_time: mockFixedRange.end,
- cursor: mockCursor,
- });
- },
- );
- });
-
- it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
- state.pods.current = mockPodName;
- state.search = mockSearch;
- state.timeRange.current = 'INVALID_TIME_RANGE';
-
- expectedMutations.splice(1, 0, {
- type: types.SHOW_TIME_RANGE_INVALID_WARNING,
- });
-
- return testAction(
- fetchMoreLogsPrepend,
- null,
- state,
- expectedMutations,
- expectedActions,
- () => {
- expect(latestGetParams()).toEqual({
- pod_name: mockPodName,
- search: mockSearch,
- });
- },
- );
- });
-
- it('should commit logs and pod data when no pod name defined', () => {
- state.timeRange.current = defaultTimeRange;
-
- return testAction(
- fetchMoreLogsPrepend,
- null,
- state,
- expectedMutations,
- expectedActions,
- () => {
- expect(latestGetParams()).toEqual({
- start_time: expect.any(String),
- end_time: expect.any(String),
- });
- },
- );
- });
-
- it('should not commit logs or pod data when it has reached the end', () => {
- state.logs.isComplete = true;
- state.logs.cursor = null;
-
- return testAction(
- fetchMoreLogsPrepend,
- null,
- state,
- [], // no mutations done
- [], // no actions dispatched
- () => {
- expect(mock.history.get).toHaveLength(0);
- },
- );
- });
- });
- });
-
- describe('when the backend responds with an error', () => {
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onGet(mockLogsEndpoint).reply(500);
- });
-
- afterEach(() => {
- mock.reset();
- });
-
- it('fetchLogs should commit logs and pod errors', () => {
- state.environments.options = mockEnvironments;
- state.environments.current = mockEnvName;
- state.timeRange.current = defaultTimeRange;
-
- return testAction(
- fetchLogs,
- null,
- state,
- [
- { type: types.REQUEST_LOGS_DATA },
- { type: types.RECEIVE_PODS_DATA_ERROR },
- { type: types.RECEIVE_LOGS_DATA_ERROR },
- ],
- [],
- () => {
- expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
- },
- );
- });
-
- it('fetchMoreLogsPrepend should commit logs and pod errors', () => {
- state.environments.options = mockEnvironments;
- state.environments.current = mockEnvName;
- state.timeRange.current = defaultTimeRange;
-
- return testAction(
- fetchMoreLogsPrepend,
- null,
- state,
- [
- { type: types.REQUEST_LOGS_DATA_PREPEND },
- { type: types.RECEIVE_LOGS_DATA_PREPEND_ERROR },
- ],
- [],
- () => {
- expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
- },
- );
- });
- });
-});
-
-describe('Tracking user interaction', () => {
- let commit;
- let dispatch;
- let state;
- let mock;
-
- beforeEach(() => {
- jest.spyOn(Tracking, 'event');
- commit = jest.fn();
- dispatch = jest.fn();
- state = logsPageState();
- state.environments.options = mockEnvironments;
- state.environments.current = mockEnvName;
-
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.reset();
- });
-
- describe('Logs with data', () => {
- beforeEach(() => {
- mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
- mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
- });
-
- it('tracks fetched logs with data', () => {
- return fetchLogs({ state, commit, dispatch }, 'environment_selected').then(() => {
- expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'logs_view', {
- label: 'environment_selected',
- property: 'count',
- value: 1,
- });
- });
- });
- });
-
- describe('Logs without data', () => {
- beforeEach(() => {
- mock.onGet(mockLogsEndpoint).reply(200, {
- ...mockResponse,
- logs: [],
- });
- mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
- });
-
- it('does not track empty log responses', () => {
- return fetchLogs({ state, commit, dispatch }).then(() => {
- expect(Tracking.event).not.toHaveBeenCalled();
- });
- });
- });
-});
diff --git a/spec/frontend/logs/stores/getters_spec.js b/spec/frontend/logs/stores/getters_spec.js
deleted file mode 100644
index 9d213d8c01f..00000000000
--- a/spec/frontend/logs/stores/getters_spec.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import { trace, showAdvancedFilters } from '~/logs/stores/getters';
-import logsPageState from '~/logs/stores/state';
-
-import { mockLogsResult, mockTrace, mockEnvName, mockEnvironments } from '../mock_data';
-
-describe('Logs Store getters', () => {
- let state;
-
- beforeEach(() => {
- state = logsPageState();
- });
-
- describe('trace', () => {
- describe('when state is initialized', () => {
- it('returns an empty string', () => {
- expect(trace(state)).toEqual('');
- });
- });
-
- describe('when state logs are empty', () => {
- beforeEach(() => {
- state.logs.lines = [];
- });
-
- it('returns an empty string', () => {
- expect(trace(state)).toEqual('');
- });
- });
-
- describe('when state logs are set', () => {
- beforeEach(() => {
- state.logs.lines = mockLogsResult;
- });
-
- it('returns an empty string', () => {
- expect(trace(state)).toEqual(mockTrace.join('\n'));
- });
- });
- });
-
- describe('showAdvancedFilters', () => {
- describe('when no environments are set', () => {
- beforeEach(() => {
- state.environments.current = mockEnvName;
- state.environments.options = [];
- });
-
- it('returns false', () => {
- expect(showAdvancedFilters(state)).toBe(false);
- });
- });
-
- describe('when the environment supports filters', () => {
- beforeEach(() => {
- state.environments.current = mockEnvName;
- state.environments.options = mockEnvironments;
- });
-
- it('returns true', () => {
- expect(showAdvancedFilters(state)).toBe(true);
- });
- });
-
- describe('when the environment does not support filters', () => {
- beforeEach(() => {
- state.environments.options = mockEnvironments;
- state.environments.current = mockEnvironments[1].name;
- });
-
- it('returns true', () => {
- expect(showAdvancedFilters(state)).toBe(false);
- });
- });
- });
-});
diff --git a/spec/frontend/logs/stores/mutations_spec.js b/spec/frontend/logs/stores/mutations_spec.js
deleted file mode 100644
index 988197a8350..00000000000
--- a/spec/frontend/logs/stores/mutations_spec.js
+++ /dev/null
@@ -1,257 +0,0 @@
-import * as types from '~/logs/stores/mutation_types';
-import mutations from '~/logs/stores/mutations';
-
-import logsPageState from '~/logs/stores/state';
-import {
- mockEnvName,
- mockEnvironments,
- mockPods,
- mockPodName,
- mockLogsResult,
- mockSearch,
- mockCursor,
- mockNextCursor,
-} from '../mock_data';
-
-describe('Logs Store Mutations', () => {
- let state;
-
- beforeEach(() => {
- state = logsPageState();
- });
-
- it('ensures mutation types are correctly named', () => {
- Object.keys(types).forEach((k) => {
- expect(k).toEqual(types[k]);
- });
- });
-
- describe('SET_PROJECT_ENVIRONMENT', () => {
- it('sets the environment', () => {
- mutations[types.SET_PROJECT_ENVIRONMENT](state, mockEnvName);
- expect(state.environments.current).toEqual(mockEnvName);
- });
- });
-
- describe('SET_SEARCH', () => {
- it('sets the search', () => {
- mutations[types.SET_SEARCH](state, mockSearch);
- expect(state.search).toEqual(mockSearch);
- });
- });
-
- describe('REQUEST_ENVIRONMENTS_DATA', () => {
- it('inits data', () => {
- mutations[types.REQUEST_ENVIRONMENTS_DATA](state);
- expect(state.environments.options).toEqual([]);
- expect(state.environments.isLoading).toEqual(true);
- });
- });
-
- describe('RECEIVE_ENVIRONMENTS_DATA_SUCCESS', () => {
- it('receives environments data and stores it as options', () => {
- expect(state.environments.options).toEqual([]);
-
- mutations[types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, mockEnvironments);
-
- expect(state.environments.options).toEqual(mockEnvironments);
- expect(state.environments.isLoading).toEqual(false);
- });
- });
-
- describe('RECEIVE_ENVIRONMENTS_DATA_ERROR', () => {
- it('captures an error loading environments', () => {
- mutations[types.RECEIVE_ENVIRONMENTS_DATA_ERROR](state);
-
- expect(state.environments).toEqual({
- options: [],
- isLoading: false,
- current: null,
- fetchError: true,
- });
- });
- });
-
- describe('REQUEST_LOGS_DATA', () => {
- it('starts loading for logs', () => {
- mutations[types.REQUEST_LOGS_DATA](state);
-
- expect(state.timeRange.current).toEqual({
- start: expect.any(String),
- end: expect.any(String),
- });
-
- expect(state.logs).toEqual({
- lines: [],
- cursor: null,
- fetchError: false,
- isLoading: true,
- isComplete: false,
- });
- });
- });
-
- describe('RECEIVE_LOGS_DATA_SUCCESS', () => {
- it('receives logs lines and cursor', () => {
- mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, {
- logs: mockLogsResult,
- cursor: mockCursor,
- });
-
- expect(state.logs).toEqual({
- lines: mockLogsResult,
- isLoading: false,
- cursor: mockCursor,
- isComplete: false,
- fetchError: false,
- });
- });
-
- it('receives logs lines and a null cursor to indicate the end', () => {
- mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, {
- logs: mockLogsResult,
- cursor: null,
- });
-
- expect(state.logs).toEqual({
- lines: mockLogsResult,
- isLoading: false,
- cursor: null,
- isComplete: true,
- fetchError: false,
- });
- });
- });
-
- describe('RECEIVE_LOGS_DATA_ERROR', () => {
- it('receives log data error and stops loading', () => {
- mutations[types.RECEIVE_LOGS_DATA_ERROR](state);
-
- expect(state.logs).toEqual({
- lines: [],
- isLoading: false,
- cursor: null,
- isComplete: false,
- fetchError: true,
- });
- });
- });
-
- describe('REQUEST_LOGS_DATA_PREPEND', () => {
- it('receives logs lines and cursor', () => {
- mutations[types.REQUEST_LOGS_DATA_PREPEND](state);
-
- expect(state.logs.isLoading).toBe(true);
- });
- });
-
- describe('RECEIVE_LOGS_DATA_PREPEND_SUCCESS', () => {
- it('receives logs lines and cursor', () => {
- mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
- logs: mockLogsResult,
- cursor: mockCursor,
- });
-
- expect(state.logs).toEqual({
- lines: mockLogsResult,
- isLoading: false,
- cursor: mockCursor,
- isComplete: false,
- fetchError: false,
- });
- });
-
- it('receives additional logs lines and a new cursor', () => {
- mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
- logs: mockLogsResult,
- cursor: mockCursor,
- });
-
- mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
- logs: mockLogsResult,
- cursor: mockNextCursor,
- });
-
- expect(state.logs).toEqual({
- lines: [...mockLogsResult, ...mockLogsResult],
- isLoading: false,
- cursor: mockNextCursor,
- isComplete: false,
- fetchError: false,
- });
- });
-
- it('receives logs lines and a null cursor to indicate is complete', () => {
- mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
- logs: mockLogsResult,
- cursor: null,
- });
-
- expect(state.logs).toEqual({
- lines: mockLogsResult,
- isLoading: false,
- cursor: null,
- isComplete: true,
- fetchError: false,
- });
- });
- });
-
- describe('RECEIVE_LOGS_DATA_PREPEND_ERROR', () => {
- it('receives logs lines and cursor', () => {
- mutations[types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state);
-
- expect(state.logs.isLoading).toBe(false);
- expect(state.logs.fetchError).toBe(true);
- });
- });
-
- describe('SET_CURRENT_POD_NAME', () => {
- it('set current pod name', () => {
- mutations[types.SET_CURRENT_POD_NAME](state, mockPodName);
-
- expect(state.pods.current).toEqual(mockPodName);
- });
- });
-
- describe('SET_TIME_RANGE', () => {
- it('sets a default range', () => {
- expect(state.timeRange.selected).toEqual(expect.any(Object));
- expect(state.timeRange.current).toEqual(expect.any(Object));
- });
-
- it('sets a time range', () => {
- const mockRange = {
- start: '2020-01-10T18:00:00.000Z',
- end: '2020-01-10T10:00:00.000Z',
- };
- mutations[types.SET_TIME_RANGE](state, mockRange);
-
- expect(state.timeRange.selected).toEqual(mockRange);
- expect(state.timeRange.current).toEqual(mockRange);
- });
- });
-
- describe('RECEIVE_PODS_DATA_SUCCESS', () => {
- it('receives pods data success', () => {
- mutations[types.RECEIVE_PODS_DATA_SUCCESS](state, mockPods);
-
- expect(state.pods).toEqual(
- expect.objectContaining({
- options: mockPods,
- }),
- );
- });
- });
- describe('RECEIVE_PODS_DATA_ERROR', () => {
- it('receives pods data error', () => {
- mutations[types.RECEIVE_PODS_DATA_ERROR](state);
-
- expect(state.pods).toEqual(
- expect.objectContaining({
- options: [],
- }),
- );
- });
- });
-});
diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js
index f0f051cbc8b..2001bb5f95e 100644
--- a/spec/frontend/merge_request_tabs_spec.js
+++ b/spec/frontend/merge_request_tabs_spec.js
@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import initMrPage from 'helpers/init_vue_mr_page_helper';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import axios from '~/lib/utils/axios_utils';
import MergeRequestTabs from '~/merge_request_tabs';
import '~/lib/utils/common_utils';
@@ -24,6 +25,8 @@ describe('MergeRequestTabs', () => {
};
beforeEach(() => {
+ stubPerformanceWebAPI();
+
initMrPage();
testContext.class = new MergeRequestTabs({ stubLocation });
@@ -331,6 +334,8 @@ describe('MergeRequestTabs', () => {
${'diffs'} | ${true} | ${'hides'}
${'commits'} | ${true} | ${'hides'}
`('it $hidesText expand button on $tab tab', ({ tab, hides }) => {
+ window.gon = { features: { movedMrSidebar: true } };
+
const expandButton = document.createElement('div');
expandButton.classList.add('js-expand-sidebar');
@@ -344,16 +349,16 @@ describe('MergeRequestTabs', () => {
testContext.class = new MergeRequestTabs({ stubLocation });
testContext.class.tabShown(tab, 'foobar');
- expect(testContext.class.expandSidebar.classList.contains('gl-display-none!')).toBe(hides);
+ testContext.class.expandSidebar.forEach((el) => {
+ expect(el.classList.contains('gl-display-none!')).toBe(hides);
+ });
+
+ window.gon = {};
});
describe('when switching tabs', () => {
const SCROLL_TOP = 100;
- beforeAll(() => {
- jest.useFakeTimers();
- });
-
beforeEach(() => {
jest.spyOn(window, 'scrollTo').mockImplementation(() => {});
testContext.class.mergeRequestTabs = document.createElement('div');
@@ -362,10 +367,6 @@ describe('MergeRequestTabs', () => {
testContext.class.scrollPositions = { newTab: SCROLL_TOP };
});
- afterAll(() => {
- jest.useRealTimers();
- });
-
it('scrolls to the stored position, if one is stored', () => {
testContext.class.tabShown('newTab');
diff --git a/spec/frontend/milestones/components/delete_milestone_modal_spec.js b/spec/frontend/milestones/components/delete_milestone_modal_spec.js
index b9ba0833c4f..6692a3b9347 100644
--- a/spec/frontend/milestones/components/delete_milestone_modal_spec.js
+++ b/spec/frontend/milestones/components/delete_milestone_modal_spec.js
@@ -1,44 +1,59 @@
-import Vue from 'vue';
+import { GlSprintf, GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
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 '~/milestones/components/delete_milestone_modal.vue';
+import DeleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue';
import eventHub from '~/milestones/event_hub';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { createAlert } from '~/flash';
-jest.mock('~/lib/utils/url_utility', () => ({
- ...jest.requireActual('~/lib/utils/url_utility'),
- redirectTo: jest.fn(),
-}));
+jest.mock('~/lib/utils/url_utility');
+jest.mock('~/flash');
-describe('delete_milestone_modal.vue', () => {
- const Component = Vue.extend(deleteMilestoneModal);
- const props = {
+describe('Delete milestone modal', () => {
+ let wrapper;
+ const mockProps = {
issueCount: 1,
mergeRequestCount: 2,
milestoneId: 3,
milestoneTitle: 'my milestone title',
milestoneUrl: `${TEST_HOST}/delete_milestone_modal.vue/milestone`,
};
- let vm;
+
+ const findModal = () => wrapper.findComponent(GlModal);
+
+ const createComponent = (props) => {
+ wrapper = shallowMount(DeleteMilestoneModal, {
+ propsData: {
+ ...mockProps,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('onSubmit', () => {
beforeEach(() => {
- vm = mountComponent(Component, props);
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
it('deletes milestone and redirects to overview page', async () => {
const responseURL = `${TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`;
jest.spyOn(axios, 'delete').mockImplementation((url) => {
- expect(url).toBe(props.milestoneUrl);
+ expect(url).toBe(mockProps.milestoneUrl);
expect(eventHub.$emit).toHaveBeenCalledWith(
'deleteMilestoneModal.requestStarted',
- props.milestoneUrl,
+ mockProps.milestoneUrl,
);
eventHub.$emit.mockReset();
return Promise.resolve({
@@ -47,55 +62,71 @@ describe('delete_milestone_modal.vue', () => {
},
});
});
-
- await vm.onSubmit();
+ await findModal().vm.$emit('primary');
expect(redirectTo).toHaveBeenCalledWith(responseURL);
expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
- milestoneUrl: props.milestoneUrl,
+ milestoneUrl: mockProps.milestoneUrl,
successful: true,
});
});
- it('displays error if deleting milestone failed', async () => {
- const dummyError = new Error('deleting milestone failed');
- dummyError.response = { status: 418 };
- jest.spyOn(axios, 'delete').mockImplementation((url) => {
- expect(url).toBe(props.milestoneUrl);
- expect(eventHub.$emit).toHaveBeenCalledWith(
- 'deleteMilestoneModal.requestStarted',
- props.milestoneUrl,
- );
- eventHub.$emit.mockReset();
- return Promise.reject(dummyError);
- });
+ it.each`
+ statusCode | alertMessage
+ ${418} | ${`Failed to delete milestone ${mockProps.milestoneTitle}`}
+ ${404} | ${`Milestone ${mockProps.milestoneTitle} was not found`}
+ `(
+ 'displays error if deleting milestone failed with code $statusCode',
+ async ({ statusCode, alertMessage }) => {
+ const dummyError = new Error('deleting milestone failed');
+ dummyError.response = { status: statusCode };
+ jest.spyOn(axios, 'delete').mockImplementation((url) => {
+ expect(url).toBe(mockProps.milestoneUrl);
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'deleteMilestoneModal.requestStarted',
+ mockProps.milestoneUrl,
+ );
+ eventHub.$emit.mockReset();
+ return Promise.reject(dummyError);
+ });
- await expect(vm.onSubmit()).rejects.toEqual(dummyError);
- expect(redirectTo).not.toHaveBeenCalled();
- expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
- milestoneUrl: props.milestoneUrl,
- successful: false,
- });
- });
+ await expect(wrapper.vm.onSubmit()).rejects.toEqual(dummyError);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: alertMessage,
+ });
+ expect(redirectTo).not.toHaveBeenCalled();
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
+ milestoneUrl: mockProps.milestoneUrl,
+ successful: false,
+ });
+ },
+ );
});
- describe('text', () => {
- it('contains the issue and milestone count', () => {
- vm = mountComponent(Component, props);
- const value = vm.text;
+ describe('Modal title and description', () => {
+ const emptyDescription = `You’re about to permanently delete the milestone ${mockProps.milestoneTitle}. This milestone is not currently used in any issues or merge requests.`;
+ const description = `You’re about to permanently delete the milestone ${mockProps.milestoneTitle} and remove it from 1 issue and 2 merge requests. Once deleted, it cannot be undone or recovered.`;
+ const title = `Delete milestone ${mockProps.milestoneTitle}?`;
- expect(value).toContain('remove it from 1 issue and 2 merge requests');
+ it('renders proper title', () => {
+ const value = findModal().props('title');
+ expect(value).toBe(title);
});
- it('contains neither issue nor milestone count', () => {
- vm = mountComponent(Component, {
- ...props,
- issueCount: 0,
- mergeRequestCount: 0,
- });
-
- const value = vm.text;
+ it.each`
+ statement | descriptionText | issueCount | mergeRequestCount
+ ${'1 issue and 2 merge requests'} | ${description} | ${1} | ${2}
+ ${'no issues and merge requests'} | ${emptyDescription} | ${0} | ${0}
+ `(
+ 'renders proper description when the milestone contains $statement',
+ ({ issueCount, mergeRequestCount, descriptionText }) => {
+ createComponent({
+ issueCount,
+ mergeRequestCount,
+ });
- expect(value).toContain('is not currently used');
- });
+ const value = findModal().text();
+ expect(value).toBe(descriptionText);
+ },
+ );
});
});
diff --git a/spec/frontend/milestones/components/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js
index afd85fb78ce..a8e3d13dca0 100644
--- a/spec/frontend/milestones/components/milestone_combobox_spec.js
+++ b/spec/frontend/milestones/components/milestone_combobox_spec.js
@@ -154,9 +154,9 @@ describe('Milestone combobox component', () => {
};
describe('initialization behavior', () => {
- beforeEach(createComponent);
-
it('initializes the dropdown with milestones when mounted', () => {
+ createComponent();
+
return waitForRequests().then(() => {
expect(projectMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
expect(groupMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
@@ -164,6 +164,8 @@ describe('Milestone combobox component', () => {
});
it('shows a spinner while network requests are in progress', () => {
+ createComponent();
+
expect(findLoadingIcon().exists()).toBe(true);
return waitForRequests().then(() => {
@@ -172,6 +174,8 @@ describe('Milestone combobox component', () => {
});
it('shows additional links', () => {
+ createComponent();
+
const links = wrapper.findAll('[data-testid="milestone-combobox-extra-links"]');
links.wrappers.forEach((item, idx) => {
expect(item.text()).toBe(extraLinks[idx].text);
diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
index a9f37f90561..14f04d9b767 100644
--- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
@@ -35,7 +35,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light"
>
<div
- class="mb-2 mr-2 d-flex d-sm-block"
+ class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"
>
<dashboards-dropdown-stub
class="flex-grow-1"
diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
index 1f9eb03b5d4..7c54a4742ac 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -5,7 +5,6 @@ import Vuex from 'vuex';
import { nextTick } from 'vue';
import { setTestTimeout } from 'helpers/timeout';
import axios from '~/lib/utils/axios_utils';
-import invalidUrl from '~/lib/utils/invalid_url';
import MonitorAnomalyChart from '~/monitoring/components/charts/anomaly.vue';
import MonitorBarChart from '~/monitoring/components/charts/bar.vue';
@@ -27,13 +26,7 @@ import {
heatmapGraphData,
barGraphData,
} from '../graph_data';
-import {
- mockLogsHref,
- mockLogsPath,
- mockNamespace,
- mockNamespacedData,
- mockTimeRange,
-} from '../mock_data';
+import { mockNamespace, mockNamespacedData, mockTimeRange } from '../mock_data';
const mocks = {
$toast: {
@@ -65,7 +58,6 @@ describe('Dashboard Panel', () => {
},
store,
mocks,
- provide: { glFeatures: { monitorLogging: true } },
...options,
});
};
@@ -335,86 +327,6 @@ describe('Dashboard Panel', () => {
});
});
- describe('View Logs dropdown item', () => {
- const findViewLogsLink = () => wrapper.find({ ref: 'viewLogsLink' });
-
- beforeEach(async () => {
- createWrapper();
- await nextTick();
- });
-
- it('is not present by default', async () => {
- await nextTick();
- expect(findViewLogsLink().exists()).toBe(false);
- });
-
- it('is not present if a time range is not set', async () => {
- state.logsPath = mockLogsPath;
- state.timeRange = null;
-
- await nextTick();
- expect(findViewLogsLink().exists()).toBe(false);
- });
-
- it('is not present if the logs path is default', async () => {
- state.logsPath = invalidUrl;
- state.timeRange = mockTimeRange;
-
- await nextTick();
- expect(findViewLogsLink().exists()).toBe(false);
- });
-
- it('is not present if the logs path is not set', async () => {
- state.logsPath = null;
- state.timeRange = mockTimeRange;
-
- await nextTick();
- expect(findViewLogsLink().exists()).toBe(false);
- });
-
- it('is present when logs path and time a range is present', async () => {
- state.logsPath = mockLogsPath;
- state.timeRange = mockTimeRange;
-
- await nextTick();
- expect(findViewLogsLink().attributes('href')).toMatch(mockLogsHref);
- });
-
- describe(':monitor_logging feature flag', () => {
- it.each`
- flagState | logsState | expected
- ${true} | ${'shows'} | ${true}
- ${false} | ${'hides'} | ${false}
- `('$logsState logs when flag state is $flagState', async ({ flagState, expected }) => {
- createWrapper({}, { provide: { glFeatures: { monitorLogging: flagState } } });
- state.logsPath = mockLogsPath;
- state.timeRange = mockTimeRange;
- await nextTick();
-
- expect(findViewLogsLink().exists()).toBe(expected);
- });
- });
-
- it('it is overridden when a datazoom event is received', async () => {
- state.logsPath = mockLogsPath;
- state.timeRange = mockTimeRange;
-
- const zoomedTimeRange = {
- start: '2020-01-01T00:00:00.000Z',
- end: '2020-01-01T01:00:00.000Z',
- };
-
- findTimeChart().vm.$emit('datazoom', zoomedTimeRange);
-
- await nextTick();
- const start = encodeURIComponent(zoomedTimeRange.start);
- const end = encodeURIComponent(zoomedTimeRange.end);
- expect(findViewLogsLink().attributes('href')).toMatch(
- `${mockLogsPath}?start=${start}&end=${end}`,
- );
- });
- });
-
describe('when clipboard data is available', () => {
const clipboardText = 'A value to copy.';
@@ -507,14 +419,6 @@ describe('Dashboard Panel', () => {
createWrapper({ namespace: mockNamespace });
});
- it('handles namespaced time range and logs path state', async () => {
- store.state[mockNamespace].timeRange = mockTimeRange;
- store.state[mockNamespace].logsPath = mockLogsPath;
-
- await nextTick();
- expect(wrapper.find({ ref: 'viewLogsLink' }).attributes().href).toBe(mockLogsHref);
- });
-
it('handles namespaced deployment data state', async () => {
store.state[mockNamespace].deploymentData = mockDeploymentData;
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 6c5972e1140..90171cfc65e 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -75,6 +75,7 @@ describe('Dashboard', () => {
if (store.dispatch.mockReset) {
store.dispatch.mockReset();
}
+ wrapper.destroy();
});
describe('request information to the server', () => {
@@ -569,28 +570,37 @@ describe('Dashboard', () => {
const findDraggablePanels = () => wrapper.findAll('.js-draggable-panel');
const findRearrangeButton = () => wrapper.find('.js-rearrange-button');
- beforeEach(async () => {
+ const setup = async () => {
// call original dispatch
store.dispatch.mockRestore();
createShallowWrapper({ hasMetrics: true });
setupStoreWithData(store);
await nextTick();
- });
+ };
+
+ it('wraps vuedraggable', async () => {
+ await setup();
- it('wraps vuedraggable', () => {
expect(findDraggablePanels().exists()).toBe(true);
expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount);
});
- it('is disabled by default', () => {
+ it('is disabled by default', async () => {
+ await setup();
+
expect(findRearrangeButton().exists()).toBe(false);
expect(findEnabledDraggables().length).toBe(0);
});
describe('when rearrange is enabled', () => {
beforeEach(async () => {
- wrapper.setProps({ rearrangePanelsAvailable: true });
+ // call original dispatch
+ store.dispatch.mockRestore();
+
+ createShallowWrapper({ hasMetrics: true, rearrangePanelsAvailable: true });
+ setupStoreWithData(store);
+
await nextTick();
});
@@ -602,17 +612,18 @@ describe('Dashboard', () => {
const findFirstDraggableRemoveButton = () =>
findDraggablePanels().at(0).find('.js-draggable-remove');
- beforeEach(async () => {
+ it('it enables draggables', async () => {
findRearrangeButton().vm.$emit('click');
await nextTick();
- });
- it('it enables draggables', () => {
expect(findRearrangeButton().attributes('pressed')).toBeTruthy();
expect(findEnabledDraggables().wrappers).toEqual(findDraggables().wrappers);
});
it('metrics can be swapped', async () => {
+ findRearrangeButton().vm.$emit('click');
+ await nextTick();
+
const firstDraggable = findDraggables().at(0);
const mockMetrics = [...metricsDashboardViewModel.panelGroups[0].panels];
@@ -624,6 +635,7 @@ describe('Dashboard', () => {
firstDraggable.vm.$emit('input', mockMetrics);
await nextTick();
+
const { panels } = wrapper.vm.dashboard.panelGroups[0];
expect(panels[1].title).toEqual(firstTitle);
@@ -631,18 +643,23 @@ describe('Dashboard', () => {
});
it('shows a remove button, which removes a panel', async () => {
+ findRearrangeButton().vm.$emit('click');
+ await nextTick();
+
expect(findFirstDraggableRemoveButton().find('a').exists()).toBe(true);
expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount);
- findFirstDraggableRemoveButton().trigger('click');
+ await findFirstDraggableRemoveButton().trigger('click');
- await nextTick();
expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount - 1);
});
it('it disables draggables when clicked again', async () => {
findRearrangeButton().vm.$emit('click');
await nextTick();
+
+ findRearrangeButton().vm.$emit('click');
+ await nextTick();
expect(findRearrangeButton().attributes('pressed')).toBeFalsy();
expect(findEnabledDraggables().length).toBe(0);
});
diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js
index ae1a4e16b30..49e8ab9ebd4 100644
--- a/spec/frontend/monitoring/store/mutations_spec.js
+++ b/spec/frontend/monitoring/store/mutations_spec.js
@@ -180,11 +180,6 @@ describe('Monitoring mutations', () => {
});
it('should not remove previously set properties', () => {
- const defaultLogsPath = stateCopy.logsPath;
-
- mutations[types.SET_INITIAL_STATE](stateCopy, {
- logsPath: defaultLogsPath,
- });
mutations[types.SET_INITIAL_STATE](stateCopy, {
dashboardEndpoint: 'dashboard.json',
});
@@ -196,7 +191,6 @@ describe('Monitoring mutations', () => {
});
expect(stateCopy).toMatchObject({
- logsPath: defaultLogsPath,
dashboardEndpoint: 'dashboard.json',
projectPath: '/gitlab-org/gitlab-foss',
currentEnvironmentName: 'canary',
@@ -227,11 +221,6 @@ describe('Monitoring mutations', () => {
});
it('should not remove previously set properties', () => {
- const defaultLogsPath = stateCopy.logsPath;
-
- mutations[types.SET_ENDPOINTS](stateCopy, {
- logsPath: defaultLogsPath,
- });
mutations[types.SET_ENDPOINTS](stateCopy, {
dashboardEndpoint: 'dashboard.json',
});
@@ -240,7 +229,6 @@ describe('Monitoring mutations', () => {
});
expect(stateCopy).toMatchObject({
- logsPath: defaultLogsPath,
dashboardEndpoint: 'dashboard.json',
projectPath: '/gitlab-org/gitlab-foss',
});
diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js
index e4f4b3fa5b5..5a09598059d 100644
--- a/spec/frontend/new_branch_spec.js
+++ b/spec/frontend/new_branch_spec.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import NewBranchForm from '~/new_branch_form';
@@ -11,17 +10,19 @@ describe('Branch', () => {
describe('create a new branch', () => {
function fillNameWith(value) {
- $('.js-branch-name').val(value).trigger('blur');
+ document.querySelector('.js-branch-name').value = value;
+ const event = new CustomEvent('blur');
+ document.querySelector('.js-branch-name').dispatchEvent(event);
}
function expectToHaveError(error) {
- expect($('.js-branch-name-error span').text()).toEqual(error);
+ expect(document.querySelector('.js-branch-name-error').textContent).toEqual(error);
}
beforeEach(() => {
loadHTMLFixture('branches/new_branch.html');
- $('form').on('submit', (e) => e.preventDefault());
- testContext.form = new NewBranchForm($('.js-create-branch-form'), []);
+ document.querySelector('form').addEventListener('submit', (e) => e.preventDefault());
+ testContext.form = new NewBranchForm(document.querySelector('.js-create-branch-form'), []);
});
afterEach(() => {
@@ -171,34 +172,34 @@ describe('Branch', () => {
it('removes the error message when is a valid name', () => {
fillNameWith('foo?bar');
- expect($('.js-branch-name-error span').length).toEqual(1);
+ expect(document.querySelector('.js-branch-name-error').textContent).not.toEqual('');
fillNameWith('foobar');
- expect($('.js-branch-name-error span').length).toEqual(0);
+ expect(document.querySelector('.js-branch-name-error').textContent).toEqual('');
});
it('can have dashes anywhere', () => {
fillNameWith('-foo-bar-zoo-');
- expect($('.js-branch-name-error span').length).toEqual(0);
+ expect(document.querySelector('.js-branch-name-error').textContent).toEqual('');
});
it('can have underscores anywhere', () => {
fillNameWith('_foo_bar_zoo_');
- expect($('.js-branch-name-error span').length).toEqual(0);
+ expect(document.querySelector('.js-branch-name-error').textContent).toEqual('');
});
it('can have numbers anywhere', () => {
fillNameWith('1foo2bar3zoo4');
- expect($('.js-branch-name-error span').length).toEqual(0);
+ expect(document.querySelector('.js-branch-name-error').textContent).toEqual('');
});
it('can be only letters', () => {
fillNameWith('foo');
- expect($('.js-branch-name-error span').length).toEqual(0);
+ expect(document.querySelector('.js-branch-name-error').textContent).toEqual('');
});
});
});
diff --git a/spec/frontend/notebook/cells/code_spec.js b/spec/frontend/notebook/cells/code_spec.js
index 9a2db061278..10762a1c3a2 100644
--- a/spec/frontend/notebook/cells/code_spec.js
+++ b/spec/frontend/notebook/cells/code_spec.js
@@ -1,89 +1,73 @@
-import Vue, { nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
import fixture from 'test_fixtures/blob/notebook/basic.json';
-import CodeComponent from '~/notebook/cells/code.vue';
-
-const Component = Vue.extend(CodeComponent);
+import Code from '~/notebook/cells/code.vue';
describe('Code component', () => {
- let vm;
-
+ let wrapper;
let json;
+ const mountComponent = (cell) => mount(Code, { propsData: { cell } });
+
beforeEach(() => {
// Clone fixture as it could be modified by tests
json = JSON.parse(JSON.stringify(fixture));
});
- const setupComponent = (cell) => {
- const comp = new Component({
- propsData: {
- cell,
- },
- });
- comp.$mount();
- return comp;
- };
+ afterEach(() => {
+ wrapper.destroy();
+ });
describe('without output', () => {
beforeEach(() => {
- vm = setupComponent(json.cells[0]);
-
- return nextTick();
+ wrapper = mountComponent(json.cells[0]);
});
it('does not render output prompt', () => {
- expect(vm.$el.querySelectorAll('.prompt').length).toBe(1);
+ expect(wrapper.findAll('.prompt')).toHaveLength(1);
});
});
describe('with output', () => {
beforeEach(() => {
- vm = setupComponent(json.cells[2]);
-
- return nextTick();
+ wrapper = mountComponent(json.cells[2]);
});
it('does not render output prompt', () => {
- expect(vm.$el.querySelectorAll('.prompt').length).toBe(2);
+ expect(wrapper.findAll('.prompt')).toHaveLength(2);
});
it('renders output cell', () => {
- expect(vm.$el.querySelector('.output')).toBeDefined();
+ expect(wrapper.find('.output').exists()).toBe(true);
});
});
describe('with string for output', () => {
// NBFormat Version 4.1 allows outputs.text to be a string
- beforeEach(async () => {
+ beforeEach(() => {
const cell = json.cells[2];
cell.outputs[0].text = cell.outputs[0].text.join('');
- vm = setupComponent(cell);
- await nextTick();
+ wrapper = mountComponent(cell);
});
it('does not render output prompt', () => {
- expect(vm.$el.querySelectorAll('.prompt').length).toBe(2);
+ expect(wrapper.findAll('.prompt')).toHaveLength(2);
});
it('renders output cell', () => {
- expect(vm.$el.querySelector('.output')).toBeDefined();
+ expect(wrapper.find('.output').exists()).toBe(true);
});
});
describe('with string for cell.source', () => {
- beforeEach(async () => {
+ beforeEach(() => {
const cell = json.cells[0];
cell.source = cell.source.join('');
-
- vm = setupComponent(cell);
- await nextTick();
+ wrapper = mountComponent(cell);
});
it('renders the same input as when cell.source is an array', () => {
- const expected = "console.log('test')";
-
- expect(vm.$el.querySelector('.input').innerText).toContain(expected);
+ expect(wrapper.find('.input').text()).toContain("console.log('test')");
});
});
});
diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js
index de415b5bfe0..c757b55faf4 100644
--- a/spec/frontend/notebook/cells/markdown_spec.js
+++ b/spec/frontend/notebook/cells/markdown_spec.js
@@ -130,7 +130,7 @@ describe('Markdown component', () => {
expect(columns[0].innerHTML).toContain('<img src="data:image/jpeg;base64');
expect(columns[1].innerHTML).toContain('<img src="data:image/png;base64');
expect(columns[2].innerHTML).toContain('<img src="data:image/jpeg;base64');
- expect(columns[3].innerHTML).toContain('<img>');
+ expect(columns[3].innerHTML).toContain('<img src="attachment:bogus">');
expect(columns[4].innerHTML).toContain('<img src="https://www.google.com/');
});
});
diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js
index 8e04e4c146c..4d1d03e5e34 100644
--- a/spec/frontend/notebook/cells/output/index_spec.js
+++ b/spec/frontend/notebook/cells/output/index_spec.js
@@ -1,36 +1,35 @@
-import Vue, { nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
import json from 'test_fixtures/blob/notebook/basic.json';
-import CodeComponent from '~/notebook/cells/output/index.vue';
-
-const Component = Vue.extend(CodeComponent);
+import Output from '~/notebook/cells/output/index.vue';
describe('Output component', () => {
- let vm;
+ let wrapper;
const createComponent = (output) => {
- vm = new Component({
+ wrapper = mount(Output, {
propsData: {
outputs: [].concat(output),
count: 1,
},
});
- vm.$mount();
};
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
describe('text output', () => {
beforeEach(() => {
const textType = json.cells[2];
createComponent(textType.outputs[0]);
-
- return nextTick();
});
it('renders as plain text', () => {
- expect(vm.$el.querySelector('pre')).not.toBeNull();
+ expect(wrapper.find('pre').exists()).toBe(true);
});
it('renders prompt', () => {
- expect(vm.$el.querySelector('.prompt span')).not.toBeNull();
+ expect(wrapper.find('.prompt span').exists()).toBe(true);
});
});
@@ -38,12 +37,10 @@ describe('Output component', () => {
beforeEach(() => {
const imageType = json.cells[3];
createComponent(imageType.outputs[0]);
-
- return nextTick();
});
it('renders as an image', () => {
- expect(vm.$el.querySelector('img')).not.toBeNull();
+ expect(wrapper.find('img').exists()).toBe(true);
});
});
@@ -52,16 +49,15 @@ describe('Output component', () => {
const htmlType = json.cells[4];
createComponent(htmlType.outputs[0]);
- expect(vm.$el.querySelector('p')).not.toBeNull();
- expect(vm.$el.querySelectorAll('p')).toHaveLength(1);
- expect(vm.$el.textContent.trim()).toContain('test');
+ expect(wrapper.findAll('p')).toHaveLength(1);
+ expect(wrapper.text()).toContain('test');
});
it('renders multiple raw HTML outputs', () => {
const htmlType = json.cells[4];
createComponent([htmlType.outputs[0], htmlType.outputs[0]]);
- expect(vm.$el.querySelectorAll('p')).toHaveLength(2);
+ expect(wrapper.findAll('p')).toHaveLength(2);
});
});
@@ -77,7 +73,7 @@ describe('Output component', () => {
};
createComponent(output);
- expect(vm.$el.querySelector('.MathJax')).not.toBeNull();
+ expect(wrapper.find('.MathJax').exists()).toBe(true);
});
});
@@ -85,12 +81,10 @@ describe('Output component', () => {
beforeEach(() => {
const svgType = json.cells[5];
createComponent(svgType.outputs[0]);
-
- return nextTick();
});
it('renders as an svg', () => {
- expect(vm.$el.querySelector('svg')).not.toBeNull();
+ expect(wrapper.find('svg').exists()).toBe(true);
});
});
@@ -98,27 +92,23 @@ describe('Output component', () => {
beforeEach(() => {
const unknownType = json.cells[6];
createComponent(unknownType.outputs[0]);
-
- return nextTick();
});
it('renders as plain text', () => {
- expect(vm.$el.querySelector('pre')).not.toBeNull();
- expect(vm.$el.textContent.trim()).toContain('testing');
+ expect(wrapper.find('pre').exists()).toBe(true);
+ expect(wrapper.text()).toContain('testing');
});
- it('renders promot', () => {
- expect(vm.$el.querySelector('.prompt span')).not.toBeNull();
+ it('renders prompt', () => {
+ expect(wrapper.find('.prompt span').exists()).toBe(true);
});
- it("renders as plain text when doesn't recognise other types", async () => {
+ it("renders as plain text when doesn't recognise other types", () => {
const unknownType = json.cells[7];
createComponent(unknownType.outputs[0]);
- await nextTick();
-
- expect(vm.$el.querySelector('pre')).not.toBeNull();
- expect(vm.$el.textContent.trim()).toContain('testing');
+ expect(wrapper.find('pre').exists()).toBe(true);
+ expect(wrapper.text()).toContain('testing');
});
});
});
diff --git a/spec/frontend/notebook/cells/prompt_spec.js b/spec/frontend/notebook/cells/prompt_spec.js
index 89b2d7b2b90..0cda0c5bc2b 100644
--- a/spec/frontend/notebook/cells/prompt_spec.js
+++ b/spec/frontend/notebook/cells/prompt_spec.js
@@ -1,52 +1,40 @@
-import Vue, { nextTick } from 'vue';
-import PromptComponent from '~/notebook/cells/prompt.vue';
-
-const Component = Vue.extend(PromptComponent);
+import { shallowMount } from '@vue/test-utils';
+import Prompt from '~/notebook/cells/prompt.vue';
describe('Prompt component', () => {
- let vm;
+ let wrapper;
+
+ const mountComponent = ({ type }) => shallowMount(Prompt, { propsData: { type, count: 1 } });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
describe('input', () => {
beforeEach(() => {
- vm = new Component({
- propsData: {
- type: 'In',
- count: 1,
- },
- });
- vm.$mount();
-
- return nextTick();
+ wrapper = mountComponent({ type: 'In' });
});
it('renders in label', () => {
- expect(vm.$el.textContent.trim()).toContain('In');
+ expect(wrapper.text()).toContain('In');
});
it('renders count', () => {
- expect(vm.$el.textContent.trim()).toContain('1');
+ expect(wrapper.text()).toContain('1');
});
});
describe('output', () => {
beforeEach(() => {
- vm = new Component({
- propsData: {
- type: 'Out',
- count: 1,
- },
- });
- vm.$mount();
-
- return nextTick();
+ wrapper = mountComponent({ type: 'Out' });
});
it('renders in label', () => {
- expect(vm.$el.textContent.trim()).toContain('Out');
+ expect(wrapper.text()).toContain('Out');
});
it('renders count', () => {
- expect(vm.$el.textContent.trim()).toContain('1');
+ expect(wrapper.text()).toContain('1');
});
});
});
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index 116016ecae2..463787c148b 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -550,98 +550,74 @@ describe('issue_comment_form component', () => {
});
describe('confidential notes checkbox', () => {
- describe('when confidentialNotes feature flag is `false`', () => {
- const features = { confidentialNotes: false };
+ it('should render checkbox as unchecked by default', () => {
+ mountComponent({
+ mountFunction: mount,
+ initialData: { note: 'confidential note' },
+ noteableData: { ...notableDataMockCanUpdateIssuable },
+ });
- it('should not render checkbox', () => {
+ const checkbox = findConfidentialNoteCheckbox();
+ expect(checkbox.exists()).toBe(true);
+ expect(checkbox.element.checked).toBe(false);
+ });
+
+ it.each`
+ noteableType | rendered | message
+ ${'Issue'} | ${true} | ${'render'}
+ ${'Epic'} | ${true} | ${'render'}
+ ${'MergeRequest'} | ${false} | ${'not render'}
+ `(
+ 'should $message checkbox when noteableType is $noteableType',
+ ({ noteableType, rendered }) => {
mountComponent({
mountFunction: mount,
- initialData: { note: 'confidential note' },
- noteableData: { ...notableDataMockCanUpdateIssuable },
- features,
+ noteableType,
+ initialData: { note: 'internal note' },
+ noteableData: { ...notableDataMockCanUpdateIssuable, noteableType },
});
- const checkbox = findConfidentialNoteCheckbox();
- expect(checkbox.exists()).toBe(false);
- });
- });
-
- describe('when confidentialNotes feature flag is `true`', () => {
- const features = { confidentialNotes: true };
+ expect(findConfidentialNoteCheckbox().exists()).toBe(rendered);
+ },
+ );
- it('should render checkbox as unchecked by default', () => {
+ describe.each`
+ shouldCheckboxBeChecked
+ ${true}
+ ${false}
+ `('when checkbox value is `$shouldCheckboxBeChecked`', ({ shouldCheckboxBeChecked }) => {
+ it(`sets \`confidential\` to \`${shouldCheckboxBeChecked}\``, async () => {
mountComponent({
mountFunction: mount,
initialData: { note: 'confidential note' },
noteableData: { ...notableDataMockCanUpdateIssuable },
- features,
});
- const checkbox = findConfidentialNoteCheckbox();
- expect(checkbox.exists()).toBe(true);
- expect(checkbox.element.checked).toBe(false);
- });
+ jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue({});
- it.each`
- noteableType | rendered | message
- ${'Issue'} | ${true} | ${'render'}
- ${'Epic'} | ${true} | ${'render'}
- ${'MergeRequest'} | ${false} | ${'not render'}
- `(
- 'should $message checkbox when noteableType is $noteableType',
- ({ noteableType, rendered }) => {
- mountComponent({
- mountFunction: mount,
- noteableType,
- initialData: { note: 'internal note' },
- noteableData: { ...notableDataMockCanUpdateIssuable, noteableType },
- features,
- });
-
- expect(findConfidentialNoteCheckbox().exists()).toBe(rendered);
- },
- );
-
- describe.each`
- shouldCheckboxBeChecked
- ${true}
- ${false}
- `('when checkbox value is `$shouldCheckboxBeChecked`', ({ shouldCheckboxBeChecked }) => {
- it(`sets \`confidential\` to \`${shouldCheckboxBeChecked}\``, async () => {
- mountComponent({
- mountFunction: mount,
- initialData: { note: 'confidential note' },
- noteableData: { ...notableDataMockCanUpdateIssuable },
- features,
- });
-
- jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue({});
-
- const checkbox = findConfidentialNoteCheckbox();
+ const checkbox = findConfidentialNoteCheckbox();
- // check checkbox
- checkbox.element.checked = shouldCheckboxBeChecked;
- checkbox.trigger('change');
- await nextTick();
+ // check checkbox
+ checkbox.element.checked = shouldCheckboxBeChecked;
+ checkbox.trigger('change');
+ await nextTick();
- // submit comment
- findCommentButton().trigger('click');
+ // submit comment
+ findCommentButton().trigger('click');
- const [providedData] = wrapper.vm.saveNote.mock.calls[0];
- expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked);
- });
+ const [providedData] = wrapper.vm.saveNote.mock.calls[0];
+ expect(providedData.data.note.confidential).toBe(shouldCheckboxBeChecked);
});
+ });
- describe('when user cannot update issuable', () => {
- it('should not render checkbox', () => {
- mountComponent({
- mountFunction: mount,
- noteableData: { ...notableDataMockCannotUpdateIssuable },
- features,
- });
-
- expect(findConfidentialNoteCheckbox().exists()).toBe(false);
+ describe('when user cannot update issuable', () => {
+ it('should not render checkbox', () => {
+ mountComponent({
+ mountFunction: mount,
+ noteableData: { ...notableDataMockCannotUpdateIssuable },
});
+
+ expect(findConfidentialNoteCheckbox().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/notes/components/note_signed_out_widget_spec.js b/spec/frontend/notes/components/note_signed_out_widget_spec.js
index e217a2caa73..84f20e4ad58 100644
--- a/spec/frontend/notes/components/note_signed_out_widget_spec.js
+++ b/spec/frontend/notes/components/note_signed_out_widget_spec.js
@@ -1,41 +1,30 @@
-import Vue from 'vue';
-import noteSignedOut from '~/notes/components/note_signed_out_widget.vue';
+import { shallowMount } from '@vue/test-utils';
+import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
import createStore from '~/notes/stores';
import { notesDataMock } from '../mock_data';
-describe('note_signed_out_widget component', () => {
- let store;
- let vm;
+describe('NoteSignedOutWidget component', () => {
+ let wrapper;
beforeEach(() => {
- const Component = Vue.extend(noteSignedOut);
- store = createStore();
+ const store = createStore();
store.dispatch('setNotesData', notesDataMock);
-
- vm = new Component({
- store,
- }).$mount();
+ wrapper = shallowMount(NoteSignedOutWidget, { store });
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
- it('should render sign in link provided in the store', () => {
- expect(vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent).toEqual(
- 'sign in',
- );
+ it('renders sign in link provided in the store', () => {
+ expect(wrapper.find(`a[href="${notesDataMock.newSessionPath}"]`).text()).toBe('sign in');
});
- it('should render register link provided in the store', () => {
- expect(vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent).toEqual(
- 'register',
- );
+ it('renders register link provided in the store', () => {
+ expect(wrapper.find(`a[href="${notesDataMock.registerPath}"]`).text()).toBe('register');
});
- it('should render information text', () => {
- expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual(
- 'Please register or sign in to reply',
- );
+ it('renders information text', () => {
+ expect(wrapper.text()).toContain('Please register or sign in to reply');
});
});
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index ddfa77117ca..603db56a098 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
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 { getDiffFileMock } from 'jest/diffs/mock_data/diff_file';
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';
@@ -45,7 +45,7 @@ describe('noteable_discussion component', () => {
it('should render thread header', async () => {
const discussion = { ...discussionMock };
- discussion.diff_file = mockDiffFile;
+ discussion.diff_file = getDiffFileMock();
discussion.diff_discussion = true;
discussion.expanded = false;
@@ -57,7 +57,7 @@ describe('noteable_discussion component', () => {
it('should hide actions when diff refs do not exists', async () => {
const discussion = { ...discussionMock };
- discussion.diff_file = { ...mockDiffFile, diff_refs: null };
+ discussion.diff_file = { ...getDiffFileMock(), diff_refs: null };
discussion.diff_discussion = true;
discussion.expanded = false;
diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js
index 385edc59eb6..3350609bb90 100644
--- a/spec/frontend/notes/components/noteable_note_spec.js
+++ b/spec/frontend/notes/components/noteable_note_spec.js
@@ -1,20 +1,15 @@
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
-
+import { GlAvatar } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
-
import DiffsModule from '~/diffs/store/modules';
-
import NoteActions from '~/notes/components/note_actions.vue';
import NoteBody from '~/notes/components/note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
import issueNote from '~/notes/components/noteable_note.vue';
import NotesModule from '~/notes/stores/modules';
import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants';
-
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-
import { noteableDataMock, notesDataMock, note } from '../mock_data';
Vue.use(Vuex);
@@ -205,19 +200,21 @@ describe('issue_note', () => {
await nextTick();
- expect(wrapper.findComponent(UserAvatarLink).props('imgSize')).toBe(24);
+ const avatar = wrapper.findComponent(GlAvatar);
+ const avatarProps = avatar.props();
+ expect(avatarProps.size).toBe(24);
});
});
- it('should render user information', () => {
+ it('should render user avatar', () => {
const { author } = note;
- const avatar = wrapper.findComponent(UserAvatarLink);
+ const avatar = wrapper.findComponent(GlAvatar);
const avatarProps = avatar.props();
- expect(avatarProps.linkHref).toBe(author.path);
- expect(avatarProps.imgSrc).toBe(author.avatar_url);
- expect(avatarProps.imgAlt).toBe(author.name);
- expect(avatarProps.imgSize).toBe(40);
+ expect(avatarProps.src).toBe(author.avatar_url);
+ expect(avatarProps.entityName).toBe(author.username);
+ expect(avatarProps.alt).toBe(author.name);
+ expect(avatarProps.size).toEqual({ default: 24, md: 32 });
});
it('should render note header content', () => {
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index f4eb69e0d49..36a68118fa7 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -44,22 +44,6 @@ describe('note_app', () => {
.wrappers.map((node) => (node.is(CommentForm) ? TYPE_COMMENT_FORM : TYPE_NOTES_LIST));
};
- /**
- * waits for fetchNotes() to complete
- */
- const waitForDiscussionsRequest = () =>
- new Promise((resolve) => {
- const { vm } = wrapper.find(NotesApp);
- const unwatch = vm.$watch('isFetching', (isFetching) => {
- if (isFetching) {
- return;
- }
-
- unwatch();
- resolve();
- });
- });
-
beforeEach(() => {
$('body').attr('data-page', 'projects:merge_requests:show');
@@ -95,7 +79,7 @@ describe('note_app', () => {
axiosMock.onAny().reply(200, []);
wrapper = mountComponent();
- return waitForDiscussionsRequest();
+ return waitForPromises();
});
afterEach(() => {
@@ -129,7 +113,7 @@ describe('note_app', () => {
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
- return waitForDiscussionsRequest();
+ return waitForPromises();
});
afterEach(() => {
@@ -172,7 +156,7 @@ describe('note_app', () => {
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
store.state.commentsDisabled = true;
wrapper = mountComponent();
- return waitForDiscussionsRequest();
+ return waitForPromises();
});
afterEach(() => {
@@ -197,7 +181,7 @@ describe('note_app', () => {
store.state.isTimelineEnabled = true;
wrapper = mountComponent();
- return waitForDiscussionsRequest();
+ return waitForPromises();
});
afterEach(() => {
@@ -210,15 +194,13 @@ describe('note_app', () => {
});
describe('while fetching data', () => {
- beforeEach(() => {
+ beforeEach(async () => {
setHTMLFixture('<div class="js-discussions-count"></div>');
- axiosMock.onAny().reply(200, []);
wrapper = mountComponent();
});
afterEach(() => {
- waitForDiscussionsRequest();
- resetHTMLFixture();
+ return waitForPromises().then(() => resetHTMLFixture());
});
it('renders skeleton notes', () => {
@@ -242,7 +224,7 @@ describe('note_app', () => {
beforeEach(() => {
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
- return waitForDiscussionsRequest().then(() => {
+ return waitForPromises().then(() => {
wrapper.find('.js-note-edit').trigger('click');
});
});
@@ -264,7 +246,7 @@ describe('note_app', () => {
beforeEach(() => {
axiosMock.onAny().reply(mockData.getDiscussionNoteResponse);
wrapper = mountComponent();
- return waitForDiscussionsRequest().then(() => {
+ return waitForPromises().then(() => {
wrapper.find('.js-note-edit').trigger('click');
});
});
@@ -287,7 +269,7 @@ describe('note_app', () => {
beforeEach(() => {
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
- return waitForDiscussionsRequest();
+ return waitForPromises();
});
it('should render markdown docs url', () => {
@@ -309,7 +291,7 @@ describe('note_app', () => {
beforeEach(() => {
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
- return waitForDiscussionsRequest();
+ return waitForPromises();
});
it('should render markdown docs url', async () => {
@@ -337,7 +319,7 @@ describe('note_app', () => {
beforeEach(() => {
axiosMock.onAny().reply(200, []);
wrapper = mountComponent();
- return waitForDiscussionsRequest();
+ return waitForPromises();
});
it('dispatches toggleAward after toggleAward event', () => {
@@ -373,7 +355,7 @@ describe('note_app', () => {
beforeEach(() => {
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
- return waitForDiscussionsRequest();
+ return waitForPromises();
});
it('should listen hashchange event', () => {
@@ -471,7 +453,7 @@ describe('note_app', () => {
wrapper = shallowMount(NotesApp, { propsData, store: createStore() });
await waitForPromises();
- expect(axiosMock.history.get[0].params).toBeUndefined();
+ expect(axiosMock.history.get[0].params).toEqual({ per_page: 20 });
});
});
@@ -496,14 +478,14 @@ describe('note_app', () => {
wrapper = mountWithNotesFilter(undefined);
await waitForPromises();
- expect(axiosMock.history.get[0].params).toBeUndefined();
+ expect(axiosMock.history.get[0].params).toEqual({ per_page: 20 });
});
it('does not include extra query params when filter is already set to default', async () => {
wrapper = mountWithNotesFilter(constants.DISCUSSION_FILTERS_DEFAULT_VALUE);
await waitForPromises();
- expect(axiosMock.history.get[0].params).toBeUndefined();
+ expect(axiosMock.history.get[0].params).toEqual({ per_page: 20 });
});
it('includes extra query params when filter is not set to default', async () => {
@@ -512,6 +494,7 @@ describe('note_app', () => {
expect(axiosMock.history.get[0].params).toEqual({
notes_filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE,
+ per_page: 20,
persist_filter: false,
});
});
diff --git a/spec/frontend/notes/components/toggle_replies_widget_spec.js b/spec/frontend/notes/components/toggle_replies_widget_spec.js
index 409e1bc3951..8c3696e88b7 100644
--- a/spec/frontend/notes/components/toggle_replies_widget_spec.js
+++ b/spec/frontend/notes/components/toggle_replies_widget_spec.js
@@ -1,13 +1,14 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import toggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { note } from '../mock_data';
-const deepCloneObject = (obj) => JSON.parse(JSON.stringify(obj));
-
describe('toggle replies widget for notes', () => {
- let vm;
- let ToggleRepliesWidget;
+ let wrapper;
+
+ const deepCloneObject = (obj) => JSON.parse(JSON.stringify(obj));
+
const noteFromOtherUser = deepCloneObject(note);
noteFromOtherUser.author.username = 'fatihacet';
@@ -17,62 +18,62 @@ describe('toggle replies widget for notes', () => {
const replies = [note, note, note, noteFromOtherUser, noteFromAnotherUser];
- beforeEach(() => {
- ToggleRepliesWidget = Vue.extend(toggleRepliesWidget);
- });
+ const findCollapseToggleButton = () =>
+ wrapper.findByRole('button', { text: ToggleRepliesWidget.i18n.collapseReplies });
+ const findExpandToggleButton = () =>
+ wrapper.findByRole('button', { text: ToggleRepliesWidget.i18n.expandReplies });
+ const findRepliesButton = () => wrapper.findByRole('button', { text: '5 replies' });
+ const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
+ const findUserAvatarLink = () => wrapper.findAllComponents(UserAvatarLink);
+ const findUserLink = () => wrapper.findByRole('link', { text: noteFromAnotherUser.author.name });
+
+ const mountComponent = ({ collapsed = false }) =>
+ mountExtended(ToggleRepliesWidget, { propsData: { replies, collapsed } });
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('collapsed state', () => {
beforeEach(() => {
- vm = mountComponent(ToggleRepliesWidget, {
- replies,
- collapsed: true,
- });
+ wrapper = mountComponent({ collapsed: true });
});
- it('should render the collapsed', () => {
- const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' ');
-
- expect(vm.$el.classList.contains('collapsed')).toEqual(true);
- expect(vm.$el.querySelectorAll('.user-avatar-link').length).toEqual(3);
- expect(vm.$el.querySelector('time')).not.toBeNull();
- expect(vmTextContent).toContain('5 replies');
- expect(vmTextContent).toContain(`Last reply by ${noteFromAnotherUser.author.name}`);
+ it('renders collapsed state elements', () => {
+ expect(findExpandToggleButton().exists()).toBe(true);
+ expect(findUserAvatarLink()).toHaveLength(3);
+ expect(findRepliesButton().exists()).toBe(true);
+ expect(wrapper.text()).toContain('Last reply by');
+ expect(findUserLink().exists()).toBe(true);
+ expect(findTimeAgoTooltip().exists()).toBe(true);
});
- it('should emit toggle event when the replies text clicked', () => {
- const spy = jest.spyOn(vm, '$emit');
+ it('emits "toggle" event when expand toggle button is clicked', () => {
+ findExpandToggleButton().trigger('click');
+
+ expect(wrapper.emitted('toggle')).toEqual([[]]);
+ });
- vm.$el.querySelector('.js-replies-text').click();
+ it('emits "toggle" event when replies button is clicked', () => {
+ findRepliesButton().trigger('click');
- expect(spy).toHaveBeenCalledWith('toggle');
+ expect(wrapper.emitted('toggle')).toEqual([[]]);
});
});
describe('expanded state', () => {
beforeEach(() => {
- vm = mountComponent(ToggleRepliesWidget, {
- replies,
- collapsed: false,
- });
+ wrapper = mountComponent({ collapsed: false });
});
- it('should render expanded state', () => {
- const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' ');
-
- expect(vm.$el.querySelector('.collapse-replies-btn')).not.toBeNull();
- expect(vmTextContent).toContain('Collapse replies');
+ it('renders expanded state elements', () => {
+ expect(findCollapseToggleButton().exists()).toBe(true);
});
- it('should emit toggle event when the collapse replies text called', () => {
- const spy = jest.spyOn(vm, '$emit');
-
- vm.$el.querySelector('.js-collapse-replies').click();
+ it('emits "toggle" event when collapse toggle button is clicked', () => {
+ findCollapseToggleButton().trigger('click');
- expect(spy).toHaveBeenCalledWith('toggle');
+ expect(wrapper.emitted('toggle')).toEqual([[]]);
});
});
});
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index 38f29ac2559..02b27eca196 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -15,6 +15,7 @@ import * as utils from '~/notes/stores/utils';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
+import waitForPromises from 'helpers/wait_for_promises';
import { resetStore } from '../helpers';
import {
discussionMock,
@@ -254,9 +255,7 @@ describe('Actions Notes Store', () => {
jest.advanceTimersByTime(time);
}
- return new Promise((resolve) => {
- requestAnimationFrame(resolve);
- });
+ return waitForPromises();
};
const advanceXMoreIntervals = async (number) => {
const timeoutLength = pollInterval * number;
@@ -365,7 +364,6 @@ describe('Actions Notes Store', () => {
});
it('hides the error display if it exists on success', async () => {
- jest.mock();
failureMock();
await startPolling();
@@ -668,7 +666,6 @@ describe('Actions Notes Store', () => {
describe('updateOrCreateNotes', () => {
it('Prevents `fetchDiscussions` being called multiple times within time limit', () => {
- jest.useFakeTimers();
const note = { id: 1234, type: notesConstants.DIFF_NOTE };
const getters = { notesById: {} };
state = { discussions: [note], notesData: { discussionsPath: '' } };
@@ -1351,7 +1348,7 @@ describe('Actions Notes Store', () => {
return testAction(
actions.fetchDiscussions,
{},
- null,
+ { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE },
[
{ type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } },
{ type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false },
@@ -1360,13 +1357,11 @@ describe('Actions Notes Store', () => {
);
});
- it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', () => {
- window.gon = { features: { paginatedIssueDiscussions: true } };
-
+ it('dispatches `fetchDiscussionsBatch` action if noteable is an Issue', () => {
return testAction(
actions.fetchDiscussions,
{ path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' },
- null,
+ { noteableType: notesConstants.ISSUE_NOTEABLE_TYPE },
[],
[
{
@@ -1389,7 +1384,7 @@ describe('Actions Notes Store', () => {
return testAction(
actions.fetchDiscussions,
{ path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' },
- null,
+ { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE },
[],
[
{
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
index ca666e38291..9982286c625 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
@@ -18,7 +18,6 @@ import {
CLEANUP_SCHEDULED_TOOLTIP,
CLEANUP_ONGOING_TOOLTIP,
CLEANUP_UNFINISHED_TOOLTIP,
- ROOT_IMAGE_TEXT,
ROOT_IMAGE_TOOLTIP,
} from '~/packages_and_registries/container_registry/explorer/constants';
import getContainerRepositoryMetadata from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_metadata.query.graphql';
@@ -35,6 +34,7 @@ describe('Details Header', () => {
canDelete: true,
project: {
visibility: 'public',
+ path: 'path',
containerExpirationPolicy: {
enabled: false,
},
@@ -98,8 +98,8 @@ describe('Details Header', () => {
return waitForPromises();
});
- it('root image ', () => {
- expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT);
+ it('root image shows project path name', () => {
+ expect(findTitle().text()).toBe('path');
});
it('has an icon', () => {
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
index 0581a40b6a2..a5b2b1d7cf8 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
@@ -109,5 +109,17 @@ describe('cleanup_status', () => {
expect(findPopover().findComponent(GlLink).exists()).toBe(true);
expect(findPopover().findComponent(GlLink).attributes('href')).toBe(cleanupPolicyHelpPage);
});
+
+ it('id matches popover target attribute', () => {
+ mountComponent({
+ status: UNFINISHED_STATUS,
+ next_run_at: '2063-04-08T01:44:03Z',
+ });
+
+ const id = findExtraInfoIcon().attributes('id');
+
+ expect(id).toMatch(/status-info-[0-9]+/);
+ expect(findPopover().props('target')).toEqual(id);
+ });
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
index 979e1500d7d..d12933526bc 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
@@ -1,6 +1,7 @@
-import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import { GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { mockTracking } from 'helpers/tracking_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import DeleteButton from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue';
import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue';
@@ -12,7 +13,6 @@ import {
IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_MIGRATING_STATE,
SCHEDULED_STATUS,
- ROOT_IMAGE_TEXT,
COPY_IMAGE_PATH_TITLE,
} from '~/packages_and_registries/container_registry/explorer/constants';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -31,13 +31,15 @@ describe('Image List Row', () => {
const findCleanupStatus = () => wrapper.findComponent(CleanupStatus);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findListItemComponent = () => wrapper.findComponent(ListItem);
+ const findShowFullPathButton = () => wrapper.findComponent(GlButton);
- const mountComponent = (props) => {
+ const mountComponent = (props, features = {}) => {
wrapper = shallowMount(Component, {
stubs: {
RouterLink,
GlSprintf,
ListItem,
+ GlButton,
},
propsData: {
item,
@@ -45,6 +47,9 @@ describe('Image List Row', () => {
},
provide: {
config: {},
+ glFeatures: {
+ ...features,
+ },
},
directives: {
GlTooltip: createMockDirective(),
@@ -96,10 +101,10 @@ describe('Image List Row', () => {
});
});
- it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => {
+ it('when the image has no name lists the path', () => {
mountComponent({ item: { ...item, name: '' } });
- expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`);
+ expect(findDetailsLink().text()).toBe(item.path);
});
it('contains a clipboard button', () => {
@@ -144,6 +149,35 @@ describe('Image List Row', () => {
expect(findClipboardButton().attributes('disabled')).toBe('true');
});
});
+
+ describe('when containerRegistryShowShortenedPath feature enabled', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ mountComponent({}, { containerRegistryShowShortenedPath: true });
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it('renders shortened name of image', () => {
+ expect(findShowFullPathButton().exists()).toBe(true);
+ expect(findDetailsLink().text()).toBe('gitlab-test/rails-12009');
+ });
+
+ it('clicking on shortened name of image hides the button & shows full path', async () => {
+ const btn = findShowFullPathButton();
+ const mockFocusFn = jest.fn();
+ wrapper.vm.$refs.imageName.$el.focus = mockFocusFn;
+
+ await btn.trigger('click');
+
+ expect(findShowFullPathButton().exists()).toBe(false);
+ expect(findDetailsLink().text()).toBe(item.path);
+ expect(mockFocusFn).toHaveBeenCalled();
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_show_full_path', {
+ label: 'registry_image_list',
+ });
+ });
+ });
});
describe('delete button', () => {
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 7e6f88fe5bc..f9739509ef9 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
@@ -11,6 +11,10 @@ export const imagesListResponse = [
createdAt: '2020-11-03T13:29:21Z',
expirationPolicyStartedAt: null,
expirationPolicyCleanupStatus: 'UNSCHEDULED',
+ project: {
+ id: 'gid://gitlab/Project/22',
+ path: 'gitlab-test',
+ },
},
{
__typename: 'ContainerRepository',
@@ -24,6 +28,10 @@ export const imagesListResponse = [
createdAt: '2020-09-21T06:57:43Z',
expirationPolicyStartedAt: null,
expirationPolicyCleanupStatus: 'UNSCHEDULED',
+ project: {
+ id: 'gid://gitlab/Project/22',
+ path: 'gitlab-test',
+ },
},
];
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 59ca47bee50..1d161888a4d 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
@@ -20,7 +20,6 @@ import {
ALERT_DANGER_IMAGE,
ALERT_DANGER_IMPORTING,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
- ROOT_IMAGE_TEXT,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '~/packages_and_registries/container_registry/explorer/constants';
@@ -482,7 +481,7 @@ describe('Details Page', () => {
expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB);
});
- it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => {
+ it(`when the image has no name set the breadcrumb to project name`, async () => {
mountComponent({
resolver: jest
.fn()
@@ -491,7 +490,7 @@ describe('Details Page', () => {
await waitForApolloRequestRender();
- expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT);
+ expect(breadCrumbState.updateName).toHaveBeenCalledWith('gitlab-test');
});
});
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 fe4a2c06f1c..f2901148e17 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
@@ -38,6 +38,8 @@ const dummyGon = {
let originalGon;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${dummyGrouptId}/dependency_proxy/cache`;
+Vue.use(VueApollo);
+
describe('DependencyProxyApp', () => {
let wrapper;
let apolloProvider;
@@ -51,8 +53,6 @@ describe('DependencyProxyApp', () => {
};
function createComponent({ provide = provideDefaults } = {}) {
- Vue.use(VueApollo);
-
const requestHandlers = [[getDependencyProxyDetailsQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
@@ -103,19 +103,21 @@ describe('DependencyProxyApp', () => {
describe('when the dependency proxy is available', () => {
describe('when is loading', () => {
- beforeEach(() => {
+ it('renders the skeleton loader', () => {
createComponent();
- });
- it('renders the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
it('does not render a form group with label', () => {
+ createComponent();
+
expect(findFormGroup().exists()).toBe(false);
});
it('does not show the main section', () => {
+ createComponent();
+
expect(findMainArea().exists()).toBe(false);
});
});
@@ -215,23 +217,26 @@ describe('DependencyProxyApp', () => {
});
describe('triggering page event on list', () => {
- beforeEach(async () => {
+ it('re-renders the skeleton loader', async () => {
findManifestList().vm.$emit('next-page');
-
await nextTick();
- });
- it('re-renders the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
- it('renders form group with label', () => {
+ it('renders form group with label', async () => {
+ findManifestList().vm.$emit('next-page');
+ await nextTick();
+
expect(findFormGroup().attributes('label')).toEqual(
expect.stringMatching(DependencyProxyApp.i18n.proxyImagePrefix),
);
});
- it('does not show the main section', () => {
+ it('does not show the main section', async () => {
+ findManifestList().vm.$emit('next-page');
+ await nextTick();
+
expect(findMainArea().exists()).toBe(false);
});
});
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 e60989b0949..9d4c7f4737b 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
@@ -6,13 +6,15 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import component from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue';
-import { DEPENDENCY_PROXY_HEADER } from '~/packages_and_registries/settings/group/constants';
+import {
+ DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_DESCRIPTION,
+} 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 SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
import {
updateGroupDependencyProxySettingsOptimisticResponse,
updateDependencyProxyImageTtlGroupPolicyOptimisticResponse,
@@ -36,7 +38,6 @@ describe('DependencyProxySettings', () => {
let updateTtlPoliciesMutationResolver;
const defaultProvide = {
- defaultExpanded: false,
groupPath: 'foo_group_path',
groupDependencyProxyPath: 'group_dependency_proxy_path',
};
@@ -86,7 +87,6 @@ describe('DependencyProxySettings', () => {
});
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
- const findSettingsTitles = () => wrapper.findComponent(SettingsTitles);
const findEnableProxyToggle = () => wrapper.findByTestId('dependency-proxy-setting-toggle');
const findEnableTtlPoliciesToggle = () =>
wrapper.findByTestId('dependency-proxy-ttl-policies-toggle');
@@ -108,16 +108,11 @@ describe('DependencyProxySettings', () => {
expect(findSettingsBlock().exists()).toBe(true);
});
- it('passes the correct props to settings block', () => {
- mountComponent();
-
- expect(findSettingsBlock().props('defaultExpanded')).toBe(false);
- });
-
- it('has the correct header text', () => {
+ it('has the correct header text and description', () => {
mountComponent();
expect(wrapper.text()).toContain(DEPENDENCY_PROXY_HEADER);
+ expect(wrapper.text()).toContain(DEPENDENCY_PROXY_DESCRIPTION);
});
describe('enable toggle', () => {
@@ -158,14 +153,6 @@ describe('DependencyProxySettings', () => {
});
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();
diff --git a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js
index 79c2f811c08..3eecdeb5b1f 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js
@@ -4,8 +4,6 @@ import component from '~/packages_and_registries/settings/group/components/dupli
import {
DUPLICATES_TOGGLE_LABEL,
- DUPLICATES_ALLOWED_ENABLED,
- DUPLICATES_ALLOWED_DISABLED,
DUPLICATES_SETTING_EXCEPTION_TITLE,
DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
} from '~/packages_and_registries/settings/group/constants';
@@ -36,7 +34,6 @@ describe('Duplicates Settings', () => {
});
const findToggle = () => wrapper.findComponent(GlToggle);
- const findToggleLabel = () => wrapper.find('[data-testid="toggle-label"');
const findInputGroup = () => wrapper.findComponent(GlFormGroup);
const findInput = () => wrapper.findComponent(GlFormInput);
@@ -47,7 +44,7 @@ describe('Duplicates Settings', () => {
expect(findToggle().exists()).toBe(true);
expect(findToggle().props()).toMatchObject({
label: DUPLICATES_TOGGLE_LABEL,
- value: defaultProps.duplicatesAllowed,
+ value: !defaultProps.duplicatesAllowed,
});
});
@@ -57,18 +54,11 @@ describe('Duplicates Settings', () => {
findToggle().vm.$emit('change', false);
expect(wrapper.emitted('update')).toStrictEqual([
- [{ [defaultProps.modelNames.allowed]: false }],
+ [{ [defaultProps.modelNames.allowed]: true }],
]);
});
describe('when the duplicates are disabled', () => {
- it('the toggle has the disabled message', () => {
- mountComponent();
-
- expect(findToggleLabel().exists()).toBe(true);
- expect(findToggleLabel().text()).toMatchInterpolatedText(DUPLICATES_ALLOWED_DISABLED);
- });
-
it('shows a form group with an input field', () => {
mountComponent();
@@ -130,13 +120,6 @@ describe('Duplicates Settings', () => {
});
describe('when the duplicates are enabled', () => {
- it('has the correct toggle label', () => {
- mountComponent({ ...defaultProps, duplicatesAllowed: true });
-
- expect(findToggleLabel().exists()).toBe(true);
- expect(findToggleLabel().text()).toMatchInterpolatedText(DUPLICATES_ALLOWED_ENABLED);
- });
-
it('hides the form input group', () => {
mountComponent({ ...defaultProps, duplicatesAllowed: 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 635195ff0a4..31fc3ad419c 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
@@ -26,7 +26,6 @@ describe('Group Settings App', () => {
let show;
const defaultProvide = {
- defaultExpanded: false,
groupPath: 'foo_group_path',
};
diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js
index d92d42e7834..274930ce668 100644
--- a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js
+++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js
@@ -1,4 +1,3 @@
-import { GlSprintf, GlLink } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -11,7 +10,6 @@ import MavenSettings from '~/packages_and_registries/settings/group/components/m
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
- PACKAGES_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
@@ -33,7 +31,6 @@ describe('Packages Settings', () => {
let apolloProvider;
const defaultProvide = {
- defaultExpanded: false,
groupPath: 'foo_group_path',
};
@@ -53,7 +50,6 @@ describe('Packages Settings', () => {
packageSettings: packageSettings(),
},
stubs: {
- GlSprintf,
SettingsBlock,
MavenSettings,
GenericSettings,
@@ -67,7 +63,6 @@ describe('Packages Settings', () => {
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
const findDescription = () => wrapper.findByTestId('description');
- const findLink = () => wrapper.findComponent(GlLink);
const findMavenSettings = () => wrapper.findComponent(MavenSettings);
const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings);
const findGenericSettings = () => wrapper.findComponent(GenericSettings);
@@ -97,12 +92,6 @@ describe('Packages Settings', () => {
expect(findSettingsBlock().exists()).toBe(true);
});
- it('passes the correct props to settings block', () => {
- mountComponent();
-
- expect(findSettingsBlock().props('defaultExpanded')).toBe(false);
- });
-
it('has the correct header text', () => {
mountComponent();
@@ -115,16 +104,6 @@ describe('Packages Settings', () => {
expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION);
});
- it('has the correct link', () => {
- mountComponent();
-
- expect(findLink().attributes()).toMatchObject({
- href: PACKAGES_DOCS_PATH,
- target: '_blank',
- });
- expect(findLink().text()).toBe('Learn more.');
- });
-
describe('maven settings', () => {
it('exists', () => {
mountComponent();
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap
index faa313118f3..108d9478788 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/container_expiration_policy_form_spec.js.snap
@@ -4,6 +4,7 @@ exports[`Container Expiration Policy Settings Form Cadence matches snapshot 1`]
<expiration-dropdown-stub
class="gl-mr-7 gl-mb-0!"
data-testid="cadence-dropdown"
+ description=""
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Run cleanup:"
name="cadence"
@@ -22,6 +23,7 @@ exports[`Container Expiration Policy Settings Form Enable matches snapshot 1`] =
exports[`Container Expiration Policy Settings Form Keep N matches snapshot 1`] = `
<expiration-dropdown-stub
data-testid="keep-n-dropdown"
+ description=""
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Keep the most recent:"
name="keep-n"
@@ -44,6 +46,7 @@ exports[`Container Expiration Policy Settings Form Keep Regex matches snapshot 1
exports[`Container Expiration Policy Settings Form OlderThan matches snapshot 1`] = `
<expiration-dropdown-stub
data-testid="older-than-dropdown"
+ description=""
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Remove tags older than:"
name="older-than"
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js
index aa3506771fa..d83c717da6a 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_spec.js
@@ -43,11 +43,6 @@ describe('Container expiration policy project settings', () => {
GlSprintf,
SettingsBlock,
},
- mocks: {
- $toast: {
- show: jest.fn(),
- },
- },
provide,
...config,
});
@@ -98,7 +93,7 @@ describe('Container expiration policy project settings', () => {
await waitForPromises();
expect(findFormComponent().exists()).toBe(true);
- expect(findSettingsBlock().props('collapsible')).toBe(false);
+ expect(findSettingsBlock().exists()).toBe(true);
});
describe('the form is disabled', () => {
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js
index 5c9ade7f785..8b99ac6b06c 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/expiration_dropdown_spec.js
@@ -16,6 +16,7 @@ describe('ExpirationDropdown', () => {
const findFormSelect = () => wrapper.find(GlFormSelect);
const findFormGroup = () => wrapper.find(GlFormGroup);
+ const findDescription = () => wrapper.find('[data-testid="description"]');
const findOptions = () => wrapper.findAll('[data-testid="option"]');
const mountComponent = (props) => {
@@ -47,6 +48,14 @@ describe('ExpirationDropdown', () => {
expect(findOptions()).toHaveLength(defaultProps.formOptions.length);
});
+
+ it('renders the description if passed', () => {
+ mountComponent({
+ description: 'test description',
+ });
+
+ expect(findDescription().html()).toContain('test description');
+ });
});
describe('model', () => {
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js
new file mode 100644
index 00000000000..86f45d78bae
--- /dev/null
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_form_spec.js
@@ -0,0 +1,267 @@
+import VueApollo from 'vue-apollo';
+import Vue from 'vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { GlLoadingIcon } from 'jest/packages_and_registries/shared/stubs';
+import component from '~/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue';
+import {
+ UPDATE_SETTINGS_ERROR_MESSAGE,
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
+ KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION,
+} from '~/packages_and_registries/settings/project/constants';
+import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql';
+import Tracking from '~/tracking';
+import { packagesCleanupPolicyPayload, packagesCleanupPolicyMutationPayload } from '../mock_data';
+
+Vue.use(VueApollo);
+
+describe('Packages Cleanup Policy Settings Form', () => {
+ let wrapper;
+ let fakeApollo;
+
+ const defaultProvidedValues = {
+ projectPath: 'path',
+ };
+
+ const {
+ data: {
+ project: { packagesCleanupPolicy },
+ },
+ } = packagesCleanupPolicyPayload();
+
+ const defaultProps = {
+ value: { ...packagesCleanupPolicy },
+ };
+
+ const trackingPayload = {
+ label: 'packages_cleanup_policies',
+ };
+
+ const findForm = () => wrapper.find({ ref: 'form-element' });
+ const findSaveButton = () => wrapper.findByTestId('save-button');
+ const findKeepNDuplicatedPackageFilesDropdown = () =>
+ wrapper.findByTestId('keep-n-duplicated-package-files-dropdown');
+
+ const submitForm = async () => {
+ findForm().trigger('submit');
+ return waitForPromises();
+ };
+
+ const mountComponent = ({
+ props = defaultProps,
+ data,
+ config,
+ provide = defaultProvidedValues,
+ } = {}) => {
+ wrapper = shallowMountExtended(component, {
+ stubs: {
+ GlLoadingIcon,
+ },
+ propsData: { ...props },
+ provide,
+ data() {
+ return {
+ ...data,
+ };
+ },
+ mocks: {
+ $toast: {
+ show: jest.fn(),
+ },
+ },
+ ...config,
+ });
+ };
+
+ const mountComponentWithApollo = ({
+ provide = defaultProvidedValues,
+ mutationResolver,
+ queryPayload = packagesCleanupPolicyPayload(),
+ } = {}) => {
+ const requestHandlers = [[updatePackagesCleanupPolicyMutation, mutationResolver]];
+
+ fakeApollo = createMockApollo(requestHandlers);
+
+ const {
+ data: {
+ project: { packagesCleanupPolicy: value },
+ },
+ } = queryPayload;
+
+ mountComponent({
+ provide,
+ props: {
+ ...defaultProps,
+ value,
+ },
+ config: {
+ apolloProvider: fakeApollo,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ describe('keepNDuplicatedPackageFiles', () => {
+ it('renders dropdown', () => {
+ mountComponent();
+
+ const element = findKeepNDuplicatedPackageFilesDropdown();
+
+ expect(element.exists()).toBe(true);
+ expect(element.props('label')).toMatchInterpolatedText(KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL);
+ expect(element.props('description')).toEqual(KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION);
+ });
+
+ it('input event triggers a model update', () => {
+ mountComponent();
+
+ findKeepNDuplicatedPackageFilesDropdown().vm.$emit('input', 'foo');
+ expect(wrapper.emitted('input')[0][0]).toMatchObject({
+ keepNDuplicatedPackageFiles: 'foo',
+ });
+ });
+
+ it('shows the default option when none are selected', () => {
+ mountComponent({ props: { value: {} } });
+ expect(findKeepNDuplicatedPackageFilesDropdown().props('value')).toEqual('ALL_PACKAGE_FILES');
+ });
+
+ it.each`
+ isLoading | mutationLoading
+ ${true} | ${false}
+ ${true} | ${true}
+ ${false} | ${true}
+ `(
+ 'is disabled when is loading is $isLoading and mutationLoading is $mutationLoading',
+ ({ isLoading, mutationLoading }) => {
+ mountComponent({
+ props: { isLoading, value: {} },
+ data: { mutationLoading },
+ });
+ expect(findKeepNDuplicatedPackageFilesDropdown().props('disabled')).toEqual(true);
+ },
+ );
+
+ it('has the correct formOptions', () => {
+ mountComponent();
+ expect(findKeepNDuplicatedPackageFilesDropdown().props('formOptions')).toEqual(
+ wrapper.vm.$options.formOptions.keepNDuplicatedPackageFiles,
+ );
+ });
+ });
+
+ describe('form', () => {
+ describe('actions', () => {
+ describe('submit button', () => {
+ it('has type submit', () => {
+ mountComponent();
+
+ expect(findSaveButton().attributes('type')).toBe('submit');
+ });
+
+ it.each`
+ isLoading | mutationLoading | disabled
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ ${false} | ${false} | ${false}
+ `(
+ 'when isLoading is $isLoading and mutationLoading is $mutationLoading is disabled',
+ ({ isLoading, mutationLoading, disabled }) => {
+ mountComponent({
+ props: { ...defaultProps, isLoading },
+ data: { mutationLoading },
+ });
+
+ expect(findSaveButton().props('disabled')).toBe(disabled);
+ expect(findKeepNDuplicatedPackageFilesDropdown().props('disabled')).toBe(disabled);
+ },
+ );
+
+ it.each`
+ isLoading | mutationLoading | showLoading
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ ${false} | ${false} | ${false}
+ `(
+ 'when isLoading is $isLoading and mutationLoading is $mutationLoading is $showLoading that the loading icon is shown',
+ ({ isLoading, mutationLoading, showLoading }) => {
+ mountComponent({
+ props: { ...defaultProps, isLoading },
+ data: { mutationLoading },
+ });
+
+ expect(findSaveButton().props('loading')).toBe(showLoading);
+ },
+ );
+ });
+ });
+
+ describe('form submit event', () => {
+ it('dispatches the correct apollo mutation', () => {
+ const mutationResolver = jest
+ .fn()
+ .mockResolvedValue(packagesCleanupPolicyMutationPayload());
+ mountComponentWithApollo({
+ mutationResolver,
+ });
+
+ findForm().trigger('submit');
+
+ expect(mutationResolver).toHaveBeenCalledWith({
+ input: {
+ keepNDuplicatedPackageFiles: 'ALL_PACKAGE_FILES',
+ projectPath: 'path',
+ },
+ });
+ });
+
+ it('tracks the submit event', () => {
+ mountComponentWithApollo({
+ mutationResolver: jest.fn().mockResolvedValue(packagesCleanupPolicyMutationPayload()),
+ });
+
+ findForm().trigger('submit');
+
+ expect(Tracking.event).toHaveBeenCalledWith(
+ undefined,
+ 'submit_packages_cleanup_form',
+ trackingPayload,
+ );
+ });
+
+ it('show a success toast when submit succeed', async () => {
+ mountComponentWithApollo({
+ mutationResolver: jest.fn().mockResolvedValue(packagesCleanupPolicyMutationPayload()),
+ });
+
+ await submitForm();
+
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE);
+ });
+
+ describe('when submit fails', () => {
+ it('shows an error', async () => {
+ mountComponentWithApollo({
+ mutationResolver: jest.fn().mockRejectedValue(packagesCleanupPolicyMutationPayload()),
+ });
+
+ await submitForm();
+
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js
new file mode 100644
index 00000000000..6dfeeca6862
--- /dev/null
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/packages_cleanup_policy_spec.js
@@ -0,0 +1,81 @@
+import { GlAlert, GlSprintf } 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 waitForPromises from 'helpers/wait_for_promises';
+import component from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue';
+import PackagesCleanupPolicyForm from '~/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue';
+import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/packages_and_registries/settings/project/constants';
+import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+
+import { packagesCleanupPolicyPayload, packagesCleanupPolicyData } from '../mock_data';
+
+Vue.use(VueApollo);
+
+describe('Packages cleanup policy project settings', () => {
+ let wrapper;
+ let fakeApollo;
+
+ const defaultProvidedValues = {
+ projectPath: 'path',
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findFormComponent = () => wrapper.findComponent(PackagesCleanupPolicyForm);
+ const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
+
+ const mountComponent = (provide = defaultProvidedValues, config) => {
+ wrapper = shallowMount(component, {
+ stubs: {
+ GlSprintf,
+ SettingsBlock,
+ },
+ provide,
+ ...config,
+ });
+ };
+
+ const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => {
+ const requestHandlers = [[packagesCleanupPolicyQuery, resolver]];
+
+ fakeApollo = createMockApollo(requestHandlers);
+ mountComponent(provide, {
+ apolloProvider: fakeApollo,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ it('renders the setting form', async () => {
+ mountComponentWithApollo({
+ resolver: jest.fn().mockResolvedValue(packagesCleanupPolicyPayload()),
+ });
+ await waitForPromises();
+
+ expect(findFormComponent().exists()).toBe(true);
+ expect(findFormComponent().props('value')).toEqual(packagesCleanupPolicyData);
+ expect(findSettingsBlock().exists()).toBe(true);
+ });
+
+ describe('fetchSettingsError', () => {
+ beforeEach(async () => {
+ mountComponentWithApollo({
+ resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')),
+ });
+ await waitForPromises();
+ });
+
+ it('the form is hidden', () => {
+ expect(findFormComponent().exists()).toBe(false);
+ });
+
+ it('shows an alert', () => {
+ expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
index 337991dfae0..f576bc79eae 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
@@ -1,19 +1,41 @@
import { shallowMount } from '@vue/test-utils';
import component from '~/packages_and_registries/settings/project/components/registry_settings_app.vue';
import ContainerExpirationPolicy from '~/packages_and_registries/settings/project/components/container_expiration_policy.vue';
+import PackagesCleanupPolicy from '~/packages_and_registries/settings/project/components/packages_cleanup_policy.vue';
describe('Registry Settings app', () => {
let wrapper;
+
const findContainerExpirationPolicy = () => wrapper.find(ContainerExpirationPolicy);
+ const findPackagesCleanupPolicy = () => wrapper.find(PackagesCleanupPolicy);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
- it('renders container expiration policy component', () => {
- wrapper = shallowMount(component);
+ const mountComponent = (provide) => {
+ wrapper = shallowMount(component, {
+ provide,
+ });
+ };
- expect(findContainerExpirationPolicy().exists()).toBe(true);
- });
+ it.each`
+ showContainerRegistrySettings | showPackageRegistrySettings
+ ${true} | ${false}
+ ${true} | ${true}
+ ${false} | ${true}
+ ${false} | ${false}
+ `(
+ 'container expiration policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings',
+ ({ showContainerRegistrySettings, showPackageRegistrySettings }) => {
+ mountComponent({
+ showContainerRegistrySettings,
+ showPackageRegistrySettings,
+ });
+
+ expect(findContainerExpirationPolicy().exists()).toBe(showContainerRegistrySettings);
+ expect(findPackagesCleanupPolicy().exists()).toBe(showPackageRegistrySettings);
+ },
+ );
});
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 33406c98f4b..d4b6c66ddeb 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
@@ -40,3 +40,33 @@ export const expirationPolicyMutationPayload = ({ override, errors = [] } = {})
},
},
});
+
+export const packagesCleanupPolicyData = {
+ keepNDuplicatedPackageFiles: 'ALL_PACKAGE_FILES',
+ nextRunAt: '2020-11-19T07:37:03.941Z',
+};
+
+export const packagesCleanupPolicyPayload = (override) => ({
+ data: {
+ project: {
+ id: '1',
+ packagesCleanupPolicy: {
+ __typename: 'PackagesCleanupPolicy',
+ ...packagesCleanupPolicyData,
+ ...override,
+ },
+ },
+ },
+});
+
+export const packagesCleanupPolicyMutationPayload = ({ override, errors = [] } = {}) => ({
+ data: {
+ updatePackagesCleanupPolicy: {
+ packagesCleanupPolicy: {
+ ...packagesCleanupPolicyData,
+ ...override,
+ },
+ errors,
+ },
+ },
+});
diff --git a/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js b/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js
new file mode 100644
index 00000000000..a4c1b989dac
--- /dev/null
+++ b/spec/frontend/packages_and_registries/shared/components/settings_block_spec.js
@@ -0,0 +1,43 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
+
+describe('SettingsBlock', () => {
+ let wrapper;
+
+ const mountComponent = (propsData) => {
+ wrapper = shallowMountExtended(SettingsBlock, {
+ propsData,
+ slots: {
+ title: '<div data-testid="title-slot"></div>',
+ description: '<div data-testid="description-slot"></div>',
+ default: '<div data-testid="default-slot"></div>',
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDefaultSlot = () => wrapper.findByTestId('default-slot');
+ const findTitleSlot = () => wrapper.findByTestId('title-slot');
+ const findDescriptionSlot = () => wrapper.findByTestId('description-slot');
+
+ it('has a default slot', () => {
+ mountComponent();
+
+ expect(findDefaultSlot().exists()).toBe(true);
+ });
+
+ it('has a title slot', () => {
+ mountComponent();
+
+ expect(findTitleSlot().exists()).toBe(true);
+ });
+
+ it('has a description slot', () => {
+ mountComponent();
+
+ expect(findDescriptionSlot().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
index 3a9b59f291c..03aed7454e3 100644
--- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js
+++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
@@ -1,5 +1,4 @@
import MockAdapter from 'axios-mock-adapter';
-import $ from 'jquery';
import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import waitForPromises from 'helpers/wait_for_promises';
import '~/lib/utils/common_utils';
@@ -54,22 +53,28 @@ describe('Todos', () => {
let metakeyEvent;
beforeEach(() => {
- metakeyEvent = $.Event('click', { keyCode: 91, ctrlKey: true });
+ metakeyEvent = new MouseEvent('click', { ctrlKey: true });
windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => {});
});
it('opens the todo url in another tab', () => {
const todoLink = todoItem.dataset.url;
- $('.todos-list .todo').trigger(metakeyEvent);
+ document.querySelectorAll('.todos-list .todo').forEach((el) => {
+ el.dispatchEvent(metakeyEvent);
+ });
expect(visitUrl).not.toHaveBeenCalled();
expect(windowOpenSpy).toHaveBeenCalledWith(todoLink, '_blank');
});
it('run native funcionality when avatar is clicked', () => {
- $('.todos-list a').on('click', (e) => e.preventDefault());
- $('.todos-list img').trigger(metakeyEvent);
+ document.querySelectorAll('.todos-list a').forEach((el) => {
+ el.addEventListener('click', (e) => e.preventDefault());
+ });
+ document.querySelectorAll('.todos-list img').forEach((el) => {
+ el.dispatchEvent(metakeyEvent);
+ });
expect(visitUrl).not.toHaveBeenCalled();
expect(windowOpenSpy).not.toHaveBeenCalled();
@@ -88,7 +93,7 @@ describe('Todos', () => {
.onDelete(path)
.replyOnce(200, { count: TEST_COUNT_BIG, done_count: TEST_DONE_COUNT_BIG });
onToggleSpy = jest.fn();
- $(document).on('todo:toggle', onToggleSpy);
+ document.addEventListener('todo:toggle', onToggleSpy);
// Act
el.click();
@@ -98,7 +103,13 @@ describe('Todos', () => {
});
it('dispatches todo:toggle', () => {
- expect(onToggleSpy).toHaveBeenCalledWith(expect.anything(), TEST_COUNT_BIG);
+ expect(onToggleSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ detail: {
+ count: TEST_COUNT_BIG,
+ },
+ }),
+ );
});
it('updates pending text', () => {
diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
index efbfd83a071..2a0fde45384 100644
--- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
+++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js
@@ -400,10 +400,6 @@ describe('ForkForm component', () => {
);
};
- beforeEach(() => {
- setupComponent();
- });
-
const selectedMockNamespaceIndex = 1;
const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id;
@@ -425,10 +421,14 @@ describe('ForkForm component', () => {
it('does not make POST request', async () => {
jest.spyOn(axios, 'post');
+ setupComponent();
+
expect(axios.post).not.toHaveBeenCalled();
});
it('does not redirect the current page', async () => {
+ setupComponent();
+
await submitForm();
expect(urlUtility.redirectTo).not.toHaveBeenCalled();
@@ -452,13 +452,10 @@ describe('ForkForm component', () => {
});
describe('with valid form', () => {
- beforeEach(() => {
- fillForm();
- });
-
it('make POST request with project param', async () => {
jest.spyOn(axios, 'post');
+ setupComponent();
await submitForm();
const {
@@ -486,6 +483,7 @@ describe('ForkForm component', () => {
const webUrl = `new/fork-project`;
jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } });
+ setupComponent();
await submitForm();
expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl);
@@ -496,6 +494,7 @@ describe('ForkForm component', () => {
jest.spyOn(axios, 'post').mockRejectedValue(dummyError);
+ setupComponent();
await submitForm();
expect(urlUtility.redirectTo).not.toHaveBeenCalled();
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 005b8968383..aab78c99190 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
@@ -85,8 +85,6 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <!---->
-
<div
class="flex align-items-center"
>
@@ -105,7 +103,8 @@ exports[`Learn GitLab renders correctly 1`] = `
</svg>
Invite your colleagues
-
+
+ <!---->
</span>
<!---->
@@ -114,8 +113,6 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <!---->
-
<div
class="flex align-items-center"
>
@@ -133,8 +130,9 @@ exports[`Learn GitLab renders correctly 1`] = `
/>
</svg>
- Create or import a repository
-
+ Create a repository
+
+ <!---->
</span>
<!---->
@@ -143,23 +141,23 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <!---->
-
<div
class="flex align-items-center"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Set up CI/CD"
- href="http://example.com/"
- target="_self"
- >
-
- Set up CI/CD
-
- </a>
+ <div>
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="set_up_your_first_project_s_ci_cd"
+ href="http://example.com/"
+ target="_self"
+ >
+ Set up your first project's CI/CD
+ </a>
+
+ <!---->
+ </div>
<!---->
</div>
@@ -167,24 +165,24 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <!---->
-
<div
class="flex align-items-center"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Start a free Ultimate trial"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Start a free Ultimate trial
-
- </a>
+ <div>
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="start_a_free_trial_of_gitlab_ultimate"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Start a free trial of GitLab Ultimate
+ </a>
+
+ <!---->
+ </div>
<!---->
</div>
@@ -193,30 +191,30 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-mb-4"
>
<div
- class="gl-font-style-italic gl-text-gray-500"
- data-testid="trial-only"
- >
-
- Trial only
-
- </div>
-
- <div
class="flex align-items-center"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Add code owners"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Add code owners
-
- </a>
+ <div>
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="add_code_owners"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Add code owners
+ </a>
+
+ <span
+ class="gl-font-style-italic gl-text-gray-500"
+ data-testid="trial-only"
+ >
+
+ - Included in trial
+
+ </span>
+ </div>
<!---->
</div>
@@ -225,30 +223,30 @@ exports[`Learn GitLab renders correctly 1`] = `
class="gl-mb-4"
>
<div
- class="gl-font-style-italic gl-text-gray-500"
- data-testid="trial-only"
- >
-
- Trial only
-
- </div>
-
- <div
class="flex align-items-center"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Add merge request approval"
- href="http://example.com/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Add merge request approval
-
- </a>
+ <div>
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="enable_require_merge_approvals"
+ href="http://example.com/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Enable require merge approvals
+ </a>
+
+ <span
+ class="gl-font-style-italic gl-text-gray-500"
+ data-testid="trial-only"
+ >
+
+ - Included in trial
+
+ </span>
+ </div>
<!---->
</div>
@@ -290,23 +288,23 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <!---->
-
<div
class="flex align-items-center"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Create an issue"
- href="http://example.com/"
- target="_self"
- >
-
- Create an issue
-
- </a>
+ <div>
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="create_an_issue"
+ href="http://example.com/"
+ target="_self"
+ >
+ Create an issue
+ </a>
+
+ <!---->
+ </div>
<!---->
</div>
@@ -314,23 +312,23 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <!---->
-
<div
class="flex align-items-center"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Submit a merge request"
- href="http://example.com/"
- target="_self"
- >
-
- Submit a merge request
-
- </a>
+ <div>
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="submit_a_merge_request_mr"
+ href="http://example.com/"
+ target="_self"
+ >
+ Submit a merge request (MR)
+ </a>
+
+ <!---->
+ </div>
<!---->
</div>
@@ -372,24 +370,24 @@ exports[`Learn GitLab renders correctly 1`] = `
<div
class="gl-mb-4"
>
- <!---->
-
<div
class="flex align-items-center"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Run a Security scan using CI/CD"
- href="https://docs.gitlab.com/ee/foobar/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Run a Security scan using CI/CD
-
- </a>
+ <div>
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="run_a_security_scan_using_ci_cd"
+ href="https://docs.gitlab.com/ee/foobar/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ Run a Security scan using CI/CD
+ </a>
+
+ <!---->
+ </div>
<!---->
</div>
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js
deleted file mode 100644
index ad4bc826a9d..00000000000
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_info_card_spec.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import LearnGitlabInfoCard from '~/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue';
-
-const defaultProps = {
- title: 'Create Repository',
- description: 'Some description',
- actionLabel: 'Create Repository now',
- url: 'https://example.com',
- completed: false,
- svg: 'https://example.com/illustration.svg',
-};
-
-describe('Learn GitLab Info Card', () => {
- let wrapper;
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- const createWrapper = (props = {}) => {
- wrapper = shallowMount(LearnGitlabInfoCard, {
- propsData: { ...defaultProps, ...props },
- });
- };
-
- it('renders no icon when not completed', () => {
- createWrapper({ completed: false });
-
- expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(false);
- });
-
- it('renders the completion icon when completed', () => {
- createWrapper({ completed: true });
-
- expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true);
- });
-
- it('renders no trial only when it is not required', () => {
- createWrapper();
-
- expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false);
- });
-
- it('renders trial only when trial is required', () => {
- createWrapper({ trialRequired: true });
-
- expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(true);
- });
-
- it('renders completion icon when completed a trial-only feature', () => {
- createWrapper({ trialRequired: true, completed: true });
-
- expect(wrapper.find('[data-testid="trial-only"]').exists()).toBe(false);
- expect(wrapper.find('[data-testid="completed-icon"]').exists()).toBe(true);
- });
-});
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 d9aff37f703..897cbf5eaa4 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
@@ -119,7 +119,7 @@ describe('Learn GitLab Section Link', () => {
findUncompletedLink().trigger('click');
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', {
- label: 'Run a Security scan using CI/CD',
+ label: 'run_a_security_scan_using_ci_cd',
});
unmockTracking();
@@ -164,7 +164,7 @@ describe('Learn GitLab Section Link', () => {
triggerEvent(openInviteMembesrModalLink().element);
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', {
- label: 'Invite your colleagues',
+ label: 'invite_your_colleagues',
property: 'Growth::Activation::Experiment::InviteForHelpContinuousOnboarding',
});
@@ -203,7 +203,7 @@ describe('Learn GitLab Section Link', () => {
videoTutorialLink().trigger('click');
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_video_link', {
- label: 'Add code owners',
+ label: 'add_code_owners',
property: 'Growth::Conversion::Experiment::LearnGitLab',
context: {
data: {
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_trial_card_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_trial_card_spec.js
new file mode 100644
index 00000000000..6ab57e31fed
--- /dev/null
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_trial_card_spec.js
@@ -0,0 +1,12 @@
+import { shallowMount } from '@vue/test-utils';
+import IncludedInTrialIndicator from '~/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue';
+
+describe('Learn GitLab Trial Card', () => {
+ it('renders correctly', () => {
+ const wrapper = shallowMount(IncludedInTrialIndicator);
+
+ expect(wrapper.text()).toEqual('- Included in trial');
+
+ wrapper.destroy();
+ });
+});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
index d5b4b3c22d8..99df5b58d90 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/interval_pattern_input_spec.js
@@ -31,10 +31,10 @@ describe('Interval Pattern Input Component', () => {
wrapper.findAll('input[type="radio"]').wrappers.find((x) => x.element.checked);
const findIcon = () => wrapper.findComponent(GlIcon);
const findSelectedRadioKey = () => findSelectedRadio()?.attributes('data-testid');
- const selectEveryDayRadio = () => findEveryDayRadio().trigger('click');
- const selectEveryWeekRadio = () => findEveryWeekRadio().trigger('click');
- const selectEveryMonthRadio = () => findEveryMonthRadio().trigger('click');
- const selectCustomRadio = () => findCustomRadio().trigger('click');
+ const selectEveryDayRadio = () => findEveryDayRadio().setChecked(true);
+ const selectEveryWeekRadio = () => findEveryWeekRadio().setChecked(true);
+ const selectEveryMonthRadio = () => findEveryMonthRadio().setChecked(true);
+ const selectCustomRadio = () => findCustomRadio().setChecked(true);
const createWrapper = (props = {}, data = {}) => {
if (wrapper) {
diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
index 46f83ac89e5..85660d09baa 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
@@ -51,6 +51,7 @@ const defaultProps = {
requestCveAvailable: true,
confirmationPhrase: 'my-fake-project',
showVisibilityConfirmModal: false,
+ membersPagePath: '/my-fake-project/-/project_members',
};
const FEATURE_ACCESS_LEVEL_ANONYMOUS = 30;
@@ -59,7 +60,7 @@ describe('Settings Panel', () => {
let wrapper;
const mountComponent = (
- { currentSettings = {}, glFeatures = {}, ...customProps } = {},
+ { currentSettings = {}, glFeatures = {}, stubs = {}, ...customProps } = {},
mountFn = shallowMount,
) => {
const propsData = {
@@ -76,6 +77,7 @@ describe('Settings Panel', () => {
...glFeatures,
},
},
+ stubs,
});
};
@@ -176,7 +178,7 @@ describe('Settings Panel', () => {
);
it('should set the visibility level description based upon the selected visibility level', () => {
- wrapper = mountComponent();
+ wrapper = mountComponent({ stubs: { GlSprintf } });
findProjectVisibilityLevelInput().setValue(visibilityOptions.INTERNAL);
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js
index 365bb878485..108f816fe01 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js
@@ -7,8 +7,10 @@ import { renderGFM } from '~/pages/shared/wikis/render_gfm_facade';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import waitForPromises from 'helpers/wait_for_promises';
+import { handleLocationHash } from '~/lib/utils/common_utils';
jest.mock('~/pages/shared/wikis/render_gfm_facade');
+jest.mock('~/lib/utils/common_utils');
describe('pages/shared/wikis/components/wiki_content', () => {
const PATH = '/test';
@@ -76,6 +78,12 @@ describe('pages/shared/wikis/components/wiki_content', () => {
expect(renderGFM).toHaveBeenCalledWith(wrapper.element);
});
+
+ it('handles hash after render', async () => {
+ await nextTick();
+
+ expect(handleLocationHash).toHaveBeenCalled();
+ });
});
describe('when loading content fails', () => {
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 d7f8dc3c98e..a5db10d106d 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
@@ -1,12 +1,14 @@
import { nextTick } from 'vue';
-import { GlAlert, GlButton } from '@gitlab/ui';
+import { GlAlert, GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { mockTracking } from 'helpers/tracking_helper';
+import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ContentEditor from '~/content_editor/components/content_editor.vue';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue';
import {
CONTENT_EDITOR_LOADED_ACTION,
@@ -37,6 +39,7 @@ describe('WikiForm', () => {
const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link');
const findContentEditor = () => wrapper.findComponent(ContentEditor);
const findClassicEditor = () => wrapper.findComponent(MarkdownField);
+ const findLocalStorageSync = () => wrapper.find(LocalStorageSync);
const setFormat = (value) => {
const format = findFormat();
@@ -103,6 +106,9 @@ describe('WikiForm', () => {
MarkdownField,
GlAlert,
GlButton,
+ LocalStorageSync: stubComponent(LocalStorageSync),
+ GlFormInput,
+ GlFormGroup,
},
}),
);
@@ -128,7 +134,7 @@ describe('WikiForm', () => {
`(
'updates the commit message to $message when title is $title and persisted=$persisted',
async ({ title, message, persisted }) => {
- createWrapper({ persisted });
+ createWrapper({ persisted, mountFn: mount });
await findTitle().setValue(title);
@@ -137,7 +143,7 @@ describe('WikiForm', () => {
);
it('sets the commit message to "Update My page" when the page first loads when persisted', async () => {
- createWrapper({ persisted: true });
+ createWrapper({ persisted: true, mountFn: mount });
await nextTick();
@@ -157,7 +163,7 @@ describe('WikiForm', () => {
${'asciidoc'} | ${false} | ${'hides'}
${'org'} | ${false} | ${'hides'}
`('$action preview in the markdown field when format is $format', async ({ format, enabled }) => {
- createWrapper();
+ createWrapper({ mountFn: mount });
await setFormat(format);
@@ -254,7 +260,7 @@ describe('WikiForm', () => {
`(
"when title='$title', content='$content', then the button is $buttonState'",
async ({ title, content, disabledAttr }) => {
- createWrapper();
+ createWrapper({ mountFn: mount });
await findTitle().setValue(title);
await findContent().setValue(content);
@@ -291,7 +297,7 @@ describe('WikiForm', () => {
describe('toggle editing mode control', () => {
beforeEach(() => {
- createWrapper();
+ createWrapper({ mountFn: mount });
});
it.each`
@@ -330,6 +336,19 @@ describe('WikiForm', () => {
});
});
+ describe('markdown editor type persistance', () => {
+ it('loads content editor by default if it is persisted in local storage', async () => {
+ expect(findClassicEditor().exists()).toBe(true);
+ expect(findContentEditor().exists()).toBe(false);
+
+ // enable content editor
+ await findLocalStorageSync().vm.$emit('input', true);
+
+ expect(findContentEditor().exists()).toBe(true);
+ expect(findClassicEditor().exists()).toBe(false);
+ });
+ });
+
describe('when content editor is active', () => {
let mockContentEditor;
@@ -374,7 +393,7 @@ describe('WikiForm', () => {
});
describe('wiki content editor', () => {
- describe('clicking "use new editor": editor fails to load', () => {
+ describe('clicking "Edit rich text": editor fails to load', () => {
beforeEach(async () => {
createWrapper({ mountFn: mount });
mock.onPost(/preview-markdown/).reply(400);
@@ -401,7 +420,7 @@ describe('WikiForm', () => {
});
});
- describe('clicking "use new editor": editor loads successfully', () => {
+ describe('clicking "Edit rich text": editor loads successfully', () => {
beforeEach(async () => {
createWrapper({ persisted: true, mountFn: mount });
diff --git a/spec/frontend/pdf/index_spec.js b/spec/frontend/pdf/index_spec.js
index 2b0932493bb..98946412264 100644
--- a/spec/frontend/pdf/index_spec.js
+++ b/spec/frontend/pdf/index_spec.js
@@ -1,48 +1,33 @@
-import Vue from 'vue';
-
+import { shallowMount } from '@vue/test-utils';
import { FIXTURES_PATH } from 'spec/test_constants';
import PDFLab from '~/pdf/index.vue';
-jest.mock('pdfjs-dist/webpack', () => {
- return { default: jest.requireActual('pdfjs-dist/build/pdf') };
-});
-
-const pdf = `${FIXTURES_PATH}/blob/pdf/test.pdf`;
+describe('PDFLab component', () => {
+ let wrapper;
-const Component = Vue.extend(PDFLab);
+ const mountComponent = ({ pdf }) => shallowMount(PDFLab, { propsData: { pdf } });
-describe('PDF component', () => {
- let vm;
+ afterEach(() => {
+ wrapper.destroy();
+ });
describe('without PDF data', () => {
beforeEach(() => {
- vm = new Component({
- propsData: {
- pdf: '',
- },
- });
-
- vm.$mount();
+ wrapper = mountComponent({ pdf: '' });
});
it('does not render', () => {
- expect(vm.$el.tagName).toBeUndefined();
+ expect(wrapper.isVisible()).toBe(false);
});
});
describe('with PDF data', () => {
beforeEach(() => {
- vm = new Component({
- propsData: {
- pdf,
- },
- });
-
- vm.$mount();
+ wrapper = mountComponent({ pdf: `${FIXTURES_PATH}/blob/pdf/test.pdf` });
});
- it('renders pdf component', () => {
- expect(vm.$el.tagName).toBeDefined();
+ it('renders', () => {
+ expect(wrapper.isVisible()).toBe(true);
});
});
});
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 ae19ed9ab02..82ac390971d 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
@@ -152,4 +152,26 @@ describe('CI Lint Results', () => {
expect(findAfterScripts()).toHaveLength(filterEmptyScripts('afterScript').length);
});
});
+
+ describe('Hide Alert', () => {
+ it('hides alert on success if hide-alert prop is true', async () => {
+ await createComponent({ dryRun: true, hideAlert: true }, mount);
+
+ expect(findStatus().exists()).toBe(false);
+ });
+
+ it('hides alert on error if hide-alert prop is true', async () => {
+ await createComponent(
+ {
+ hideAlert: true,
+ isValid: false,
+ errors: mockErrors,
+ warnings: mockWarnings,
+ },
+ mount,
+ );
+
+ expect(findStatus().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
index 3ecf6472544..87a7f07f7d4 100644
--- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
+++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
@@ -1,6 +1,8 @@
-import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
-import { shallowMount, mount } from '@vue/test-utils';
+import { GlAlert, GlBadge, GlLoadingIcon, GlTabs } from '@gitlab/ui';
+import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
import Vue, { nextTick } from 'vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue';
import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue';
@@ -15,9 +17,21 @@ import {
EDITOR_APP_STATUS_INVALID,
EDITOR_APP_STATUS_VALID,
TAB_QUERY_PARAM,
+ VALIDATE_TAB,
+ VALIDATE_TAB_BADGE_DISMISSED_KEY,
} from '~/pipeline_editor/constants';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
-import { mockLintResponse, mockLintResponseWithoutMerged, mockCiYml } from '../mock_data';
+import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql';
+import {
+ mockBlobContentQueryResponse,
+ mockCiLintPath,
+ mockCiYml,
+ mockLintResponse,
+ mockLintResponseWithoutMerged,
+} from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
Vue.config.ignoredElements = ['gl-emoji'];
@@ -33,11 +47,13 @@ describe('Pipeline editor tabs component', () => {
provide = {},
appStatus = EDITOR_APP_STATUS_VALID,
mountFn = shallowMount,
+ options = {},
} = {}) => {
wrapper = mountFn(PipelineEditorTabs, {
propsData: {
ciConfigData: mockLintResponse,
ciFileContent: mockCiYml,
+ currentTab: CREATE_TAB,
isNewCiConfigFile: true,
showDrawer: false,
...props,
@@ -47,12 +63,34 @@ describe('Pipeline editor tabs component', () => {
appStatus,
};
},
- provide: { ...provide },
+ provide: {
+ ciLintPath: mockCiLintPath,
+ ...provide,
+ },
stubs: {
TextEditor: MockTextEditor,
EditorTab,
},
listeners,
+ ...options,
+ });
+ };
+
+ let mockBlobContentData;
+ let mockApollo;
+
+ const createComponentWithApollo = ({ props, provide = {}, mountFn = shallowMount } = {}) => {
+ const handlers = [[getBlobContent, mockBlobContentData]];
+ mockApollo = createMockApollo(handlers);
+
+ createComponent({
+ props,
+ provide,
+ mountFn,
+ options: {
+ localVue,
+ apolloProvider: mockApollo,
+ },
});
};
@@ -63,6 +101,7 @@ describe('Pipeline editor tabs component', () => {
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
const findAlert = () => wrapper.findComponent(GlAlert);
+ const findBadge = () => wrapper.findComponent(GlBadge);
const findCiLint = () => wrapper.findComponent(CiLint);
const findCiValidate = () => wrapper.findComponent(CiValidate);
const findGlTabs = () => wrapper.findComponent(GlTabs);
@@ -72,6 +111,10 @@ describe('Pipeline editor tabs component', () => {
const findMergedPreview = () => wrapper.findComponent(CiConfigMergedPreview);
const findWalkthroughPopover = () => wrapper.findComponent(WalkthroughPopover);
+ beforeEach(() => {
+ mockBlobContentData = jest.fn();
+ });
+
afterEach(() => {
wrapper.destroy();
});
@@ -114,37 +157,73 @@ describe('Pipeline editor tabs component', () => {
describe('validate tab', () => {
describe('with simulatePipeline feature flag ON', () => {
- describe('while loading', () => {
+ describe('after loading', () => {
beforeEach(() => {
createComponent({
- appStatus: EDITOR_APP_STATUS_LOADING,
- provide: {
- glFeatures: {
- simulatePipeline: true,
- },
- },
+ provide: { glFeatures: { simulatePipeline: true } },
});
});
- it('displays a loading icon if the lint query is loading', () => {
- expect(findLoadingIcon().exists()).toBe(true);
- });
-
- it('does not display the validate component', () => {
- expect(findCiValidate().exists()).toBe(false);
+ it('displays the tab and the validate component', () => {
+ expect(findValidateTab().exists()).toBe(true);
+ expect(findCiValidate().exists()).toBe(true);
});
});
- describe('after loading', () => {
- beforeEach(() => {
- createComponent({
- provide: { glFeatures: { simulatePipeline: true } },
+ describe('NEW badge', () => {
+ describe('default', () => {
+ beforeEach(() => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ createComponentWithApollo({
+ mountFn: mount,
+ props: {
+ currentTab: VALIDATE_TAB,
+ },
+ provide: {
+ glFeatures: { simulatePipeline: true },
+ ciConfigPath: '/path/to/ci-config',
+ currentBranch: 'main',
+ projectFullPath: '/path/to/project',
+ simulatePipelineHelpPagePath: 'path/to/help/page',
+ validateTabIllustrationPath: 'path/to/svg',
+ },
+ });
+ });
+
+ it('renders badge by default', () => {
+ expect(findBadge().exists()).toBe(true);
+ expect(findBadge().text()).toBe(wrapper.vm.$options.i18n.new);
+ });
+
+ it('hides badge when moving away from the validate tab', async () => {
+ expect(findBadge().exists()).toBe(true);
+
+ await findEditorTab().vm.$emit('click');
+
+ expect(findBadge().exists()).toBe(false);
});
});
- it('displays the tab and the validate component', () => {
- expect(findValidateTab().exists()).toBe(true);
- expect(findCiValidate().exists()).toBe(true);
+ describe('if badge has been dismissed before', () => {
+ beforeEach(() => {
+ localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true');
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ createComponentWithApollo({
+ mountFn: mount,
+ provide: {
+ glFeatures: { simulatePipeline: true },
+ ciConfigPath: '/path/to/ci-config',
+ currentBranch: 'main',
+ projectFullPath: '/path/to/project',
+ simulatePipelineHelpPagePath: 'path/to/help/page',
+ validateTabIllustrationPath: 'path/to/svg',
+ },
+ });
+ });
+
+ it('does not render badge if it has been dismissed before', () => {
+ expect(findBadge().exists()).toBe(false);
+ });
});
});
});
@@ -181,7 +260,6 @@ describe('Pipeline editor tabs component', () => {
expect(findCiLint().exists()).toBe(false);
});
});
-
describe('after loading', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js b/spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js
new file mode 100644
index 00000000000..97f785a71bc
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js
@@ -0,0 +1,43 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ValidatePopover from '~/pipeline_editor/components/popovers/validate_pipeline_popover.vue';
+import { VALIDATE_TAB_FEEDBACK_URL } from '~/pipeline_editor/constants';
+import { mockSimulatePipelineHelpPagePath } from '../../mock_data';
+
+describe('ValidatePopover component', () => {
+ let wrapper;
+
+ const createComponent = ({ stubs } = {}) => {
+ wrapper = shallowMountExtended(ValidatePopover, {
+ provide: {
+ simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath,
+ },
+ stubs,
+ });
+ };
+
+ const findHelpLink = () => wrapper.findByTestId('help-link');
+ const findFeedbackLink = () => wrapper.findByTestId('feedback-link');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(async () => {
+ createComponent({
+ stubs: { GlLink, GlSprintf },
+ });
+ });
+
+ it('renders help link', () => {
+ expect(findHelpLink().exists()).toBe(true);
+ expect(findHelpLink().attributes('href')).toBe(mockSimulatePipelineHelpPagePath);
+ });
+
+ it('renders feedback link', () => {
+ expect(findFeedbackLink().exists()).toBe(true);
+ expect(findFeedbackLink().attributes('href')).toBe(VALIDATE_TAB_FEEDBACK_URL);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js
index 6206a0f6aed..3a40ce32a24 100644
--- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js
+++ b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js
@@ -1,4 +1,4 @@
-import { GlAlert, GlTabs } from '@gitlab/ui';
+import { GlAlert, GlBadge, GlTabs } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue';
@@ -30,10 +30,10 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
},
template: `
<gl-tabs>
- <editor-tab :title-link-attributes="{ 'data-testid': 'tab1-btn' }" :lazy="true">
+ <editor-tab title="Tab 1" :title-link-attributes="{ 'data-testid': 'tab1-btn' }" :lazy="true">
<mock-child content="${mockContent1}"/>
</editor-tab>
- <editor-tab :title-link-attributes="{ 'data-testid': 'tab2-btn' }" :lazy="true">
+ <editor-tab title="Tab 2" :title-link-attributes="{ 'data-testid': 'tab2-btn' }" :lazy="true" badge-title="NEW">
<mock-child content="${mockContent2}"/>
</editor-tab>
</gl-tabs>
@@ -46,7 +46,10 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
const createWrapper = ({ props } = {}) => {
wrapper = mount(EditorTab, {
- propsData: props,
+ propsData: {
+ title: 'Tab 1',
+ ...props,
+ },
slots: {
default: MockSourceEditor,
},
@@ -55,6 +58,7 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
const findSlotComponent = () => wrapper.findComponent(MockSourceEditor);
const findAlert = () => wrapper.findComponent(GlAlert);
+ const findBadges = () => wrapper.findAll(GlBadge);
beforeEach(() => {
mockChildMounted = jest.fn();
@@ -182,4 +186,15 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => {
expect(mockChildMounted).toHaveBeenNthCalledWith(2, mockContent2);
});
});
+
+ describe('valid state', () => {
+ beforeEach(() => {
+ createMockedWrapper();
+ });
+
+ it('renders correct number of badges', async () => {
+ expect(findBadges()).toHaveLength(1);
+ expect(findBadges().at(0).text()).toBe('NEW');
+ });
+ });
});
diff --git a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js
index 25972317593..f5f01b675b2 100644
--- a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js
+++ b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js
@@ -1,40 +1,279 @@
-import { GlButton, GlDropdown } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlAlert, GlDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui';
+import { nextTick } from 'vue';
+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 CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue';
import CiValidate, { i18n } from '~/pipeline_editor/components/validate/ci_validate.vue';
+import ValidatePipelinePopover from '~/pipeline_editor/components/popovers/validate_pipeline_popover.vue';
+import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql';
+import lintCIMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql';
+import {
+ mockBlobContentQueryResponse,
+ mockCiLintPath,
+ mockCiYml,
+ mockSimulatePipelineHelpPagePath,
+} from '../../mock_data';
+import { mockLintDataError, mockLintDataValid } from '../../../ci_lint/mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
describe('Pipeline Editor Validate Tab', () => {
let wrapper;
+ let mockApollo;
+ let mockBlobContentData;
- const createComponent = ({ stubs } = {}) => {
- wrapper = shallowMount(CiValidate, {
+ const createComponent = ({
+ props,
+ stubs,
+ options,
+ isBlobLoading = false,
+ isSimulationLoading = false,
+ } = {}) => {
+ wrapper = shallowMountExtended(CiValidate, {
+ propsData: {
+ ciFileContent: mockCiYml,
+ ...props,
+ },
provide: {
+ ciConfigPath: '/path/to/ci-config',
+ ciLintPath: mockCiLintPath,
+ currentBranch: 'main',
+ projectFullPath: '/path/to/project',
validateTabIllustrationPath: '/path/to/img',
+ simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath,
+ },
+ stubs,
+ mocks: {
+ $apollo: {
+ queries: {
+ initialBlobContent: {
+ loading: isBlobLoading,
+ },
+ },
+ mutations: {
+ lintCiMutation: {
+ loading: isSimulationLoading,
+ },
+ },
+ },
},
+ ...options,
+ });
+ };
+
+ const createComponentWithApollo = ({ props, stubs } = {}) => {
+ const handlers = [[getBlobContent, mockBlobContentData]];
+ mockApollo = createMockApollo(handlers);
+
+ createComponent({
+ props,
stubs,
+ options: {
+ localVue,
+ apolloProvider: mockApollo,
+ mocks: {},
+ },
});
};
- const findCta = () => wrapper.findComponent(GlButton);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findCancelBtn = () => wrapper.findByTestId('cancel-simulation');
+ const findContentChangeStatus = () => wrapper.findByTestId('content-status');
+ const findCta = () => wrapper.findByTestId('simulate-pipeline-button');
+ const findDisabledCtaTooltip = () => wrapper.findByTestId('cta-tooltip');
+ const findHelpIcon = () => wrapper.findComponent(GlIcon);
+ const findIllustration = () => wrapper.findByRole('img');
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findPipelineSource = () => wrapper.findComponent(GlDropdown);
+ const findPopover = () => wrapper.findComponent(GlPopover);
+ const findCiLintResults = () => wrapper.findComponent(CiLintResults);
+ const findResultsCta = () => wrapper.findByTestId('resimulate-pipeline-button');
+
+ beforeEach(() => {
+ mockBlobContentData = jest.fn();
+ });
afterEach(() => {
wrapper.destroy();
});
- describe('template', () => {
+ describe('while initial CI content is loading', () => {
beforeEach(() => {
- createComponent();
+ createComponent({ isBlobLoading: true });
+ });
+
+ it('renders disabled CTA with tooltip', () => {
+ expect(findCta().props('disabled')).toBe(true);
+ expect(findDisabledCtaTooltip().exists()).toBe(true);
+ });
+ });
+
+ describe('after initial CI content is loaded', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await createComponentWithApollo({ stubs: { GlPopover, ValidatePipelinePopover } });
});
it('renders disabled pipeline source dropdown', () => {
expect(findPipelineSource().exists()).toBe(true);
expect(findPipelineSource().attributes('text')).toBe(i18n.pipelineSourceDefault);
- expect(findPipelineSource().attributes('disabled')).toBe('true');
+ expect(findPipelineSource().props('disabled')).toBe(true);
});
- it('renders CTA', () => {
+ it('renders enabled CTA without tooltip', () => {
expect(findCta().exists()).toBe(true);
- expect(findCta().text()).toBe(i18n.cta);
+ expect(findCta().props('disabled')).toBe(false);
+ expect(findDisabledCtaTooltip().exists()).toBe(false);
+ });
+
+ it('popover is set to render when hovering over help icon', () => {
+ expect(findPopover().props('target')).toBe(findHelpIcon().attributes('id'));
+ expect(findPopover().props('triggers')).toBe('hover focus');
+ });
+ });
+
+ describe('simulating the pipeline', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await createComponentWithApollo();
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
+ });
+
+ it('renders loading state while simulation is ongoing', async () => {
+ findCta().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findCancelBtn().exists()).toBe(true);
+ expect(findCta().props('loading')).toBe(true);
+ });
+
+ it('calls mutation with the correct input', async () => {
+ await findCta().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: lintCIMutation,
+ variables: {
+ dry_run: true,
+ content: mockCiYml,
+ endpoint: mockCiLintPath,
+ },
+ });
+ });
+
+ describe('when results are successful', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
+ await findCta().vm.$emit('click');
+ });
+
+ it('renders success alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().attributes('variant')).toBe('success');
+ expect(findAlert().attributes('title')).toBe(i18n.successAlertTitle);
+ });
+
+ it('does not render content change status or CTA for results page', () => {
+ expect(findContentChangeStatus().exists()).toBe(false);
+ expect(findResultsCta().exists()).toBe(false);
+ });
+
+ it('renders CI lint results with correct props', () => {
+ expect(findCiLintResults().exists()).toBe(true);
+ expect(findCiLintResults().props()).toMatchObject({
+ dryRun: true,
+ hideAlert: true,
+ isValid: true,
+ jobs: mockLintDataValid.data.lintCI.jobs,
+ });
+ });
+ });
+
+ describe('when results have errors', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataError);
+ await findCta().vm.$emit('click');
+ });
+
+ it('renders error alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().attributes('variant')).toBe('danger');
+ expect(findAlert().attributes('title')).toBe(i18n.errorAlertTitle);
+ });
+
+ it('renders CI lint results with correct props', () => {
+ expect(findCiLintResults().exists()).toBe(true);
+ expect(findCiLintResults().props()).toMatchObject({
+ dryRun: true,
+ hideAlert: true,
+ isValid: false,
+ errors: mockLintDataError.data.lintCI.errors,
+ warnings: mockLintDataError.data.lintCI.warnings,
+ });
+ });
+ });
+ });
+
+ describe('when CI content has changed after a simulation', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await createComponentWithApollo();
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
+ await findCta().vm.$emit('click');
+ });
+
+ it('renders content change status', async () => {
+ await wrapper.setProps({ ciFileContent: 'new yaml content' });
+
+ expect(findContentChangeStatus().exists()).toBe(true);
+ expect(findResultsCta().exists()).toBe(true);
+ });
+
+ it('calls mutation with new content', async () => {
+ await wrapper.setProps({ ciFileContent: 'new yaml content' });
+ await findResultsCta().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(2);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: lintCIMutation,
+ variables: {
+ dry_run: true,
+ content: 'new yaml content',
+ endpoint: mockCiLintPath,
+ },
+ });
+ });
+ });
+
+ describe('canceling a simulation', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await createComponentWithApollo();
+ });
+
+ it('returns to init state', async () => {
+ // init state
+ expect(findIllustration().exists()).toBe(true);
+ expect(findCiLintResults().exists()).toBe(false);
+
+ // mutations should have successful results
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
+ findCta().vm.$emit('click');
+ await nextTick();
+
+ // cancel before simulation succeeds
+ expect(findCancelBtn().exists()).toBe(true);
+ await findCancelBtn().vm.$emit('click');
+
+ // should still render init state
+ expect(findIllustration().exists()).toBe(true);
+ expect(findCiLintResults().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js
index 560b2820fae..2ea580b7b53 100644
--- a/spec/frontend/pipeline_editor/mock_data.js
+++ b/spec/frontend/pipeline_editor/mock_data.js
@@ -7,11 +7,13 @@ export const mockProjectFullPath = `${mockProjectNamespace}/${mockProjectPath}`;
export const mockDefaultBranch = 'main';
export const mockNewBranch = 'new-branch';
export const mockNewMergeRequestPath = '/-/merge_requests/new';
+export const mockCiLintPath = '/-/ci/lint';
export const mockCommitSha = 'aabbccdd';
export const mockCommitNextSha = 'eeffgghh';
export const mockIncludesHelpPagePath = '/-/includes/help';
export const mockLintHelpPagePath = '/-/lint-help';
export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot';
+export const mockSimulatePipelineHelpPagePath = '/-/simulate-pipeline-help';
export const mockYmlHelpPagePath = '/-/yml-help';
export const mockCommitMessage = 'My commit message';
diff --git a/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js b/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js
new file mode 100644
index 00000000000..43719595c5c
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/components/widgets/checklist_spec.js
@@ -0,0 +1,110 @@
+import { GlFormCheckbox, GlFormCheckboxGroup } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ChecklistWidget from '~/pipeline_wizard/components/widgets/checklist.vue';
+
+describe('Pipeline Wizard - Checklist Widget', () => {
+ let wrapper;
+ const props = {
+ title: 'Foobar',
+ items: [
+ 'foo bar baz', // simple, text-only content
+ {
+ text: 'abc',
+ help: 'def',
+ },
+ ],
+ };
+
+ const getLastUpdateValidEvent = () => {
+ const eventArray = wrapper.emitted('update:valid');
+ return eventArray[eventArray.length - 1];
+ };
+ const findItem = (atIndex = 0) => wrapper.findAllComponents(GlFormCheckbox).at(atIndex);
+ const getGlFormCheckboxGroup = () => wrapper.getComponent(GlFormCheckboxGroup);
+
+ // The item.ids *can* be passed inside props.items, but are usually
+ // autogenerated by lodash.uniqueId() inside the component. So to
+ // get the actual value that the component expects to be emitted in
+ // GlFormCheckboxGroup's `v-model`, we need to obtain the value that is
+ // actually passed to GlFormCheckbox.
+ const getAllItemIds = () => props.items.map((_, i) => findItem(i).attributes().value);
+
+ const createComponent = (mountFn = shallowMountExtended) => {
+ wrapper = mountFn(ChecklistWidget, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('creates the component', () => {
+ createComponent();
+ expect(wrapper.exists()).toBe(true);
+ });
+
+ it('displays the item', () => {
+ createComponent();
+ expect(findItem().exists()).toBe(true);
+ });
+
+ it("displays the item's text", () => {
+ createComponent();
+ expect(findItem().text()).toBe(props.items[0]);
+ });
+
+ it('displays an item with a help text', () => {
+ createComponent();
+ const { text, help } = props.items[1];
+
+ const itemWrapper = findItem(1);
+ const itemText = itemWrapper.text();
+ // Unfortunately there is no wrapper.slots() accessor in vue_test_utils.
+ // To make sure the help text is being passed to the correct slot, we need to
+ // access the slot internally.
+ // This selector accesses the text of the first slot named "help" in itemWrapper
+ const helpText = itemWrapper.vm.$slots?.help[0]?.text?.trim();
+
+ expect(itemText).toBe(text);
+ expect(helpText).toBe(help);
+ });
+
+ it("emits a 'update:valid' event after all boxes have been checked", async () => {
+ createComponent();
+ // initially, `valid` should be false
+ expect(wrapper.emitted('update:valid')).toEqual([[false]]);
+ const values = getAllItemIds();
+ // this mocks checking all the boxes
+ getGlFormCheckboxGroup().vm.$emit('input', values);
+
+ await nextTick();
+
+ expect(wrapper.emitted('update:valid')).toEqual([[false], [true]]);
+ });
+
+ it('emits a invalid event after a box has been unchecked', async () => {
+ createComponent();
+ // initially, `valid` should be false
+ expect(wrapper.emitted('update:valid')).toEqual([[false]]);
+
+ // checking all the boxes first
+ const values = getAllItemIds();
+ getGlFormCheckboxGroup().vm.$emit('input', values);
+ await nextTick();
+
+ // ensure the test later doesn't just pass because it doesn't emit
+ // `true` to begin with
+ expect(getLastUpdateValidEvent()).toEqual([true]);
+
+ // Now we're unchecking the last box.
+ values.pop();
+ getGlFormCheckboxGroup().vm.$emit('input', values);
+ await nextTick();
+
+ expect(getLastUpdateValidEvent()).toEqual([false]);
+ });
+});
diff --git a/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js b/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js
index dd0304518a3..3f689ffdbc8 100644
--- a/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js
+++ b/spec/frontend/pipeline_wizard/pipeline_wizard_spec.js
@@ -99,4 +99,12 @@ describe('PipelineWizard', () => {
parseDocument(template).get('description').toString(),
);
});
+
+ it('bubbles the done event upwards', () => {
+ createComponent();
+
+ wrapper.findComponent(PipelineWizardWrapper).vm.$emit('done');
+
+ expect(wrapper.emitted().done.length).toBe(1);
+ });
});
diff --git a/spec/frontend/pipeline_wizard/templates/pages_spec.js b/spec/frontend/pipeline_wizard/templates/pages_spec.js
new file mode 100644
index 00000000000..f89e8f05475
--- /dev/null
+++ b/spec/frontend/pipeline_wizard/templates/pages_spec.js
@@ -0,0 +1,89 @@
+import { Document, parseDocument } from 'yaml';
+import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml';
+import { merge } from '~/lib/utils/yaml';
+
+const VAR_BUILD_IMAGE = '$BUILD_IMAGE';
+const VAR_INSTALLATION_STEPS = '$INSTALLATION_STEPS';
+const VAR_BUILD_STEPS = '$BUILD_STEPS';
+
+const getYaml = () => parseDocument(PagesWizardTemplate);
+const getFinalTemplate = () => {
+ const merged = new Document();
+ const yaml = getYaml();
+ yaml.toJS().steps.forEach((_, i) => {
+ merge(merged, yaml.getIn(['steps', i, 'template']));
+ });
+ return merged;
+};
+
+describe('Pages Template', () => {
+ it('is valid yaml', () => {
+ // Testing equality to an empty array (as opposed to just comparing
+ // errors.length) will cause jest to print the underlying error
+ expect(getYaml().errors).toEqual([]);
+ });
+
+ it('includes all `target`s in the respective `template`', () => {
+ const yaml = getYaml();
+ const actual = yaml.toJS().steps.map((x, i) => ({
+ inputs: x.inputs,
+ template: yaml.getIn(['steps', i, 'template']).toString(),
+ }));
+
+ expect(actual).toEqual([
+ {
+ inputs: [
+ expect.objectContaining({
+ label: 'Select your build image',
+ target: VAR_BUILD_IMAGE,
+ }),
+ expect.objectContaining({
+ widget: 'checklist',
+ title: 'Before we begin, please check:',
+ }),
+ ],
+ template: expect.stringContaining(VAR_BUILD_IMAGE),
+ },
+ {
+ inputs: [
+ expect.objectContaining({
+ label: 'Installation Steps',
+ target: VAR_INSTALLATION_STEPS,
+ }),
+ ],
+ template: expect.stringContaining(VAR_INSTALLATION_STEPS),
+ },
+ {
+ inputs: [
+ expect.objectContaining({
+ label: 'Build Steps',
+ target: VAR_BUILD_STEPS,
+ }),
+ ],
+ template: expect.stringContaining(VAR_BUILD_STEPS),
+ },
+ ]);
+ });
+
+ it('addresses all relevant instructions for a pages pipeline', () => {
+ const fullTemplate = getFinalTemplate();
+
+ expect(fullTemplate.toString()).toEqual(
+ `# The Docker image that will be used to build your app
+image: ${VAR_BUILD_IMAGE}
+# Functions that should be executed before the build script is run
+before_script: ${VAR_INSTALLATION_STEPS}
+pages:
+ script: ${VAR_BUILD_STEPS}
+ artifacts:
+ paths:
+ # The folder that contains the files to be exposed at the Page URL
+ - public
+ rules:
+ # This ensures that only pushes to the default branch will trigger
+ # a pages deploy
+ - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
+`,
+ );
+ });
+});
diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js
index 3b5632a8a4e..bfbb5f934b9 100644
--- a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js
+++ b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js
@@ -49,15 +49,15 @@ describe('Failed Jobs App', () => {
});
describe('loading spinner', () => {
- beforeEach(() => {
+ it('displays loading spinner when fetching failed jobs', () => {
createComponent(resolverSpy);
- });
- it('displays loading spinner when fetching failed jobs', () => {
expect(findLoadingSpinner().exists()).toBe(true);
});
it('hides loading spinner after the failed jobs have been fetched', async () => {
+ createComponent(resolverSpy);
+
await waitForPromises();
expect(findLoadingSpinner().exists()).toBe(false);
diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
index 81e19a6c221..89b6f764b2f 100644
--- a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
+++ b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js
@@ -50,20 +50,23 @@ describe('Jobs app', () => {
});
describe('loading spinner', () => {
- beforeEach(async () => {
+ const setup = async () => {
createComponent(resolverSpy);
await waitForPromises();
triggerInfiniteScroll();
- });
+ };
+
+ it('displays loading spinner when fetching more jobs', async () => {
+ await setup();
- it('displays loading spinner when fetching more jobs', () => {
expect(findLoadingSpinner().exists()).toBe(true);
expect(findSkeletonLoader().exists()).toBe(false);
});
it('hides loading spinner after jobs have been fetched', async () => {
+ await setup();
await waitForPromises();
expect(findLoadingSpinner().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 49d64c6eac0..3eaf06e0656 100644
--- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
@@ -5,6 +5,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import waitForPromises from 'helpers/wait_for_promises';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql';
@@ -29,10 +30,16 @@ import * as Api from '~/pipelines/components/graph_shared/api';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql';
+import getPerformanceInsights from '~/pipelines/graphql/queries/get_performance_insights.query.graphql';
import * as sentryUtils from '~/pipelines/utils';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { mockRunningPipelineHeaderData } from '../mock_data';
-import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data';
+import {
+ mapCallouts,
+ mockCalloutsResponse,
+ mockPipelineResponse,
+ mockPerformanceInsightsResponse,
+} from './mock_data';
const defaultProvide = {
graphqlResourceEtag: 'frog/amphibirama/etag/',
@@ -88,11 +95,15 @@ describe('Pipeline graph wrapper', () => {
const callouts = mapCallouts(calloutsList);
const getUserCalloutsHandler = jest.fn().mockResolvedValue(mockCalloutsResponse(callouts));
const getPipelineHeaderDataHandler = jest.fn().mockResolvedValue(mockRunningPipelineHeaderData);
+ const getPerformanceInsightsHandler = jest
+ .fn()
+ .mockResolvedValue(mockPerformanceInsightsResponse);
const requestHandlers = [
[getPipelineHeaderData, getPipelineHeaderDataHandler],
[getPipelineDetails, getPipelineDetailsHandler],
[getUserCallouts, getUserCalloutsHandler],
+ [getPerformanceInsights, getPerformanceInsightsHandler],
];
const apolloProvider = createMockApollo(requestHandlers);
@@ -502,9 +513,7 @@ describe('Pipeline graph wrapper', () => {
describe('when no duration is obtained', () => {
beforeEach(async () => {
- jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => {
- return [];
- });
+ stubPerformanceWebAPI();
createComponentWithApollo({
provide: {
diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js
index f574f4dccc5..1397500bdc7 100644
--- a/spec/frontend/pipelines/graph/graph_view_selector_spec.js
+++ b/spec/frontend/pipelines/graph/graph_view_selector_spec.js
@@ -1,10 +1,19 @@
import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import getPerformanceInsights from '~/pipelines/graphql/queries/get_performance_insights.query.graphql';
+import { mockPerformanceInsightsResponse } from './mock_data';
+
+Vue.use(VueApollo);
describe('the graph view selector component', () => {
let wrapper;
+ let trackingSpy;
const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]');
const findViewTypeSelector = () => wrapper.findComponent(GlButtonGroup);
@@ -13,11 +22,13 @@ describe('the graph view selector component', () => {
const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]');
const findToggleLoader = () => findDependenciesToggle().find(GlLoadingIcon);
const findHoverTip = () => wrapper.findComponent(GlAlert);
+ const findPipelineInsightsBtn = () => wrapper.find('[data-testid="pipeline-insights-btn"]');
const defaultProps = {
showLinks: false,
tipPreviouslyDismissed: false,
type: STAGE_VIEW,
+ isPipelineComplete: true,
};
const defaultData = {
@@ -27,6 +38,14 @@ describe('the graph view selector component', () => {
showLinksActive: false,
};
+ const getPerformanceInsightsHandler = jest
+ .fn()
+ .mockResolvedValue(mockPerformanceInsightsResponse);
+
+ const requestHandlers = [[getPerformanceInsights, getPerformanceInsightsHandler]];
+
+ const apolloProvider = createMockApollo(requestHandlers);
+
const createComponent = ({ data = {}, mountFn = shallowMount, props = {} } = {}) => {
wrapper = mountFn(GraphViewSelector, {
propsData: {
@@ -39,6 +58,7 @@ describe('the graph view selector component', () => {
...data,
};
},
+ apolloProvider,
});
};
@@ -91,7 +111,6 @@ describe('the graph view selector component', () => {
describe('events', () => {
beforeEach(() => {
- jest.useFakeTimers();
createComponent({
mountFn: mount,
props: {
@@ -203,5 +222,44 @@ describe('the graph view selector component', () => {
expect(findHoverTip().exists()).toBe(false);
});
});
+
+ describe('pipeline insights', () => {
+ it.each`
+ isPipelineComplete | shouldShow
+ ${true} | ${true}
+ ${false} | ${false}
+ `(
+ 'button should display $shouldShow if isPipelineComplete is $isPipelineComplete ',
+ ({ isPipelineComplete, shouldShow }) => {
+ createComponent({
+ props: {
+ isPipelineComplete,
+ },
+ });
+
+ expect(findPipelineInsightsBtn().exists()).toBe(shouldShow);
+ },
+ );
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ createComponent();
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks performance insights button click', () => {
+ findPipelineInsightsBtn().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_insights_button', {
+ label: 'performance_insights',
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
index 5d8e70bac31..d8afb33e148 100644
--- a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
+++ b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js
@@ -79,7 +79,7 @@ describe('job group dropdown component', () => {
it('renders button with group name and size', () => {
expect(findButton().text()).toContain(group.name);
- expect(findButton().text()).toContain(group.size);
+ expect(findButton().text()).toContain(group.size.toString());
});
it('renders dropdown with jobs', () => {
diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
index fd97c2dbe77..cdeaa0db61d 100644
--- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
@@ -103,7 +103,7 @@ describe('Linked pipeline', () => {
expect(findCardTooltip().text()).toContain(mockPipeline.project.name);
expect(findCardTooltip().text()).toContain(mockPipeline.status.label);
expect(findCardTooltip().text()).toContain(mockPipeline.sourceJob.name);
- expect(findCardTooltip().text()).toContain(mockPipeline.id);
+ expect(findCardTooltip().text()).toContain(mockPipeline.id.toString());
});
it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => {
diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js
index 6124d67af09..959bbcefc98 100644
--- a/spec/frontend/pipelines/graph/mock_data.js
+++ b/spec/frontend/pipelines/graph/mock_data.js
@@ -1038,3 +1038,245 @@ export const triggerJob = {
action: null,
},
};
+
+export const mockPerformanceInsightsResponse = {
+ data: {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ __typename: 'Pipeline',
+ id: 'gid://gitlab/Ci::Pipeline/97',
+ jobs: {
+ __typename: 'CiJobConnection',
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: false,
+ },
+ nodes: [
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Bridge/2502',
+ duration: null,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2502-2502',
+ detailsPath: '/root/lots-of-jobs-project/-/pipelines/98',
+ },
+ name: 'trigger_job',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/303',
+ name: 'deploy',
+ },
+ startedAt: null,
+ queuedDuration: 424850.376278,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2501',
+ duration: 10,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2501-2501',
+ detailsPath: '/root/ci-project/-/jobs/2501',
+ },
+ name: 'artifact_job',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/303',
+ name: 'deploy',
+ },
+ startedAt: '2022-07-01T16:31:41Z',
+ queuedDuration: 2.621553,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2500',
+ duration: 4,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2500-2500',
+ detailsPath: '/root/ci-project/-/jobs/2500',
+ },
+ name: 'coverage_job',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/302',
+ name: 'test',
+ },
+ startedAt: '2022-07-01T16:31:33Z',
+ queuedDuration: 14.388869,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2499',
+ duration: 4,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2499-2499',
+ detailsPath: '/root/ci-project/-/jobs/2499',
+ },
+ name: 'test_job_two',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/302',
+ name: 'test',
+ },
+ startedAt: '2022-07-01T16:31:28Z',
+ queuedDuration: 15.792664,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2498',
+ duration: 4,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2498-2498',
+ detailsPath: '/root/ci-project/-/jobs/2498',
+ },
+ name: 'test_job_one',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/302',
+ name: 'test',
+ },
+ startedAt: '2022-07-01T16:31:17Z',
+ queuedDuration: 8.317072,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2497',
+ duration: 5,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'failed-2497-2497',
+ detailsPath: '/root/ci-project/-/jobs/2497',
+ },
+ name: 'allow_failure_test_job',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/302',
+ name: 'test',
+ },
+ startedAt: '2022-07-01T16:31:22Z',
+ queuedDuration: 3.547553,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2496',
+ duration: null,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'manual-2496-2496',
+ detailsPath: '/root/ci-project/-/jobs/2496',
+ },
+ name: 'test_manual_job',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/302',
+ name: 'test',
+ },
+ startedAt: null,
+ queuedDuration: null,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2495',
+ duration: 5,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2495-2495',
+ detailsPath: '/root/ci-project/-/jobs/2495',
+ },
+ name: 'large_log_output',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/301',
+ name: 'build',
+ },
+ startedAt: '2022-07-01T16:31:11Z',
+ queuedDuration: 79.128625,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2494',
+ duration: 5,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2494-2494',
+ detailsPath: '/root/ci-project/-/jobs/2494',
+ },
+ name: 'build_job',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/301',
+ name: 'build',
+ },
+ startedAt: '2022-07-01T16:31:05Z',
+ queuedDuration: 73.286895,
+ },
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Build/2493',
+ duration: 16,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2493-2493',
+ detailsPath: '/root/ci-project/-/jobs/2493',
+ },
+ name: 'wait_job',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/301',
+ name: 'build',
+ },
+ startedAt: '2022-07-01T16:30:48Z',
+ queuedDuration: 56.258856,
+ },
+ ],
+ },
+ },
+ },
+ },
+};
+
+export const mockPerformanceInsightsNextPageResponse = {
+ data: {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ __typename: 'Pipeline',
+ id: 'gid://gitlab/Ci::Pipeline/97',
+ jobs: {
+ __typename: 'CiJobConnection',
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: true,
+ },
+ nodes: [
+ {
+ __typename: 'CiJob',
+ id: 'gid://gitlab/Ci::Bridge/2502',
+ duration: null,
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'success-2502-2502',
+ detailsPath: '/root/lots-of-jobs-project/-/pipelines/98',
+ },
+ name: 'trigger_job',
+ stage: {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/303',
+ name: 'deploy',
+ },
+ startedAt: null,
+ queuedDuration: 424850.376278,
+ },
+ ],
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index 5cc11adf696..859be8d342c 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -205,7 +205,7 @@ describe('Pipeline details header', () => {
});
it('should call deletePipeline Mutation with pipeline id when modal is submitted', () => {
- findDeleteModal().vm.$emit('ok');
+ findDeleteModal().vm.$emit('primary');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: deletePipelineMutation,
@@ -223,7 +223,7 @@ describe('Pipeline details header', () => {
},
});
- findDeleteModal().vm.$emit('ok');
+ findDeleteModal().vm.$emit('primary');
await waitForPromises();
expect(findAlert().text()).toBe(failureMessage);
diff --git a/spec/frontend/pipelines/performance_insights_modal_spec.js b/spec/frontend/pipelines/performance_insights_modal_spec.js
new file mode 100644
index 00000000000..b745eb1d78e
--- /dev/null
+++ b/spec/frontend/pipelines/performance_insights_modal_spec.js
@@ -0,0 +1,122 @@
+import { GlAlert, GlLink, GlModal } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
+import PerformanceInsightsModal from '~/pipelines/components/performance_insights_modal.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { trimText } from 'helpers/text_helper';
+import getPerformanceInsights from '~/pipelines/graphql/queries/get_performance_insights.query.graphql';
+import {
+ mockPerformanceInsightsResponse,
+ mockPerformanceInsightsNextPageResponse,
+} from './graph/mock_data';
+
+Vue.use(VueApollo);
+
+describe('Performance insights modal', () => {
+ let wrapper;
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findQueuedCardData = () => wrapper.findByTestId('insights-queued-card-data');
+ const findQueuedCardLink = () => wrapper.findByTestId('insights-queued-card-link');
+ const findExecutedCardData = () => wrapper.findByTestId('insights-executed-card-data');
+ const findExecutedCardLink = () => wrapper.findByTestId('insights-executed-card-link');
+ const findSlowJobsStage = (index) => wrapper.findAllByTestId('insights-slow-job-stage').at(index);
+ const findSlowJobsLink = (index) => wrapper.findAllByTestId('insights-slow-job-link').at(index);
+
+ const getPerformanceInsightsHandler = jest
+ .fn()
+ .mockResolvedValue(mockPerformanceInsightsResponse);
+
+ const getPerformanceInsightsNextPageHandler = jest
+ .fn()
+ .mockResolvedValue(mockPerformanceInsightsNextPageResponse);
+
+ const requestHandlers = [[getPerformanceInsights, getPerformanceInsightsHandler]];
+
+ const createComponent = (handlers = requestHandlers) => {
+ wrapper = shallowMountExtended(PerformanceInsightsModal, {
+ provide: {
+ pipelineIid: '1',
+ pipelineProjectPath: 'root/ci-project',
+ },
+ apolloProvider: createMockApollo(handlers),
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('without next page', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('displays modal', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('does not dispaly alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ describe('queued duration card', () => {
+ it('displays card data', () => {
+ expect(trimText(findQueuedCardData().text())).toBe('4.9 days');
+ });
+ it('displays card link', () => {
+ expect(findQueuedCardLink().attributes('href')).toBe(
+ '/root/lots-of-jobs-project/-/pipelines/98',
+ );
+ });
+ });
+
+ describe('executed duration card', () => {
+ it('displays card data', () => {
+ expect(trimText(findExecutedCardData().text())).toBe('trigger_job');
+ });
+ it('displays card link', () => {
+ expect(findExecutedCardLink().attributes('href')).toBe(
+ '/root/lots-of-jobs-project/-/pipelines/98',
+ );
+ });
+ });
+
+ describe('slow jobs', () => {
+ it.each`
+ index | expectedStage | expectedName | expectedLink
+ ${0} | ${'build'} | ${'wait_job'} | ${'/root/ci-project/-/jobs/2493'}
+ ${1} | ${'deploy'} | ${'artifact_job'} | ${'/root/ci-project/-/jobs/2501'}
+ ${2} | ${'test'} | ${'allow_failure_test_job'} | ${'/root/ci-project/-/jobs/2497'}
+ ${3} | ${'build'} | ${'large_log_output'} | ${'/root/ci-project/-/jobs/2495'}
+ ${4} | ${'build'} | ${'build_job'} | ${'/root/ci-project/-/jobs/2494'}
+ `(
+ 'should display slow job correctly',
+ ({ index, expectedStage, expectedName, expectedLink }) => {
+ expect(findSlowJobsStage(index).text()).toBe(expectedStage);
+ expect(findSlowJobsLink(index).text()).toBe(expectedName);
+ expect(findSlowJobsLink(index).attributes('href')).toBe(expectedLink);
+ },
+ );
+ });
+ });
+
+ describe('limit alert', () => {
+ it('displays limit alert when there is a next page', async () => {
+ createComponent([[getPerformanceInsights, getPerformanceInsightsNextPageHandler]]);
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ expect(findLink().attributes('href')).toBe(
+ 'https://gitlab.com/gitlab-org/gitlab/-/issues/365902',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index de9f394db43..ad6d650670a 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -665,7 +665,6 @@ describe('Pipelines', () => {
it('stops polling & restarts polling', async () => {
findStagesDropdownToggle().trigger('click');
-
await waitForPromises();
expect(cancelMock).not.toHaveBeenCalled();
diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
index 6ab479a257c..f9b9da01a2b 100644
--- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js
@@ -49,7 +49,7 @@ describe('Mutations TestReports Store', () => {
describe('set suite error', () => {
it('should set the error message in state if provided', () => {
- const message = 'Test report artifacts have expired';
+ const message = 'Test report artifacts not found';
mutations[types.SET_SUITE_ERROR](mockState, {
response: { data: { errors: message } },
diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js
index 29c07e5e9f8..f194864447c 100644
--- a/spec/frontend/pipelines/test_reports/test_case_details_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_case_details_spec.js
@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
describe('Test case details', () => {
let wrapper;
@@ -19,6 +20,7 @@ describe('Test case details', () => {
system_output: 'Line 42 is broken',
};
+ const findCopyFileBtn = () => wrapper.findComponent(ModalCopyButton);
const findModal = () => wrapper.findComponent(GlModal);
const findName = () => wrapper.findByTestId('test-case-name');
const findFile = () => wrapper.findByTestId('test-case-file');
@@ -66,6 +68,10 @@ describe('Test case details', () => {
expect(findFileLink().attributes('href')).toBe(defaultTestCase.filePath);
});
+ it('renders copy button for test case file', () => {
+ expect(findCopyFileBtn().attributes('text')).toBe(defaultTestCase.file);
+ });
+
it('renders the test case duration', () => {
expect(findDuration().text()).toBe(defaultTestCase.formattedTime);
});
diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js
index e0daf8cb4b5..3c3143b1865 100644
--- a/spec/frontend/pipelines/test_reports/test_reports_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js
@@ -31,18 +31,30 @@ describe('Test reports app', () => {
const createComponent = ({ state = {} } = {}) => {
store = new Vuex.Store({
- state: {
- isLoading: false,
- selectedSuiteIndex: null,
- testReports,
- ...state,
+ modules: {
+ testReports: {
+ namespaced: true,
+ state: {
+ isLoading: false,
+ selectedSuiteIndex: null,
+ testReports,
+ ...state,
+ },
+ actions: actionSpies,
+ getters,
+ },
},
- actions: actionSpies,
- getters,
});
+ jest.spyOn(store, 'registerModule').mockReturnValue(null);
+
wrapper = extendedWrapper(
shallowMount(TestReports, {
+ provide: {
+ blobPath: '/blob/path',
+ summaryEndpoint: '/summary.json',
+ suiteEndpoint: '/suite.json',
+ },
store,
}),
);
diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
index 25650b24705..c372ac06c35 100644
--- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
@@ -34,22 +34,32 @@ describe('Test reports suite table', () => {
const createComponent = ({ suite = testSuite, perPage = 20, errorMessage } = {}) => {
store = new Vuex.Store({
- state: {
- blobPath,
+ modules: {
testReports: {
- test_suites: [suite],
+ namespaced: true,
+ state: {
+ blobPath,
+ testReports: {
+ test_suites: [suite],
+ },
+ selectedSuiteIndex: 0,
+ pageInfo: {
+ page: 1,
+ perPage,
+ },
+ errorMessage,
+ },
+ getters,
},
- selectedSuiteIndex: 0,
- pageInfo: {
- page: 1,
- perPage,
- },
- errorMessage,
},
- getters,
});
wrapper = shallowMountExtended(SuiteTable, {
+ provide: {
+ blobPath: '/blob/path',
+ summaryEndpoint: '/summary.json',
+ suiteEndpoint: '/suite.json',
+ },
store,
stubs: { GlFriendlyWrap },
});
diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
index 1598d5c337f..0e1229f7067 100644
--- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js
@@ -20,13 +20,23 @@ describe('Test reports summary table', () => {
const createComponent = (reports = null) => {
store = new Vuex.Store({
- state: {
- testReports: reports || testReports,
+ modules: {
+ testReports: {
+ namespaced: true,
+ state: {
+ testReports: reports || testReports,
+ },
+ getters,
+ },
},
- getters,
});
wrapper = mount(SummaryTable, {
+ provide: {
+ blobPath: '/blob/path',
+ summaryEndpoint: '/summary.json',
+ suiteEndpoint: '/suite.json',
+ },
propsData: defaultProps,
store,
});
diff --git a/spec/frontend/pipelines/utils_spec.js b/spec/frontend/pipelines/utils_spec.js
index 1c23a7e4fcf..a82390fae22 100644
--- a/spec/frontend/pipelines/utils_spec.js
+++ b/spec/frontend/pipelines/utils_spec.js
@@ -8,10 +8,14 @@ import {
removeOrphanNodes,
getMaxNodes,
} from '~/pipelines/components/parsing_utils';
-import { createNodeDict } from '~/pipelines/utils';
+import { createNodeDict, calculateJobStats, calculateSlowestFiveJobs } from '~/pipelines/utils';
import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data';
-import { generateResponse, mockPipelineResponse } from './graph/mock_data';
+import {
+ generateResponse,
+ mockPipelineResponse,
+ mockPerformanceInsightsResponse,
+} from './graph/mock_data';
describe('DAG visualization parsing utilities', () => {
const nodeDict = createNodeDict(mockParsedGraphQLNodes);
@@ -158,4 +162,40 @@ describe('DAG visualization parsing utilities', () => {
expect(columns).toMatchSnapshot();
});
});
+
+ describe('performance insights', () => {
+ const {
+ data: {
+ project: {
+ pipeline: { jobs },
+ },
+ },
+ } = mockPerformanceInsightsResponse;
+
+ describe('calculateJobStats', () => {
+ const expectedJob = jobs.nodes[0];
+
+ it('returns the job that spent this longest time queued', () => {
+ expect(calculateJobStats(jobs, 'queuedDuration')).toEqual(expectedJob);
+ });
+
+ it('returns the job that was executed last', () => {
+ expect(calculateJobStats(jobs, 'startedAt')).toEqual(expectedJob);
+ });
+ });
+
+ describe('calculateSlowestFiveJobs', () => {
+ it('returns the slowest five jobs of the pipeline', () => {
+ const expectedJobs = [
+ jobs.nodes[9],
+ jobs.nodes[1],
+ jobs.nodes[5],
+ jobs.nodes[7],
+ jobs.nodes[8],
+ ];
+
+ expect(calculateSlowestFiveJobs(jobs)).toEqual(expectedJobs);
+ });
+ });
+ });
});
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 ba22622e1f7..b6d4ee32cf5 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
@@ -4,6 +4,7 @@ import {
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlTruncate,
} from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
@@ -15,7 +16,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/projects/new/event_hub';
import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue';
import searchQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
-import { s__ } from '~/locale';
describe('NewProjectUrlSelect component', () => {
let wrapper;
@@ -90,6 +90,7 @@ describe('NewProjectUrlSelect component', () => {
const findButtonLabel = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findSelectedPath = () => wrapper.findComponent(GlTruncate);
const findInput = () => wrapper.findComponent(GlSearchBoxByType);
const findHiddenNamespaceInput = () => wrapper.find('[name="project[namespace_id]"]');
@@ -121,14 +122,15 @@ describe('NewProjectUrlSelect component', () => {
describe('when namespaceId is provided', () => {
beforeEach(() => {
- wrapper = mountComponent();
+ wrapper = mountComponent({ mountFn: mount });
});
it('renders a dropdown with the given namespace full path as the text', () => {
- const dropdownProps = findDropdown().props();
+ expect(findSelectedPath().props('text')).toBe(defaultProvide.namespaceFullPath);
+ });
- expect(dropdownProps.text).toBe(defaultProvide.namespaceFullPath);
- expect(dropdownProps.toggleClass).not.toContain('gl-text-gray-500!');
+ it('renders a dropdown without the class', () => {
+ expect(findDropdown().props('toggleClass')).not.toContain('gl-text-gray-500!');
});
it('renders a hidden input with the given namespace id', () => {
@@ -150,14 +152,15 @@ describe('NewProjectUrlSelect component', () => {
};
beforeEach(() => {
- wrapper = mountComponent({ provide });
+ wrapper = mountComponent({ provide, mountFn: mount });
});
it("renders a dropdown with the user's namespace full path as the text", () => {
- const dropdownProps = findDropdown().props();
+ expect(findSelectedPath().props('text')).toBe('Pick a group or namespace');
+ });
- expect(dropdownProps.text).toBe(s__('ProjectsNew|Pick a group or namespace'));
- expect(dropdownProps.toggleClass).toContain('gl-text-gray-500!');
+ it('renders a dropdown with the class', () => {
+ expect(findDropdown().props('toggleClass')).toContain('gl-text-gray-500!');
});
it("renders a hidden input with the user's namespace id", () => {
@@ -236,8 +239,8 @@ describe('NewProjectUrlSelect component', () => {
expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[2].fullPath);
});
- it('sets the selection to the group', async () => {
- expect(findDropdown().props('text')).toBe(fullPath);
+ it('sets the selection to the group', () => {
+ expect(findSelectedPath().props('text')).toBe(fullPath);
});
});
diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js
index 98c7856a61a..7b9011fa3d9 100644
--- a/spec/frontend/projects/pipelines/charts/components/app_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js
@@ -14,6 +14,7 @@ jest.mock('~/lib/utils/url_utility');
const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} };
const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} };
const TimeToRestoreServiceChartsStub = { name: 'TimeToRestoreServiceCharts', render: () => {} };
+const ChangeFailureRateChartsStub = { name: 'ChangeFailureRateCharts', render: () => {} };
const ProjectQualitySummaryStub = { name: 'ProjectQualitySummary', render: () => {} };
describe('ProjectsPipelinesChartsApp', () => {
@@ -33,6 +34,7 @@ describe('ProjectsPipelinesChartsApp', () => {
DeploymentFrequencyCharts: DeploymentFrequencyChartsStub,
LeadTimeCharts: LeadTimeChartsStub,
TimeToRestoreServiceCharts: TimeToRestoreServiceChartsStub,
+ ChangeFailureRateCharts: ChangeFailureRateChartsStub,
ProjectQualitySummary: ProjectQualitySummaryStub,
},
},
@@ -50,6 +52,7 @@ describe('ProjectsPipelinesChartsApp', () => {
const findGlTabAtIndex = (index) => findAllGlTabs().at(index);
const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub);
const findTimeToRestoreServiceCharts = () => wrapper.find(TimeToRestoreServiceChartsStub);
+ const findChangeFailureRateCharts = () => wrapper.find(ChangeFailureRateChartsStub);
const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub);
const findPipelineCharts = () => wrapper.find(PipelineCharts);
const findProjectQualitySummary = () => wrapper.find(ProjectQualitySummaryStub);
@@ -59,58 +62,49 @@ describe('ProjectsPipelinesChartsApp', () => {
createComponent();
});
- it('renders tabs', () => {
- expect(findGlTabs().exists()).toBe(true);
+ describe.each`
+ title | finderFn | index
+ ${'Pipelines'} | ${findPipelineCharts} | ${0}
+ ${'Deployment frequency'} | ${findDeploymentFrequencyCharts} | ${1}
+ ${'Lead time'} | ${findLeadTimeCharts} | ${2}
+ ${'Time to restore service'} | ${findTimeToRestoreServiceCharts} | ${3}
+ ${'Change failure rate'} | ${findChangeFailureRateCharts} | ${4}
+ ${'Project quality'} | ${findProjectQualitySummary} | ${5}
+ `('Tabs', ({ title, finderFn, index }) => {
+ it(`renders tab with a title ${title} at index ${index}`, () => {
+ expect(findGlTabAtIndex(index).attributes('title')).toBe(title);
+ });
- expect(findGlTabAtIndex(0).attributes('title')).toBe('Pipelines');
- expect(findGlTabAtIndex(1).attributes('title')).toBe('Deployment frequency');
- expect(findGlTabAtIndex(2).attributes('title')).toBe('Lead time');
- expect(findGlTabAtIndex(3).attributes('title')).toBe('Time to restore service');
- });
+ it(`renders the ${title} chart`, () => {
+ expect(finderFn().exists()).toBe(true);
+ });
- it('renders the pipeline charts', () => {
- expect(findPipelineCharts().exists()).toBe(true);
- });
+ it(`updates the current tab and url when the ${title} tab is clicked`, async () => {
+ let chartsPath;
+ const tabName = title.toLowerCase().replace(/\s/g, '-');
- it('renders the deployment frequency charts', () => {
- expect(findDeploymentFrequencyCharts().exists()).toBe(true);
- });
+ setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts`);
- it('renders the lead time charts', () => {
- expect(findLeadTimeCharts().exists()).toBe(true);
- });
+ mergeUrlParams.mockImplementation(({ chart }, path) => {
+ expect(chart).toBe(tabName);
+ expect(path).toBe(window.location.pathname);
+ chartsPath = `${path}?chart=${chart}`;
+ return chartsPath;
+ });
- it('renders the time to restore service charts', () => {
- expect(findTimeToRestoreServiceCharts().exists()).toBe(true);
- });
+ updateHistory.mockImplementation(({ url }) => {
+ expect(url).toBe(chartsPath);
+ });
+ const tabs = findGlTabs();
- it('renders the project quality summary', () => {
- expect(findProjectQualitySummary().exists()).toBe(true);
- });
+ expect(tabs.attributes('value')).toBe('0');
- it('sets the tab and url when a tab is clicked', async () => {
- let chartsPath;
- setWindowLocation(`${TEST_HOST}/gitlab-org/gitlab-test/-/pipelines/charts`);
+ tabs.vm.$emit('input', index);
- mergeUrlParams.mockImplementation(({ chart }, path) => {
- expect(chart).toBe('deployment-frequency');
- expect(path).toBe(window.location.pathname);
- chartsPath = `${path}?chart=${chart}`;
- return chartsPath;
- });
+ await nextTick();
- updateHistory.mockImplementation(({ url }) => {
- expect(url).toBe(chartsPath);
+ expect(tabs.attributes('value')).toBe(index.toString());
});
- const tabs = findGlTabs();
-
- expect(tabs.attributes('value')).toBe('0');
-
- tabs.vm.$emit('input', 1);
-
- await nextTick();
-
- expect(tabs.attributes('value')).toBe('1');
});
it('should not try to push history if the tab does not change', async () => {
@@ -136,6 +130,7 @@ describe('ProjectsPipelinesChartsApp', () => {
${'deployment-frequency-tab'} | ${'p_analytics_ci_cd_deployment_frequency'}
${'lead-time-tab'} | ${'p_analytics_ci_cd_lead_time'}
${'time-to-restore-service-tab'} | ${'p_analytics_ci_cd_time_to_restore_service'}
+ ${'change-failure-rate-tab'} | ${'p_analytics_ci_cd_change_failure_rate'}
`('tracks the $event event when clicked', ({ testId, event }) => {
jest.spyOn(API, 'trackRedisHllUserEvent');
@@ -151,6 +146,7 @@ describe('ProjectsPipelinesChartsApp', () => {
describe('when provided with a query param', () => {
it.each`
chart | tab
+ ${'change-failure-rate'} | ${'4'}
${'time-to-restore-service'} | ${'3'}
${'lead-time'} | ${'2'}
${'deployment-frequency'} | ${'1'}
diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
index a42891423cd..1db48ce05d7 100644
--- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
+++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js
@@ -29,9 +29,20 @@ jest.mock('~/projects/settings/api/access_dropdown_api', () => ({
}),
getDeployKeys: jest.fn().mockResolvedValue({
data: [
- { id: 10, title: 'key10', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } },
- { id: 11, title: 'key11', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } },
- { id: 12, title: 'key12', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } },
+ {
+ id: 10,
+ title: 'key10',
+ fingerprint: 'md5-abcdefghijklmnop',
+ fingerprint_sha256: 'sha256-abcdefghijklmnop',
+ owner: { name: 'user1' },
+ },
+ {
+ id: 11,
+ title: 'key11',
+ fingerprint_sha256: 'sha256-abcdefghijklmnop',
+ owner: { name: 'user2' },
+ },
+ { id: 12, title: 'key12', fingerprint: 'md5-abcdefghijklmnop', owner: { name: 'user3' } },
],
}),
}));
@@ -279,6 +290,7 @@ describe('Access Level Dropdown', () => {
{ id: 115, type: 'group', group_id: 5 },
{ id: 118, type: 'user', user_id: 8, name: 'user2' },
{ id: 121, type: 'deploy_key', deploy_key_id: 11 },
+ { id: 122, type: 'deploy_key', deploy_key_id: 12 },
];
const findSelected = (type) =>
@@ -309,8 +321,9 @@ describe('Access Level Dropdown', () => {
it('should set selected deploy keys as intersection between the server response and preselected mapping some keys', () => {
const selectedDeployKeys = findSelected(LEVEL_TYPES.DEPLOY_KEY);
- expect(selectedDeployKeys).toHaveLength(1);
- expect(selectedDeployKeys.at(0).text()).toContain('key11 (abcdefghijklmn...)');
+ expect(selectedDeployKeys).toHaveLength(2);
+ expect(selectedDeployKeys.at(0).text()).toContain('key11 (sha256-abcdefg...)');
+ expect(selectedDeployKeys.at(1).text()).toContain('key12 (md5-abcdefghij...)');
});
});
diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js
index d842e00d850..6ef1b58a956 100644
--- a/spec/frontend/protected_branches/protected_branch_edit_spec.js
+++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js
@@ -114,27 +114,30 @@ describe('ProtectedBranchEdit', () => {
});
describe('when clicked', () => {
- beforeEach(() => {
+ beforeEach(async () => {
mock.onPatch(TEST_URL, { protected_branch: { [patchParam]: true } }).replyOnce(200, {});
-
- toggle.click();
});
- it('checks and disables button', () => {
+ it('checks and disables button', async () => {
+ await toggle.click();
+
expect(toggle).toHaveClass(IS_CHECKED_CLASS);
expect(toggle.querySelector(IS_LOADING_SELECTOR)).not.toBe(null);
expect(toggle).toHaveClass(IS_DISABLED_CLASS);
});
- it('sends update to BE', () =>
- axios.waitForAll().then(() => {
- // Args are asserted in the `.onPatch` call
- expect(mock.history.patch).toHaveLength(1);
+ it('sends update to BE', async () => {
+ await toggle.click();
+
+ await axios.waitForAll();
- expect(toggle).not.toHaveClass(IS_DISABLED_CLASS);
- expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null);
- expect(createFlash).not.toHaveBeenCalled();
- }));
+ // Args are asserted in the `.onPatch` call
+ expect(mock.history.patch).toHaveLength(1);
+
+ expect(toggle).not.toHaveClass(IS_DISABLED_CLASS);
+ expect(toggle.querySelector(IS_LOADING_SELECTOR)).toBe(null);
+ expect(createFlash).not.toHaveBeenCalled();
+ });
});
describe('when clicked and BE error', () => {
@@ -143,10 +146,11 @@ describe('ProtectedBranchEdit', () => {
toggle.click();
});
- it('flashes error', () =>
- axios.waitForAll().then(() => {
- expect(createFlash).toHaveBeenCalled();
- }));
+ it('flashes error', async () => {
+ await axios.waitForAll();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
});
});
});
diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js
index e1fc60f0d92..882cb2c1199 100644
--- a/spec/frontend/ref/components/ref_selector_spec.js
+++ b/spec/frontend/ref/components/ref_selector_spec.js
@@ -162,9 +162,9 @@ describe('Ref selector component', () => {
});
describe('initialization behavior', () => {
- beforeEach(createComponent);
-
it('initializes the dropdown with branches and tags when mounted', () => {
+ createComponent();
+
return waitForRequests().then(() => {
expect(branchesApiCallSpy).toHaveBeenCalledTimes(1);
expect(tagsApiCallSpy).toHaveBeenCalledTimes(1);
@@ -173,6 +173,8 @@ describe('Ref selector component', () => {
});
it('shows a spinner while network requests are in progress', () => {
+ createComponent();
+
expect(findLoadingIcon().exists()).toBe(true);
return waitForRequests().then(() => {
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
index fd2a8eec4d4..90a33152877 100644
--- a/spec/frontend/releases/__snapshots__/util_spec.js.snap
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -57,7 +57,7 @@ Object {
"evidences": Array [],
"milestones": Array [],
"name": "The second release",
- "releasedAt": "2019-01-10T00:00:00Z",
+ "releasedAt": 2019-01-10T00:00:00.000Z,
"tagName": "v1.2",
"tagPath": "/releases-namespace/releases-project/-/tags/v1.2",
"upcomingRelease": true,
@@ -188,7 +188,7 @@ Object {
},
],
"name": "The first release",
- "releasedAt": "2018-12-10T00:00:00Z",
+ "releasedAt": 2018-12-10T00:00:00.000Z,
"tagName": "v1.1",
"tagPath": "/releases-namespace/releases-project/-/tags/v1.1",
"upcomingRelease": true,
@@ -196,10 +196,10 @@ Object {
],
"paginationInfo": Object {
"__typename": "PageInfo",
- "endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9",
+ "endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwICswMDAwIiwiaWQiOiIxIn0",
"hasNextPage": false,
"hasPreviousPage": false,
- "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMiJ9",
+ "startCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTktMDEtMTAgMDA6MDA6MDAuMDAwMDAwMDAwICswMDAwIiwiaWQiOiIyIn0",
},
}
`;
@@ -267,7 +267,9 @@ Object {
},
],
"name": "The first release",
+ "releasedAt": 2018-12-10T00:00:00.000Z,
"tagName": "v1.1",
+ "tagPath": "/releases-namespace/releases-project/-/tags/v1.1",
},
}
`;
@@ -400,7 +402,7 @@ Object {
},
],
"name": "The first release",
- "releasedAt": "2018-12-10T00:00:00Z",
+ "releasedAt": 2018-12-10T00:00:00.000Z,
"tagName": "v1.1",
"tagPath": "/releases-namespace/releases-project/-/tags/v1.1",
"upcomingRelease": true,
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index 80be27c92ff..cb044b9e891 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -1,21 +1,24 @@
-import { mount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { merge } from 'lodash';
import Vuex from 'vuex';
import { nextTick } from 'vue';
-import { GlFormCheckbox } from '@gitlab/ui';
-import originalRelease from 'test_fixtures/api/releases/release.json';
+import { GlDatepicker, GlFormCheckbox } from '@gitlab/ui';
+import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json';
+import { convertOneReleaseGraphQLResponse } from '~/releases/util';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
-import * as commonUtils from '~/lib/utils/common_utils';
import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue';
import AssetLinksForm from '~/releases/components/asset_links_form.vue';
+import ConfirmDeleteModal from '~/releases/components/confirm_delete_modal.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+const originalRelease = originalOneReleaseForEditingQueryResponse.data.project.release;
const originalMilestones = originalRelease.milestones;
const releasesPagePath = 'path/to/releases/page';
+const upcomingReleaseDocsPath = 'path/to/upcoming/release/docs';
describe('Release edit/new component', () => {
let wrapper;
@@ -28,22 +31,24 @@ describe('Release edit/new component', () => {
const factory = async ({ featureFlags = {}, store: storeUpdates = {} } = {}) => {
state = {
release,
+ isExistingRelease: true,
markdownDocsPath: 'path/to/markdown/docs',
releasesPagePath,
projectId: '8',
groupId: '42',
groupMilestonesAvailable: true,
+ upcomingReleaseDocsPath,
};
actions = {
initializeRelease: jest.fn(),
saveRelease: jest.fn(),
addEmptyAssetLink: jest.fn(),
+ deleteRelease: jest.fn(),
};
getters = {
isValid: () => true,
- isExistingRelease: () => true,
validationErrors: () => ({
assets: {
links: [],
@@ -68,7 +73,7 @@ describe('Release edit/new component', () => {
),
);
- wrapper = mount(ReleaseEditNewApp, {
+ wrapper = mountExtended(ReleaseEditNewApp, {
store,
provide: {
glFeatures: featureFlags,
@@ -88,7 +93,7 @@ describe('Release edit/new component', () => {
mock.onGet('/api/v4/projects/8/milestones').reply(200, originalMilestones);
- release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
+ release = convertOneReleaseGraphQLResponse(originalOneReleaseForEditingQueryResponse).data;
});
afterEach(() => {
@@ -128,6 +133,18 @@ describe('Release edit/new component', () => {
expect(wrapper.find('#release-title').element.value).toBe(release.name);
});
+ it('renders the released at date in the "Released at" datepicker', () => {
+ expect(wrapper.findComponent(GlDatepicker).props('value')).toBe(release.releasedAt);
+ });
+
+ it('links to the documentation on upcoming releases in the "Released at" description', () => {
+ const link = wrapper.findByRole('link', { name: 'Upcoming Release' });
+
+ expect(link.exists()).toBe(true);
+
+ expect(link.attributes('href')).toBe(upcomingReleaseDocsPath);
+ });
+
it('renders the release notes in the "Release notes" textarea', () => {
expect(wrapper.find('#release-notes').element.value).toBe(release.description);
});
@@ -191,9 +208,7 @@ describe('Release edit/new component', () => {
store: {
modules: {
editNew: {
- getters: {
- isExistingRelease: () => false,
- },
+ state: { isExistingRelease: false },
},
},
},
@@ -274,4 +289,31 @@ describe('Release edit/new component', () => {
});
});
});
+
+ describe('delete', () => {
+ const findConfirmDeleteModal = () => wrapper.findComponent(ConfirmDeleteModal);
+
+ it('calls the deleteRelease action on confirmation', async () => {
+ await factory();
+ findConfirmDeleteModal().vm.$emit('delete');
+
+ expect(actions.deleteRelease).toHaveBeenCalled();
+ });
+
+ it('is hidden if this is a new release', async () => {
+ await factory({
+ store: {
+ modules: {
+ editNew: {
+ state: {
+ isExistingRelease: false,
+ },
+ },
+ },
+ },
+ });
+
+ expect(findConfirmDeleteModal().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js
index 63ce4c8bb17..f64f07de90e 100644
--- a/spec/frontend/releases/components/app_index_spec.js
+++ b/spec/frontend/releases/components/app_index_spec.js
@@ -8,6 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
import createFlash from '~/flash';
import { historyPushState } from '~/lib/utils/common_utils';
+import { sprintf, __ } from '~/locale';
import ReleasesIndexApp from '~/releases/components/app_index.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
@@ -15,6 +16,7 @@ import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
import ReleasesPagination from '~/releases/components/releases_pagination.vue';
import ReleasesSort from '~/releases/components/releases_sort.vue';
import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants';
+import { deleteReleaseSessionKey } from '~/releases/util';
Vue.use(VueApollo);
@@ -44,6 +46,7 @@ describe('app_index.vue', () => {
let singleRelease;
let noReleases;
let queryMock;
+ let toast;
const createComponent = ({
singleResponse = Promise.resolve(singleRelease),
@@ -58,12 +61,17 @@ describe('app_index.vue', () => {
],
]);
+ toast = jest.fn();
+
wrapper = shallowMountExtended(ReleasesIndexApp, {
apolloProvider,
provide: {
newReleasePath,
projectPath,
},
+ mocks: {
+ $toast: { show: toast },
+ },
});
};
@@ -395,4 +403,27 @@ describe('app_index.vue', () => {
},
);
});
+
+ describe('after deleting', () => {
+ const release = 'fake release';
+ const key = deleteReleaseSessionKey(projectPath);
+
+ beforeEach(async () => {
+ window.sessionStorage.setItem(key, release);
+
+ await createComponent();
+ });
+
+ it('shows a toast', async () => {
+ expect(toast).toHaveBeenCalledWith(
+ sprintf(__('Release %{release} has been successfully deleted.'), {
+ release,
+ }),
+ );
+ });
+
+ it('clears session storage', async () => {
+ expect(window.sessionStorage.getItem(key)).toBe(null);
+ });
+ });
});
diff --git a/spec/frontend/releases/components/confirm_delete_modal_spec.js b/spec/frontend/releases/components/confirm_delete_modal_spec.js
new file mode 100644
index 00000000000..f7c526c1ced
--- /dev/null
+++ b/spec/frontend/releases/components/confirm_delete_modal_spec.js
@@ -0,0 +1,89 @@
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
+import { GlModal } from '@gitlab/ui';
+import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json';
+import { convertOneReleaseGraphQLResponse } from '~/releases/util';
+import ConfirmDeleteModal from '~/releases/components/confirm_delete_modal.vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { __, sprintf } from '~/locale';
+
+Vue.use(Vuex);
+
+const release = convertOneReleaseGraphQLResponse(originalOneReleaseForEditingQueryResponse).data;
+const deleteReleaseDocsPath = 'path/to/delete/release/docs';
+
+describe('~/releases/components/confirm_delete_modal.vue', () => {
+ let wrapper;
+ let state;
+
+ const factory = async () => {
+ state = {
+ release,
+ deleteReleaseDocsPath,
+ };
+
+ const store = new Vuex.Store({
+ modules: {
+ editNew: {
+ namespaced: true,
+ state,
+ },
+ },
+ });
+
+ wrapper = mountExtended(ConfirmDeleteModal, {
+ store,
+ });
+
+ await nextTick();
+ };
+
+ beforeEach(() => {
+ factory();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('button', () => {
+ it('should open the modal on click', async () => {
+ await wrapper.findByRole('button', { name: 'Delete' }).trigger('click');
+
+ const title = wrapper.findByText(
+ sprintf(__('Delete release %{release}?'), { release: release.name }),
+ );
+
+ expect(title.exists()).toBe(true);
+ });
+ });
+
+ describe('modal', () => {
+ beforeEach(async () => {
+ await wrapper.findByRole('button', { name: 'Delete' }).trigger('click');
+ });
+
+ it('confirms the user wants to delete the release', () => {
+ const text = wrapper.findByText(__('Are you sure you want to delete this release?'));
+
+ expect(text.exists()).toBe(true);
+ });
+
+ it('links to the tag', () => {
+ const tagPath = wrapper.findByRole('link', { name: release.tagName });
+ expect(tagPath.attributes('href')).toBe(release.tagPath);
+ });
+
+ it('links to the docs on deleting releases', () => {
+ const docsPath = wrapper.findByRole('link', { name: 'Deleting a release' });
+
+ expect(docsPath.attributes('href')).toBe(deleteReleaseDocsPath);
+ });
+
+ it('emits a delete event on action primary', () => {
+ wrapper.findComponent(GlModal).vm.$emit('primary');
+
+ expect(wrapper.emitted('delete')).toEqual([[]]);
+ });
+ });
+});
diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js
index b095e9e1d78..848e802df4b 100644
--- a/spec/frontend/releases/components/release_block_footer_spec.js
+++ b/spec/frontend/releases/components/release_block_footer_spec.js
@@ -2,14 +2,16 @@ import { GlLink, GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { cloneDeep } from 'lodash';
import { nextTick } from 'vue';
-import originalRelease from 'test_fixtures/api/releases/release.json';
+import originalOneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json';
+import { convertOneReleaseGraphQLResponse } from '~/releases/util';
import { trimText } from 'helpers/text_helper';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
// TODO: Encapsulate date helpers https://gitlab.com/gitlab-org/gitlab/-/issues/320883
const MONTHS_IN_MS = 1000 * 60 * 60 * 24 * 31;
-const mockFutureDate = new Date(new Date().getTime() + MONTHS_IN_MS).toISOString();
+const mockFutureDate = new Date(new Date().getTime() + MONTHS_IN_MS);
+
+const originalRelease = convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse).data;
describe('Release block footer', () => {
let wrapper;
@@ -18,7 +20,7 @@ describe('Release block footer', () => {
const factory = async (props = {}) => {
wrapper = mount(ReleaseBlockFooter, {
propsData: {
- ...convertObjectPropsToCamelCase(release, { deep: true }),
+ ...originalRelease,
...props,
},
});
@@ -55,8 +57,8 @@ describe('Release block footer', () => {
const commitLink = commitInfoSectionLink();
expect(commitLink.exists()).toBe(true);
- expect(commitLink.text()).toBe(release.commit.short_id);
- expect(commitLink.attributes('href')).toBe(release.commit_path);
+ expect(commitLink.text()).toBe(release.commit.shortId);
+ expect(commitLink.attributes('href')).toBe(release.commitPath);
});
it('renders the tag icon', () => {
@@ -70,8 +72,8 @@ describe('Release block footer', () => {
const commitLink = tagInfoSection().find(GlLink);
expect(commitLink.exists()).toBe(true);
- expect(commitLink.text()).toBe(release.tag_name);
- expect(commitLink.attributes('href')).toBe(release.tag_path);
+ expect(commitLink.text()).toBe(release.tagName);
+ expect(commitLink.attributes('href')).toBe(release.tagPath);
});
it('renders the author and creation time info', () => {
@@ -114,14 +116,14 @@ describe('Release block footer', () => {
const avatarImg = authorDateInfoSection().find('img');
expect(avatarImg.exists()).toBe(true);
- expect(avatarImg.attributes('src')).toBe(release.author.avatar_url);
+ expect(avatarImg.attributes('src')).toBe(release.author.avatarUrl);
});
it("renders a link to the author's profile", () => {
const authorLink = authorDateInfoSection().find(GlLink);
expect(authorLink.exists()).toBe(true);
- expect(authorLink.attributes('href')).toBe(release.author.web_url);
+ expect(authorLink.attributes('href')).toBe(release.author.webUrl);
});
});
@@ -138,7 +140,7 @@ describe('Release block footer', () => {
it('renders the commit SHA as plain text (instead of a link)', () => {
expect(commitInfoSectionLink().exists()).toBe(false);
- expect(commitInfoSection().text()).toBe(release.commit.short_id);
+ expect(commitInfoSection().text()).toBe(release.commit.shortId);
});
});
@@ -155,7 +157,7 @@ describe('Release block footer', () => {
it('renders the tag name as plain text (instead of a link)', () => {
expect(tagInfoSectionLink().exists()).toBe(false);
- expect(tagInfoSection().text()).toBe(release.tag_name);
+ expect(tagInfoSection().text()).toBe(release.tagName);
});
});
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
index c4910ae9b2f..17e2af687a6 100644
--- a/spec/frontend/releases/components/release_block_spec.js
+++ b/spec/frontend/releases/components/release_block_spec.js
@@ -1,7 +1,8 @@
import { mount } from '@vue/test-utils';
import $ from 'jquery';
import { nextTick } from 'vue';
-import originalRelease from 'test_fixtures/api/releases/release.json';
+import originalOneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json';
+import { convertOneReleaseGraphQLResponse } from '~/releases/util';
import * as commonUtils from '~/lib/utils/common_utils';
import * as urlUtility from '~/lib/utils/url_utility';
import EvidenceBlock from '~/releases/components/evidence_block.vue';
@@ -34,7 +35,7 @@ describe('Release block', () => {
beforeEach(() => {
jest.spyOn($.fn, 'renderGFM');
- release = commonUtils.convertObjectPropsToCamelCase(originalRelease, { deep: true });
+ release = convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse).data;
});
afterEach(() => {
diff --git a/spec/frontend/releases/components/tag_field_spec.js b/spec/frontend/releases/components/tag_field_spec.js
index db08f874959..e7b9aa4abbb 100644
--- a/spec/frontend/releases/components/tag_field_spec.js
+++ b/spec/frontend/releases/components/tag_field_spec.js
@@ -9,14 +9,14 @@ describe('releases/components/tag_field', () => {
let store;
let wrapper;
- const createComponent = ({ tagName }) => {
+ const createComponent = ({ isExistingRelease }) => {
store = createStore({
modules: {
editNew: createEditNewModule({}),
},
});
- store.state.editNew.tagName = tagName;
+ store.state.editNew.isExistingRelease = isExistingRelease;
wrapper = shallowMount(TagField, { store });
};
@@ -31,7 +31,7 @@ describe('releases/components/tag_field', () => {
describe('when an existing release is being edited', () => {
beforeEach(() => {
- createComponent({ tagName: 'v1.0' });
+ createComponent({ isExistingRelease: true });
});
it('renders the TagFieldExisting component', () => {
@@ -45,7 +45,7 @@ describe('releases/components/tag_field', () => {
describe('when a new release is being created', () => {
beforeEach(() => {
- createComponent({ tagName: null });
+ createComponent({ isExistingRelease: false });
});
it('renders the TagFieldNew component', () => {
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index 41653f62ebf..ce3b690213c 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -9,10 +9,15 @@ import { ASSET_LINK_TYPE } from '~/releases/constants';
import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql';
import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql';
import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql';
+import deleteReleaseMutation from '~/releases/graphql/mutations/delete_release.mutation.graphql';
import * as actions from '~/releases/stores/modules/edit_new/actions';
import * as types from '~/releases/stores/modules/edit_new/mutation_types';
import createState from '~/releases/stores/modules/edit_new/state';
-import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
+import {
+ gqClient,
+ convertOneReleaseGraphQLResponse,
+ deleteReleaseSessionKey,
+} from '~/releases/util';
jest.mock('~/api/tags_api');
@@ -37,19 +42,15 @@ describe('Release edit/new actions', () => {
let error;
const setupState = (updates = {}) => {
- const getters = {
- isExistingRelease: true,
- };
-
state = {
...createState({
projectId: '18',
+ isExistingRelease: true,
tagName: releaseResponse.tag_name,
releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview',
}),
- ...getters,
...updates,
};
};
@@ -168,6 +169,15 @@ describe('Release edit/new actions', () => {
});
});
+ describe('updateReleasedAt', () => {
+ it(`commits ${types.UPDATE_RELEASED_AT} with the updated date`, () => {
+ const newDate = new Date();
+ return testAction(actions.updateReleasedAt, newDate, state, [
+ { type: types.UPDATE_RELEASED_AT, payload: newDate },
+ ]);
+ });
+ });
+
describe('updateCreateFrom', () => {
it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => {
const newRef = 'my-feature-branch';
@@ -177,6 +187,15 @@ describe('Release edit/new actions', () => {
});
});
+ describe('updateShowCreateFrom', () => {
+ it(`commits ${types.UPDATE_SHOW_CREATE_FROM} with the updated ref`, () => {
+ const newRef = 'my-feature-branch';
+ return testAction(actions.updateShowCreateFrom, newRef, state, [
+ { type: types.UPDATE_SHOW_CREATE_FROM, payload: newRef },
+ ]);
+ });
+ });
+
describe('updateReleaseTitle', () => {
it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => {
const newTitle = 'The new release title';
@@ -572,6 +591,133 @@ describe('Release edit/new actions', () => {
});
});
+ describe('deleteRelease', () => {
+ let getters;
+ let dispatch;
+ let commit;
+ let release;
+
+ beforeEach(() => {
+ getters = {
+ releaseDeleteMutationVariables: {
+ input: {
+ projectPath: 'test-org/test',
+ tagName: 'v1.0',
+ },
+ },
+ };
+
+ release = convertOneReleaseGraphQLResponse(releaseResponse).data;
+
+ setupState({
+ release,
+ originalRelease: release,
+ ...getters,
+ });
+
+ dispatch = jest.fn();
+ commit = jest.fn();
+
+ gqClient.mutate.mockResolvedValue({
+ data: {
+ releaseDelete: {
+ errors: [],
+ },
+ releaseAssetLinkDelete: {
+ errors: [],
+ },
+ },
+ });
+ });
+
+ describe('when the delete is successful', () => {
+ beforeEach(() => {
+ window.sessionStorage.clear();
+ });
+
+ it('dispatches receiveSaveReleaseSuccess', async () => {
+ await actions.deleteRelease({ commit, dispatch, state, getters });
+ expect(dispatch.mock.calls).toEqual([
+ ['receiveSaveReleaseSuccess', state.releasesPagePath],
+ ]);
+ });
+
+ it('deletes the release', async () => {
+ await actions.deleteRelease({ commit, dispatch, state, getters });
+ expect(gqClient.mutate.mock.calls[0]).toEqual([
+ {
+ mutation: deleteReleaseMutation,
+ variables: getters.releaseDeleteMutationVariables,
+ },
+ ]);
+ });
+
+ it('stores the name for toasting', async () => {
+ await actions.deleteRelease({ commit, dispatch, state, getters });
+ expect(window.sessionStorage.getItem(deleteReleaseSessionKey(state.projectPath))).toBe(
+ state.release.name,
+ );
+ });
+ });
+
+ describe('when the delete request fails', () => {
+ beforeEach(() => {
+ gqClient.mutate.mockRejectedValue(error);
+ });
+
+ it('dispatches requestDeleteRelease and receiveSaveReleaseError with an error object', async () => {
+ await actions.deleteRelease({ commit, dispatch, state, getters });
+
+ expect(commit.mock.calls).toContainEqual([types.RECEIVE_SAVE_RELEASE_ERROR, error]);
+ });
+
+ it('shows a flash message', async () => {
+ await actions.deleteRelease({ commit, dispatch, state, getters });
+
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Something went wrong while deleting the release.',
+ });
+ });
+ });
+
+ describe('when the delete returns errors', () => {
+ beforeEach(() => {
+ gqClient.mutate.mockResolvedValue({
+ data: {
+ releaseUpdate: {
+ errors: ['Something went wrong!'],
+ },
+ releaseAssetLinkDelete: {
+ errors: [],
+ },
+ releaseAssetLinkCreate: {
+ errors: [],
+ },
+ },
+ });
+ });
+
+ it('dispatches requestDeleteRelease and receiveSaveReleaseError with an error object', async () => {
+ await actions.deleteRelease({ commit, dispatch, state, getters });
+
+ expect(commit.mock.calls).toContainEqual([
+ types.RECEIVE_SAVE_RELEASE_ERROR,
+ expect.any(Error),
+ ]);
+ });
+
+ it('shows a flash message', async () => {
+ await actions.deleteRelease({ commit, dispatch, state, getters });
+
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Something went wrong while deleting the release.',
+ });
+ });
+ });
+ });
+
describe('fetchTagNotes', () => {
const tagName = 'v8.0.0';
diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js
index c42c6c00f56..4ac6eaebaa2 100644
--- a/spec/frontend/releases/stores/modules/detail/getters_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js
@@ -2,20 +2,6 @@ import { s__ } from '~/locale';
import * as getters from '~/releases/stores/modules/edit_new/getters';
describe('Release edit/new getters', () => {
- describe('isExistingRelease', () => {
- it('returns true if the release is an existing release that already exists in the database', () => {
- const state = { tagName: 'test-tag-name' };
-
- expect(getters.isExistingRelease(state)).toBe(true);
- });
-
- it('returns false if the release is a new release that has not yet been saved to the database', () => {
- const state = { tagName: null };
-
- expect(getters.isExistingRelease(state)).toBe(false);
- });
- });
-
describe('releaseLinksToCreate', () => {
it("returns an empty array if state.release doesn't exist", () => {
const state = {};
@@ -302,6 +288,7 @@ describe('Release edit/new getters', () => {
name: 'release.name',
description: 'release.description',
milestones: ['release.milestone[0].title'],
+ releasedAt: new Date(2022, 5, 30),
},
},
{
@@ -310,6 +297,7 @@ describe('Release edit/new getters', () => {
name: 'release.name',
description: 'release.description',
milestones: ['release.milestone[0].title'],
+ releasedAt: new Date(2022, 5, 30),
},
],
[
@@ -381,6 +369,26 @@ describe('Release edit/new getters', () => {
});
});
+ describe('releaseDeleteMutationVariables', () => {
+ it('returns all the data needed for the releaseDelete GraphQL mutation', () => {
+ const state = {
+ projectPath: 'test-org/test',
+ release: { tagName: 'v1.0' },
+ };
+
+ const expectedVariables = {
+ input: {
+ projectPath: 'test-org/test',
+ tagName: 'v1.0',
+ },
+ };
+
+ const actualVariables = getters.releaseDeleteMutationVariables(state);
+
+ expect(actualVariables).toEqual(expectedVariables);
+ });
+ });
+
describe('formattedReleaseNotes', () => {
it.each`
description | includeTagNotes | tagNotes | included
diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
index 85844831e0b..60b57c7a7ff 100644
--- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
@@ -25,11 +25,12 @@ describe('Release edit/new mutations', () => {
mutations[types.INITIALIZE_EMPTY_RELEASE](state);
expect(state.release).toEqual({
- tagName: null,
+ tagName: 'v1.3',
name: '',
description: '',
milestones: [],
groupMilestones: [],
+ releasedAt: new Date(),
assets: {
links: [],
},
@@ -82,6 +83,16 @@ describe('Release edit/new mutations', () => {
});
});
+ describe(`${types.UPDATE_RELEASED_AT}`, () => {
+ it("updates the release's released at date", () => {
+ state.release = release;
+ const newDate = new Date();
+ mutations[types.UPDATE_RELEASED_AT](state, newDate);
+
+ expect(state.release.releasedAt).toBe(newDate);
+ });
+ });
+
describe(`${types.UPDATE_CREATE_FROM}`, () => {
it('updates the ref that the ref will be created from', () => {
state.createFrom = 'main';
@@ -92,6 +103,16 @@ describe('Release edit/new mutations', () => {
});
});
+ describe(`${types.UPDATE_SHOW_CREATE_FROM}`, () => {
+ it('updates the ref that the ref will be created from', () => {
+ state.showCreateFrom = true;
+ const newValue = false;
+ mutations[types.UPDATE_SHOW_CREATE_FROM](state, newValue);
+
+ expect(state.showCreateFrom).toBe(newValue);
+ });
+ });
+
describe(`${types.UPDATE_RELEASE_TITLE}`, () => {
it("updates the release's title", () => {
state.release = release;
diff --git a/spec/frontend/reports/components/grouped_issues_list_spec.js b/spec/frontend/reports/components/grouped_issues_list_spec.js
index c6eebf05dd7..95ef0bcbcc7 100644
--- a/spec/frontend/reports/components/grouped_issues_list_spec.js
+++ b/spec/frontend/reports/components/grouped_issues_list_spec.js
@@ -17,7 +17,6 @@ describe('Grouped Issues List', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
it('renders a smart virtual list with the correct props', () => {
@@ -35,13 +34,15 @@ describe('Grouped Issues List', () => {
});
describe('without data', () => {
- beforeEach(createComponent);
+ beforeEach(() => {
+ createComponent();
+ });
it.each(['unresolved', 'resolved'])('does not a render a header for %s issues', (issueName) => {
expect(findHeading(issueName).exists()).toBe(false);
});
- it.each('resolved', 'unresolved')('does not render report items for %s issues', () => {
+ it.each(['resolved', 'unresolved'])('does not render report items for %s issues', () => {
expect(wrapper.find(ReportItem).exists()).toBe(false);
});
});
diff --git a/spec/frontend/reports/mock_data/new_failures_report.json b/spec/frontend/reports/mock_data/new_failures_report.json
index 8b9c12c6271..438f7c82788 100644
--- a/spec/frontend/reports/mock_data/new_failures_report.json
+++ b/spec/frontend/reports/mock_data/new_failures_report.json
@@ -8,12 +8,14 @@
{
"result": "failure",
"name": "Test#sum when a is 1 and b is 2 returns summary",
+ "file": "spec/file_1.rb",
"execution_time": 0.009411,
"system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'"
},
{
"result": "failure",
"name": "Test#sum when a is 100 and b is 200 returns summary",
+ "file": "spec/file_2.rb",
"execution_time": 0.000162,
"system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'"
}
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 4732d68c8c6..cb56f392ec9 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -17,7 +17,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
/>
<div
- class="commit-detail flex-list"
+ class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
>
<div
class="commit-content qa-commit-content"
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index d498b6f0c4f..2b70cb84c67 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -136,6 +136,7 @@ describe('Blob content viewer component', () => {
const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup);
const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion);
const findCodeIntelligence = () => wrapper.findComponent(CodeIntelligence);
+ const findSourceViewer = () => wrapper.findComponent(SourceViewer);
beforeEach(() => {
jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
@@ -197,6 +198,16 @@ describe('Blob content viewer component', () => {
expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl);
});
+ it('loads a legacy viewer when the source viewer emits an error', async () => {
+ loadViewer.mockReturnValueOnce(SourceViewer);
+ await createComponent();
+ findSourceViewer().vm.$emit('error');
+ await waitForPromises();
+
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl);
+ });
+
it('loads a legacy viewer when a viewer component is not available', async () => {
await createComponent({ blob: { ...simpleViewerMock, fileType: 'unknown' } });
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index cfbf74e34aa..3783b34e33a 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -1,179 +1,227 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
import { GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+
import LastCommit from '~/repository/components/last_commit.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-
-let vm;
-
-function createCommitData(data = {}) {
- const defaultData = {
- sha: '123456789',
- title: 'Commit title',
- titleHtml: 'Commit title',
- message: 'Commit message',
- webPath: '/commit/123',
- authoredDate: '2019-01-01',
- author: {
- name: 'Test',
- avatarUrl: 'https://test.com',
- webPath: '/test',
- },
- pipeline: {
+import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
+import { refMock } from '../mock_data';
+
+let wrapper;
+let mockResolver;
+
+const findPipeline = () => wrapper.find('.js-commit-pipeline');
+const findTextExpander = () => wrapper.find('.text-expander');
+const findUserLink = () => wrapper.find('.js-user-link');
+const findUserAvatarLink = () => wrapper.findComponent(UserAvatarLink);
+const findLastCommitLabel = () => wrapper.findByTestId('last-commit-id-label');
+const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+const findCommitRowDescription = () => wrapper.find('.commit-row-description');
+const findStatusBox = () => wrapper.find('.gpg-status-box');
+const findItemTitle = () => wrapper.find('.item-title');
+
+const defaultPipelineEdges = [
+ {
+ __typename: 'PipelineEdge',
+ node: {
+ __typename: 'Pipeline',
+ id: 'gid://gitlab/Ci::Pipeline/167',
detailedStatus: {
+ __typename: 'DetailedStatus',
+ id: 'id',
detailsPath: 'https://test.com/pipeline',
- icon: 'failed',
+ icon: 'status_running',
tooltip: 'failed',
text: 'failed',
- group: {},
+ group: 'failed',
},
},
- };
- return Object.assign(defaultData, data);
-}
-
-function factory(commit = createCommitData(), loading = false) {
- vm = shallowMount(LastCommit, {
- mocks: {
- $apollo: {
- queries: {
- commit: {
- loading: true,
+ },
+];
+
+const defaultAuthor = {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ name: 'Test',
+ avatarUrl: 'https://test.com',
+ webPath: '/test',
+};
+
+const defaultMessage = 'Commit title';
+
+const createCommitData = ({
+ pipelineEdges = defaultPipelineEdges,
+ author = defaultAuthor,
+ descriptionHtml = '',
+ signatureHtml = null,
+ message = defaultMessage,
+}) => {
+ return {
+ data: {
+ project: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/6',
+ repository: {
+ __typename: 'Repository',
+ paginatedTree: {
+ __typename: 'TreeConnection',
+ nodes: [
+ {
+ __typename: 'Tree',
+ lastCommit: {
+ __typename: 'Commit',
+ id: 'gid://gitlab/CommitPresenter/123456789',
+ sha: '123456789',
+ title: 'Commit title',
+ titleHtml: 'Commit title',
+ descriptionHtml,
+ message,
+ webPath: '/commit/123',
+ authoredDate: '2019-01-01',
+ authorName: 'Test',
+ authorGravatar: 'https://test.com',
+ author,
+ signatureHtml,
+ pipelines: {
+ __typename: 'PipelineConnection',
+ edges: pipelineEdges,
+ },
+ },
+ },
+ ],
},
},
},
},
- });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- vm.setData({ commit });
- vm.vm.$apollo.queries.commit.loading = loading;
-}
+ };
+};
-const emptyMessageClass = 'font-italic';
+const createComponent = async (data = {}) => {
+ Vue.use(VueApollo);
-describe('Repository last commit component', () => {
- afterEach(() => {
- vm.destroy();
+ const currentPath = 'path';
+
+ mockResolver = jest.fn().mockResolvedValue(createCommitData(data));
+
+ wrapper = shallowMountExtended(LastCommit, {
+ apolloProvider: createMockApollo([[pathLastCommitQuery, mockResolver]]),
+ propsData: { currentPath },
+ mixins: [{ data: () => ({ ref: refMock }) }],
});
+};
+
+afterEach(() => {
+ wrapper.destroy();
+ mockResolver = null;
+});
+describe('Repository last commit component', () => {
it.each`
loading | label
${true} | ${'shows'}
${false} | ${'hides'}
- `('$label when loading icon $loading is true', async ({ loading }) => {
- factory(createCommitData(), loading);
+ `('$label when loading icon is $loading', async ({ loading }) => {
+ createComponent();
- await nextTick();
+ if (!loading) {
+ await waitForPromises();
+ }
- expect(vm.find(GlLoadingIcon).exists()).toBe(loading);
+ expect(findLoadingIcon().exists()).toBe(loading);
});
it('renders commit widget', async () => {
- factory();
+ createComponent();
+ await waitForPromises();
- await nextTick();
-
- expect(vm.element).toMatchSnapshot();
+ expect(wrapper.element).toMatchSnapshot();
});
it('renders short commit ID', async () => {
- factory();
-
- await nextTick();
+ createComponent();
+ await waitForPromises();
- expect(vm.find('[data-testid="last-commit-id-label"]').text()).toEqual('12345678');
+ expect(findLastCommitLabel().text()).toBe('12345678');
});
it('hides pipeline components when pipeline does not exist', async () => {
- factory(createCommitData({ pipeline: null }));
+ createComponent({ pipelineEdges: [] });
+ await waitForPromises();
- await nextTick();
-
- expect(vm.find('.js-commit-pipeline').exists()).toBe(false);
+ expect(findPipeline().exists()).toBe(false);
});
- it('renders pipeline components', async () => {
- factory();
-
- await nextTick();
+ it('renders pipeline components when pipeline exists', async () => {
+ createComponent();
+ await waitForPromises();
- expect(vm.find('.js-commit-pipeline').exists()).toBe(true);
+ expect(findPipeline().exists()).toBe(true);
});
it('hides author component when author does not exist', async () => {
- factory(createCommitData({ author: null }));
+ createComponent({ author: null });
+ await waitForPromises();
- await nextTick();
-
- expect(vm.find('.js-user-link').exists()).toBe(false);
- expect(vm.find(UserAvatarLink).exists()).toBe(false);
+ expect(findUserLink().exists()).toBe(false);
+ expect(findUserAvatarLink().exists()).toBe(false);
});
it('does not render description expander when description is null', async () => {
- factory(createCommitData({ descriptionHtml: null }));
-
- await nextTick();
+ createComponent();
+ await waitForPromises();
- expect(vm.find('.text-expander').exists()).toBe(false);
- expect(vm.find('.commit-row-description').exists()).toBe(false);
+ expect(findTextExpander().exists()).toBe(false);
+ expect(findCommitRowDescription().exists()).toBe(false);
});
- it('expands commit description when clicking expander', async () => {
- factory(createCommitData({ descriptionHtml: 'Test description' }));
-
- await nextTick();
-
- vm.find('.text-expander').vm.$emit('click');
-
- await nextTick();
-
- expect(vm.find('.commit-row-description').isVisible()).toBe(true);
- expect(vm.find('.text-expander').classes('open')).toBe(true);
- });
-
- it('strips the first newline of the description', async () => {
- factory(createCommitData({ descriptionHtml: '&#x000A;Update ADOPTERS.md' }));
-
- await nextTick();
-
- expect(vm.find('.commit-row-description').html()).toBe(
- '<pre class="commit-row-description gl-mb-3">Update ADOPTERS.md</pre>',
- );
+ describe('when the description is present', () => {
+ beforeEach(async () => {
+ createComponent({ descriptionHtml: '&#x000A;Update ADOPTERS.md' });
+ await waitForPromises();
+ });
+
+ it('strips the first newline of the description', () => {
+ expect(findCommitRowDescription().html()).toBe(
+ '<pre class="commit-row-description gl-mb-3">Update ADOPTERS.md</pre>',
+ );
+ });
+
+ it('expands commit description when clicking expander', async () => {
+ findTextExpander().vm.$emit('click');
+ await nextTick();
+
+ expect(findCommitRowDescription().isVisible()).toBe(true);
+ expect(findTextExpander().classes()).toContain('open');
+ });
});
it('renders the signature HTML as returned by the backend', async () => {
- factory(
- createCommitData({
- signatureHtml: `<a
- class="btn gpg-status-box valid"
- data-content="signature-content"
- data-html="true"
- data-placement="top"
- data-title="signature-title"
- data-toggle="popover"
- role="button"
- tabindex="0"
- >
- Verified
- </a>`,
- }),
- );
-
- await nextTick();
-
- expect(vm.find('.gpg-status-box').html()).toBe(
- `<a class="btn gpg-status-box valid" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0">
- Verified
-</a>`,
+ createComponent({
+ signatureHtml: `<a
+ class="btn gpg-status-box valid"
+ data-content="signature-content"
+ data-html="true"
+ data-placement="top"
+ data-title="signature-title"
+ data-toggle="popover"
+ role="button"
+ tabindex="0"
+ >Verified</a>`,
+ });
+ await waitForPromises();
+
+ expect(findStatusBox().html()).toBe(
+ `<a class="btn gpg-status-box valid" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0">Verified</a>`,
);
});
it('sets correct CSS class if the commit message is empty', async () => {
- factory(createCommitData({ message: '' }));
-
- await nextTick();
+ createComponent({ message: '' });
+ await waitForPromises();
- expect(vm.find('.item-title').classes()).toContain(emptyMessageClass);
+ expect(findItemTitle().classes()).toContain('font-italic');
});
});
diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js
index 22570b2d6ed..13b09e57473 100644
--- a/spec/frontend/repository/components/table/row_spec.js
+++ b/spec/frontend/repository/components/table/row_spec.js
@@ -31,7 +31,7 @@ function factory(propsData = {}) {
GlHoverLoad: createMockDirective(),
},
provide: {
- glFeatures: { refactorBlobViewer: true, lazyLoadCommits: true },
+ glFeatures: { lazyLoadCommits: true },
},
mocks: {
$router,
@@ -244,8 +244,6 @@ describe('Repository table row component', () => {
});
describe('row visibility', () => {
- beforeAll(() => jest.useFakeTimers());
-
beforeEach(() => {
factory({
id: '1',
@@ -260,12 +258,13 @@ describe('Repository table row component', () => {
afterAll(() => jest.useRealTimers());
it('emits a `row-appear` event', async () => {
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
findIntersectionObserver().vm.$emit('appear');
jest.runAllTimers();
- expect(setTimeout).toHaveBeenCalledTimes(1);
- expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), ROW_APPEAR_DELAY);
+ expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
+ expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), ROW_APPEAR_DELAY);
expect(vm.emitted('row-appear')).toEqual([[123]]);
});
});
diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js
index 5186c9a8992..e3b4dcb8acc 100644
--- a/spec/frontend/repository/log_tree_spec.js
+++ b/spec/frontend/repository/log_tree_spec.js
@@ -16,19 +16,18 @@ const mockData = [
commit_path: `https://test.com`,
commit_title_html: 'commit title',
file_name: 'index.js',
- type: 'blob',
},
];
describe('resolveCommit', () => {
it('calls resolve when commit found', () => {
const resolver = {
- entry: { name: 'index.js', type: 'blob' },
+ entry: { name: 'index.js' },
resolve: jest.fn(),
};
const commits = [
- { fileName: 'index.js', filePath: '/index.js', type: 'blob' },
- { fileName: 'index.js', filePath: '/app/assets/index.js', type: 'blob' },
+ { fileName: 'index.js', filePath: '/index.js' },
+ { fileName: 'index.js', filePath: '/app/assets/index.js' },
];
resolveCommit(commits, '', resolver);
@@ -36,7 +35,6 @@ describe('resolveCommit', () => {
expect(resolver.resolve).toHaveBeenCalledWith({
fileName: 'index.js',
filePath: '/index.js',
- type: 'blob',
});
});
});
@@ -56,7 +54,7 @@ describe('fetchLogsTree', () => {
global.gon = { relative_url_root: '' };
resolver = {
- entry: { name: 'index.js', type: 'blob' },
+ entry: { name: 'index.js' },
resolve: jest.fn(),
};
@@ -119,7 +117,6 @@ describe('fetchLogsTree', () => {
filePath: '/index.js',
message: 'testing message',
sha: '123',
- type: 'blob',
}),
);
}));
@@ -136,7 +133,6 @@ describe('fetchLogsTree', () => {
message: 'testing message',
sha: '123',
titleHtml: 'commit title',
- type: 'blob',
}),
],
});
diff --git a/spec/frontend/repository/utils/commit_spec.js b/spec/frontend/repository/utils/commit_spec.js
index aaaa39f739f..b3dd5118308 100644
--- a/spec/frontend/repository/utils/commit_spec.js
+++ b/spec/frontend/repository/utils/commit_spec.js
@@ -10,7 +10,6 @@ const mockData = [
commit_path: `https://test.com`,
commit_title_html: 'testing message',
file_name: 'index.js',
- type: 'blob',
},
];
@@ -24,7 +23,6 @@ describe('normalizeData', () => {
commitPath: 'https://test.com',
fileName: 'index.js',
filePath: '/index.js',
- type: 'blob',
titleHtml: 'testing message',
__typename: 'LogTreeCommit',
},
diff --git a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js
index 28e7d192938..433be5d5027 100644
--- a/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js
+++ b/spec/frontend/runner/admin_runner_show/admin_runner_show_app_spec.js
@@ -9,6 +9,7 @@ import { redirectTo } from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '~/runner/components/runner_header.vue';
+import RunnerDetails from '~/runner/components/runner_details.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
@@ -37,6 +38,7 @@ describe('AdminRunnerShowApp', () => {
let mockRunnerQuery;
const findRunnerHeader = () => wrapper.findComponent(RunnerHeader);
+ const findRunnerDetails = () => wrapper.findComponent(RunnerDetails);
const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton);
const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton);
const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton);
@@ -179,12 +181,32 @@ describe('AdminRunnerShowApp', () => {
});
});
+ describe('When loading', () => {
+ beforeEach(() => {
+ mockRunnerQueryResult();
+
+ createComponent();
+ });
+
+ it('does not show runner details', () => {
+ expect(findRunnerDetails().exists()).toBe(false);
+ });
+
+ it('does not show runner jobs', () => {
+ expect(findRunnersJobs().exists()).toBe(false);
+ });
+ });
+
describe('When there is an error', () => {
beforeEach(async () => {
mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!'));
await createComponent();
});
+ it('does not show runner details', () => {
+ expect(findRunnerDetails().exists()).toBe(false);
+ });
+
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error('Error!'),
@@ -201,13 +223,6 @@ describe('AdminRunnerShowApp', () => {
const stubs = {
GlTab,
GlTabs,
- RunnerDetails: {
- template: `
- <div>
- <slot name="jobs-tab"></slot>
- </div>
- `,
- },
};
it('without a runner, shows no jobs', () => {
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 3d25ad075de..aa1aa723491 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -10,9 +10,11 @@ import {
} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
+import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
+import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config';
import { createLocalState } from '~/runner/graphql/list/local_state';
import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
@@ -20,6 +22,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
@@ -30,8 +33,6 @@ import {
CREATED_DESC,
DEFAULT_SORT,
INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
PARAM_KEY_PAUSED,
PARAM_KEY_STATUS,
PARAM_KEY_TAG,
@@ -40,15 +41,14 @@ import {
STATUS_STALE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
-import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql';
-import adminRunnersCountQuery from '~/runner/graphql/list/admin_runners_count.query.graphql';
+import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
+import allRunnersCountQuery from '~/runner/graphql/list/all_runners_count.query.graphql';
import { captureException } from '~/runner/sentry_utils';
-import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
- runnersData,
+ allRunnersData,
runnersCountData,
- runnersDataPaginated,
+ allRunnersDataPaginated,
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
@@ -56,9 +56,12 @@ import {
} from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
-const mockRunners = runnersData.data.runners.nodes;
+const mockRunners = allRunnersData.data.runners.nodes;
const mockRunnersCount = runnersCountData.data.runners.count;
+const mockRunnersHandler = jest.fn();
+const mockRunnersCountHandler = jest.fn();
+
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
@@ -71,8 +74,6 @@ Vue.use(GlToast);
describe('AdminRunnersApp', () => {
let wrapper;
- let mockRunnersQuery;
- let mockRunnersCountQuery;
let cacheConfig;
let localMutations;
@@ -85,7 +86,6 @@ describe('AdminRunnersApp', () => {
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
- const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const createComponent = ({
props = {},
@@ -96,8 +96,8 @@ describe('AdminRunnersApp', () => {
({ cacheConfig, localMutations } = createLocalState());
const handlers = [
- [adminRunnersQuery, mockRunnersQuery],
- [adminRunnersCountQuery, mockRunnersCountQuery],
+ [allRunnersQuery, mockRunnersHandler],
+ [allRunnersCountQuery, mockRunnersCountHandler],
];
wrapper = mountFn(AdminRunnersApp, {
@@ -116,110 +116,62 @@ describe('AdminRunnersApp', () => {
},
...options,
});
- };
- beforeEach(async () => {
- setWindowLocation('/admin/runners');
+ return waitForPromises();
+ };
- mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
- mockRunnersCountQuery = jest.fn().mockResolvedValue(runnersCountData);
- createComponent();
- await waitForPromises();
+ beforeEach(() => {
+ mockRunnersHandler.mockResolvedValue(allRunnersData);
+ mockRunnersCountHandler.mockResolvedValue(runnersCountData);
});
afterEach(() => {
- mockRunnersQuery.mockReset();
- mockRunnersCountQuery.mockReset();
+ mockRunnersHandler.mockReset();
+ mockRunnersCountHandler.mockReset();
wrapper.destroy();
});
it('shows the runner tabs with a runner count for each type', async () => {
- mockRunnersCountQuery.mockImplementation(({ type }) => {
- let count;
- switch (type) {
- case INSTANCE_TYPE:
- count = 3;
- break;
- case GROUP_TYPE:
- count = 2;
- break;
- case PROJECT_TYPE:
- count = 1;
- break;
- default:
- count = 6;
- break;
- }
- return Promise.resolve({ data: { runners: { count } } });
- });
-
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
-
- expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
- `All 6 Instance 3 Group 2 Project 1`,
- );
- });
-
- it('shows the runner tabs with a formatted runner count', async () => {
- mockRunnersCountQuery.mockImplementation(({ type }) => {
- let count;
- switch (type) {
- case INSTANCE_TYPE:
- count = 3000;
- break;
- case GROUP_TYPE:
- count = 2000;
- break;
- case PROJECT_TYPE:
- count = 1000;
- break;
- default:
- count = 6000;
- break;
- }
- return Promise.resolve({ data: { runners: { count } } });
- });
-
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
- `All 6,000 Instance 3,000 Group 2,000 Project 1,000`,
+ `All ${mockRunnersCount} Instance ${mockRunnersCount} Group ${mockRunnersCount} Project ${mockRunnersCount}`,
);
});
it('shows the runner setup instructions', () => {
+ createComponent();
+
expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE);
});
it('shows total runner counts', async () => {
- expect(mockRunnersCountQuery).toHaveBeenCalledWith({
- status: STATUS_ONLINE,
- });
- expect(mockRunnersCountQuery).toHaveBeenCalledWith({
- status: STATUS_OFFLINE,
- });
- expect(mockRunnersCountQuery).toHaveBeenCalledWith({
- status: STATUS_STALE,
- });
+ await createComponent({ mountFn: mountExtended });
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockRunnersCount,
- offlineRunnersCount: mockRunnersCount,
- staleRunnersCount: mockRunnersCount,
- });
+ expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE });
+ expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_OFFLINE });
+ expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_STALE });
+
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Online runners')} ${mockRunnersCount}`,
+ );
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Offline runners')} ${mockRunnersCount}`,
+ );
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Stale runners')} ${mockRunnersCount}`,
+ );
});
- it('shows the runners list', () => {
+ it('shows the runners list', async () => {
+ await createComponent();
+
expect(findRunnerList().props('runners')).toEqual(mockRunners);
});
it('runner item links to the runner admin page', async () => {
- createComponent({ mountFn: mountExtended });
-
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
const { id, shortSha } = mockRunners[0];
const numericId = getIdFromGraphQLId(id);
@@ -231,12 +183,9 @@ describe('AdminRunnersApp', () => {
});
it('renders runner actions for each runner', async () => {
- createComponent({ mountFn: mountExtended });
-
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell);
-
const runner = mockRunners[0];
expect(runnerActions.props()).toEqual({
@@ -245,8 +194,10 @@ describe('AdminRunnersApp', () => {
});
});
- it('requests the runners with no filters', () => {
- expect(mockRunnersQuery).toHaveBeenLastCalledWith({
+ it('requests the runners with no filters', async () => {
+ await createComponent();
+
+ expect(mockRunnersHandler).toHaveBeenLastCalledWith({
status: undefined,
type: undefined,
sort: DEFAULT_SORT,
@@ -255,9 +206,9 @@ describe('AdminRunnersApp', () => {
});
it('sets tokens in the filtered search', () => {
- createComponent({ mountFn: mountExtended });
+ createComponent();
- expect(findFilteredSearch().props('tokens')).toEqual([
+ expect(findRunnerFilteredSearchBar().props('tokens')).toEqual([
expect.objectContaining({
type: PARAM_KEY_PAUSED,
options: expect.any(Array),
@@ -270,6 +221,7 @@ describe('AdminRunnersApp', () => {
type: PARAM_KEY_TAG,
recentSuggestionsStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`,
}),
+ upgradeStatusTokenConfig,
]);
});
@@ -282,12 +234,10 @@ describe('AdminRunnersApp', () => {
const FILTERED_COUNT_QUERIES = 4; // Smart queries that display a count of runners in tabs
beforeEach(async () => {
- mockRunnersCountQuery.mockClear();
+ mockRunnersCountHandler.mockClear();
- createComponent({ mountFn: mountExtended });
+ await createComponent({ mountFn: mountExtended });
showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
-
- await waitForPromises();
});
it('Links to the runner page', async () => {
@@ -298,12 +248,11 @@ describe('AdminRunnersApp', () => {
});
it('When runner is paused or unpaused, some data is refetched', async () => {
- expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES);
+ expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
findRunnerActionsCell().vm.$emit('toggledPaused');
- expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES);
-
+ expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES);
expect(showToast).toHaveBeenCalledTimes(0);
});
@@ -319,8 +268,12 @@ describe('AdminRunnersApp', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
- createComponent();
- await waitForPromises();
+ await createComponent({
+ stubs: {
+ RunnerStats,
+ RunnerCount,
+ },
+ });
});
it('sets the filters in the search bar', () => {
@@ -336,7 +289,7 @@ describe('AdminRunnersApp', () => {
});
it('requests the runners with filter parameters', () => {
- expect(mockRunnersQuery).toHaveBeenLastCalledWith({
+ expect(mockRunnersHandler).toHaveBeenLastCalledWith({
status: STATUS_ONLINE,
type: INSTANCE_TYPE,
tagList: ['tag1'],
@@ -346,21 +299,22 @@ describe('AdminRunnersApp', () => {
});
it('fetches count results for requested status', () => {
- expect(mockRunnersCountQuery).toHaveBeenCalledWith({
+ expect(mockRunnersCountHandler).toHaveBeenCalledWith({
type: INSTANCE_TYPE,
status: STATUS_ONLINE,
tagList: ['tag1'],
});
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockRunnersCount,
- });
});
});
describe('when a filter is selected by the user', () => {
beforeEach(() => {
- mockRunnersCountQuery.mockClear();
+ createComponent({
+ stubs: {
+ RunnerStats,
+ RunnerCount,
+ },
+ });
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
@@ -375,12 +329,12 @@ describe('AdminRunnersApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
- url: 'http://test.host/admin/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC',
+ url: expect.stringContaining('?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC'),
});
});
it('requests the runners with filters', () => {
- expect(mockRunnersQuery).toHaveBeenLastCalledWith({
+ expect(mockRunnersHandler).toHaveBeenLastCalledWith({
status: STATUS_ONLINE,
tagList: ['tag1'],
sort: CREATED_ASC,
@@ -389,30 +343,10 @@ describe('AdminRunnersApp', () => {
});
it('fetches count results for requested status', () => {
- expect(mockRunnersCountQuery).toHaveBeenCalledWith({
+ expect(mockRunnersCountHandler).toHaveBeenCalledWith({
tagList: ['tag1'],
status: STATUS_ONLINE,
});
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockRunnersCount,
- });
- });
-
- it('skips fetching count results for status that were not in filter', () => {
- expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({
- tagList: ['tag1'],
- status: STATUS_OFFLINE,
- });
- expect(mockRunnersCountQuery).not.toHaveBeenCalledWith({
- tagList: ['tag1'],
- status: STATUS_STALE,
- });
-
- expect(findRunnerStats().props()).toMatchObject({
- offlineRunnersCount: null,
- staleRunnersCount: null,
- });
});
});
@@ -458,14 +392,13 @@ describe('AdminRunnersApp', () => {
describe('when no runners are found', () => {
beforeEach(async () => {
- mockRunnersQuery = jest.fn().mockResolvedValue({
+ mockRunnersHandler.mockResolvedValue({
data: {
runners: { nodes: [] },
},
});
- createComponent();
- await waitForPromises();
+ await createComponent();
});
it('shows an empty state', () => {
@@ -490,9 +423,8 @@ describe('AdminRunnersApp', () => {
describe('when runners query fails', () => {
beforeEach(async () => {
- mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
- createComponent();
- await waitForPromises();
+ mockRunnersHandler.mockRejectedValue(new Error('Error!'));
+ await createComponent();
});
it('error is shown to the user', async () => {
@@ -509,19 +441,18 @@ describe('AdminRunnersApp', () => {
describe('Pagination', () => {
beforeEach(async () => {
- mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated);
+ mockRunnersHandler.mockResolvedValue(allRunnersDataPaginated);
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
});
it('navigates to the next page', async () => {
await findRunnerPaginationNext().trigger('click');
- expect(mockRunnersQuery).toHaveBeenLastCalledWith({
+ expect(mockRunnersHandler).toHaveBeenLastCalledWith({
sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
- after: runnersDataPaginated.data.runners.pageInfo.endCursor,
+ after: allRunnersDataPaginated.data.runners.pageInfo.endCursor,
});
});
});
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 7a949cb6505..ffd6f126627 100644
--- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
+++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
@@ -4,9 +4,9 @@ import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
-import { runnersData } from '../../mock_data';
+import { allRunnersData } from '../../mock_data';
-const mockRunner = runnersData.data.runners.nodes[0];
+const mockRunner = allRunnersData.data.runners.nodes[0];
describe('RunnerActionsCell', () => {
let wrapper;
diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js
index b11c749d0a7..52fe803c536 100644
--- a/spec/frontend/runner/components/runner_delete_button_spec.js
+++ b/spec/frontend/runner/components/runner_delete_button_spec.js
@@ -17,9 +17,9 @@ import {
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
-import { runnersData } from '../mock_data';
+import { allRunnersData } from '../mock_data';
-const mockRunner = runnersData.data.runners.nodes[0];
+const mockRunner = allRunnersData.data.runners.nodes[0];
const mockRunnerId = getIdFromGraphQLId(mockRunner.id);
Vue.use(VueApollo);
diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js
index 9e0f7014750..552ee29b6f9 100644
--- a/spec/frontend/runner/components/runner_details_spec.js
+++ b/spec/frontend/runner/components/runner_details_spec.js
@@ -25,12 +25,7 @@ describe('RunnerDetails', () => {
const findDetailGroups = () => wrapper.findComponent(RunnerGroups);
- const createComponent = ({
- props = {},
- stubs,
- mountFn = shallowMountExtended,
- ...options
- } = {}) => {
+ const createComponent = ({ props = {}, stubs, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerDetails, {
propsData: {
...props,
@@ -39,7 +34,6 @@ describe('RunnerDetails', () => {
RunnerDetail,
...stubs,
},
- ...options,
});
};
@@ -47,16 +41,6 @@ describe('RunnerDetails', () => {
wrapper.destroy();
});
- it('when no runner is present, no contents are shown', () => {
- createComponent({
- props: {
- runner: null,
- },
- });
-
- expect(wrapper.text()).toBe('');
- });
-
describe('Details tab', () => {
describe.each`
field | runner | expectedValue
@@ -141,18 +125,4 @@ describe('RunnerDetails', () => {
});
});
});
-
- describe('Jobs tab slot', () => {
- it('shows job tab slot', () => {
- const JOBS_TAB = '<div>Jobs Tab</div>';
-
- createComponent({
- slots: {
- 'jobs-tab': JOBS_TAB,
- },
- });
-
- expect(wrapper.html()).toContain(JOBS_TAB);
- });
- });
});
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 b1b436e5443..83fb1764c6d 100644
--- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
+++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
@@ -89,6 +89,16 @@ describe('RunnerList', () => {
]);
});
+ it('can be configured with null or undefined tokens, which are ignored', () => {
+ createComponent({
+ props: {
+ tokens: [statusTokenConfig, null, undefined],
+ },
+ });
+
+ expect(findFilteredSearch().props('tokens')).toEqual([statusTokenConfig]);
+ });
+
it('fails validation for v-model with the wrong shape', () => {
expect(() => {
createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } });
diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
index 872394430ae..eca4bbc3490 100644
--- a/spec/frontend/runner/components/runner_list_spec.js
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -7,9 +7,9 @@ import {
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue';
-import { runnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data';
+import { allRunnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data';
-const mockRunners = runnersData.data.runners.nodes;
+const mockRunners = allRunnersData.data.runners.nodes;
const mockActiveRunnersCount = mockRunners.length;
describe('RunnerList', () => {
diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js
index 9ebb30b6ed7..61476007571 100644
--- a/spec/frontend/runner/components/runner_pause_button_spec.js
+++ b/spec/frontend/runner/components/runner_pause_button_spec.js
@@ -1,4 +1,4 @@
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import { GlButton } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -16,9 +16,9 @@ import {
} from '~/runner/constants';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
-import { runnersData } from '../mock_data';
+import { allRunnersData } from '../mock_data';
-const mockRunner = runnersData.data.runners.nodes[0];
+const mockRunner = allRunnersData.data.runners.nodes[0];
Vue.use(VueApollo);
@@ -115,15 +115,20 @@ describe('RunnerPauseButton', () => {
});
describe(`Immediately after the ${icon} button is clicked`, () => {
- beforeEach(async () => {
+ const setup = async () => {
findBtn().vm.$emit('click');
- });
+ await nextTick();
+ };
it('The button has a loading state', async () => {
+ await setup();
+
expect(findBtn().props('loading')).toBe(true);
});
it('The stale tooltip is removed', async () => {
+ await setup();
+
expect(getTooltip()).toBe('');
});
});
@@ -237,15 +242,20 @@ describe('RunnerPauseButton', () => {
});
describe('Immediately after the button is clicked', () => {
- beforeEach(async () => {
+ const setup = async () => {
findBtn().vm.$emit('click');
- });
+ await nextTick();
+ };
it('The button has a loading state', async () => {
+ await setup();
+
expect(findBtn().props('loading')).toBe(true);
});
it('The stale tooltip is removed', async () => {
+ await setup();
+
expect(getTooltip()).toBe('');
});
});
diff --git a/spec/frontend/runner/components/runner_type_tabs_spec.js b/spec/frontend/runner/components/runner_type_tabs_spec.js
index 9da5d842d8f..22d2a9e60f7 100644
--- a/spec/frontend/runner/components/runner_type_tabs_spec.js
+++ b/spec/frontend/runner/components/runner_type_tabs_spec.js
@@ -1,10 +1,30 @@
import { GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' };
+const mockCount = (type, multiplier = 1) => {
+ let count;
+ switch (type) {
+ case INSTANCE_TYPE:
+ count = 3;
+ break;
+ case GROUP_TYPE:
+ count = 2;
+ break;
+ case PROJECT_TYPE:
+ count = 1;
+ break;
+ default:
+ count = 6;
+ break;
+ }
+ return count * multiplier;
+};
+
describe('RunnerTypeTabs', () => {
let wrapper;
@@ -13,33 +33,94 @@ describe('RunnerTypeTabs', () => {
findTabs()
.filter((tab) => tab.attributes('active') === 'true')
.at(0);
- const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text());
+ const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text().replace(/\s+/g, ' '));
- const createComponent = ({ props, ...options } = {}) => {
+ const createComponent = ({ props, stubs, ...options } = {}) => {
wrapper = shallowMount(RunnerTypeTabs, {
propsData: {
value: mockSearch,
+ countScope: INSTANCE_TYPE,
+ countVariables: {},
...props,
},
stubs: {
GlTab,
+ ...stubs,
},
...options,
});
};
- beforeEach(() => {
- createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
});
it('Renders all options to filter runners by default', () => {
+ createComponent();
+
expect(getTabsTitles()).toEqual(['All', 'Instance', 'Group', 'Project']);
});
+ it('Shows count when receiving a number', () => {
+ createComponent({
+ stubs: {
+ RunnerCount: {
+ props: ['variables'],
+ render() {
+ return this.$scopedSlots.default({
+ count: mockCount(this.variables.type),
+ });
+ },
+ },
+ },
+ });
+
+ expect(getTabsTitles()).toEqual([`All 6`, `Instance 3`, `Group 2`, `Project 1`]);
+ });
+
+ it('Shows formatted count when receiving a large number', () => {
+ createComponent({
+ stubs: {
+ RunnerCount: {
+ props: ['variables'],
+ render() {
+ return this.$scopedSlots.default({
+ count: mockCount(this.variables.type, 1000),
+ });
+ },
+ },
+ },
+ });
+
+ expect(getTabsTitles()).toEqual([
+ `All 6,000`,
+ `Instance 3,000`,
+ `Group 2,000`,
+ `Project 1,000`,
+ ]);
+ });
+
+ it('Renders a count next to each tab', () => {
+ const mockVariables = {
+ paused: true,
+ status: 'ONLINE',
+ };
+
+ createComponent({
+ props: {
+ countVariables: mockVariables,
+ },
+ });
+
+ findTabs().wrappers.forEach((tab) => {
+ expect(tab.find(RunnerCount).props()).toEqual({
+ scope: INSTANCE_TYPE,
+ skip: false,
+ variables: expect.objectContaining(mockVariables),
+ });
+ });
+ });
+
it('Renders fewer options to filter runners', () => {
createComponent({
props: {
@@ -51,6 +132,8 @@ describe('RunnerTypeTabs', () => {
});
it('"All" is selected by default', () => {
+ createComponent();
+
expect(findActiveTab().text()).toBe('All');
});
@@ -71,6 +154,7 @@ describe('RunnerTypeTabs', () => {
const emittedValue = () => wrapper.emitted('input')[0][0];
beforeEach(() => {
+ createComponent();
findTabs().at(2).vm.$emit('click');
});
@@ -89,27 +173,30 @@ describe('RunnerTypeTabs', () => {
});
});
- describe('When using a custom slot', () => {
- const mockContent = 'content';
-
- beforeEach(() => {
- createComponent({
- scopedSlots: {
- title: `
- <span>
- {{props.tab.title}} ${mockContent}
- </span>`,
- },
+ describe('Component API', () => {
+ describe('When .refetch() is called', () => {
+ let mockRefetch;
+
+ beforeEach(() => {
+ mockRefetch = jest.fn();
+
+ createComponent({
+ stubs: {
+ RunnerCount: {
+ methods: {
+ refetch: mockRefetch,
+ },
+ render() {},
+ },
+ },
+ });
+
+ wrapper.vm.refetch();
});
- });
- it('Renders tabs with additional information', () => {
- expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([
- `All ${mockContent}`,
- `Instance ${mockContent}`,
- `Group ${mockContent}`,
- `Project ${mockContent}`,
- ]);
+ it('refetch is called for each count', () => {
+ expect(mockRefetch).toHaveBeenCalledTimes(4);
+ });
});
});
});
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 52557ff716d..22f0561ca5f 100644
--- a/spec/frontend/runner/components/search_tokens/tag_token_spec.js
+++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js
@@ -134,8 +134,6 @@ describe('TagToken', () => {
describe('when the users filters suggestions', () => {
beforeEach(async () => {
findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm });
-
- jest.runAllTimers();
});
it('requests filtered tags suggestions', async () => {
@@ -145,6 +143,7 @@ describe('TagToken', () => {
});
it('shows the loading icon', async () => {
+ findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm });
await nextTick();
expect(findGlLoadingIcon().exists()).toBe(true);
diff --git a/spec/frontend/runner/components/stat/runner_count_spec.js b/spec/frontend/runner/components/stat/runner_count_spec.js
new file mode 100644
index 00000000000..89b51b1b4a7
--- /dev/null
+++ b/spec/frontend/runner/components/stat/runner_count_spec.js
@@ -0,0 +1,148 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
+import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { captureException } from '~/runner/sentry_utils';
+
+import allRunnersCountQuery from '~/runner/graphql/list/all_runners_count.query.graphql';
+import groupRunnersCountQuery from '~/runner/graphql/list/group_runners_count.query.graphql';
+
+import { runnersCountData, groupRunnersCountData } from '../../mock_data';
+
+jest.mock('~/runner/sentry_utils');
+
+Vue.use(VueApollo);
+
+describe('RunnerCount', () => {
+ let wrapper;
+ let mockRunnersCountHandler;
+ let mockGroupRunnersCountHandler;
+
+ const createComponent = ({ props = {}, ...options } = {}) => {
+ const handlers = [
+ [allRunnersCountQuery, mockRunnersCountHandler],
+ [groupRunnersCountQuery, mockGroupRunnersCountHandler],
+ ];
+
+ wrapper = shallowMount(RunnerCount, {
+ apolloProvider: createMockApollo(handlers),
+ propsData: {
+ ...props,
+ },
+ scopedSlots: {
+ default: '<strong>{{props.count}}</strong>',
+ },
+ ...options,
+ });
+
+ return waitForPromises();
+ };
+
+ beforeEach(() => {
+ mockRunnersCountHandler = jest.fn().mockResolvedValue(runnersCountData);
+ mockGroupRunnersCountHandler = jest.fn().mockResolvedValue(groupRunnersCountData);
+ });
+
+ describe('in admin scope', () => {
+ const mockVariables = { status: 'ONLINE' };
+
+ beforeEach(async () => {
+ await createComponent({ props: { scope: INSTANCE_TYPE } });
+ });
+
+ it('fetches data from admin query', () => {
+ expect(mockRunnersCountHandler).toHaveBeenCalledTimes(1);
+ expect(mockRunnersCountHandler).toHaveBeenCalledWith({});
+ });
+
+ it('fetches data with filters', async () => {
+ await createComponent({ props: { scope: INSTANCE_TYPE, variables: mockVariables } });
+
+ expect(mockRunnersCountHandler).toHaveBeenCalledTimes(2);
+ expect(mockRunnersCountHandler).toHaveBeenCalledWith(mockVariables);
+
+ expect(wrapper.html()).toBe(`<strong>${runnersCountData.data.runners.count}</strong>`);
+ });
+
+ it('does not fetch from the group query', async () => {
+ expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled();
+ });
+
+ describe('when this query is skipped after data was loaded', () => {
+ beforeEach(async () => {
+ wrapper.setProps({ skip: true });
+
+ await nextTick();
+ });
+
+ it('clears current data', () => {
+ expect(wrapper.html()).toBe('<strong></strong>');
+ });
+ });
+ });
+
+ describe('when skipping query', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { scope: INSTANCE_TYPE, skip: true } });
+ });
+
+ it('does not fetch data', async () => {
+ expect(mockRunnersCountHandler).not.toHaveBeenCalled();
+ expect(mockGroupRunnersCountHandler).not.toHaveBeenCalled();
+
+ expect(wrapper.html()).toBe('<strong></strong>');
+ });
+ });
+
+ describe('when runners query fails', () => {
+ const mockError = new Error('error!');
+
+ beforeEach(async () => {
+ mockRunnersCountHandler.mockRejectedValue(mockError);
+
+ await createComponent({ props: { scope: INSTANCE_TYPE } });
+ });
+
+ it('data is not shown and error is reported', async () => {
+ expect(wrapper.html()).toBe('<strong></strong>');
+
+ expect(captureException).toHaveBeenCalledWith({
+ component: 'RunnerCount',
+ error: mockError,
+ });
+ });
+ });
+
+ describe('in group scope', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { scope: GROUP_TYPE } });
+ });
+
+ it('fetches data from the group query', async () => {
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(1);
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({});
+
+ expect(wrapper.html()).toBe(
+ `<strong>${groupRunnersCountData.data.group.runners.count}</strong>`,
+ );
+ });
+
+ it('does not fetch from the group query', () => {
+ expect(mockRunnersCountHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when .refetch() is called', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { scope: INSTANCE_TYPE } });
+ wrapper.vm.refetch();
+ });
+
+ it('data is not shown and error is reported', async () => {
+ expect(mockRunnersCountHandler).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/stat/runner_stats_spec.js b/spec/frontend/runner/components/stat/runner_stats_spec.js
index 68db8621ef0..f1ba6403dfb 100644
--- a/spec/frontend/runner/components/stat/runner_stats_spec.js
+++ b/spec/frontend/runner/components/stat/runner_stats_spec.js
@@ -1,21 +1,24 @@
import { shallowMount, mount } from '@vue/test-utils';
+import { s__ } from '~/locale';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue';
-import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants';
+import { INSTANCE_TYPE, STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants';
describe('RunnerStats', () => {
let wrapper;
+ const findRunnerCountAt = (i) => wrapper.findAllComponents(RunnerCount).at(i);
const findRunnerStatusStatAt = (i) => wrapper.findAllComponents(RunnerStatusStat).at(i);
- const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
+ const createComponent = ({ props = {}, mountFn = shallowMount, ...options } = {}) => {
wrapper = mountFn(RunnerStats, {
propsData: {
- onlineRunnersCount: 3,
- offlineRunnersCount: 2,
- staleRunnersCount: 1,
+ scope: INSTANCE_TYPE,
+ variables: {},
...props,
},
+ ...options,
});
};
@@ -24,13 +27,46 @@ describe('RunnerStats', () => {
});
it('Displays all the stats', () => {
- createComponent({ mountFn: mount });
+ const mockCounts = {
+ [STATUS_ONLINE]: 3,
+ [STATUS_OFFLINE]: 2,
+ [STATUS_STALE]: 1,
+ };
+
+ createComponent({
+ mountFn: mount,
+ stubs: {
+ RunnerCount: {
+ props: ['variables'],
+ render() {
+ return this.$scopedSlots.default({
+ count: mockCounts[this.variables.status],
+ });
+ },
+ },
+ },
+ });
+
+ const text = wrapper.text();
+ expect(text).toMatch(`${s__('Runners|Online runners')} 3`);
+ expect(text).toMatch(`${s__('Runners|Offline runners')} 2`);
+ expect(text).toMatch(`${s__('Runners|Stale runners')} 1`);
+ });
- const stats = wrapper.text();
+ it('Displays counts for filtered searches', () => {
+ createComponent({ props: { variables: { paused: true } } });
- expect(stats).toMatch('Online runners 3');
- expect(stats).toMatch('Offline runners 2');
- expect(stats).toMatch('Stale runners 1');
+ expect(findRunnerCountAt(0).props('variables').paused).toBe(true);
+ expect(findRunnerCountAt(1).props('variables').paused).toBe(true);
+ expect(findRunnerCountAt(2).props('variables').paused).toBe(true);
+ });
+
+ it('Skips overlapping statuses', () => {
+ createComponent({ props: { variables: { status: STATUS_ONLINE } } });
+
+ expect(findRunnerCountAt(0).props('skip')).toBe(false);
+ expect(findRunnerCountAt(1).props('skip')).toBe(true);
+ expect(findRunnerCountAt(2).props('skip')).toBe(true);
});
it.each`
@@ -38,9 +74,10 @@ describe('RunnerStats', () => {
${0} | ${STATUS_ONLINE}
${1} | ${STATUS_OFFLINE}
${2} | ${STATUS_STALE}
- `('Displays status types at index $i', ({ i, status }) => {
- createComponent();
+ `('Displays status $status at index $i', ({ i, status }) => {
+ createComponent({ mountFn: mount });
+ expect(findRunnerCountAt(i).props('variables').status).toBe(status);
expect(findRunnerStatusStatAt(i).props('status')).toBe(status);
});
});
diff --git a/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js
new file mode 100644
index 00000000000..2065874c288
--- /dev/null
+++ b/spec/frontend/runner/group_runner_show/group_runner_show_app_spec.js
@@ -0,0 +1,213 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { redirectTo } from '~/lib/utils/url_utility';
+
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import RunnerHeader from '~/runner/components/runner_header.vue';
+import RunnerDetails from '~/runner/components/runner_details.vue';
+import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
+import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
+import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
+import runnerQuery from '~/runner/graphql/show/runner.query.graphql';
+import GroupRunnerShowApp from '~/runner/group_runner_show/group_runner_show_app.vue';
+import { captureException } from '~/runner/sentry_utils';
+import { saveAlertToLocalStorage } from '~/runner/local_storage_alert/save_alert_to_local_storage';
+
+import { runnerData } from '../mock_data';
+
+jest.mock('~/runner/local_storage_alert/save_alert_to_local_storage');
+jest.mock('~/flash');
+jest.mock('~/runner/sentry_utils');
+jest.mock('~/lib/utils/url_utility');
+
+const mockRunner = runnerData.data.runner;
+const mockRunnerGraphqlId = mockRunner.id;
+const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`;
+const mockRunnersPath = '/groups/group1/-/runners';
+const mockEditGroupRunnerPath = `/groups/group1/-/runners/${mockRunnerId}/edit`;
+
+Vue.use(VueApollo);
+
+describe('GroupRunnerShowApp', () => {
+ let wrapper;
+ let mockRunnerQuery;
+
+ const findRunnerHeader = () => wrapper.findComponent(RunnerHeader);
+ const findRunnerDetails = () => wrapper.findComponent(RunnerDetails);
+ const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton);
+ const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton);
+ const findRunnerPauseButton = () => wrapper.findComponent(RunnerPauseButton);
+
+ const mockRunnerQueryResult = (runner = {}) => {
+ mockRunnerQuery = jest.fn().mockResolvedValue({
+ data: {
+ runner: { ...mockRunner, ...runner },
+ },
+ });
+ };
+
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
+ wrapper = mountFn(GroupRunnerShowApp, {
+ apolloProvider: createMockApollo([[runnerQuery, mockRunnerQuery]]),
+ propsData: {
+ runnerId: mockRunnerId,
+ runnersPath: mockRunnersPath,
+ editGroupRunnerPath: mockEditGroupRunnerPath,
+ ...props,
+ },
+ ...options,
+ });
+
+ return waitForPromises();
+ };
+
+ afterEach(() => {
+ mockRunnerQuery.mockReset();
+ wrapper.destroy();
+ });
+
+ describe('When showing runner details', () => {
+ beforeEach(async () => {
+ mockRunnerQueryResult();
+
+ await createComponent({ mountFn: mountExtended });
+ });
+
+ it('expect GraphQL ID to be requested', async () => {
+ expect(mockRunnerQuery).toHaveBeenCalledWith({ id: mockRunnerGraphqlId });
+ });
+
+ it('displays the header', async () => {
+ expect(findRunnerHeader().text()).toContain(`Runner #${mockRunnerId}`);
+ });
+
+ it('displays edit, pause, delete buttons', async () => {
+ expect(findRunnerEditButton().exists()).toBe(true);
+ expect(findRunnerPauseButton().exists()).toBe(true);
+ expect(findRunnerDeleteButton().exists()).toBe(true);
+ });
+
+ it('shows basic runner details', () => {
+ const expected = `Description Instance runner
+ Last contact Never contacted
+ Version 1.0.0
+ IP Address 127.0.0.1
+ Executor None
+ Architecture None
+ Platform darwin
+ Configuration Runs untagged jobs
+ Maximum job timeout None
+ Tags None`.replace(/\s+/g, ' ');
+
+ expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected);
+ });
+
+ it('renders runner details component', () => {
+ expect(findRunnerDetails().props('runner')).toEqual(mockRunner);
+ });
+
+ describe('when runner cannot be updated', () => {
+ beforeEach(async () => {
+ mockRunnerQueryResult({
+ userPermissions: {
+ ...mockRunner.userPermissions,
+ updateRunner: false,
+ },
+ });
+
+ await createComponent({
+ mountFn: mountExtended,
+ });
+ });
+
+ it('does not display edit and pause buttons', () => {
+ expect(findRunnerEditButton().exists()).toBe(false);
+ expect(findRunnerPauseButton().exists()).toBe(false);
+ });
+
+ it('displays delete button', () => {
+ expect(findRunnerDeleteButton().exists()).toBe(true);
+ });
+ });
+
+ describe('when runner cannot be deleted', () => {
+ beforeEach(async () => {
+ mockRunnerQueryResult({
+ userPermissions: {
+ ...mockRunner.userPermissions,
+ deleteRunner: false,
+ },
+ });
+
+ await createComponent({
+ mountFn: mountExtended,
+ });
+ });
+
+ it('does not display delete button', () => {
+ expect(findRunnerDeleteButton().exists()).toBe(false);
+ });
+
+ it('displays edit and pause buttons', () => {
+ expect(findRunnerEditButton().exists()).toBe(true);
+ expect(findRunnerPauseButton().exists()).toBe(true);
+ });
+ });
+
+ describe('when runner is deleted', () => {
+ beforeEach(async () => {
+ await createComponent({
+ mountFn: mountExtended,
+ });
+ });
+
+ it('redirects to the runner list page', () => {
+ findRunnerDeleteButton().vm.$emit('deleted', { message: 'Runner deleted' });
+
+ expect(saveAlertToLocalStorage).toHaveBeenCalledWith({
+ message: 'Runner deleted',
+ variant: VARIANT_SUCCESS,
+ });
+ expect(redirectTo).toHaveBeenCalledWith(mockRunnersPath);
+ });
+ });
+ });
+
+ describe('When loading', () => {
+ beforeEach(() => {
+ mockRunnerQueryResult();
+
+ createComponent();
+ });
+
+ it('does not show runner details', () => {
+ expect(findRunnerDetails().exists()).toBe(false);
+ });
+ });
+
+ describe('When there is an error', () => {
+ beforeEach(async () => {
+ mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!'));
+ await createComponent();
+ });
+
+ it('does not show runner details', () => {
+ expect(findRunnerDetails().exists()).toBe(false);
+ });
+
+ it('error is reported to sentry', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ error: new Error('Error!'),
+ component: 'GroupRunnerShowApp',
+ });
+ });
+
+ it('error is shown to the user', () => {
+ expect(createAlert).toHaveBeenCalled();
+ });
+ });
+});
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 eb9f85a7d0f..9c42b0d6865 100644
--- a/spec/frontend/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -10,6 +10,7 @@ import {
} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
+import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -18,6 +19,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
+import RunnerCount from '~/runner/components/stat/runner_count.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
@@ -28,7 +30,6 @@ import {
DEFAULT_SORT,
INSTANCE_TYPE,
GROUP_TYPE,
- PROJECT_TYPE,
PARAM_KEY_PAUSED,
PARAM_KEY_STATUS,
PARAM_KEY_TAG,
@@ -38,11 +39,10 @@ import {
RUNNER_PAGE_SIZE,
I18N_EDIT,
} from '~/runner/constants';
-import getGroupRunnersQuery from '~/runner/graphql/list/group_runners.query.graphql';
-import getGroupRunnersCountQuery from '~/runner/graphql/list/group_runners_count.query.graphql';
+import groupRunnersQuery from '~/runner/graphql/list/group_runners.query.graphql';
+import groupRunnersCountQuery from '~/runner/graphql/list/group_runners_count.query.graphql';
import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue';
import { captureException } from '~/runner/sentry_utils';
-import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
groupRunnersData,
groupRunnersDataPaginated,
@@ -61,6 +61,9 @@ const mockRegistrationToken = 'AABBCC';
const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges;
const mockGroupRunnersCount = mockGroupRunnersEdges.length;
+const mockGroupRunnersHandler = jest.fn();
+const mockGroupRunnersCountHandler = jest.fn();
+
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
@@ -70,8 +73,6 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('GroupRunnersApp', () => {
let wrapper;
- let mockGroupRunnersQuery;
- let mockGroupRunnersCountQuery;
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
@@ -83,17 +84,11 @@ describe('GroupRunnersApp', () => {
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
- const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
-
- const mockCountQueryResult = (count) =>
- Promise.resolve({
- data: { group: { id: groupRunnersCountData.data.group.id, runners: { count } } },
- });
- const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+ const createComponent = ({ props = {}, mountFn = shallowMountExtended, ...options } = {}) => {
const handlers = [
- [getGroupRunnersQuery, mockGroupRunnersQuery],
- [getGroupRunnersCountQuery, mockGroupRunnersCountQuery],
+ [groupRunnersQuery, mockGroupRunnersHandler],
+ [groupRunnersCountQuery, mockGroupRunnersCountHandler],
];
wrapper = mountFn(GroupRunnersApp, {
@@ -110,90 +105,76 @@ describe('GroupRunnersApp', () => {
emptyStateSvgPath,
emptyStateFilteredSvgPath,
},
+ ...options,
});
+
+ return waitForPromises();
};
beforeEach(async () => {
- setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`);
+ mockGroupRunnersHandler.mockResolvedValue(groupRunnersData);
+ mockGroupRunnersCountHandler.mockResolvedValue(groupRunnersCountData);
+ });
- mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData);
- mockGroupRunnersCountQuery = jest.fn().mockResolvedValue(groupRunnersCountData);
+ afterEach(() => {
+ mockGroupRunnersHandler.mockReset();
+ mockGroupRunnersCountHandler.mockReset();
+ wrapper.destroy();
+ });
+
+ it('shows the runner tabs with a runner count for each type', async () => {
+ await createComponent({ mountFn: mountExtended });
+ expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
+ `All ${mockGroupRunnersCount} Group ${mockGroupRunnersCount} Project ${mockGroupRunnersCount}`,
+ );
+ });
+
+ it('shows the runner setup instructions', () => {
createComponent();
- await waitForPromises();
+
+ expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
+ expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
});
it('shows total runner counts', async () => {
- expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
- groupFullPath: mockGroupFullPath,
+ await createComponent({ mountFn: mountExtended });
+
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
status: STATUS_ONLINE,
- });
- expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
groupFullPath: mockGroupFullPath,
- status: STATUS_OFFLINE,
});
- expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
+ status: STATUS_OFFLINE,
groupFullPath: mockGroupFullPath,
- status: STATUS_STALE,
- });
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockGroupRunnersCount,
- offlineRunnersCount: mockGroupRunnersCount,
- staleRunnersCount: mockGroupRunnersCount,
- });
- });
-
- it('shows the runner tabs with a runner count for each type', async () => {
- mockGroupRunnersCountQuery.mockImplementation(({ type }) => {
- switch (type) {
- case GROUP_TYPE:
- return mockCountQueryResult(2);
- case PROJECT_TYPE:
- return mockCountQueryResult(1);
- default:
- return mockCountQueryResult(4);
- }
});
-
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
-
- expect(findRunnerTypeTabs().text()).toMatchInterpolatedText('All 4 Group 2 Project 1');
- });
-
- it('shows the runner tabs with a formatted runner count', async () => {
- mockGroupRunnersCountQuery.mockImplementation(({ type }) => {
- switch (type) {
- case GROUP_TYPE:
- return mockCountQueryResult(2000);
- case PROJECT_TYPE:
- return mockCountQueryResult(1000);
- default:
- return mockCountQueryResult(3000);
- }
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
+ status: STATUS_STALE,
+ groupFullPath: mockGroupFullPath,
});
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
-
- expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
- 'All 3,000 Group 2,000 Project 1,000',
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Online runners')} ${mockGroupRunnersCount}`,
+ );
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Offline runners')} ${mockGroupRunnersCount}`,
+ );
+ expect(findRunnerStats().text()).toContain(
+ `${s__('Runners|Stale runners')} ${mockGroupRunnersCount}`,
);
});
- it('shows the runner setup instructions', () => {
- expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
- expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
- });
+ it('shows the runners list', async () => {
+ await createComponent();
- it('shows the runners list', () => {
const runners = findRunnerList().props('runners');
expect(runners).toEqual(mockGroupRunnersEdges.map(({ node }) => node));
});
- it('requests the runners with group path and no other filters', () => {
- expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
+ it('requests the runners with group path and no other filters', async () => {
+ await createComponent();
+
+ expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: undefined,
type: undefined,
@@ -203,9 +184,9 @@ describe('GroupRunnersApp', () => {
});
it('sets tokens in the filtered search', () => {
- createComponent({ mountFn: mountExtended });
+ createComponent();
- const tokens = findFilteredSearch().props('tokens');
+ const tokens = findRunnerFilteredSearchBar().props('tokens');
expect(tokens).toEqual([
expect.objectContaining({
@@ -229,12 +210,8 @@ describe('GroupRunnersApp', () => {
const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs
beforeEach(async () => {
- mockGroupRunnersCountQuery.mockClear();
-
- createComponent({ mountFn: mountExtended });
+ await createComponent({ mountFn: mountExtended });
showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
-
- await waitForPromises();
});
it('view link is displayed correctly', () => {
@@ -254,11 +231,11 @@ describe('GroupRunnersApp', () => {
});
it('When runner is paused or unpaused, some data is refetched', async () => {
- expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES);
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
findRunnerActionsCell().vm.$emit('toggledPaused');
- expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledTimes(
COUNT_QUERIES + FILTERED_COUNT_QUERIES,
);
@@ -277,8 +254,12 @@ describe('GroupRunnersApp', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`);
- createComponent();
- await waitForPromises();
+ await createComponent({
+ stubs: {
+ RunnerStats,
+ RunnerCount,
+ },
+ });
});
it('sets the filters in the search bar', () => {
@@ -291,7 +272,7 @@ describe('GroupRunnersApp', () => {
});
it('requests the runners with filter parameters', () => {
- expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
+ expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ONLINE,
type: INSTANCE_TYPE,
@@ -301,20 +282,23 @@ describe('GroupRunnersApp', () => {
});
it('fetches count results for requested status', () => {
- expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
groupFullPath: mockGroupFullPath,
type: INSTANCE_TYPE,
status: STATUS_ONLINE,
});
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockGroupRunnersCount,
- });
});
});
describe('when a filter is selected by the user', () => {
beforeEach(async () => {
+ createComponent({
+ stubs: {
+ RunnerStats,
+ RunnerCount,
+ },
+ });
+
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
filters: [
@@ -330,12 +314,12 @@ describe('GroupRunnersApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
- url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC',
+ url: expect.stringContaining('?status[]=ONLINE&tag[]=tag1&sort=CREATED_ASC'),
});
});
it('requests the runners with filters', () => {
- expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
+ expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ONLINE,
tagList: ['tag1'],
@@ -345,33 +329,11 @@ describe('GroupRunnersApp', () => {
});
it('fetches count results for requested status', () => {
- expect(mockGroupRunnersCountQuery).toHaveBeenCalledWith({
+ expect(mockGroupRunnersCountHandler).toHaveBeenCalledWith({
groupFullPath: mockGroupFullPath,
tagList: ['tag1'],
status: STATUS_ONLINE,
});
-
- expect(findRunnerStats().props()).toMatchObject({
- onlineRunnersCount: mockGroupRunnersCount,
- });
- });
-
- it('skips fetching count results for status that were not in filter', () => {
- expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({
- groupFullPath: mockGroupFullPath,
- tagList: ['tag1'],
- status: STATUS_OFFLINE,
- });
- expect(mockGroupRunnersCountQuery).not.toHaveBeenCalledWith({
- groupFullPath: mockGroupFullPath,
- tagList: ['tag1'],
- status: STATUS_STALE,
- });
-
- expect(findRunnerStats().props()).toMatchObject({
- offlineRunnersCount: null,
- staleRunnersCount: null,
- });
});
});
@@ -382,7 +344,7 @@ describe('GroupRunnersApp', () => {
describe('when no runners are found', () => {
beforeEach(async () => {
- mockGroupRunnersQuery = jest.fn().mockResolvedValue({
+ mockGroupRunnersHandler.mockResolvedValue({
data: {
group: {
id: '1',
@@ -390,8 +352,7 @@ describe('GroupRunnersApp', () => {
},
},
});
- createComponent();
- await waitForPromises();
+ await createComponent();
});
it('shows an empty state', async () => {
@@ -401,9 +362,8 @@ describe('GroupRunnersApp', () => {
describe('when runners query fails', () => {
beforeEach(async () => {
- mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
- createComponent();
- await waitForPromises();
+ mockGroupRunnersHandler.mockRejectedValue(new Error('Error!'));
+ await createComponent();
});
it('error is shown to the user', async () => {
@@ -420,16 +380,15 @@ describe('GroupRunnersApp', () => {
describe('Pagination', () => {
beforeEach(async () => {
- mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated);
+ mockGroupRunnersHandler.mockResolvedValue(groupRunnersDataPaginated);
- createComponent({ mountFn: mountExtended });
- await waitForPromises();
+ await createComponent({ mountFn: mountExtended });
});
it('navigates to the next page', async () => {
await findRunnerPaginationNext().trigger('click');
- expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
+ expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js
index 3368fc21544..e5472ace817 100644
--- a/spec/frontend/runner/mock_data.js
+++ b/spec/frontend/runner/mock_data.js
@@ -10,14 +10,216 @@ import runnerJobsData from 'test_fixtures/graphql/runner/show/runner_jobs.query.
import runnerFormData from 'test_fixtures/graphql/runner/edit/runner_form.query.graphql.json';
// List queries
-import runnersData from 'test_fixtures/graphql/runner/list/admin_runners.query.graphql.json';
-import runnersDataPaginated from 'test_fixtures/graphql/runner/list/admin_runners.query.graphql.paginated.json';
-import runnersCountData from 'test_fixtures/graphql/runner/list/admin_runners_count.query.graphql.json';
+import allRunnersData from 'test_fixtures/graphql/runner/list/all_runners.query.graphql.json';
+import allRunnersDataPaginated from 'test_fixtures/graphql/runner/list/all_runners.query.graphql.paginated.json';
+import runnersCountData from 'test_fixtures/graphql/runner/list/all_runners_count.query.graphql.json';
import groupRunnersData from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.json';
import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/list/group_runners.query.graphql.paginated.json';
import groupRunnersCountData from 'test_fixtures/graphql/runner/list/group_runners_count.query.graphql.json';
+import { RUNNER_PAGE_SIZE } from '~/runner/constants';
+
// Other mock data
+
+// Mock searches and their corresponding urls
+export const mockSearchExamples = [
+ {
+ name: 'a default query',
+ urlQuery: '',
+ search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' },
+ graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ isDefault: true,
+ },
+ {
+ name: 'a single status',
+ urlQuery: '?status[]=ACTIVE',
+ search: {
+ runnerType: null,
+ filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'a single term text search',
+ urlQuery: '?search=something',
+ search: {
+ runnerType: null,
+ filters: [
+ {
+ type: 'filtered-search-term',
+ value: { data: 'something' },
+ },
+ ],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'a two terms text search',
+ urlQuery: '?search=something+else',
+ search: {
+ runnerType: null,
+ filters: [
+ {
+ type: 'filtered-search-term',
+ value: { data: 'something' },
+ },
+ {
+ type: 'filtered-search-term',
+ value: { data: 'else' },
+ },
+ ],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'single instance type',
+ urlQuery: '?runner_type[]=INSTANCE_TYPE',
+ search: {
+ runnerType: 'INSTANCE_TYPE',
+ filters: [],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'multiple runner status',
+ urlQuery: '?status[]=ACTIVE&status[]=PAUSED',
+ search: {
+ runnerType: null,
+ filters: [
+ { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
+ { type: 'status', value: { data: 'PAUSED', operator: '=' } },
+ ],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'multiple status, a single instance type and a non default sort',
+ urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC',
+ search: {
+ runnerType: 'INSTANCE_TYPE',
+ filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
+ pagination: { page: 1 },
+ sort: 'CREATED_ASC',
+ },
+ graphqlVariables: {
+ status: 'ACTIVE',
+ type: 'INSTANCE_TYPE',
+ sort: 'CREATED_ASC',
+ first: RUNNER_PAGE_SIZE,
+ },
+ },
+ {
+ name: 'a tag',
+ urlQuery: '?tag[]=tag-1',
+ search: {
+ runnerType: null,
+ filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: {
+ tagList: ['tag-1'],
+ first: 20,
+ sort: 'CREATED_DESC',
+ },
+ },
+ {
+ name: 'two tags',
+ urlQuery: '?tag[]=tag-1&tag[]=tag-2',
+ search: {
+ runnerType: null,
+ filters: [
+ { type: 'tag', value: { data: 'tag-1', operator: '=' } },
+ { type: 'tag', value: { data: 'tag-2', operator: '=' } },
+ ],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: {
+ tagList: ['tag-1', 'tag-2'],
+ first: 20,
+ sort: 'CREATED_DESC',
+ },
+ },
+ {
+ name: 'the next page',
+ urlQuery: '?page=2&after=AFTER_CURSOR',
+ search: {
+ runnerType: null,
+ filters: [],
+ pagination: { page: 2, after: 'AFTER_CURSOR' },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'the previous page',
+ urlQuery: '?page=2&before=BEFORE_CURSOR',
+ search: {
+ runnerType: null,
+ filters: [],
+ pagination: { page: 2, before: 'BEFORE_CURSOR' },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'the next page filtered by a status, an instance type, tags and a non default sort',
+ urlQuery:
+ '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR',
+ search: {
+ runnerType: 'INSTANCE_TYPE',
+ filters: [
+ { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
+ { type: 'tag', value: { data: 'tag-1', operator: '=' } },
+ { type: 'tag', value: { data: 'tag-2', operator: '=' } },
+ ],
+ pagination: { page: 2, after: 'AFTER_CURSOR' },
+ sort: 'CREATED_ASC',
+ },
+ graphqlVariables: {
+ status: 'ACTIVE',
+ type: 'INSTANCE_TYPE',
+ tagList: ['tag-1', 'tag-2'],
+ sort: 'CREATED_ASC',
+ after: 'AFTER_CURSOR',
+ first: RUNNER_PAGE_SIZE,
+ },
+ },
+ {
+ name: 'paused runners',
+ urlQuery: '?paused[]=true',
+ search: {
+ runnerType: null,
+ filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'active runners',
+ urlQuery: '?paused[]=false',
+ search: {
+ runnerType: null,
+ filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
+];
+
export const onlineContactTimeoutSecs = 2 * 60 * 60;
export const staleTimeoutSecs = 7889238; // Ruby's `3.months`
@@ -25,8 +227,8 @@ export const emptyStateSvgPath = 'emptyStateSvgPath.svg';
export const emptyStateFilteredSvgPath = 'emptyStateFilteredSvgPath.svg';
export {
- runnersData,
- runnersDataPaginated,
+ allRunnersData,
+ allRunnersDataPaginated,
runnersCountData,
groupRunnersData,
groupRunnersDataPaginated,
diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js
index 1f102f86b2a..6f954143ab1 100644
--- a/spec/frontend/runner/runner_search_utils_spec.js
+++ b/spec/frontend/runner/runner_search_utils_spec.js
@@ -1,4 +1,3 @@
-import { RUNNER_PAGE_SIZE } from '~/runner/constants';
import {
searchValidator,
updateOutdatedUrl,
@@ -6,209 +5,12 @@ import {
fromSearchToUrl,
fromSearchToVariables,
isSearchFiltered,
-} from '~/runner/runner_search_utils';
+} from 'ee_else_ce/runner/runner_search_utils';
+import { mockSearchExamples } from './mock_data';
describe('search_params.js', () => {
- const examples = [
- {
- name: 'a default query',
- urlQuery: '',
- search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' },
- graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
- isDefault: true,
- },
- {
- name: 'a single status',
- urlQuery: '?status[]=ACTIVE',
- search: {
- runnerType: null,
- filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
- },
- {
- name: 'a single term text search',
- urlQuery: '?search=something',
- search: {
- runnerType: null,
- filters: [
- {
- type: 'filtered-search-term',
- value: { data: 'something' },
- },
- ],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
- },
- {
- name: 'a two terms text search',
- urlQuery: '?search=something+else',
- search: {
- runnerType: null,
- filters: [
- {
- type: 'filtered-search-term',
- value: { data: 'something' },
- },
- {
- type: 'filtered-search-term',
- value: { data: 'else' },
- },
- ],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
- },
- {
- name: 'single instance type',
- urlQuery: '?runner_type[]=INSTANCE_TYPE',
- search: {
- runnerType: 'INSTANCE_TYPE',
- filters: [],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
- },
- {
- name: 'multiple runner status',
- urlQuery: '?status[]=ACTIVE&status[]=PAUSED',
- search: {
- runnerType: null,
- filters: [
- { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
- { type: 'status', value: { data: 'PAUSED', operator: '=' } },
- ],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
- },
- {
- name: 'multiple status, a single instance type and a non default sort',
- urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC',
- search: {
- runnerType: 'INSTANCE_TYPE',
- filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
- pagination: { page: 1 },
- sort: 'CREATED_ASC',
- },
- graphqlVariables: {
- status: 'ACTIVE',
- type: 'INSTANCE_TYPE',
- sort: 'CREATED_ASC',
- first: RUNNER_PAGE_SIZE,
- },
- },
- {
- name: 'a tag',
- urlQuery: '?tag[]=tag-1',
- search: {
- runnerType: null,
- filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: {
- tagList: ['tag-1'],
- first: 20,
- sort: 'CREATED_DESC',
- },
- },
- {
- name: 'two tags',
- urlQuery: '?tag[]=tag-1&tag[]=tag-2',
- search: {
- runnerType: null,
- filters: [
- { type: 'tag', value: { data: 'tag-1', operator: '=' } },
- { type: 'tag', value: { data: 'tag-2', operator: '=' } },
- ],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: {
- tagList: ['tag-1', 'tag-2'],
- first: 20,
- sort: 'CREATED_DESC',
- },
- },
- {
- name: 'the next page',
- urlQuery: '?page=2&after=AFTER_CURSOR',
- search: {
- runnerType: null,
- filters: [],
- pagination: { page: 2, after: 'AFTER_CURSOR' },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE },
- },
- {
- name: 'the previous page',
- urlQuery: '?page=2&before=BEFORE_CURSOR',
- search: {
- runnerType: null,
- filters: [],
- pagination: { page: 2, before: 'BEFORE_CURSOR' },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE },
- },
- {
- name: 'the next page filtered by a status, an instance type, tags and a non default sort',
- urlQuery:
- '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR',
- search: {
- runnerType: 'INSTANCE_TYPE',
- filters: [
- { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
- { type: 'tag', value: { data: 'tag-1', operator: '=' } },
- { type: 'tag', value: { data: 'tag-2', operator: '=' } },
- ],
- pagination: { page: 2, after: 'AFTER_CURSOR' },
- sort: 'CREATED_ASC',
- },
- graphqlVariables: {
- status: 'ACTIVE',
- type: 'INSTANCE_TYPE',
- tagList: ['tag-1', 'tag-2'],
- sort: 'CREATED_ASC',
- after: 'AFTER_CURSOR',
- first: RUNNER_PAGE_SIZE,
- },
- },
- {
- name: 'paused runners',
- urlQuery: '?paused[]=true',
- search: {
- runnerType: null,
- filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
- },
- {
- name: 'active runners',
- urlQuery: '?paused[]=false',
- search: {
- runnerType: null,
- filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }],
- pagination: { page: 1 },
- sort: 'CREATED_DESC',
- },
- graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
- },
- ];
-
describe('searchValidator', () => {
- examples.forEach(({ name, search }) => {
+ mockSearchExamples.forEach(({ name, search }) => {
it(`Validates ${name} as a search object`, () => {
expect(searchValidator(search)).toBe(true);
});
@@ -235,7 +37,7 @@ describe('search_params.js', () => {
});
describe('fromUrlQueryToSearch', () => {
- examples.forEach(({ name, urlQuery, search }) => {
+ mockSearchExamples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a search object`, () => {
expect(fromUrlQueryToSearch(urlQuery)).toEqual(search);
});
@@ -268,7 +70,7 @@ describe('search_params.js', () => {
});
describe('fromSearchToUrl', () => {
- examples.forEach(({ name, urlQuery, search }) => {
+ mockSearchExamples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a url`, () => {
expect(fromSearchToUrl(search)).toBe(`http://test.host/${urlQuery}`);
});
@@ -295,7 +97,7 @@ describe('search_params.js', () => {
});
describe('fromSearchToVariables', () => {
- examples.forEach(({ name, graphqlVariables, search }) => {
+ mockSearchExamples.forEach(({ name, graphqlVariables, search }) => {
it(`Converts ${name} to a GraphQL query variables object`, () => {
expect(fromSearchToVariables(search)).toEqual(graphqlVariables);
});
@@ -335,7 +137,7 @@ describe('search_params.js', () => {
});
describe('isSearchFiltered', () => {
- examples.forEach(({ name, search, isDefault }) => {
+ mockSearchExamples.forEach(({ name, search, isDefault }) => {
it(`Given ${name}, evaluates to ${isDefault ? 'not ' : ''}filtered`, () => {
expect(isSearchFiltered(search)).toBe(!isDefault);
});
diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js
index de91e51924d..222cabc6a63 100644
--- a/spec/frontend/security_configuration/components/app_spec.js
+++ b/spec/frontend/security_configuration/components/app_spec.js
@@ -1,11 +1,10 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlTab, GlTabs, GlLink } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
import stubChildren from 'helpers/stub_children';
-import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue';
import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue';
import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue';
@@ -42,35 +41,57 @@ describe('App component', () => {
let wrapper;
let userCalloutDismissSpy;
- const createComponent = ({ shouldShowCallout = true, ...propsData }) => {
+ const securityFeaturesMock = [
+ {
+ name: SAST_NAME,
+ shortName: SAST_SHORT_NAME,
+ description: SAST_DESCRIPTION,
+ helpPath: SAST_HELP_PATH,
+ configurationHelpPath: SAST_CONFIG_HELP_PATH,
+ type: REPORT_TYPE_SAST,
+ available: true,
+ },
+ ];
+
+ const complianceFeaturesMock = [
+ {
+ name: LICENSE_COMPLIANCE_NAME,
+ description: LICENSE_COMPLIANCE_DESCRIPTION,
+ helpPath: LICENSE_COMPLIANCE_HELP_PATH,
+ type: REPORT_TYPE_LICENSE_COMPLIANCE,
+ configurationHelpPath: LICENSE_COMPLIANCE_HELP_PATH,
+ },
+ ];
+
+ const createComponent = ({ shouldShowCallout = true, ...propsData } = {}) => {
userCalloutDismissSpy = jest.fn();
- wrapper = extendedWrapper(
- mount(SecurityConfigurationApp, {
- propsData: {
- securityTrainingEnabled: true,
- ...propsData,
- },
- provide: {
- upgradePath,
- autoDevopsHelpPagePath,
- autoDevopsPath,
- projectFullPath,
- vulnerabilityTrainingDocsPath,
- },
- stubs: {
- ...stubChildren(SecurityConfigurationApp),
- GlLink: false,
- GlSprintf: false,
- LocalStorageSync: false,
- SectionLayout: false,
- UserCalloutDismisser: makeMockUserCalloutDismisser({
- dismiss: userCalloutDismissSpy,
- shouldShowCallout,
- }),
- },
- }),
- );
+ wrapper = mountExtended(SecurityConfigurationApp, {
+ propsData: {
+ augmentedSecurityFeatures: securityFeaturesMock,
+ augmentedComplianceFeatures: complianceFeaturesMock,
+ securityTrainingEnabled: true,
+ ...propsData,
+ },
+ provide: {
+ upgradePath,
+ autoDevopsHelpPagePath,
+ autoDevopsPath,
+ projectFullPath,
+ vulnerabilityTrainingDocsPath,
+ },
+ stubs: {
+ ...stubChildren(SecurityConfigurationApp),
+ GlLink: false,
+ GlSprintf: false,
+ LocalStorageSync: false,
+ SectionLayout: false,
+ UserCalloutDismisser: makeMockUserCalloutDismisser({
+ dismiss: userCalloutDismissSpy,
+ shouldShowCallout,
+ }),
+ },
+ });
};
const findMainHeading = () => wrapper.find('h1');
@@ -108,38 +129,13 @@ describe('App component', () => {
const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert);
const findVulnerabilityManagementTab = () => wrapper.findByTestId('vulnerability-management-tab');
- const securityFeaturesMock = [
- {
- name: SAST_NAME,
- shortName: SAST_SHORT_NAME,
- description: SAST_DESCRIPTION,
- helpPath: SAST_HELP_PATH,
- configurationHelpPath: SAST_CONFIG_HELP_PATH,
- type: REPORT_TYPE_SAST,
- available: true,
- },
- ];
-
- const complianceFeaturesMock = [
- {
- name: LICENSE_COMPLIANCE_NAME,
- description: LICENSE_COMPLIANCE_DESCRIPTION,
- helpPath: LICENSE_COMPLIANCE_HELP_PATH,
- type: REPORT_TYPE_LICENSE_COMPLIANCE,
- configurationHelpPath: LICENSE_COMPLIANCE_HELP_PATH,
- },
- ];
-
afterEach(() => {
wrapper.destroy();
});
describe('basic structure', () => {
- beforeEach(async () => {
- createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
- });
+ beforeEach(() => {
+ createComponent();
});
it('renders main-heading with correct text', () => {
@@ -199,10 +195,7 @@ describe('App component', () => {
describe('Manage via MR Error Alert', () => {
beforeEach(() => {
- createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
- });
+ createComponent();
});
describe('on initial load', () => {
@@ -238,8 +231,6 @@ describe('App component', () => {
describe('given the right props', () => {
beforeEach(() => {
createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
autoDevopsEnabled: false,
gitlabCiPresent: false,
canEnableAutoDevops: true,
@@ -261,10 +252,7 @@ describe('App component', () => {
describe('given the wrong props', () => {
beforeEach(() => {
- createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
- });
+ createComponent();
});
it('should not show AutoDevopsAlert', () => {
expect(findAutoDevopsAlert().exists()).toBe(false);
@@ -289,8 +277,6 @@ describe('App component', () => {
}
createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
autoDevopsEnabled,
});
});
@@ -348,7 +334,6 @@ describe('App component', () => {
describe('given at least one unavailable feature', () => {
beforeEach(() => {
createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)),
});
});
@@ -369,7 +354,6 @@ describe('App component', () => {
describe('given at least one unavailable feature, but banner is already dismissed', () => {
beforeEach(() => {
createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
augmentedComplianceFeatures: complianceFeaturesMock.map(makeAvailable(false)),
shouldShowCallout: false,
});
@@ -397,8 +381,6 @@ describe('App component', () => {
describe('when given latestPipelinePath props', () => {
beforeEach(() => {
createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
latestPipelinePath: 'test/path',
});
});
@@ -425,8 +407,6 @@ describe('App component', () => {
describe('given gitlabCiPresent & gitlabCiHistoryPath props', () => {
beforeEach(() => {
createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
gitlabCiPresent: true,
gitlabCiHistoryPath,
});
@@ -442,42 +422,31 @@ describe('App component', () => {
});
describe('Vulnerability management', () => {
- it('does not show tab if security training is disabled', () => {
+ const props = { securityTrainingEnabled: true };
+
+ beforeEach(async () => {
createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
- securityTrainingEnabled: false,
+ ...props,
});
-
- expect(findVulnerabilityManagementTab().exists()).toBe(false);
});
- describe('security training enabled', () => {
- beforeEach(async () => {
- createComponent({
- augmentedSecurityFeatures: securityFeaturesMock,
- augmentedComplianceFeatures: complianceFeaturesMock,
- });
- });
-
- it('shows the tab if security training is enabled', () => {
- expect(findVulnerabilityManagementTab().exists()).toBe(true);
- });
+ it('shows the tab', () => {
+ expect(findVulnerabilityManagementTab().exists()).toBe(true);
+ });
- it('renders TrainingProviderList component', () => {
- expect(findTrainingProviderList().exists()).toBe(true);
- });
+ it('renders TrainingProviderList component', () => {
+ expect(findTrainingProviderList().props()).toMatchObject(props);
+ });
- it('renders security training description', () => {
- expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription);
- });
+ it('renders security training description', () => {
+ expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription);
+ });
- it('renders link to help docs', () => {
- const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink);
+ it('renders link to help docs', () => {
+ const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink);
- expect(trainingLink.text()).toBe('Learn more about vulnerability training');
- expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath);
- });
+ expect(trainingLink.text()).toBe('Learn more about vulnerability training');
+ expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath);
});
});
});
diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js
index 309a9cd4cd6..184c16fda6e 100644
--- a/spec/frontend/security_configuration/components/training_provider_list_spec.js
+++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js
@@ -36,7 +36,6 @@ import {
testProjectPath,
testProviderIds,
testProviderName,
- tempProviderLogos,
} from '../mock_data';
Vue.use(VueApollo);
@@ -54,6 +53,31 @@ const TEST_TRAINING_PROVIDERS_ALL_ENABLED = getSecurityTrainingProvidersData({
});
const TEST_TRAINING_PROVIDERS_DEFAULT = TEST_TRAINING_PROVIDERS_ALL_DISABLED;
+const TEMP_PROVIDER_LOGOS = {
+ Kontra: {
+ svg: '<svg>Kontra</svg>',
+ },
+ 'Secure Code Warrior': {
+ svg: '<svg>Secure Code Warrior</svg>',
+ },
+};
+jest.mock('~/security_configuration/components/constants', () => {
+ return {
+ TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/components/constants')
+ .TEMP_PROVIDER_URLS,
+ // NOTE: Jest hoists all mocks to the top so we can't use TEMP_PROVIDER_LOGOS
+ // here directly.
+ TEMP_PROVIDER_LOGOS: {
+ Kontra: {
+ svg: '<svg>Kontra</svg>',
+ },
+ 'Secure Code Warrior': {
+ svg: '<svg>Secure Code Warrior</svg>',
+ },
+ },
+ };
+});
+
describe('TrainingProviderList component', () => {
let wrapper;
let apolloProvider;
@@ -76,7 +100,7 @@ describe('TrainingProviderList component', () => {
apolloProvider = createMockApollo(mergedHandlers);
};
- const createComponent = () => {
+ const createComponent = (props = {}) => {
wrapper = shallowMountExtended(TrainingProviderList, {
provide: {
projectFullPath: testProjectPath,
@@ -84,6 +108,10 @@ describe('TrainingProviderList component', () => {
directives: {
GlTooltip: createMockDirective(),
},
+ propsData: {
+ securityTrainingEnabled: true,
+ ...props,
+ },
apolloProvider,
});
};
@@ -99,6 +127,7 @@ describe('TrainingProviderList component', () => {
const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findErrorAlert = () => wrapper.findComponent(GlAlert);
const findLogos = () => wrapper.findAllByTestId('provider-logo');
+ const findUnavailableTexts = () => wrapper.findAllByTestId('unavailable-text');
const toggleFirstProvider = () => findFirstToggle().vm.$emit('change', testProviderIds[0]);
@@ -212,7 +241,6 @@ describe('TrainingProviderList component', () => {
describe('provider logo', () => {
beforeEach(async () => {
- wrapper.vm.$options.TEMP_PROVIDER_LOGOS = tempProviderLogos;
await waitForQueryToBeLoaded();
});
@@ -226,9 +254,9 @@ describe('TrainingProviderList component', () => {
expect(findLogos().at(provider).attributes('role')).toBe('presentation');
});
- it.each(providerIndexArray)('renders the svg content for provider %s', (provider) => {
+ it.each(providerIndexArray)('renders the svg content for provider %s', async (provider) => {
expect(findLogos().at(provider).html()).toContain(
- tempProviderLogos[testProviderName[provider]].svg,
+ TEMP_PROVIDER_LOGOS[testProviderName[provider]].svg,
);
});
});
@@ -351,6 +379,41 @@ describe('TrainingProviderList component', () => {
);
});
});
+
+ describe('non ultimate users', () => {
+ beforeEach(async () => {
+ createComponent({
+ securityTrainingEnabled: false,
+ });
+ await waitForQueryToBeLoaded();
+ });
+
+ it('displays unavailable text', () => {
+ findUnavailableTexts().wrappers.forEach((unavailableText) => {
+ expect(unavailableText.text()).toBe(TrainingProviderList.i18n.unavailableText);
+ });
+ });
+
+ it('has disabled state for toggle', () => {
+ findToggles().wrappers.forEach((toggle) => {
+ expect(toggle.props('disabled')).toBe(true);
+ });
+ });
+
+ it('has disabled state for radio', () => {
+ findPrimaryProviderRadios().wrappers.forEach((radio) => {
+ expect(radio.attributes('disabled')).toBeTruthy();
+ });
+ });
+
+ it('adds backgrounds color', () => {
+ findCards().wrappers.forEach((card) => {
+ expect(card.props('bodyClass')).toMatchObject({
+ 'gl-bg-gray-10': true,
+ });
+ });
+ });
+ });
});
describe('primary provider settings', () => {
@@ -442,7 +505,7 @@ describe('TrainingProviderList component', () => {
${'backend error'} | ${jest.fn().mockReturnValue(dismissUserCalloutErrorResponse)}
${'network error'} | ${jest.fn().mockRejectedValue()}
`('when dismissing the callout and a "$errorType" happens', ({ mutationHandler }) => {
- beforeEach(async () => {
+ it('logs the error to sentry', async () => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
createApolloProvider({
@@ -460,9 +523,7 @@ describe('TrainingProviderList component', () => {
await waitForQueryToBeLoaded();
toggleFirstProvider();
- });
- it('logs the error to sentry', async () => {
expect(Sentry.captureException).not.toHaveBeenCalled();
await waitForMutationToBeLoaded();
diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js
index 18a480bf082..2fe3b59cea3 100644
--- a/spec/frontend/security_configuration/mock_data.js
+++ b/spec/frontend/security_configuration/mock_data.js
@@ -100,14 +100,3 @@ export const updateSecurityTrainingProvidersErrorResponse = {
},
},
};
-
-// Will remove once this issue is resolved where the svg path will be available in the GraphQL query
-// https://gitlab.com/gitlab-org/gitlab/-/issues/346899
-export const tempProviderLogos = {
- [testProviderName[0]]: {
- svg: `<svg>${[testProviderName[0]]}</svg>`,
- },
- [testProviderName[1]]: {
- svg: `<svg>${[testProviderName[1]]}</svg>`,
- },
-};
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 62a9ff98243..11841106ed0 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
@@ -8,7 +8,7 @@ exports[`self monitor component When the self monitor project has not been creat
class="settings-header"
>
<h4
- class="js-section-header"
+ class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
>
Self monitoring
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 0b672cbc93e..e3b5478290a 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,10 +1,11 @@
import { GlModal, GlFormCheckbox } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { initEmojiMock, clearEmojiMock } from 'helpers/emoji';
import * as UserApi from '~/api/user_api';
import EmojiPicker from '~/emoji/components/picker.vue';
import createFlash from '~/flash';
+import stubChildren from 'helpers/stub_children';
import SetStatusModalWrapper, {
AVAILABILITY_STATUS,
} from '~/set_status_modal/set_status_modal_wrapper.vue';
@@ -26,12 +27,23 @@ describe('SetStatusModalWrapper', () => {
defaultEmoji,
};
+ const EmojiPickerStub = {
+ props: EmojiPicker.props,
+ template: '<div></div>',
+ };
+
const createComponent = (props = {}) => {
- return shallowMount(SetStatusModalWrapper, {
+ return mount(SetStatusModalWrapper, {
propsData: {
...defaultProps,
...props,
},
+ stubs: {
+ ...stubChildren(SetStatusModalWrapper),
+ GlFormInput: false,
+ GlFormInputGroup: false,
+ EmojiPicker: EmojiPickerStub,
+ },
mocks: {
$toast,
},
@@ -43,7 +55,7 @@ describe('SetStatusModalWrapper', () => {
const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button');
const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox);
const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]');
- const getEmojiPicker = () => wrapper.findComponent(EmojiPicker);
+ const getEmojiPicker = () => wrapper.findComponent(EmojiPickerStub);
const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => {
const modal = findModal();
@@ -88,7 +100,7 @@ describe('SetStatusModalWrapper', () => {
});
it('has a clear status button', () => {
- expect(findClearStatusButton().isVisible()).toBe(true);
+ expect(findClearStatusButton().exists()).toBe(true);
});
it('displays the clear status at dropdown', () => {
@@ -125,7 +137,7 @@ describe('SetStatusModalWrapper', () => {
});
it('hides the clear status button', () => {
- expect(findClearStatusButton().isVisible()).toBe(false);
+ expect(findClearStatusButton().exists()).toBe(false);
});
});
diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
index a286eeef14f..517b4f12559 100644
--- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
+++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js
@@ -120,6 +120,7 @@ describe('AssigneeAvatarLink component', () => {
it('passes the correct user id for REST API', () => {
createComponent({
tooltipHasName: true,
+ issuableType: 'issue',
user: userDataMock(),
});
@@ -131,9 +132,22 @@ describe('AssigneeAvatarLink component', () => {
createComponent({
tooltipHasName: true,
+ issuableType: 'issue',
user: { ...userDataMock(), id: convertToGraphQLId(TYPE_USER, userId) },
});
expect(findUserLink().attributes('data-user-id')).toBe(String(userId));
});
+
+ it.each`
+ issuableType | userId
+ ${'merge_request'} | ${undefined}
+ ${'issue'} | ${'1'}
+ `('it sets data-user-id as $userId for $issuableType', ({ issuableType, userId }) => {
+ createComponent({
+ issuableType,
+ });
+
+ expect(findUserLink().attributes('data-user-id')).toBe(userId);
+ });
});
diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
index 3ddd41c0bd4..8ebd2dabfc2 100644
--- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
+++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js
@@ -107,6 +107,7 @@ describe('SidebarDropdownWidget', () => {
currentMilestoneSpy = jest.fn().mockResolvedValue(noCurrentMilestoneResponse),
} = {}) => {
Vue.use(VueApollo);
+
mockApollo = createMockApollo([
[projectMilestonesQuery, projectMilestonesSpy],
[projectIssueMilestoneQuery, currentMilestoneSpy],
@@ -415,11 +416,9 @@ describe('SidebarDropdownWidget', () => {
describe('when currentAttribute is not equal to attribute id', () => {
describe('when update is successful', () => {
- beforeEach(() => {
+ it('calls setIssueAttribute mutation', () => {
findDropdownItemWithText(mockMilestone2.title).vm.$emit('click');
- });
- it('calls setIssueAttribute mutation', () => {
expect(milestoneMutationSpy).toHaveBeenCalledWith({
iid: mockIssue.iid,
attributeId: getIdFromGraphQLId(mockMilestone2.id),
@@ -428,6 +427,8 @@ describe('SidebarDropdownWidget', () => {
});
it('sets the value returned from the mutation to currentAttribute', async () => {
+ findDropdownItemWithText(mockMilestone2.title).vm.$emit('click');
+ await nextTick();
expect(findSelectedAttribute().text()).toBe(mockMilestone2.title);
});
});
diff --git a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
index 549ab99c6af..9a68940590d 100644
--- a/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
+++ b/spec/frontend/sidebar/components/subscriptions/sidebar_subscriptions_widget_spec.js
@@ -8,15 +8,22 @@ import createFlash from '~/flash';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import SidebarSubscriptionWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
-import { issueSubscriptionsResponse } from '../../mock_data';
+import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
+import toast from '~/vue_shared/plugins/global_toast';
+import {
+ issueSubscriptionsResponse,
+ mergeRequestSubscriptionMutationResponse,
+} from '../../mock_data';
jest.mock('~/flash');
+jest.mock('~/vue_shared/plugins/global_toast');
Vue.use(VueApollo);
describe('Sidebar Subscriptions Widget', () => {
let wrapper;
let fakeApollo;
+ let subscriptionMutationHandler;
const findEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findToggle = () => wrapper.findComponent(GlToggle);
@@ -24,18 +31,29 @@ describe('Sidebar Subscriptions Widget', () => {
const createComponent = ({
subscriptionsQueryHandler = jest.fn().mockResolvedValue(issueSubscriptionsResponse()),
+ issuableType = 'issue',
+ movedMrSidebar = false,
} = {}) => {
- fakeApollo = createMockApollo([[issueSubscribedQuery, subscriptionsQueryHandler]]);
+ subscriptionMutationHandler = jest
+ .fn()
+ .mockResolvedValue(mergeRequestSubscriptionMutationResponse);
+ fakeApollo = createMockApollo([
+ [issueSubscribedQuery, subscriptionsQueryHandler],
+ [updateMergeRequestSubscriptionMutation, subscriptionMutationHandler],
+ ]);
wrapper = shallowMount(SidebarSubscriptionWidget, {
apolloProvider: fakeApollo,
provide: {
canUpdate: true,
+ glFeatures: {
+ movedMrSidebar,
+ },
},
propsData: {
fullPath: 'group/project',
iid: '1',
- issuableType: 'issue',
+ issuableType,
},
stubs: {
SidebarEditableItem,
@@ -128,4 +146,21 @@ describe('Sidebar Subscriptions Widget', () => {
expect(createFlash).toHaveBeenCalled();
});
+
+ describe('merge request', () => {
+ it('displays toast when mutation is successful', async () => {
+ createComponent({
+ issuableType: 'merge_request',
+ movedMrSidebar: true,
+ subscriptionsQueryHandler: jest.fn().mockResolvedValue(issueSubscriptionsResponse(true)),
+ });
+ await waitForPromises();
+
+ await wrapper.find('.dropdown-item').trigger('click');
+
+ await waitForPromises();
+
+ expect(toast).toHaveBeenCalledWith('Notifications turned on.');
+ });
+ });
});
diff --git a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js
index 8478d3d674d..bb757fdf63b 100644
--- a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js
+++ b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js
@@ -1,17 +1,23 @@
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue, { nextTick } from 'vue';
+import Vuex from 'vuex';
import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { createStore as createMrStore } from '~/mr_notes/stores';
import createStore from '~/notes/stores';
import EditForm from '~/sidebar/components/lock/edit_form.vue';
import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue';
+import toast from '~/vue_shared/plugins/global_toast';
import { ISSUABLE_TYPE_ISSUE, ISSUABLE_TYPE_MR } from './constants';
+jest.mock('~/vue_shared/plugins/global_toast');
+
+Vue.use(Vuex);
+
describe('IssuableLockForm', () => {
let wrapper;
let store;
let issuableType; // Either ISSUABLE_TYPE_ISSUE or ISSUABLE_TYPE_MR
+ let updateLockedAttribute;
const setIssuableType = (pageType) => {
issuableType = pageType;
@@ -29,16 +35,27 @@ describe('IssuableLockForm', () => {
store = createStore();
store.getters.getNoteableData.targetType = 'issue';
} else {
- store = createMrStore();
+ updateLockedAttribute = jest.fn().mockResolvedValue();
+ store = new Vuex.Store({
+ getters: {
+ getNoteableData: () => ({ targetType: issuableType }),
+ },
+ actions: {
+ updateLockedAttribute,
+ },
+ });
}
store.getters.getNoteableData.discussion_locked = isLocked;
};
- const createComponent = ({ props = {} }) => {
+ const createComponent = ({ props = {} }, movedMrSidebar = false) => {
wrapper = shallowMount(IssuableLockForm, {
store,
provide: {
fullPath: '',
+ glFeatures: {
+ movedMrSidebar,
+ },
},
propsData: {
isEditable: true,
@@ -144,4 +161,24 @@ describe('IssuableLockForm', () => {
});
});
});
+
+ describe('merge requests', () => {
+ beforeEach(() => {
+ setIssuableType('merge_request');
+ });
+
+ it.each`
+ locked | message
+ ${true} | ${'Merge request locked.'}
+ ${false} | ${'Merge request unlocked.'}
+ `('displays $message when merge request is $locked', async ({ locked, message }) => {
+ initStore(locked);
+
+ createComponent({}, true);
+
+ await wrapper.find('.dropdown-item').trigger('click');
+
+ expect(toast).toHaveBeenCalledWith(message);
+ });
+ });
});
diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js
index 2b421037339..229757ff40c 100644
--- a/spec/frontend/sidebar/mock_data.js
+++ b/spec/frontend/sidebar/mock_data.js
@@ -321,6 +321,19 @@ export const issueSubscriptionsResponse = (subscribed = false, emailsDisabled =
},
});
+export const mergeRequestSubscriptionMutationResponse = {
+ data: {
+ updateIssuableSubscription: {
+ issuable: {
+ __typename: 'MergeRequest',
+ id: 'gid://gitlab/MergeRequest/4',
+ subscribed: true,
+ },
+ errors: [],
+ },
+ },
+};
+
export const issuableQueryResponse = {
data: {
workspace: {
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index 8a767765149..f49ceb2fede 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -5,6 +5,7 @@ import { merge } from 'lodash';
import VueApollo, { ApolloMutation } from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { stubPerformanceWebAPI } from 'helpers/performance';
import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
@@ -96,6 +97,8 @@ describe('Snippet Edit app', () => {
const originalRelativeUrlRoot = gon.relative_url_root;
beforeEach(() => {
+ stubPerformanceWebAPI();
+
getSpy = jest.fn().mockResolvedValue(createQueryResponse());
// See `mutateSpy` declaration comment for why we send a key
diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js
index c73bf8f80a2..b29ed97099f 100644
--- a/spec/frontend/snippets/components/show_spec.js
+++ b/spec/frontend/snippets/components/show_spec.js
@@ -12,6 +12,7 @@ import {
SNIPPET_VISIBILITY_PUBLIC,
} from '~/snippets/constants';
import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
+import { stubPerformanceWebAPI } from 'helpers/performance';
describe('Snippet view app', () => {
let wrapper;
@@ -45,6 +46,10 @@ describe('Snippet view app', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findEmbedDropdown = () => wrapper.findComponent(EmbedDropdown);
+ beforeEach(() => {
+ stubPerformanceWebAPI();
+ });
+
afterEach(() => {
wrapper.destroy();
});
diff --git a/spec/frontend/surveys/merge_request_performance/app_spec.js b/spec/frontend/surveys/merge_request_performance/app_spec.js
new file mode 100644
index 00000000000..6e8cc660b1d
--- /dev/null
+++ b/spec/frontend/surveys/merge_request_performance/app_spec.js
@@ -0,0 +1,143 @@
+import { nextTick } from 'vue';
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
+import MergeRequestExperienceSurveyApp from '~/surveys/merge_request_experience/app.vue';
+import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue';
+
+describe('MergeRequestExperienceSurveyApp', () => {
+ let trackingSpy;
+ let wrapper;
+ let dismiss;
+ let dismisserComponent;
+
+ const findCloseButton = () =>
+ wrapper
+ .findAllComponents(GlButton)
+ .filter((button) => button.attributes('aria-label') === 'Close')
+ .at(0);
+
+ const createWrapper = ({ shouldShowCallout = true } = {}) => {
+ dismiss = jest.fn();
+ dismisserComponent = makeMockUserCalloutDismisser({
+ dismiss,
+ shouldShowCallout,
+ });
+ wrapper = shallowMountExtended(MergeRequestExperienceSurveyApp, {
+ stubs: {
+ UserCalloutDismisser: dismisserComponent,
+ GlSprintf,
+ },
+ });
+ };
+
+ describe('when user callout is visible', () => {
+ beforeEach(() => {
+ createWrapper();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it('shows survey', async () => {
+ expect(wrapper.html()).toContain('Overall, how satisfied are you with merge requests?');
+ expect(wrapper.findComponent(SatisfactionRate).exists()).toBe(true);
+ expect(wrapper.emitted().close).toBe(undefined);
+ });
+
+ it('triggers user callout on close', async () => {
+ findCloseButton().vm.$emit('click');
+ expect(dismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('emits close event on close button click', async () => {
+ findCloseButton().vm.$emit('click');
+ expect(wrapper.emitted()).toMatchObject({ close: [[]] });
+ });
+
+ it('applies correct feature name for user callout', () => {
+ expect(wrapper.findComponent(dismisserComponent).props('featureName')).toBe(
+ 'mr_experience_survey',
+ );
+ });
+
+ it('dismisses user callout on survey rate', async () => {
+ const rate = wrapper.findComponent(SatisfactionRate);
+ expect(dismiss).not.toHaveBeenCalled();
+ rate.vm.$emit('rate', 5);
+ expect(dismiss).toHaveBeenCalledTimes(1);
+ });
+
+ it('steps through survey steps', async () => {
+ const rate = wrapper.findComponent(SatisfactionRate);
+ rate.vm.$emit('rate', 5);
+ await nextTick();
+ expect(wrapper.text()).toContain(
+ 'How satisfied are you with speed/performance of merge requests?',
+ );
+ });
+
+ it('tracks survey rates', async () => {
+ const rate = wrapper.findComponent(SatisfactionRate);
+ rate.vm.$emit('rate', 5);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', {
+ value: 5,
+ label: 'overall',
+ });
+ rate.vm.$emit('rate', 4);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'survey:mr_experience', {
+ value: 4,
+ label: 'performance',
+ });
+ });
+
+ it('shows legal note', async () => {
+ expect(wrapper.text()).toContain(
+ 'By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the GitLab Privacy Policy.',
+ );
+ });
+
+ it('hides legal note after first step', async () => {
+ const rate = wrapper.findComponent(SatisfactionRate);
+ rate.vm.$emit('rate', 5);
+ await nextTick();
+ expect(wrapper.text()).not.toContain(
+ 'By continuing, you acknowledge that responses will be used to improve GitLab and in accordance with the GitLab Privacy Policy.',
+ );
+ });
+
+ it('shows disappearing thanks message', async () => {
+ const rate = wrapper.findComponent(SatisfactionRate);
+ rate.vm.$emit('rate', 5);
+ await nextTick();
+ rate.vm.$emit('rate', 5);
+ await nextTick();
+ expect(wrapper.text()).toContain('Thank you for your feedback!');
+ expect(wrapper.emitted()).toMatchObject({});
+ jest.runOnlyPendingTimers();
+ expect(wrapper.emitted()).toMatchObject({ close: [[]] });
+ });
+ });
+
+ describe('when user callout is hidden', () => {
+ beforeEach(() => {
+ createWrapper({ shouldShowCallout: false });
+ });
+
+ it('emits close event', async () => {
+ expect(wrapper.emitted()).toMatchObject({ close: [[]] });
+ });
+ });
+
+ describe('when Escape key is pressed', () => {
+ beforeEach(() => {
+ createWrapper();
+ const event = new KeyboardEvent('keyup', { key: 'Escape' });
+ document.dispatchEvent(event);
+ });
+
+ it('emits close event', async () => {
+ expect(wrapper.emitted()).toMatchObject({ close: [[]] });
+ expect(dismiss).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/tabs/index_spec.js b/spec/frontend/tabs/index_spec.js
index 67e3d707adb..1d61d38a488 100644
--- a/spec/frontend/tabs/index_spec.js
+++ b/spec/frontend/tabs/index_spec.js
@@ -1,9 +1,16 @@
-import { GlTabsBehavior, TAB_SHOWN_EVENT } from '~/tabs';
+import { GlTabsBehavior, TAB_SHOWN_EVENT, HISTORY_TYPE_HASH } from '~/tabs';
import { ACTIVE_PANEL_CLASS, ACTIVE_TAB_CLASSES } from '~/tabs/constants';
+import { getLocationHash } from '~/lib/utils/url_utility';
+import { NO_SCROLL_TO_HASH_CLASS } from '~/lib/utils/common_utils';
import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import setWindowLocation from 'helpers/set_window_location_helper';
const tabsFixture = getFixture('tabs/tabs.html');
+global.CSS = {
+ escape: (val) => val,
+};
+
describe('GlTabsBehavior', () => {
let glTabs;
let tabShownEventSpy;
@@ -41,6 +48,7 @@ describe('GlTabsBehavior', () => {
});
expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(true);
+ expect(panel.classList.contains(NO_SCROLL_TO_HASH_CLASS)).toBe(true);
};
const expectInactiveTabAndPanel = (name) => {
@@ -67,6 +75,7 @@ describe('GlTabsBehavior', () => {
});
expect(panel.classList.contains(ACTIVE_PANEL_CLASS)).toBe(false);
+ expect(panel.classList.contains(NO_SCROLL_TO_HASH_CLASS)).toBe(true);
};
const expectGlTabShownEvent = (name) => {
@@ -263,4 +272,98 @@ describe('GlTabsBehavior', () => {
expectInactiveTabAndPanel('foo');
});
});
+
+ describe('using history=hash', () => {
+ const defaultTab = 'foo';
+ let tab;
+ let tabsEl;
+
+ beforeEach(() => {
+ setHTMLFixture(tabsFixture);
+ tabsEl = findByTestId('tabs');
+ });
+
+ afterEach(() => {
+ glTabs.destroy();
+ resetHTMLFixture();
+ });
+
+ describe('when a hash exists onInit', () => {
+ beforeEach(() => {
+ tab = 'bar';
+ setWindowLocation(`http://foo.com/index#${tab}`);
+ glTabs = new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH });
+ });
+
+ it('sets the active tab to the hash and preserves hash', () => {
+ expectActiveTabAndPanel(tab);
+ expect(getLocationHash()).toBe(tab);
+ });
+ });
+
+ describe('when a hash does not exist onInit', () => {
+ beforeEach(() => {
+ setWindowLocation(`http://foo.com/index`);
+ glTabs = new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH });
+ });
+
+ it('sets the active tab to the first tab and sets hash', () => {
+ expectActiveTabAndPanel(defaultTab);
+ expect(getLocationHash()).toBe(defaultTab);
+ });
+ });
+
+ describe('clicking on an inactive tab', () => {
+ beforeEach(() => {
+ tab = 'qux';
+ setWindowLocation(`http://foo.com/index`);
+ glTabs = new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH });
+
+ findTab(tab).click();
+ });
+
+ it('changes the tabs and updates the hash', () => {
+ expectInactiveTabAndPanel(defaultTab);
+ expectActiveTabAndPanel(tab);
+ expect(getLocationHash()).toBe(tab);
+ });
+ });
+
+ describe('keyboard navigation', () => {
+ const secondTab = 'bar';
+
+ beforeEach(() => {
+ setWindowLocation(`http://foo.com/index`);
+ glTabs = new GlTabsBehavior(tabsEl, { history: HISTORY_TYPE_HASH });
+ });
+
+ it.each(['ArrowRight', 'ArrowDown'])(
+ 'pressing %s moves to next tab and updates hash',
+ (code) => {
+ expectActiveTabAndPanel(defaultTab);
+
+ triggerKeyDown(code, glTabs.activeTab);
+
+ expectInactiveTabAndPanel(defaultTab);
+ expectActiveTabAndPanel(secondTab);
+ expect(getLocationHash()).toBe(secondTab);
+ },
+ );
+
+ it.each(['ArrowLeft', 'ArrowUp'])(
+ 'pressing %s moves to previous tab and updates hash',
+ (code) => {
+ // First, make the 2nd tab active
+ findTab(secondTab).click();
+ expectActiveTabAndPanel(secondTab);
+
+ triggerKeyDown(code, glTabs.activeTab);
+
+ expectInactiveTabAndPanel(secondTab);
+ expectActiveTabAndPanel(defaultTab);
+ expect(getLocationHash()).toBe(defaultTab);
+ },
+ );
+ });
+ });
});
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 6c336152e9a..b4626625f31 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -2,8 +2,6 @@
import 'helpers/shared_test_setup';
import { initializeTestTimeout } from 'helpers/timeout';
-jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils'));
-
initializeTestTimeout(process.env.CI ? 6000 : 500);
afterEach(() =>
diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js
index 08da3a9a465..4871644d99f 100644
--- a/spec/frontend/tracking/tracking_spec.js
+++ b/spec/frontend/tracking/tracking_spec.js
@@ -412,7 +412,7 @@ describe('Tracking', () => {
Tracking.setAnonymousUrls();
expect(snowplowSpy).not.toHaveBeenCalledWith('setReferrerUrl', testUrl);
- expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp);
+ expect(localStorage.getItem(URLS_CACHE_STORAGE_KEY)).not.toContain(oldTimestamp.toString());
});
});
});
diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js
index 7971906429b..e07d9c0a1b5 100644
--- a/spec/frontend/user_lists/store/edit/mutations_spec.js
+++ b/spec/frontend/user_lists/store/edit/mutations_spec.js
@@ -35,7 +35,7 @@ describe('User List Edit Mutations', () => {
});
});
- describe(types.RECIEVE_USER_LIST_ERROR, () => {
+ describe(types.RECEIVE_USER_LIST_ERROR, () => {
beforeEach(() => {
mutations[types.RECEIVE_USER_LIST_ERROR](state, ['network error']);
});
@@ -44,7 +44,7 @@ describe('User List Edit Mutations', () => {
expect(state.status).toBe(statuses.ERROR);
});
- it('sets the error message to the recieved one', () => {
+ it('sets the error message to the received one', () => {
expect(state.errorMessage).toEqual(['network error']);
});
});
diff --git a/spec/frontend/user_lists/store/new/mutations_spec.js b/spec/frontend/user_lists/store/new/mutations_spec.js
index a928849e941..647ddd9c062 100644
--- a/spec/frontend/user_lists/store/new/mutations_spec.js
+++ b/spec/frontend/user_lists/store/new/mutations_spec.js
@@ -9,7 +9,7 @@ describe('User List Edit Mutations', () => {
state = createState({ projectId: '1' });
});
- describe(types.RECIEVE_USER_LIST_ERROR, () => {
+ describe(types.RECEIVE_USER_LIST_ERROR, () => {
beforeEach(() => {
mutations[types.RECEIVE_CREATE_USER_LIST_ERROR](state, ['network error']);
});
diff --git a/spec/frontend/user_popovers_spec.js b/spec/frontend/user_popovers_spec.js
index 1544fed5240..b171c8fc9ed 100644
--- a/spec/frontend/user_popovers_spec.js
+++ b/spec/frontend/user_popovers_spec.js
@@ -12,12 +12,8 @@ jest.mock('~/api/user_api', () => ({
describe('User Popovers', () => {
const fixtureTemplate = 'merge_requests/merge_request_with_mentions.html';
- const selector = '.js-user-link, .gfm-project_member';
- const findFixtureLinks = () => {
- return Array.from(document.querySelectorAll(selector)).filter(
- ({ dataset }) => dataset.user || dataset.userId,
- );
- };
+ const selector = '.js-user-link[data-user], .js-user-link[data-user-id]';
+ const findFixtureLinks = () => Array.from(document.querySelectorAll(selector));
const createUserLink = () => {
const link = document.createElement('a');
@@ -95,6 +91,24 @@ describe('User Popovers', () => {
});
});
+ it('does not initialize the popovers for group references', async () => {
+ const [groupLink] = Array.from(document.querySelectorAll('.js-user-link[data-group]'));
+
+ triggerEvent('mouseover', groupLink);
+ jest.runOnlyPendingTimers();
+
+ expect(findPopovers().length).toBe(0);
+ });
+
+ it('does not initialize the popovers for @all references', async () => {
+ const [projectLink] = Array.from(document.querySelectorAll('.js-user-link[data-project]'));
+
+ triggerEvent('mouseover', projectLink);
+ jest.runOnlyPendingTimers();
+
+ expect(findPopovers().length).toBe(0);
+ });
+
it('does not initialize the user popovers twice for the same element', async () => {
const [firstUserLink] = findFixtureLinks();
triggerEvent('mouseover', firstUserLink);
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
index 6386746aee4..6db82cedd80 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js
@@ -2,6 +2,9 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
+import toast from '~/vue_shared/plugins/global_toast';
+
+jest.mock('~/vue_shared/plugins/global_toast');
let wrapper;
@@ -261,6 +264,7 @@ describe('Merge request widget rebase component', () => {
return Promise.resolve({
data: {
rebase_in_progress: false,
+ should_be_rebased: false,
merge_error: null,
},
});
@@ -280,6 +284,7 @@ describe('Merge request widget rebase component', () => {
await nextTick();
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetRebaseSuccess');
+ expect(toast).toHaveBeenCalledWith('Rebase completed');
});
});
});
diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js
index 8c5036e35f6..7e7438bcc0f 100644
--- a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js
+++ b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js
@@ -60,7 +60,7 @@ describe('Deployment action button', () => {
it('renders slot and icon prop correctly', () => {
expect(wrapper.find(GlIcon).exists()).toBe(true);
- expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING]);
+ expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING].toString());
});
});
diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js
index da4b990c078..5c1d3c8e8e8 100644
--- a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js
+++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js
@@ -1,4 +1,4 @@
-import { GlButton } from '@gitlab/ui';
+import { nextTick } from 'vue';
import MockAdapter from 'axios-mock-adapter';
import testReportExtension from '~/vue_merge_request_widget/extensions/test_report';
import { i18n } from '~/vue_merge_request_widget/extensions/test_report/constants';
@@ -38,7 +38,8 @@ describe('Test report extension', () => {
};
const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
- const findTertiaryButton = () => wrapper.find(GlButton);
+ const findFullReportLink = () => wrapper.findByTestId('full-report-link');
+ const findCopyFailedSpecsBtn = () => wrapper.findByTestId('copy-failed-specs-btn');
const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
const findModal = () => wrapper.find(TestCaseDetails);
@@ -72,14 +73,23 @@ describe('Test report extension', () => {
});
describe('summary', () => {
- it('displays loading text', () => {
+ it('displays loading state initially', () => {
mockApi(httpStatusCodes.OK);
createComponent();
expect(wrapper.text()).toContain(i18n.loading);
});
- it('displays failed loading text', async () => {
+ it('with a 204 response, continues to display loading state', async () => {
+ mockApi(httpStatusCodes.NO_CONTENT, '');
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toContain(i18n.loading);
+ });
+
+ it('with an error response, displays failed to load text', async () => {
mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
createComponent();
@@ -121,8 +131,57 @@ describe('Test report extension', () => {
await waitForPromises();
- expect(findTertiaryButton().text()).toBe('Full report');
- expect(findTertiaryButton().attributes('href')).toBe('pipeline/path/test_report');
+ expect(findFullReportLink().text()).toBe('Full report');
+ expect(findFullReportLink().attributes('href')).toBe('pipeline/path/test_report');
+ });
+
+ it('hides copy failed tests button when there are no failing tests', async () => {
+ mockApi(httpStatusCodes.OK);
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findCopyFailedSpecsBtn().exists()).toBe(false);
+ });
+
+ it('displays copy failed tests button when there are failing tests', async () => {
+ mockApi(httpStatusCodes.OK, newFailedTestReports);
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findCopyFailedSpecsBtn().exists()).toBe(true);
+ expect(findCopyFailedSpecsBtn().text()).toBe(i18n.copyFailedSpecs);
+ expect(findCopyFailedSpecsBtn().attributes('data-clipboard-text')).toBe(
+ 'spec/file_1.rb spec/file_2.rb',
+ );
+ });
+
+ it('copy failed tests button updates tooltip text when clicked', async () => {
+ mockApi(httpStatusCodes.OK, newFailedTestReports);
+ createComponent();
+
+ await waitForPromises();
+
+ // original tooltip shows up
+ expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({
+ title: i18n.copyFailedSpecsTooltip,
+ });
+
+ await findCopyFailedSpecsBtn().trigger('click');
+
+ // tooltip text is replaced for 1 second
+ expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({
+ title: 'Copied',
+ });
+
+ jest.runAllTimers();
+ await nextTick();
+
+ // tooltip reverts back to original string
+ expect(findCopyFailedSpecsBtn().attributes()).toMatchObject({
+ title: i18n.copyFailedSpecsTooltip,
+ });
});
it('shows an error when a suite has a parsing error', async () => {
diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/utils_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/utils_spec.js
new file mode 100644
index 00000000000..69ea70549fe
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/extensions/test_report/utils_spec.js
@@ -0,0 +1,242 @@
+import * as utils from '~/vue_merge_request_widget/extensions/test_report/utils';
+
+describe('test report widget extension utils', () => {
+ describe('summaryTextbuilder', () => {
+ it('should render text for no changed results in multiple tests', () => {
+ const name = 'Test summary';
+ const data = { total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}no%{strong_end} changed test results, %{strong_start}10%{strong_end} total tests',
+ );
+ });
+
+ it('should render text for no changed results in one test', () => {
+ const name = 'Test summary';
+ const data = { total: 1 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}no%{strong_end} changed test results, %{strong_start}1%{strong_end} total test',
+ );
+ });
+
+ it('should render text for multiple failed results', () => {
+ const name = 'Test summary';
+ const data = { failed: 3, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}3%{strong_end} failed, %{strong_start}10%{strong_end} total tests',
+ );
+ });
+
+ it('should render text for multiple errored results', () => {
+ const name = 'Test summary';
+ const data = { errored: 7, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}7%{strong_end} errors, %{strong_start}10%{strong_end} total tests',
+ );
+ });
+
+ it('should render text for multiple fixed results', () => {
+ const name = 'Test summary';
+ const data = { resolved: 4, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests',
+ );
+ });
+
+ it('should render text for multiple fixed, and multiple failed results', () => {
+ const name = 'Test summary';
+ const data = { failed: 3, resolved: 4, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}3%{strong_end} failed and %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests',
+ );
+ });
+
+ it('should render text for a singular fixed, and a singular failed result', () => {
+ const name = 'Test summary';
+ const data = { failed: 1, resolved: 1, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}1%{strong_end} failed and %{strong_start}1%{strong_end} fixed test result, %{strong_start}10%{strong_end} total tests',
+ );
+ });
+
+ it('should render text for singular failed, errored, and fixed results', () => {
+ const name = 'Test summary';
+ const data = { failed: 1, errored: 1, resolved: 1, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}1%{strong_end} failed, %{strong_start}1%{strong_end} error and %{strong_start}1%{strong_end} fixed test result, %{strong_start}10%{strong_end} total tests',
+ );
+ });
+
+ it('should render text for multiple failed, errored, and fixed results', () => {
+ const name = 'Test summary';
+ const data = { failed: 2, errored: 3, resolved: 4, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary: %{strong_start}2%{strong_end} failed, %{strong_start}3%{strong_end} errors and %{strong_start}4%{strong_end} fixed test results, %{strong_start}10%{strong_end} total tests',
+ );
+ });
+ });
+
+ describe('reportTextBuilder', () => {
+ const name = 'Rspec';
+
+ it('should render text for no changed results in multiple tests', () => {
+ const data = { name, summary: { total: 10 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: no changed test results, 10 total tests');
+ });
+
+ it('should render text for no changed results in one test', () => {
+ const data = { name, summary: { total: 1 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: no changed test results, 1 total test');
+ });
+
+ it('should render text for multiple failed results', () => {
+ const data = { name, summary: { failed: 3, total: 10 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: 3 failed, 10 total tests');
+ });
+
+ it('should render text for multiple errored results', () => {
+ const data = { name, summary: { errored: 7, total: 10 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: 7 errors, 10 total tests');
+ });
+
+ it('should render text for multiple fixed results', () => {
+ const data = { name, summary: { resolved: 4, total: 10 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: 4 fixed test results, 10 total tests');
+ });
+
+ it('should render text for multiple fixed, and multiple failed results', () => {
+ const data = { name, summary: { failed: 3, resolved: 4, total: 10 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: 3 failed and 4 fixed test results, 10 total tests');
+ });
+
+ it('should render text for a singular fixed, and a singular failed result', () => {
+ const data = { name, summary: { failed: 1, resolved: 1, total: 10 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: 1 failed and 1 fixed test result, 10 total tests');
+ });
+
+ it('should render text for singular failed, errored, and fixed results', () => {
+ const data = { name, summary: { failed: 1, errored: 1, resolved: 1, total: 10 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: 1 failed, 1 error and 1 fixed test result, 10 total tests');
+ });
+
+ it('should render text for multiple failed, errored, and fixed results', () => {
+ const data = { name, summary: { failed: 2, errored: 3, resolved: 4, total: 10 } };
+ const result = utils.reportTextBuilder(data);
+
+ expect(result).toBe('Rspec: 2 failed, 3 errors and 4 fixed test results, 10 total tests');
+ });
+ });
+
+ describe('recentFailuresTextBuilder', () => {
+ it.each`
+ recentlyFailed | failed | expected
+ ${0} | ${1} | ${''}
+ ${1} | ${1} | ${'1 out of 1 failed test has failed more than once in the last 14 days'}
+ ${1} | ${2} | ${'1 out of 2 failed tests has failed more than once in the last 14 days'}
+ ${2} | ${3} | ${'2 out of 3 failed tests have failed more than once in the last 14 days'}
+ `(
+ 'should render summary for $recentlyFailed out of $failed failures',
+ ({ recentlyFailed, failed, expected }) => {
+ const result = utils.recentFailuresTextBuilder({ recentlyFailed, failed });
+
+ expect(result).toBe(expected);
+ },
+ );
+ });
+
+ describe('countRecentlyFailedTests', () => {
+ it('counts tests with more than one recent failure in a report', () => {
+ const report = {
+ new_failures: [{ recent_failures: { count: 2 } }],
+ existing_failures: [{ recent_failures: { count: 1 } }],
+ resolved_failures: [{ recent_failures: { count: 20 } }, { recent_failures: { count: 5 } }],
+ };
+ const result = utils.countRecentlyFailedTests(report);
+
+ expect(result).toBe(3);
+ });
+
+ it('counts tests with more than one recent failure in an array of reports', () => {
+ const reports = [
+ {
+ new_failures: [{ recent_failures: { count: 2 } }],
+ existing_failures: [
+ { recent_failures: { count: 20 } },
+ { recent_failures: { count: 5 } },
+ ],
+ resolved_failures: [{ recent_failures: { count: 2 } }],
+ },
+ {
+ new_failures: [{ recent_failures: { count: 8 } }, { recent_failures: { count: 14 } }],
+ existing_failures: [{ recent_failures: { count: 1 } }],
+ resolved_failures: [{ recent_failures: { count: 7 } }, { recent_failures: { count: 5 } }],
+ },
+ ];
+ const result = utils.countRecentlyFailedTests(reports);
+
+ expect(result).toBe(8);
+ });
+
+ it.each([
+ [],
+ {},
+ null,
+ undefined,
+ { new_failures: undefined },
+ [{ existing_failures: null }],
+ { resolved_failures: [{}] },
+ [{ new_failures: [{ recent_failures: {} }] }],
+ ])('returns 0 when subject is %s', (subject) => {
+ const result = utils.countRecentlyFailedTests(subject);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe('formatFilePath', () => {
+ it.each`
+ file | expected
+ ${'./test.js'} | ${'test.js'}
+ ${'/test.js'} | ${'test.js'}
+ ${'.//////////////test.js'} | ${'test.js'}
+ ${'test.js'} | ${'test.js'}
+ ${'mock/path./test.js'} | ${'mock/path./test.js'}
+ ${'./mock/path./test.js'} | ${'mock/path./test.js'}
+ `('should format $file to be $expected', ({ file, expected }) => {
+ expect(utils.formatFilePath(file)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js
index 6d1b3bb34a5..a06ad930abe 100644
--- a/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js
+++ b/spec/frontend/vue_mr_widget/extentions/accessibility/index_spec.js
@@ -62,7 +62,7 @@ describe('Accessibility extension', () => {
expect(wrapper.text()).toBe('Accessibility scanning failed loading results');
});
- it('displays detected errors', async () => {
+ it('displays detected errors and is expandable', async () => {
mockApi(httpStatusCodes.OK, accessibilityReportResponseErrors);
createComponent();
@@ -72,9 +72,10 @@ describe('Accessibility extension', () => {
expect(wrapper.text()).toBe(
'Accessibility scanning detected 8 issues for the source branch only',
);
+ expect(findToggleCollapsedButton().exists()).toBe(true);
});
- it('displays no detected errors', async () => {
+ it('displays no detected errors and is not expandable', async () => {
mockApi(httpStatusCodes.OK, accessibilityReportResponseSuccess);
createComponent();
@@ -84,6 +85,7 @@ describe('Accessibility extension', () => {
expect(wrapper.text()).toBe(
'Accessibility scanning detected no issues for the source branch only',
);
+ expect(findToggleCollapsedButton().exists()).toBe(false);
});
});
diff --git a/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js
index 77b3576a3d3..d9faa7b2d25 100644
--- a/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js
+++ b/spec/frontend/vue_mr_widget/extentions/terraform/index_spec.js
@@ -142,11 +142,11 @@ describe('Terraform extension', () => {
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_terraform_click_full_report',
+ 'i_code_review_merge_request_widget_terraform_click_full_report',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_terraform_count_click_full_report',
+ 'i_code_review_merge_request_widget_terraform_count_click_full_report',
);
});
});
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 6abbb052aef..b3af5eba364 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -32,6 +32,7 @@ import {
fullReportExtension,
noTelemetryExtension,
pollingExtension,
+ pollingFullDataExtension,
pollingErrorExtension,
multiPollingExtension,
} from './test_extensions';
@@ -42,6 +43,13 @@ jest.mock('~/smart_interval');
jest.mock('~/lib/utils/favicon');
+jest.mock('@sentry/browser', () => ({
+ setExtra: jest.fn(),
+ setExtras: jest.fn(),
+ captureMessage: jest.fn(),
+ captureException: jest.fn(),
+}));
+
Vue.use(VueApollo);
describe('MrWidgetOptions', () => {
@@ -66,24 +74,16 @@ describe('MrWidgetOptions', () => {
afterEach(() => {
mock.restore();
wrapper.destroy();
- wrapper = null;
gl.mrWidgetData = {};
gon.features = {};
});
- const createComponent = (mrData = mockData, options = {}, glFeatures = {}) => {
- if (wrapper) {
- wrapper.destroy();
- }
-
+ const createComponent = (mrData = mockData, options = {}) => {
wrapper = mount(MrWidgetOptions, {
propsData: {
mrData: { ...mrData },
},
- provide: {
- glFeatures,
- },
...options,
});
@@ -521,7 +521,7 @@ describe('MrWidgetOptions', () => {
describe('rendering relatedLinks', () => {
beforeEach(() => {
- createComponent({
+ return createComponent({
...mockData,
issues_links: {
closing: `
@@ -531,8 +531,10 @@ describe('MrWidgetOptions', () => {
`,
},
});
+ });
- return nextTick();
+ afterEach(() => {
+ wrapper.destroy();
});
it('renders if there are relatedLinks', () => {
@@ -875,8 +877,8 @@ describe('MrWidgetOptions', () => {
});
describe('given feature flag is enabled', () => {
- beforeEach(() => {
- createComponent();
+ beforeEach(async () => {
+ await createComponent();
wrapper.vm.mr.hasCI = false;
});
@@ -905,6 +907,19 @@ describe('MrWidgetOptions', () => {
});
});
+ describe('merge error', () => {
+ it.each`
+ state | show | showText
+ ${'closed'} | ${false} | ${'hides'}
+ ${'merged'} | ${true} | ${'shows'}
+ ${'open'} | ${true} | ${'shows'}
+ `('it $showText merge error when state is $state', ({ state, show }) => {
+ createComponent({ ...mockData, state, merge_error: 'Error!' });
+
+ expect(wrapper.find('[data-testid="merge_error"]').exists()).toBe(show);
+ });
+ });
+
describe('mock extension', () => {
let pollRequest;
@@ -917,8 +932,6 @@ describe('MrWidgetOptions', () => {
});
afterEach(() => {
- pollRequest.mockRestore();
-
registeredExtensions.extensions = [];
});
@@ -970,16 +983,14 @@ describe('MrWidgetOptions', () => {
describe('expansion', () => {
it('hides collapse button', async () => {
registerExtension(workingExtension(false));
- createComponent();
- await waitForPromises();
+ await createComponent();
expect(findExtensionToggleButton().exists()).toBe(false);
});
it('shows collapse button', async () => {
registerExtension(workingExtension(true));
- createComponent();
- await waitForPromises();
+ await createComponent();
expect(findExtensionToggleButton().exists()).toBe(true);
});
@@ -997,17 +1008,7 @@ describe('MrWidgetOptions', () => {
});
afterEach(() => {
- pollRequest.mockRestore();
-
registeredExtensions.extensions = [];
-
- // Clear all left-over timeouts that may be registered in the poll class
- let id = window.setTimeout(() => {}, 0);
-
- while (id > 0) {
- window.clearTimeout(id);
- id -= 1;
- }
});
describe('success - multi polling', () => {
@@ -1058,87 +1059,81 @@ describe('MrWidgetOptions', () => {
describe('success', () => {
it('does not make additional requests after poll is successful', async () => {
registerExtension(pollingExtension);
+
await createComponent();
- // called two times due to parent component polling (mount) and extension polling
- expect(pollRequest).toHaveBeenCalledTimes(2);
+
+ expect(pollRequest).toHaveBeenCalledTimes(6);
});
+ });
+
+ describe('success - full data polling', () => {
+ it('sets data when polling is complete', async () => {
+ registerExtension(pollingFullDataExtension);
- it('keeps polling when poll-interval header is provided', async () => {
- registerExtension({
- ...pollingExtension,
- methods: {
- ...pollingExtension.methods,
- fetchCollapsedData() {
- return Promise.resolve({
- data: {},
- headers: { 'poll-interval': 1 },
- status: 204,
- });
- },
- },
- });
await createComponent();
- expect(findWidgetTestExtension().html()).toContain('Test extension loading...');
+
+ api.trackRedisHllUserEvent.mockClear();
+ api.trackRedisCounterEvent.mockClear();
+
+ findExtensionToggleButton().trigger('click');
+
+ // The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions
+ expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2);
+ expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
+ 'i_code_review_merge_request_widget_test_extension_expand',
+ );
+ expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
+ 'i_code_review_merge_request_widget_test_extension_expand_warning',
+ );
+ expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2);
+ expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
+ 'i_code_review_merge_request_widget_test_extension_count_expand',
+ );
+ expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
+ 'i_code_review_merge_request_widget_test_extension_count_expand_warning',
+ );
});
});
describe('error', () => {
- let captureException;
-
- beforeEach(() => {
- captureException = jest.spyOn(Sentry, 'captureException');
-
+ it('does not make additional requests after poll has failed', async () => {
registerExtension(pollingErrorExtension);
+ await createComponent();
- createComponent();
+ expect(pollRequest).toHaveBeenCalledTimes(6);
});
- it('does not make additional requests after poll has failed', () => {
- // called two times due to parent component polling (mount) and extension polling
- expect(pollRequest).toHaveBeenCalledTimes(2);
- });
+ it('captures sentry error and displays error when poll has failed', async () => {
+ registerExtension(pollingErrorExtension);
+ await createComponent();
- it('captures sentry error and displays error when poll has failed', () => {
- expect(captureException).toHaveBeenCalledTimes(1);
- expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
+ expect(Sentry.captureException).toHaveBeenCalledTimes(5);
+ expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
});
});
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('failed');
- };
-
- beforeEach(() => {
- captureException = jest.spyOn(Sentry, 'captureException');
- });
-
afterEach(() => {
registeredExtensions.extensions = [];
- captureException = null;
});
it('handles collapsed data fetch errors', async () => {
registerExtension(collapsedDataErrorExtension);
- createComponent();
- await waitForPromises();
+ await createComponent();
expect(
wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]').exists(),
).toBe(false);
- itHandlesTheException();
+ expect(Sentry.captureException).toHaveBeenCalledTimes(5);
+ expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
+ expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
it('handles full data fetch errors', async () => {
registerExtension(fullDataErrorExtension);
- createComponent();
- await waitForPromises();
+ await createComponent();
expect(wrapper.findComponent(StatusIcon).props('iconName')).not.toBe('error');
wrapper
@@ -1148,7 +1143,9 @@ describe('MrWidgetOptions', () => {
await nextTick();
await waitForPromises();
- itHandlesTheException();
+ expect(Sentry.captureException).toHaveBeenCalledTimes(1);
+ expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Fetch error'));
+ expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
});
@@ -1163,11 +1160,11 @@ describe('MrWidgetOptions', () => {
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_test_extension_view',
+ 'i_code_review_merge_request_widget_test_extension_view',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_test_extension_count_view',
+ 'i_code_review_merge_request_widget_test_extension_count_view',
);
});
@@ -1186,17 +1183,17 @@ describe('MrWidgetOptions', () => {
// The default working extension is a "warning" type, which generates a second - more specific - telemetry event for expansions
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(2);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_test_extension_expand',
+ 'i_code_review_merge_request_widget_test_extension_expand',
);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_test_extension_expand_warning',
+ 'i_code_review_merge_request_widget_test_extension_expand_warning',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(2);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_test_extension_count_expand',
+ 'i_code_review_merge_request_widget_test_extension_count_expand',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_test_extension_count_expand_warning',
+ 'i_code_review_merge_request_widget_test_extension_count_expand_warning',
);
});
@@ -1239,11 +1236,11 @@ describe('MrWidgetOptions', () => {
expect(api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisHllUserEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_test_extension_click_full_report',
+ 'i_code_review_merge_request_widget_test_extension_click_full_report',
);
expect(api.trackRedisCounterEvent).toHaveBeenCalledTimes(1);
expect(api.trackRedisCounterEvent).toHaveBeenCalledWith(
- 'i_merge_request_widget_test_extension_count_click_full_report',
+ 'i_code_review_merge_request_widget_test_extension_count_click_full_report',
);
});
diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js
index 76644e0be77..1977f550577 100644
--- a/spec/frontend/vue_mr_widget/test_extensions.js
+++ b/spec/frontend/vue_mr_widget/test_extensions.js
@@ -109,6 +109,39 @@ export const pollingExtension = {
enablePolling: true,
};
+export const pollingFullDataExtension = {
+ ...workingExtension(),
+ enableExpandedPolling: true,
+ methods: {
+ fetchCollapsedData({ targetProjectFullPath }) {
+ return Promise.resolve({ targetProjectFullPath, count: 1 });
+ },
+ fetchFullData() {
+ return Promise.resolve([
+ {
+ headers: { 'poll-interval': 0 },
+ status: 200,
+ data: {
+ 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 fullReportExtension = {
...workingExtension(),
computed: {
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js
index 93b59800c27..441e21ee905 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js
@@ -84,15 +84,15 @@ describe('LabelsSelectRoot', () => {
});
describe('if the variant is `sidebar`', () => {
- beforeEach(() => {
+ it('renders SidebarEditableItem component', () => {
createComponent();
- });
- it('renders SidebarEditableItem component', () => {
expect(findSidebarEditableItem().exists()).toBe(true);
});
it('renders correct props for the SidebarEditableItem component', () => {
+ createComponent();
+
expect(findSidebarEditableItem().props()).toMatchObject({
title: wrapper.vm.$options.i18n.widgetTitle,
canEdit: defaultProps.allowEdit,
@@ -135,7 +135,7 @@ describe('LabelsSelectRoot', () => {
it('handles DropdownContents setColor', () => {
findDropdownContents().vm.$emit('setColor', color);
- expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]);
+ expect(wrapper.emitted('updateSelectedColor')).toEqual([[{ color }]]);
});
});
@@ -157,20 +157,24 @@ describe('LabelsSelectRoot', () => {
createComponent({ propsData: { iid: undefined } });
findDropdownContents().vm.$emit('setColor', color);
- expect(wrapper.emitted('updateSelectedColor')).toEqual([[color]]);
+ expect(wrapper.emitted('updateSelectedColor')).toEqual([[{ color }]]);
});
describe('when updating color for epic', () => {
- beforeEach(() => {
+ const setup = () => {
createComponent();
findDropdownContents().vm.$emit('setColor', color);
- });
+ };
it('sets the loading state', () => {
+ setup();
+
expect(findSidebarEditableItem().props('loading')).toBe(true);
});
it('updates color correctly after successful mutation', async () => {
+ setup();
+
await waitForPromises();
expect(findDropdownValue().props('selectedColor').color).toEqual(
updateColorMutationResponse.data.updateIssuableColor.issuable.color,
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js
index 74f50b878e2..ee4d3a2630a 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_contents_spec.js
@@ -1,57 +1,30 @@
-import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
+import { GlDropdown } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { DROPDOWN_VARIANT } from '~/vue_shared/components/color_select_dropdown/constants';
import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue';
import DropdownContentsColorView from '~/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue';
+import DropdownHeader from '~/vue_shared/components/color_select_dropdown/dropdown_header.vue';
import { color } from './mock_data';
-const showDropdown = jest.fn();
-const focusInput = jest.fn();
-
const defaultProps = {
dropdownTitle: '',
selectedColor: color,
- dropdownButtonText: '',
+ dropdownButtonText: 'Pick a color',
variant: '',
isVisible: false,
};
-const GlDropdownStub = {
- template: `
- <div>
- <slot name="header"></slot>
- <slot></slot>
- </div>
- `,
- methods: {
- show: showDropdown,
- hide: jest.fn(),
- },
-};
-
-const DropdownHeaderStub = {
- template: `
- <div>Hello, I am a header</div>
- `,
- methods: {
- focusInput,
- },
-};
-
describe('DropdownContent', () => {
let wrapper;
const createComponent = ({ propsData = {} } = {}) => {
- wrapper = shallowMount(DropdownContents, {
+ wrapper = mountExtended(DropdownContents, {
propsData: {
...defaultProps,
...propsData,
},
- stubs: {
- GlDropdown: GlDropdownStub,
- DropdownHeader: DropdownHeaderStub,
- },
});
};
@@ -60,16 +33,17 @@ describe('DropdownContent', () => {
});
const findColorView = () => wrapper.findComponent(DropdownContentsColorView);
- const findDropdownHeader = () => wrapper.findComponent(DropdownHeaderStub);
- const findDropdown = () => wrapper.findComponent(GlDropdownStub);
+ const findDropdownHeader = () => wrapper.findComponent(DropdownHeader);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
it('calls dropdown `show` method on `isVisible` prop change', async () => {
createComponent();
+ const spy = jest.spyOn(wrapper.vm.$refs.dropdown, 'show');
await wrapper.setProps({
isVisible: true,
});
- expect(showDropdown).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledTimes(1);
});
it('does not emit `setColor` event on dropdown hide if color did not change', () => {
@@ -110,4 +84,12 @@ describe('DropdownContent', () => {
expect(findDropdownHeader().exists()).toBe(true);
});
+
+ it('handles no selected color', () => {
+ createComponent({ propsData: { selectedColor: {} } });
+
+ expect(wrapper.findByTestId('fallback-button-text').text()).toEqual(
+ defaultProps.dropdownButtonText,
+ );
+ });
});
diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js
index f22592dd604..5bbdb136353 100644
--- a/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js
+++ b/spec/frontend/vue_shared/components/color_select_dropdown/dropdown_value_spec.js
@@ -33,7 +33,7 @@ describe('DropdownValue', () => {
it.each`
index | cssClass
- ${0} | ${['gl-font-base', 'gl-line-height-24']}
+ ${0} | ${[]}
${1} | ${['hide-collapsed']}
`(
'passes correct props to the ColorItem with CSS class `$cssClass`',
diff --git a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
index e3d8bfd22ca..79001b9282f 100644
--- a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
+++ b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js
@@ -1,7 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import DeployBoardInstance from '~/vue_shared/components/deployment_instance.vue';
-import { folder } from './mock_data';
describe('Deploy Board Instance', () => {
let wrapper;
@@ -13,7 +12,6 @@ describe('Deploy Board Instance', () => {
...props,
},
provide: {
- glFeatures: { monitorLogging: true },
...provide,
},
});
@@ -25,7 +23,6 @@ describe('Deploy Board Instance', () => {
it('should render a div with the correct css status and tooltip data', () => {
wrapper = createComponent({
- logsPath: folder.logs_path,
tooltipText: 'This is a pod',
});
@@ -43,17 +40,6 @@ describe('Deploy Board Instance', () => {
expect(wrapper.classes('deployment-instance-deploying')).toBe(true);
expect(wrapper.attributes('title')).toEqual('');
});
-
- it('should have a log path computed with a pod name as a parameter', () => {
- wrapper = createComponent({
- logsPath: folder.logs_path,
- podName: 'tanuki-1',
- });
-
- expect(wrapper.vm.computedLogPath).toEqual(
- '/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1',
- );
- });
});
describe('as a canary deployment', () => {
@@ -76,46 +62,10 @@ describe('Deploy Board Instance', () => {
wrapper.destroy();
});
- it('should not be a link without a logsPath prop', async () => {
- wrapper = createComponent({
- stable: false,
- logsPath: '',
- });
-
- await nextTick();
- expect(wrapper.vm.computedLogPath).toBeNull();
- expect(wrapper.vm.isLink).toBeFalsy();
- });
-
- it('should render a link without href if path is not passed', () => {
- wrapper = createComponent();
-
- expect(wrapper.attributes('href')).toBeUndefined();
- });
-
it('should not have a tooltip', () => {
wrapper = createComponent();
expect(wrapper.attributes('title')).toEqual('');
});
});
-
- describe(':monitor_logging feature flag', () => {
- afterEach(() => {
- wrapper.destroy();
- });
-
- it.each`
- flagState | logsState | expected
- ${true} | ${'shows'} | ${'/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1'}
- ${false} | ${'hides'} | ${undefined}
- `('$logsState log link when flag state is $flagState', async ({ flagState, expected }) => {
- wrapper = createComponent(
- { logsPath: folder.logs_path, podName: 'tanuki-1' },
- { glFeatures: { monitorLogging: flagState } },
- );
-
- expect(wrapper.attributes('href')).toEqual(expected);
- });
- });
});
diff --git a/spec/frontend/vue_shared/components/deployment_instance/mock_data.js b/spec/frontend/vue_shared/components/deployment_instance/mock_data.js
index 6618c57948c..098787cd1b4 100644
--- a/spec/frontend/vue_shared/components/deployment_instance/mock_data.js
+++ b/spec/frontend/vue_shared/components/deployment_instance/mock_data.js
@@ -140,5 +140,4 @@ export const folder = {
created_at: '2017-02-01T19:42:18.400Z',
updated_at: '2017-02-01T19:42:18.400Z',
rollout_status: {},
- logs_path: '/root/review-app/-/logs?environment_name=foo',
};
diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
index d0fa8b8dacb..16f924b44d8 100644
--- a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js
@@ -1,11 +1,9 @@
import { mount } from '@vue/test-utils';
-import Vue, { nextTick } from 'vue';
-import { compileToFunctions } from 'vue-template-compiler';
-
+import { nextTick } from 'vue';
import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
-import imageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue';
+import ImageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue';
-describe('ImageDiffViewer', () => {
+describe('ImageDiffViewer component', () => {
const requiredProps = {
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
@@ -17,15 +15,12 @@ describe('ImageDiffViewer', () => {
newSize: 1024,
};
let wrapper;
- let vm;
- function createComponent(props) {
- const ImageDiffViewer = Vue.extend(imageDiffViewer);
- wrapper = mount(ImageDiffViewer, { propsData: props });
- vm = wrapper.vm;
- }
+ const createComponent = (props, slots) => {
+ wrapper = mount(ImageDiffViewer, { propsData: props, slots });
+ };
- const triggerEvent = (eventName, el = vm.$el, clientX = 0) => {
+ const triggerEvent = (eventName, el = wrapper.$el, clientX = 0) => {
const event = new MouseEvent(eventName, {
bubbles: true,
cancelable: true,
@@ -51,128 +46,76 @@ describe('ImageDiffViewer', () => {
wrapper.destroy();
});
- it('renders image diff for replaced', async () => {
- createComponent({ ...allProps });
-
- await nextTick();
- const metaInfoElements = vm.$el.querySelectorAll('.image-info');
-
- expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL);
-
- expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL);
-
- expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('2-up');
- expect(vm.$el.querySelector('.view-modes-menu li:nth-child(2)').textContent.trim()).toBe(
- 'Swipe',
- );
-
- expect(vm.$el.querySelector('.view-modes-menu li:nth-child(3)').textContent.trim()).toBe(
- 'Onion skin',
- );
-
- expect(metaInfoElements.length).toBe(2);
- expect(metaInfoElements[0]).toHaveText('2.00 KiB');
- expect(metaInfoElements[1]).toHaveText('1.00 KiB');
+ it('renders image diff for replaced', () => {
+ createComponent(allProps);
+ const metaInfoElements = wrapper.findAll('.image-info');
+
+ expect(wrapper.find('.added img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL);
+ expect(wrapper.find('.deleted img').attributes('src')).toBe(RED_BOX_IMAGE_URL);
+ expect(wrapper.find('.view-modes-menu li.active').text()).toBe('2-up');
+ expect(wrapper.find('.view-modes-menu li:nth-child(2)').text()).toBe('Swipe');
+ expect(wrapper.find('.view-modes-menu li:nth-child(3)').text()).toBe('Onion skin');
+ expect(metaInfoElements).toHaveLength(2);
+ expect(metaInfoElements.at(0).text()).toBe('2.00 KiB');
+ expect(metaInfoElements.at(1).text()).toBe('1.00 KiB');
});
- it('renders image diff for new', async () => {
+ it('renders image diff for new', () => {
createComponent({ ...allProps, diffMode: 'new', oldPath: '' });
- await nextTick();
-
- const metaInfoElement = vm.$el.querySelector('.image-info');
-
- expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL);
- expect(metaInfoElement).toHaveText('1.00 KiB');
+ expect(wrapper.find('.added img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL);
+ expect(wrapper.find('.image-info').text()).toBe('1.00 KiB');
});
- it('renders image diff for deleted', async () => {
+ it('renders image diff for deleted', () => {
createComponent({ ...allProps, diffMode: 'deleted', newPath: '' });
- await nextTick();
-
- const metaInfoElement = vm.$el.querySelector('.image-info');
-
- expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe(RED_BOX_IMAGE_URL);
- expect(metaInfoElement).toHaveText('2.00 KiB');
+ expect(wrapper.find('.deleted img').attributes('src')).toBe(RED_BOX_IMAGE_URL);
+ expect(wrapper.find('.image-info').text()).toBe('2.00 KiB');
});
- it('renders image diff for renamed', async () => {
- vm = new Vue({
- components: {
- imageDiffViewer,
- },
- data() {
- return {
- ...allProps,
- diffMode: 'renamed',
- };
- },
- ...compileToFunctions(`
- <image-diff-viewer
- :diff-mode="diffMode"
- :new-path="newPath"
- :old-path="oldPath"
- :new-size="newSize"
- :old-size="oldSize"
- >
- <template #image-overlay>
- <span class="overlay">test</span>
- </template>
- </image-diff-viewer>
- `),
- }).$mount();
-
- await nextTick();
-
- const metaInfoElement = vm.$el.querySelector('.image-info');
-
- expect(vm.$el.querySelector('img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL);
- expect(vm.$el.querySelector('.overlay')).not.toBe(null);
-
- expect(metaInfoElement).toHaveText('2.00 KiB');
+ it('renders image diff for renamed', () => {
+ createComponent(
+ { ...allProps, diffMode: 'renamed' },
+ { 'image-overlay': '<span class="overlay">test</span>' },
+ );
+
+ expect(wrapper.find('img').attributes('src')).toBe(GREEN_BOX_IMAGE_URL);
+ expect(wrapper.find('.overlay').exists()).toBe(true);
+ expect(wrapper.find('.image-info').text()).toBe('2.00 KiB');
});
describe('swipeMode', () => {
beforeEach(() => {
- createComponent({ ...requiredProps });
-
- return nextTick();
+ createComponent(requiredProps);
});
it('switches to Swipe Mode', async () => {
- vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click();
+ await wrapper.find('.view-modes-menu li:nth-child(2)').trigger('click');
- await nextTick();
- expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('Swipe');
+ expect(wrapper.find('.view-modes-menu li.active').text()).toBe('Swipe');
});
});
describe('onionSkin', () => {
beforeEach(() => {
createComponent({ ...requiredProps });
-
- return nextTick();
});
it('switches to Onion Skin Mode', async () => {
- vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
+ await wrapper.find('.view-modes-menu li:nth-child(3)').trigger('click');
- await nextTick();
- expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe(
- 'Onion skin',
- );
+ expect(wrapper.find('.view-modes-menu li.active').text()).toBe('Onion skin');
});
it('has working drag handler', async () => {
- vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
+ await wrapper.find('.view-modes-menu li:nth-child(3)').trigger('click');
+ dragSlider(wrapper.find('.dragger').element, document, 20);
await nextTick();
- dragSlider(vm.$el.querySelector('.dragger'), document, 20);
- await nextTick();
- expect(vm.$el.querySelector('.dragger').style.left).toBe('20px');
- expect(vm.$el.querySelector('.added.frame').style.opacity).toBe('0.2');
+ expect(wrapper.find('.dragger').attributes('style')).toBe('left: 20px;');
+ expect(wrapper.find('.added.frame').attributes('style')).toBe('opacity: 0.2;');
});
});
});
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 e3e2ef5610d..86d1f21fd04 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
@@ -8,6 +8,8 @@ import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji
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 CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue';
+import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue';
export const mockAuthor1 = {
id: 1,
@@ -62,6 +64,128 @@ export const mockMilestones = [
mockEscapedMilestone,
];
+export const mockCrmContacts = [
+ {
+ id: 'gid://gitlab/CustomerRelations::Contact/1',
+ firstName: 'John',
+ lastName: 'Smith',
+ email: 'john@smith.com',
+ },
+ {
+ id: 'gid://gitlab/CustomerRelations::Contact/2',
+ firstName: 'Andy',
+ lastName: 'Green',
+ email: 'andy@green.net',
+ },
+];
+
+export const mockCrmOrganizations = [
+ {
+ id: 'gid://gitlab/CustomerRelations::Organization/1',
+ name: 'First Org Ltd.',
+ },
+ {
+ id: 'gid://gitlab/CustomerRelations::Organization/2',
+ name: 'Organizer S.p.a.',
+ },
+];
+
+export const mockProjectCrmContactsQueryResponse = {
+ data: {
+ project: {
+ __typename: 'Project',
+ id: 1,
+ group: {
+ __typename: 'Group',
+ id: 1,
+ contacts: {
+ __typename: 'CustomerRelationsContactConnection',
+ nodes: [
+ {
+ __typename: 'CustomerRelationsContact',
+ ...mockCrmContacts[0],
+ },
+ {
+ __typename: 'CustomerRelationsContact',
+ ...mockCrmContacts[1],
+ },
+ ],
+ },
+ },
+ },
+ },
+};
+
+export const mockProjectCrmOrganizationsQueryResponse = {
+ data: {
+ project: {
+ __typename: 'Project',
+ id: 1,
+ group: {
+ __typename: 'Group',
+ id: 1,
+ organizations: {
+ __typename: 'CustomerRelationsOrganizationConnection',
+ nodes: [
+ {
+ __typename: 'CustomerRelationsOrganization',
+ ...mockCrmOrganizations[0],
+ },
+ {
+ __typename: 'CustomerRelationsOrganization',
+ ...mockCrmOrganizations[1],
+ },
+ ],
+ },
+ },
+ },
+ },
+};
+
+export const mockGroupCrmContactsQueryResponse = {
+ data: {
+ group: {
+ __typename: 'Group',
+ id: 1,
+ contacts: {
+ __typename: 'CustomerRelationsContactConnection',
+ nodes: [
+ {
+ __typename: 'CustomerRelationsContact',
+ ...mockCrmContacts[0],
+ },
+ {
+ __typename: 'CustomerRelationsContact',
+ ...mockCrmContacts[1],
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const mockGroupCrmOrganizationsQueryResponse = {
+ data: {
+ group: {
+ __typename: 'Group',
+ id: 1,
+ organizations: {
+ __typename: 'CustomerRelationsOrganizationConnection',
+ nodes: [
+ {
+ __typename: 'CustomerRelationsOrganization',
+ ...mockCrmOrganizations[0],
+ },
+ {
+ __typename: 'CustomerRelationsOrganization',
+ ...mockCrmOrganizations[1],
+ },
+ ],
+ },
+ },
+ },
+};
+
export const mockEmoji1 = {
name: 'thumbsup',
};
@@ -134,6 +258,28 @@ export const mockReactionEmojiToken = {
fetchEmojis: () => Promise.resolve(mockEmojis),
};
+export const mockCrmContactToken = {
+ type: 'crm_contact',
+ title: 'Contact',
+ icon: 'user',
+ token: CrmContactToken,
+ isProject: false,
+ fullPath: 'group',
+ operators: OPERATOR_IS_ONLY,
+ unique: true,
+};
+
+export const mockCrmOrganizationToken = {
+ type: 'crm_contact',
+ title: 'Organization',
+ icon: 'user',
+ token: CrmOrganizationToken,
+ isProject: false,
+ fullPath: 'group',
+ operators: OPERATOR_IS_ONLY,
+ unique: true,
+};
+
export const mockMembershipToken = {
type: 'with_inherited_permissions',
icon: 'group',
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 ca8cd419d87..a0126c2bd63 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
@@ -418,8 +418,6 @@ describe('BaseToken', () => {
});
it('does not emit `fetch-suggestions` event on component after a delay when component emits `input` event', async () => {
- jest.useFakeTimers();
-
findGlFilteredSearchToken().vm.$emit('input', { data: 'foo' });
await nextTick();
@@ -437,8 +435,6 @@ describe('BaseToken', () => {
});
it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => {
- jest.useFakeTimers();
-
findGlFilteredSearchToken().vm.$emit('input', { data: 'foo' });
await nextTick();
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
new file mode 100644
index 00000000000..157e021fc60
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
@@ -0,0 +1,283 @@
+import {
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchTokenSegment,
+ GlDropdownDivider,
+} from '@gitlab/ui';
+import { mount } 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 { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue';
+import searchCrmContactsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql';
+
+import {
+ mockCrmContacts,
+ mockCrmContactToken,
+ mockGroupCrmContactsQueryResponse,
+ mockProjectCrmContactsQueryResponse,
+} from '../mock_data';
+
+jest.mock('~/flash');
+
+const defaultStubs = {
+ Portal: true,
+ BaseToken,
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+};
+
+describe('CrmContactToken', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+ let fakeApollo;
+
+ const getBaseToken = () => wrapper.findComponent(BaseToken);
+
+ const searchGroupCrmContactsQueryHandler = jest
+ .fn()
+ .mockResolvedValue(mockGroupCrmContactsQueryResponse);
+ const searchProjectCrmContactsQueryHandler = jest
+ .fn()
+ .mockResolvedValue(mockProjectCrmContactsQueryResponse);
+
+ const mountComponent = ({
+ config = mockCrmContactToken,
+ value = { data: '' },
+ active = false,
+ stubs = defaultStubs,
+ listeners = {},
+ queryHandler = searchGroupCrmContactsQueryHandler,
+ } = {}) => {
+ fakeApollo = createMockApollo([[searchCrmContactsQuery, queryHandler]]);
+
+ wrapper = mount(CrmContactToken, {
+ propsData: {
+ config,
+ value,
+ active,
+ cursorPosition: 'start',
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: () => 'custom-class',
+ },
+ stubs,
+ listeners,
+ apolloProvider: fakeApollo,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ describe('methods', () => {
+ describe('fetchContacts', () => {
+ describe('for groups', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('calls the apollo query providing the searchString when search term is a string', async () => {
+ getBaseToken().vm.$emit('fetch-suggestions', 'foo');
+ await waitForPromises();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'group',
+ isProject: false,
+ searchString: 'foo',
+ searchIds: null,
+ });
+ expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts);
+ });
+
+ it('calls the apollo query providing the searchId when search term is a number', async () => {
+ getBaseToken().vm.$emit('fetch-suggestions', '5');
+ await waitForPromises();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'group',
+ isProject: false,
+ searchString: null,
+ searchIds: ['gid://gitlab/CustomerRelations::Contact/5'],
+ });
+ expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts);
+ });
+ });
+
+ describe('for projects', () => {
+ beforeEach(() => {
+ mountComponent({
+ config: {
+ fullPath: 'project',
+ isProject: true,
+ },
+ queryHandler: searchProjectCrmContactsQueryHandler,
+ });
+ });
+
+ it('calls the apollo query providing the searchString when search term is a string', async () => {
+ getBaseToken().vm.$emit('fetch-suggestions', 'foo');
+ await waitForPromises();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'project',
+ isProject: true,
+ searchString: 'foo',
+ searchIds: null,
+ });
+ expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts);
+ });
+
+ it('calls the apollo query providing the searchId when search term is a number', async () => {
+ getBaseToken().vm.$emit('fetch-suggestions', '5');
+ await waitForPromises();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'project',
+ isProject: true,
+ searchString: null,
+ searchIds: ['gid://gitlab/CustomerRelations::Contact/5'],
+ });
+ expect(getBaseToken().props('suggestions')).toEqual(mockCrmContacts);
+ });
+ });
+
+ it('calls `createFlash` with flash error message when request fails', async () => {
+ mountComponent();
+
+ jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({});
+
+ getBaseToken().vm.$emit('fetch-suggestions');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem fetching CRM contacts.',
+ });
+ });
+
+ it('sets `loading` to false when request completes', async () => {
+ mountComponent();
+
+ jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({});
+
+ getBaseToken().vm.$emit('fetch-suggestions');
+
+ await waitForPromises();
+
+ expect(getBaseToken().props('suggestionsLoading')).toBe(false);
+ });
+ });
+ });
+
+ describe('template', () => {
+ const defaultContacts = DEFAULT_NONE_ANY;
+
+ it('renders base-token component', () => {
+ mountComponent({
+ config: { ...mockCrmContactToken, initialContacts: mockCrmContacts },
+ value: { data: '1' },
+ });
+
+ const baseTokenEl = wrapper.find(BaseToken);
+
+ expect(baseTokenEl.exists()).toBe(true);
+ expect(baseTokenEl.props()).toMatchObject({
+ suggestions: mockCrmContacts,
+ getActiveTokenValue: wrapper.vm.getActiveContact,
+ });
+ });
+
+ it.each(mockCrmContacts)('renders token item when value is selected', (contact) => {
+ mountComponent({
+ config: { ...mockCrmContactToken, initialContacts: mockCrmContacts },
+ value: { data: `${getIdFromGraphQLId(contact.id)}` },
+ });
+
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+
+ expect(tokenSegments).toHaveLength(3); // Contact, =, Contact name
+ expect(tokenSegments.at(2).text()).toBe(`${contact.firstName} ${contact.lastName}`); // Contact name
+ });
+
+ it('renders provided defaultContacts as suggestions', async () => {
+ mountComponent({
+ active: true,
+ config: { ...mockCrmContactToken, defaultContacts },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(defaultContacts.length);
+ defaultContacts.forEach((contact, index) => {
+ expect(suggestions.at(index).text()).toBe(contact.text);
+ });
+ });
+
+ it('does not render divider when no defaultContacts', async () => {
+ mountComponent({
+ active: true,
+ config: { ...mockCrmContactToken, defaultContacts: [] },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await nextTick();
+
+ expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false);
+ expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
+ });
+
+ it('renders `DEFAULT_NONE_ANY` as default suggestions', () => {
+ mountComponent({
+ active: true,
+ config: { ...mockCrmContactToken },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length);
+ DEFAULT_NONE_ANY.forEach((contact, index) => {
+ expect(suggestions.at(index).text()).toBe(contact.text);
+ });
+ });
+
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ mountComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
new file mode 100644
index 00000000000..977f8bbef61
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
@@ -0,0 +1,282 @@
+import {
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchTokenSegment,
+ GlDropdownDivider,
+} from '@gitlab/ui';
+import { mount } 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 { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue';
+import searchCrmOrganizationsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql';
+
+import {
+ mockCrmOrganizations,
+ mockCrmOrganizationToken,
+ mockGroupCrmOrganizationsQueryResponse,
+ mockProjectCrmOrganizationsQueryResponse,
+} from '../mock_data';
+
+jest.mock('~/flash');
+
+const defaultStubs = {
+ Portal: true,
+ BaseToken,
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+};
+
+describe('CrmOrganizationToken', () => {
+ Vue.use(VueApollo);
+
+ let wrapper;
+ let fakeApollo;
+
+ const getBaseToken = () => wrapper.findComponent(BaseToken);
+
+ const searchGroupCrmOrganizationsQueryHandler = jest
+ .fn()
+ .mockResolvedValue(mockGroupCrmOrganizationsQueryResponse);
+ const searchProjectCrmOrganizationsQueryHandler = jest
+ .fn()
+ .mockResolvedValue(mockProjectCrmOrganizationsQueryResponse);
+
+ const mountComponent = ({
+ config = mockCrmOrganizationToken,
+ value = { data: '' },
+ active = false,
+ stubs = defaultStubs,
+ listeners = {},
+ queryHandler = searchGroupCrmOrganizationsQueryHandler,
+ } = {}) => {
+ fakeApollo = createMockApollo([[searchCrmOrganizationsQuery, queryHandler]]);
+ wrapper = mount(CrmOrganizationToken, {
+ propsData: {
+ config,
+ value,
+ active,
+ cursorPosition: 'start',
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: () => 'custom-class',
+ },
+ stubs,
+ listeners,
+ apolloProvider: fakeApollo,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ describe('methods', () => {
+ describe('fetchOrganizations', () => {
+ describe('for groups', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('calls the apollo query providing the searchString when search term is a string', async () => {
+ getBaseToken().vm.$emit('fetch-suggestions', 'foo');
+ await waitForPromises();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'group',
+ isProject: false,
+ searchString: 'foo',
+ searchIds: null,
+ });
+ expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations);
+ });
+
+ it('calls the apollo query providing the searchId when search term is a number', async () => {
+ getBaseToken().vm.$emit('fetch-suggestions', '5');
+ await waitForPromises();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'group',
+ isProject: false,
+ searchString: null,
+ searchIds: ['gid://gitlab/CustomerRelations::Organization/5'],
+ });
+ expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations);
+ });
+ });
+
+ describe('for projects', () => {
+ beforeEach(() => {
+ mountComponent({
+ config: {
+ fullPath: 'project',
+ isProject: true,
+ },
+ queryHandler: searchProjectCrmOrganizationsQueryHandler,
+ });
+ });
+
+ it('calls the apollo query providing the searchString when search term is a string', async () => {
+ getBaseToken().vm.$emit('fetch-suggestions', 'foo');
+ await waitForPromises();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'project',
+ isProject: true,
+ searchString: 'foo',
+ searchIds: null,
+ });
+ expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations);
+ });
+
+ it('calls the apollo query providing the searchId when search term is a number', async () => {
+ getBaseToken().vm.$emit('fetch-suggestions', '5');
+ await waitForPromises();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({
+ fullPath: 'project',
+ isProject: true,
+ searchString: null,
+ searchIds: ['gid://gitlab/CustomerRelations::Organization/5'],
+ });
+ expect(getBaseToken().props('suggestions')).toEqual(mockCrmOrganizations);
+ });
+ });
+
+ it('calls `createFlash` with flash error message when request fails', async () => {
+ mountComponent();
+
+ jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({});
+
+ getBaseToken().vm.$emit('fetch-suggestions');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem fetching CRM organizations.',
+ });
+ });
+
+ it('sets `loading` to false when request completes', async () => {
+ mountComponent();
+
+ jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({});
+
+ getBaseToken().vm.$emit('fetch-suggestions');
+
+ await waitForPromises();
+
+ expect(getBaseToken().props('suggestionsLoading')).toBe(false);
+ });
+ });
+ });
+
+ describe('template', () => {
+ const defaultOrganizations = DEFAULT_NONE_ANY;
+
+ it('renders base-token component', () => {
+ mountComponent({
+ config: { ...mockCrmOrganizationToken, initialOrganizations: mockCrmOrganizations },
+ value: { data: '1' },
+ });
+
+ const baseTokenEl = wrapper.find(BaseToken);
+
+ expect(baseTokenEl.exists()).toBe(true);
+ expect(baseTokenEl.props()).toMatchObject({
+ suggestions: mockCrmOrganizations,
+ getActiveTokenValue: wrapper.vm.getActiveOrganization,
+ });
+ });
+
+ it.each(mockCrmOrganizations)('renders token item when value is selected', (organization) => {
+ mountComponent({
+ config: { ...mockCrmOrganizationToken, initialOrganizations: mockCrmOrganizations },
+ value: { data: `${getIdFromGraphQLId(organization.id)}` },
+ });
+
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+
+ expect(tokenSegments).toHaveLength(3); // Organization, =, Organization name
+ expect(tokenSegments.at(2).text()).toBe(organization.name); // Organization name
+ });
+
+ it('renders provided defaultOrganizations as suggestions', async () => {
+ mountComponent({
+ active: true,
+ config: { ...mockCrmOrganizationToken, defaultOrganizations },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await nextTick();
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(defaultOrganizations.length);
+ defaultOrganizations.forEach((organization, index) => {
+ expect(suggestions.at(index).text()).toBe(organization.text);
+ });
+ });
+
+ it('does not render divider when no defaultOrganizations', async () => {
+ mountComponent({
+ active: true,
+ config: { ...mockCrmOrganizationToken, defaultOrganizations: [] },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+ await nextTick();
+
+ expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false);
+ expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
+ });
+
+ it('renders `DEFAULT_NONE_ANY` as default suggestions', () => {
+ mountComponent({
+ active: true,
+ config: { ...mockCrmOrganizationToken },
+ stubs: { Portal: true },
+ });
+ const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
+ const suggestionsSegment = tokenSegments.at(2);
+ suggestionsSegment.vm.$emit('activate');
+
+ const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
+
+ expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length);
+ DEFAULT_NONE_ANY.forEach((organization, index) => {
+ expect(suggestions.at(index).text()).toBe(organization.text);
+ });
+ });
+
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ mountComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index b3376f26a25..85a135d2b89 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -67,11 +67,6 @@ describe('Markdown field component', () => {
enablePreview,
restrictedToolBarItems,
},
- provide: {
- glFeatures: {
- contactsAutocomplete: true,
- },
- },
},
);
}
diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap
index 5e956d66b6a..bf6c8e8c704 100644
--- a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap
+++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap
@@ -7,16 +7,19 @@ exports[`Issue placeholder note component matches snapshot 1`] = `
<div
class="timeline-icon"
>
- <user-avatar-link-stub
- imgalt=""
- imgcssclasses=""
- imgsize="40"
- imgsrc="mock_path"
- linkhref="/root"
- tooltipplacement="top"
- tooltiptext=""
- username=""
- />
+ <gl-avatar-link-stub
+ class="gl-mr-3"
+ href="/root"
+ >
+ <gl-avatar-stub
+ alt="Root"
+ entityid="0"
+ entityname="root"
+ shape="circle"
+ size="[object Object]"
+ src="mock_path"
+ />
+ </gl-avatar-link-stub>
</div>
<div
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 6881cb79740..f951cfd5cd9 100644
--- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
+++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
@@ -1,8 +1,8 @@
import { shallowMount } from '@vue/test-utils';
+import { GlAvatar } from '@gitlab/ui';
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 'jest/notes/mock_data';
Vue.use(Vuex);
@@ -56,14 +56,14 @@ describe('Issue placeholder note component', () => {
describe('avatar size', () => {
it.each`
- size | line | isOverviewTab
- ${40} | ${null} | ${false}
- ${24} | ${{ line_code: '123' }} | ${false}
- ${40} | ${{ line_code: '123' }} | ${true}
+ size | line | isOverviewTab
+ ${{ default: 24, md: 32 }} | ${null} | ${false}
+ ${24} | ${{ line_code: '123' }} | ${false}
+ ${{ default: 24, md: 32 }} | ${{ 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);
+ expect(wrapper.findComponent(GlAvatar).props('size')).toEqual(size);
});
});
});
diff --git a/spec/frontend/vue_shared/components/page_size_selector_spec.js b/spec/frontend/vue_shared/components/page_size_selector_spec.js
new file mode 100644
index 00000000000..5ec0b863afd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/page_size_selector_spec.js
@@ -0,0 +1,44 @@
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PageSizeSelector, { PAGE_SIZES } from '~/vue_shared/components/page_size_selector.vue';
+
+describe('Page size selector component', () => {
+ let wrapper;
+
+ const createWrapper = ({ pageSize = 20 } = {}) => {
+ wrapper = shallowMount(PageSizeSelector, {
+ propsData: { value: pageSize },
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each(PAGE_SIZES)('shows expected text in the dropdown button for page size %s', (pageSize) => {
+ createWrapper({ pageSize });
+
+ expect(findDropdown().props('text')).toBe(`Show ${pageSize} items`);
+ });
+
+ it('shows the expected dropdown items', () => {
+ createWrapper();
+
+ PAGE_SIZES.forEach((pageSize, index) => {
+ expect(findDropdownItems().at(index).text()).toBe(`Show ${pageSize} items`);
+ });
+ });
+
+ it('will emit the new page size when a dropdown item is clicked', () => {
+ createWrapper();
+
+ findDropdownItems().wrappers.forEach((itemWrapper, index) => {
+ itemWrapper.vm.$emit('click');
+
+ expect(wrapper.emitted('input')[index][0]).toBe(PAGE_SIZES[index]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
index 8270ff31574..51a936c0509 100644
--- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
@@ -195,7 +195,7 @@ describe('AlertManagementEmptyState', () => {
tabs.forEach((tab, i) => {
const status = ITEMS_STATUS_TABS[i].status.toLowerCase();
expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status);
- expect(badges.at(i).text()).toContain(itemsCount[status]);
+ expect(badges.at(i).text()).toContain(itemsCount[status].toString());
});
});
});
diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
index 8ff49271eb5..2ea8985b16a 100644
--- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
+++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
@@ -42,6 +42,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
>
<gl-accordion-item-stub
class="gl-font-weight-normal"
+ headerclass=""
title="More Details"
titlevisible="Less Details"
>
@@ -76,6 +77,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
>
<gl-accordion-item-stub
class="gl-font-weight-normal"
+ headerclass=""
title="More Details"
titlevisible="Less Details"
>
@@ -110,6 +112,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
>
<gl-accordion-item-stub
class="gl-font-weight-normal"
+ headerclass=""
title="More Details"
titlevisible="Less Details"
>
@@ -144,6 +147,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
>
<gl-accordion-item-stub
class="gl-font-weight-normal"
+ headerclass=""
title="More Details"
titlevisible="Less Details"
>
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 7173abe1316..a38dcd626f4 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
@@ -79,7 +79,7 @@ describe('RunnerInstructionsModal component', () => {
}
};
- beforeEach(async () => {
+ beforeEach(() => {
runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms);
runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions);
});
@@ -259,11 +259,11 @@ describe('RunnerInstructionsModal component', () => {
});
describe('when apollo is loading', () => {
- beforeEach(() => {
+ it('should show a skeleton loader', async () => {
createComponent();
- });
+ await nextTick();
+ await nextTick();
- it('should show a skeleton loader', async () => {
expect(findSkeletonLoader().exists()).toBe(true);
expect(findGlLoadingIcon().exists()).toBe(false);
@@ -275,6 +275,8 @@ describe('RunnerInstructionsModal component', () => {
});
it('once loaded, should not show a loading state', async () => {
+ createComponent();
+
await waitForPromises();
expect(findSkeletonLoader().exists()).toBe(false);
diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js
new file mode 100644
index 00000000000..3036ce43888
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js
@@ -0,0 +1,14 @@
+import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker';
+import linkDependencies from '~/vue_shared/components/source_viewer/plugins/link_dependencies';
+import { PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT } from './mock_data';
+
+jest.mock('~/vue_shared/components/source_viewer/plugins/utils/package_json_linker');
+
+describe('Highlight.js plugin for linking dependencies', () => {
+ const hljsResultMock = { value: 'test' };
+
+ it('calls packageJsonLinker for package_json file types', () => {
+ linkDependencies(hljsResultMock, PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT);
+ expect(packageJsonLinker).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js
new file mode 100644
index 00000000000..75659770e2c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js
@@ -0,0 +1,2 @@
+export const PACKAGE_JSON_FILE_TYPE = 'package_json';
+export const PACKAGE_JSON_CONTENT = '{ "dependencies": { "@babel/core": "^7.18.5" } }';
diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js
new file mode 100644
index 00000000000..ee200747af9
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js
@@ -0,0 +1,33 @@
+import {
+ createLink,
+ generateHLJSOpenTag,
+} from '~/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util';
+
+describe('createLink', () => {
+ it('generates a link with the correct attributes', () => {
+ const href = 'http://test.com';
+ const innerText = 'testing';
+ const result = `<a href="${href}" rel="nofollow noreferrer noopener">${innerText}</a>`;
+
+ expect(createLink(href, innerText)).toBe(result);
+ });
+
+ it('escapes the user-controlled content', () => {
+ const unescapedXSS = '<script>XSS</script>';
+ const escapedXSS = '&amp;lt;script&amp;gt;XSS&amp;lt;/script&amp;gt;';
+ const href = `http://test.com/${unescapedXSS}`;
+ const innerText = `testing${unescapedXSS}`;
+ const result = `<a href="http://test.com/${escapedXSS}" rel="nofollow noreferrer noopener">testing${escapedXSS}</a>`;
+
+ expect(createLink(href, innerText)).toBe(result);
+ });
+});
+
+describe('generateHLJSOpenTag', () => {
+ it('generates an open tag with the correct selector', () => {
+ const type = 'string';
+ const result = `<span class="hljs-${type}">&quot;`;
+
+ expect(generateHLJSOpenTag(type)).toBe(result);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js
new file mode 100644
index 00000000000..e83c129818c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js
@@ -0,0 +1,15 @@
+import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker';
+import { PACKAGE_JSON_CONTENT } from '../mock_data';
+
+describe('Highlight.js plugin for linking package.json dependencies', () => {
+ it('mutates the input value by wrapping dependency names and versions in anchors', () => {
+ const inputValue =
+ '<span class="hljs-attr">&quot;@babel/core&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;^7.18.5&quot;</span>';
+ const outputValue =
+ '<span class="hljs-attr">&quot;<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">@babel/core</a>&quot;</span>: <span class="hljs-attr">&quot;<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">^7.18.5</a>&quot;</span>';
+ const hljsResultMock = { value: inputValue };
+
+ const output = packageJsonLinker(hljsResultMock, PACKAGE_JSON_CONTENT);
+ expect(output).toBe(outputValue);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
index bb0945a1f3e..2c03b7aa7d3 100644
--- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
@@ -5,10 +5,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
import { registerPlugins } from '~/vue_shared/components/source_viewer/plugins/index';
import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
-import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants';
+import {
+ EVENT_ACTION,
+ EVENT_LABEL_VIEWER,
+ EVENT_LABEL_FALLBACK,
+ ROUGE_TO_HLJS_LANGUAGE_MAP,
+} from '~/vue_shared/components/source_viewer/constants';
import waitForPromises from 'helpers/wait_for_promises';
import LineHighlighter from '~/blob/line_highlighter';
import eventHub from '~/notes/event_hub';
+import Tracking from '~/tracking';
jest.mock('~/blob/line_highlighter');
jest.mock('highlight.js/lib/core');
@@ -34,7 +40,8 @@ describe('Source Viewer component', () => {
const chunk2 = generateContent('// Some source code 2', 70);
const content = chunk1 + chunk2;
const path = 'some/path.js';
- const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path };
+ const fileType = 'javascript';
+ const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path, fileType };
const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
const createComponent = async (blob = {}) => {
@@ -52,17 +59,38 @@ describe('Source Viewer component', () => {
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
jest.spyOn(eventHub, '$emit');
+ jest.spyOn(Tracking, 'event');
return createComponent();
});
afterEach(() => wrapper.destroy());
+ describe('event tracking', () => {
+ it('fires a tracking event when the component is created', () => {
+ const eventData = { label: EVENT_LABEL_VIEWER, property: language };
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
+ });
+
+ it('does not emit an error event when the language is supported', () => {
+ expect(wrapper.emitted('error')).toBeUndefined();
+ });
+
+ it('fires a tracking event and emits an error when the language is not supported', () => {
+ const unsupportedLanguage = 'apex';
+ const eventData = { label: EVENT_LABEL_FALLBACK, property: unsupportedLanguage };
+ createComponent({ language: unsupportedLanguage });
+
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, EVENT_ACTION, eventData);
+ expect(wrapper.emitted('error')).toHaveLength(1);
+ });
+ });
+
describe('highlight.js', () => {
beforeEach(() => createComponent({ language: mappedLanguage }));
it('registers our plugins for Highlight.js', () => {
- expect(registerPlugins).toHaveBeenCalledWith(hljs);
+ expect(registerPlugins).toHaveBeenCalledWith(hljs, fileType, content);
});
it('registers the language definition', async () => {
@@ -74,6 +102,13 @@ describe('Source Viewer component', () => {
);
});
+ it('registers json language definition if fileType is package_json', async () => {
+ await createComponent({ language: 'json', fileType: 'package_json' });
+ const languageDefinition = await import(`highlight.js/lib/languages/json`);
+
+ expect(hljs.registerLanguage).toHaveBeenCalledWith('json', languageDefinition.default);
+ });
+
it('highlights the first chunk', () => {
expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage });
});
diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
index a54f3450633..9550368eefc 100644
--- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
+++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
@@ -2,7 +2,6 @@ import { GlSkeletonLoader, GlIcon } from '@gitlab/ui';
import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
-import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
@@ -48,7 +47,6 @@ describe('User Popover Component', () => {
const findUserStatus = () => wrapper.findByTestId('user-popover-status');
const findTarget = () => document.querySelector('.js-user-link');
- const findUserName = () => wrapper.find(UserNameWithStatus);
const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link');
const findUserLocalTime = () => wrapper.findByTestId('user-popover-local-time');
const findToggleFollowButton = () => wrapper.findByTestId('toggle-follow-button');
@@ -245,9 +243,7 @@ describe('User Popover Component', () => {
createWrapper({ user });
- expect(findUserName().exists()).toBe(true);
- expect(wrapper.text()).toContain(user.name);
- expect(wrapper.text()).toContain('(Busy)');
+ expect(wrapper.findByText('(Busy)').exists()).toBe(true);
});
it('should hide the busy status for any other status', () => {
@@ -258,13 +254,32 @@ describe('User Popover Component', () => {
createWrapper({ user });
- expect(wrapper.text()).not.toContain('(Busy)');
+ expect(wrapper.findByText('(Busy)').exists()).toBe(false);
});
- it('passes `pronouns` prop to `UserNameWithStatus` component', () => {
+ it('shows pronouns when user has them set', () => {
createWrapper();
- expect(findUserName().props('pronouns')).toBe('they/them');
+ expect(wrapper.findByText('(they/them)').exists()).toBe(true);
+ });
+
+ describe.each`
+ pronouns
+ ${undefined}
+ ${null}
+ ${''}
+ ${' '}
+ `('when pronouns are set to $pronouns', ({ pronouns }) => {
+ it('does not render pronouns', () => {
+ const user = {
+ ...DEFAULT_PROPS.user,
+ pronouns,
+ };
+
+ createWrapper({ user });
+
+ expect(wrapper.findByTestId('user-popover-pronouns').exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
index 70017903079..80f14dffd08 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js
@@ -39,6 +39,8 @@ describe('IssuableItem', () => {
const originalUrl = gon.gitlab_url;
let wrapper;
+ const findTimestampWrapper = () => wrapper.find('[data-testid="issuable-timestamp"]');
+
beforeEach(() => {
gon.gitlab_url = MOCK_GITLAB_URL;
});
@@ -150,12 +152,54 @@ describe('IssuableItem', () => {
});
});
- describe('updatedAt', () => {
- it('returns string containing timeago string based on `issuable.updatedAt`', () => {
+ describe('timestamp', () => {
+ it('returns timestamp based on `issuable.updatedAt` when the issue is open', () => {
wrapper = createComponent();
- expect(wrapper.vm.updatedAt).toContain('updated');
- expect(wrapper.vm.updatedAt).toContain('ago');
+ expect(findTimestampWrapper().attributes('title')).toBe('Sep 10, 2020 11:41am UTC');
+ });
+
+ it('returns timestamp based on `issuable.closedAt` when the issue is closed', () => {
+ wrapper = createComponent({
+ issuable: { ...mockIssuable, closedAt: '2020-06-18T11:30:00Z', state: 'closed' },
+ });
+
+ expect(findTimestampWrapper().attributes('title')).toBe('Jun 18, 2020 11:30am UTC');
+ });
+
+ it('returns timestamp based on `issuable.updatedAt` when the issue is closed but `issuable.closedAt` is undefined', () => {
+ wrapper = createComponent({
+ issuable: { ...mockIssuable, closedAt: undefined, state: 'closed' },
+ });
+
+ expect(findTimestampWrapper().attributes('title')).toBe('Sep 10, 2020 11:41am UTC');
+ });
+ });
+
+ describe('formattedTimestamp', () => {
+ it('returns timeago string based on `issuable.updatedAt` when the issue is open', () => {
+ wrapper = createComponent();
+
+ expect(findTimestampWrapper().text()).toContain('updated');
+ expect(findTimestampWrapper().text()).toContain('ago');
+ });
+
+ it('returns timeago string based on `issuable.closedAt` when the issue is closed', () => {
+ wrapper = createComponent({
+ issuable: { ...mockIssuable, closedAt: '2020-06-18T11:30:00Z', state: 'closed' },
+ });
+
+ expect(findTimestampWrapper().text()).toContain('closed');
+ expect(findTimestampWrapper().text()).toContain('ago');
+ });
+
+ it('returns timeago string based on `issuable.updatedAt` when the issue is closed but `issuable.closedAt` is undefined', () => {
+ wrapper = createComponent({
+ issuable: { ...mockIssuable, closedAt: undefined, state: 'closed' },
+ });
+
+ expect(findTimestampWrapper().text()).toContain('updated');
+ expect(findTimestampWrapper().text()).toContain('ago');
});
});
@@ -456,18 +500,31 @@ describe('IssuableItem', () => {
it('renders issuable updatedAt info', () => {
wrapper = createComponent();
- const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]');
+ const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]');
- expect(updatedAtEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC');
- expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt);
+ expect(timestampEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC');
+ expect(timestampEl.text()).toBe(wrapper.vm.formattedTimestamp);
});
describe('when issuable is closed', () => {
it('renders issuable card with a closed style', () => {
- wrapper = createComponent({ issuable: { ...mockIssuable, closedAt: '2020-12-10' } });
+ wrapper = createComponent({
+ issuable: { ...mockIssuable, closedAt: '2020-12-10', state: 'closed' },
+ });
expect(wrapper.classes()).toContain('closed');
});
+
+ it('renders issuable closedAt info and does not render updatedAt info', () => {
+ wrapper = createComponent({
+ issuable: { ...mockIssuable, closedAt: '2022-06-18T11:30:00Z', state: 'closed' },
+ });
+
+ const timestampEl = wrapper.find('[data-testid="issuable-timestamp"]');
+
+ expect(timestampEl.attributes('title')).toBe('Jun 18, 2022 11:30am UTC');
+ expect(timestampEl.text()).toBe(wrapper.vm.formattedTimestamp);
+ });
});
describe('when issuable was created within the past 24 hours', () => {
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
index 66f71c0b028..50e79dbe589 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
@@ -9,6 +9,7 @@ import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vu
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 PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
import { mockIssuableListProps, mockIssuables } from '../mock_data';
@@ -44,6 +45,7 @@ describe('IssuableListRoot', () => {
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
const findVueDraggable = () => wrapper.findComponent(VueDraggable);
+ const findPageSizeSelector = () => wrapper.findComponent(PageSizeSelector);
afterEach(() => {
wrapper.destroy();
@@ -292,6 +294,7 @@ describe('IssuableListRoot', () => {
});
expect(findGlKeysetPagination().exists()).toBe(false);
+ expect(findPageSizeSelector().exists()).toBe(false);
expect(findGlPagination().props()).toMatchObject({
perPage: 20,
value: 1,
@@ -483,4 +486,24 @@ describe('IssuableListRoot', () => {
});
});
});
+
+ describe('page size selector', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ props: {
+ showPageSizeChangeControls: true,
+ },
+ });
+ });
+
+ it('has the page size change component', async () => {
+ expect(findPageSizeSelector().exists()).toBe(true);
+ });
+
+ it('emits "page-size-change" event when its input is changed', () => {
+ const pageSize = 123;
+ findPageSizeSelector().vm.$emit('input', pageSize);
+ expect(wrapper.emitted('page-size-change')).toEqual([[pageSize]]);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
index 5aa67667033..6f62fb77353 100644
--- a/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
+++ b/spec/frontend/vue_shared/issuable/show/components/issuable_title_spec.js
@@ -70,7 +70,7 @@ describe('IssuableTitle', () => {
expect(titleEl.exists()).toBe(true);
expect(titleEl.html()).toBe(
- '<h1 dir="auto" data-testid="title" class="title qa-title gl-font-size-h-display"><b>Sample</b> title</h1>',
+ '<h1 dir="auto" data-qa-selector="title_content" data-testid="title" class="title gl-font-size-h-display"><b>Sample</b> title</h1>',
);
wrapperWithTitle.destroy();
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
index 2c3f6ef8634..a55f448c9a2 100644
--- a/spec/frontend/work_items/components/item_title_spec.js
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -1,5 +1,4 @@
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));
@@ -51,6 +50,5 @@ describe('ItemTitle', () => {
await findInputEl().trigger(sourceEvent);
expect(wrapper.emitted(eventName)).toBeTruthy();
- expect(escape).toHaveBeenCalledWith(mockUpdatedTitle);
});
});
diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js
index 0552fe5050e..299949a4baa 100644
--- a/spec/frontend/work_items/components/work_item_assignees_spec.js
+++ b/spec/frontend/work_items/components/work_item_assignees_spec.js
@@ -1,52 +1,90 @@
-import { GlLink, GlTokenSelector } from '@gitlab/ui';
-import { nextTick } from 'vue';
+import { GlLink, GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui';
+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 { mountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import { stripTypenames } from 'helpers/graphql_helpers';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
-import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql';
-
-const mockAssignees = [
- {
- __typename: 'UserCore',
- id: 'gid://gitlab/User/1',
- avatarUrl: '',
- webUrl: '',
- name: 'John Doe',
- username: 'doe_I',
- },
- {
- __typename: 'UserCore',
- id: 'gid://gitlab/User/2',
- avatarUrl: '',
- webUrl: '',
- name: 'Marcus Rutherford',
- username: 'ruthfull',
- },
-];
+import { i18n, TASK_TYPE_NAME, TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import { temporaryConfig, resolvers } from '~/work_items/graphql/provider';
+import {
+ projectMembersResponseWithCurrentUser,
+ mockAssignees,
+ workItemQueryResponse,
+ currentUserResponse,
+ currentUserNullResponse,
+ projectMembersResponseWithoutCurrentUser,
+} from '../mock_data';
-const workItemId = 'gid://gitlab/WorkItem/1';
+Vue.use(VueApollo);
-const mutate = jest.fn();
+const workItemId = 'gid://gitlab/WorkItem/1';
+const dropdownItems = projectMembersResponseWithCurrentUser.data.workspace.users.nodes;
describe('WorkItemAssignees component', () => {
let wrapper;
const findAssigneeLinks = () => wrapper.findAllComponents(GlLink);
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findEmptyState = () => wrapper.findByTestId('empty-state');
+ const findAssignSelfButton = () => wrapper.findByTestId('assign-self');
+ const findAssigneesTitle = () => wrapper.findByTestId('assignees-title');
+
+ const successSearchQueryHandler = jest
+ .fn()
+ .mockResolvedValue(projectMembersResponseWithCurrentUser);
+ const successCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserResponse);
+ const noCurrentUserQueryHandler = jest.fn().mockResolvedValue(currentUserNullResponse);
+
+ const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+ const createComponent = ({
+ assignees = mockAssignees,
+ searchQueryHandler = successSearchQueryHandler,
+ currentUserQueryHandler = successCurrentUserQueryHandler,
+ allowsMultipleAssignees = true,
+ canUpdate = true,
+ } = {}) => {
+ const apolloProvider = createMockApollo(
+ [
+ [userSearchQuery, searchQueryHandler],
+ [currentUserQuery, currentUserQueryHandler],
+ ],
+ resolvers,
+ {
+ typePolicies: temporaryConfig.cacheConfig.typePolicies,
+ },
+ );
+
+ apolloProvider.clients.defaultClient.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id: workItemId,
+ },
+ data: workItemQueryResponse.data,
+ });
- const createComponent = ({ assignees = mockAssignees } = {}) => {
wrapper = mountExtended(WorkItemAssignees, {
+ provide: {
+ fullPath: 'test-project-path',
+ },
propsData: {
assignees,
workItemId,
- },
- mocks: {
- $apollo: {
- mutate,
- },
+ allowsMultipleAssignees,
+ workItemType: TASK_TYPE_NAME,
+ canUpdate,
},
attachTo: document.body,
+ apolloProvider,
});
};
@@ -54,39 +92,316 @@ describe('WorkItemAssignees component', () => {
wrapper.destroy();
});
- it('should pass the correct data-user-id attribute', () => {
+ it('passes the correct data-user-id attribute', () => {
createComponent();
expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1');
});
- describe('when there are assignees', () => {
+ it('container does not have shadow by default', () => {
+ createComponent();
+ expect(findTokenSelector().props('containerClass')).toBe('gl-shadow-none!');
+ });
+
+ it('container has shadow after focusing token selector', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findTokenSelector().props('containerClass')).toBe('');
+ });
+
+ it('focuses token selector on token selector input event', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
+ await nextTick();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findTokenSelector().element.contains(document.activeElement)).toBe(true);
+ });
+
+ it('calls a mutation on clicking outside the token selector', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
+ findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null }));
+ await waitForPromises();
+
+ expect(findTokenSelector().props('selectedTokens')).toEqual([mockAssignees[0]]);
+ });
+
+ it('passes `false` to `viewOnly` token selector prop if user can update assignees', () => {
+ createComponent();
+
+ expect(findTokenSelector().props('viewOnly')).toBe(false);
+ });
+
+ it('passes `true` to `viewOnly` token selector prop if user can not update assignees', () => {
+ createComponent({ canUpdate: false });
+
+ expect(findTokenSelector().props('viewOnly')).toBe(true);
+ });
+
+ describe('when searching for users', () => {
beforeEach(() => {
createComponent();
});
- it('should focus token selector on token removal', async () => {
- findTokenSelector().vm.$emit('token-remove', mockAssignees[0].id);
+ it('does not start user search by default', () => {
+ expect(findTokenSelector().props('loading')).toBe(false);
+ expect(findTokenSelector().props('dropdownItems')).toEqual([]);
+ });
+
+ it('starts user search on hovering for more than 250ms', async () => {
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick();
- expect(findEmptyState().exists()).toBe(false);
- expect(findTokenSelector().element.contains(document.activeElement)).toBe(true);
+ expect(findTokenSelector().props('loading')).toBe(true);
});
- it('should call a mutation on clicking outside the token selector', async () => {
- findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
- findTokenSelector().vm.$emit('token-remove');
+ it('starts user search on focusing token selector', async () => {
+ findTokenSelector().vm.$emit('focus');
await nextTick();
- expect(mutate).not.toHaveBeenCalled();
- findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null }));
+ expect(findTokenSelector().props('loading')).toBe(true);
+ });
+
+ it('does not start searching if token-selector was hovered for less than 250ms', async () => {
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(100);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+
+ it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => {
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(100);
+
+ findTokenSelector().trigger('mouseout');
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+
+ it('shows skeleton loader on dropdown when loading users', async () => {
+ findTokenSelector().vm.$emit('focus');
await nextTick();
- expect(mutate).toHaveBeenCalledWith({
- mutation: localUpdateWorkItemMutation,
- variables: {
- input: { id: workItemId, assigneeIds: [mockAssignees[0].id] },
- },
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('shows correct users list in dropdown when loaded', async () => {
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findTokenSelector().props('dropdownItems')).toHaveLength(2);
+ });
+
+ it('should search for users with correct key after text input', async () => {
+ const searchKey = 'Hello';
+
+ findTokenSelector().vm.$emit('focus');
+ findTokenSelector().vm.$emit('text-input', searchKey);
+ await waitForPromises();
+
+ expect(successSearchQueryHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ search: searchKey }),
+ );
+ });
+ });
+
+ it('emits error event if search users query fails', async () => {
+ createComponent({ searchQueryHandler: errorHandler });
+ findTokenSelector().vm.$emit('focus');
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]);
+ });
+
+ describe('when assigning to current user', () => {
+ it('does not show `Assign myself` button if current user is loading', () => {
+ createComponent();
+ findTokenSelector().trigger('mouseover');
+
+ expect(findAssignSelfButton().exists()).toBe(false);
+ });
+
+ it('does not show `Assign myself` button if work item has assignees', async () => {
+ createComponent();
+ await waitForPromises();
+ findTokenSelector().trigger('mouseover');
+
+ expect(findAssignSelfButton().exists()).toBe(false);
+ });
+
+ it('does now show `Assign myself` button if user is not logged in', async () => {
+ createComponent({ currentUserQueryHandler: noCurrentUserQueryHandler, assignees: [] });
+ await waitForPromises();
+ findTokenSelector().trigger('mouseover');
+
+ expect(findAssignSelfButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when user is logged in and there are no assignees', () => {
+ beforeEach(() => {
+ createComponent({ assignees: [] });
+ return waitForPromises();
+ });
+
+ it('renders `Assign myself` button', async () => {
+ findTokenSelector().trigger('mouseover');
+ expect(findAssignSelfButton().exists()).toBe(true);
+ });
+
+ it('calls update work item assignees mutation with current user as a variable on button click', () => {
+ // TODO: replace this test as soon as we have a real mutation implemented
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementation(jest.fn());
+
+ findTokenSelector().trigger('mouseover');
+ findAssignSelfButton().vm.$emit('click', new MouseEvent('click'));
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variables: {
+ input: {
+ assignees: [stripTypenames(currentUserResponse.data.currentUser)],
+ id: workItemId,
+ },
+ },
+ }),
+ );
+ });
+ });
+
+ it('moves current user to the top of dropdown items if user is a project member', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findTokenSelector().props('dropdownItems')[0]).toEqual(
+ expect.objectContaining({
+ ...stripTypenames(currentUserResponse.data.currentUser),
+ }),
+ );
+ });
+
+ describe('when current user is not in the list of project members', () => {
+ const searchQueryHandler = jest
+ .fn()
+ .mockResolvedValue(projectMembersResponseWithoutCurrentUser);
+
+ beforeEach(() => {
+ createComponent({ searchQueryHandler });
+ return waitForPromises();
+ });
+
+ it('adds current user to the top of dropdown items', () => {
+ expect(findTokenSelector().props('dropdownItems')[0]).toEqual(
+ stripTypenames(currentUserResponse.data.currentUser),
+ );
+ });
+
+ it('does not add current user if search is not empty', async () => {
+ findTokenSelector().vm.$emit('text-input', 'test');
+ await waitForPromises();
+
+ expect(findTokenSelector().props('dropdownItems')[0]).not.toEqual(
+ stripTypenames(currentUserResponse.data.currentUser),
+ );
+ });
+ });
+
+ it('has `Assignee` label when only one assignee is present', () => {
+ createComponent({ assignees: [mockAssignees[0]] });
+
+ expect(findAssigneesTitle().text()).toBe('Assignee');
+ });
+
+ it('has `Assignees` label if more than one assignee is present', () => {
+ createComponent();
+
+ expect(findAssigneesTitle().text()).toBe('Assignees');
+ });
+
+ describe('when multiple assignees are allowed', () => {
+ beforeEach(() => {
+ createComponent({ allowsMultipleAssignees: true, assignees: [] });
+ return waitForPromises();
+ });
+
+ it('has `Add assignees` text on placeholder', () => {
+ expect(findEmptyState().text()).toContain('Add assignees');
+ });
+
+ it('adds multiple assignees when token-selector provides multiple values', async () => {
+ findTokenSelector().vm.$emit('input', dropdownItems);
+ await nextTick();
+
+ expect(findTokenSelector().props('selectedTokens')).toHaveLength(2);
+ });
+ });
+
+ describe('when multiple assignees are not allowed', () => {
+ beforeEach(() => {
+ createComponent({ allowsMultipleAssignees: false, assignees: [] });
+ return waitForPromises();
+ });
+
+ it('has `Add assignee` text on placeholder', () => {
+ expect(findEmptyState().text()).toContain('Add assignee');
+ expect(findEmptyState().text()).not.toContain('Add assignees');
+ });
+
+ it('adds a single assignee token-selector provides multiple values', async () => {
+ findTokenSelector().vm.$emit('input', dropdownItems);
+ await nextTick();
+
+ expect(findTokenSelector().props('selectedTokens')).toHaveLength(1);
+ });
+
+ it('removes shadow after token-selector input', async () => {
+ findTokenSelector().vm.$emit('input', dropdownItems);
+ await nextTick();
+
+ expect(findTokenSelector().props('containerClass')).toBe('gl-shadow-none!');
+ });
+ });
+
+ describe('tracking', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ trackingSpy = null;
+ });
+
+ it('does not track updating assignees until token selector blur event', async () => {
+ findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
+ await waitForPromises();
+
+ expect(trackingSpy).not.toHaveBeenCalled();
+ });
+
+ it('tracks editing the assignees on token selector blur', async () => {
+ findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
+ findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null }));
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_assignees', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_assignees',
+ property: 'type_Task',
});
});
});
diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
index d55ba318e46..70b1261bdb7 100644
--- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
@@ -66,6 +66,7 @@ describe('WorkItemDetailModal component', () => {
createComponent();
expect(findWorkItemDetail().props()).toEqual({
+ isModal: true,
workItemId: '1',
workItemParentId: '2',
});
@@ -98,6 +99,15 @@ describe('WorkItemDetailModal component', () => {
expect(wrapper.emitted('close')).toBeTruthy();
});
+ it('hides the modal when WorkItemDetail emits `close` event', () => {
+ createComponent();
+ const closeSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide');
+
+ findWorkItemDetail().vm.$emit('close');
+
+ expect(closeSpy).toHaveBeenCalled();
+ });
+
describe('delete work item', () => {
it('emits workItemDeleted and closes modal', async () => {
createComponent();
diff --git a/spec/frontend/work_items/components/work_item_information_spec.js b/spec/frontend/work_items/components/work_item_information_spec.js
new file mode 100644
index 00000000000..d5f6921c2bc
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_information_spec.js
@@ -0,0 +1,48 @@
+import { mount } from '@vue/test-utils';
+import { GlAlert, GlLink } from '@gitlab/ui';
+import WorkItemInformation from '~/work_items/components/work_item_information.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+const createComponent = () => mount(WorkItemInformation);
+
+describe('Work item information alert', () => {
+ let wrapper;
+ const tasksHelpPath = helpPagePath('user/tasks');
+ const workItemsHelpPath = helpPagePath('development/work_items');
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findHelpLink = () => wrapper.findComponent(GlLink);
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should be visible', () => {
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ it('should emit `work-item-banner-dismissed` event when cross icon is clicked', () => {
+ findAlert().vm.$emit('dismiss');
+ expect(wrapper.emitted('work-item-banner-dismissed').length).toBe(1);
+ });
+
+ it('the alert variant should be tip', () => {
+ expect(findAlert().props('variant')).toBe('tip');
+ });
+
+ it('should have the correct text for primary button and link', () => {
+ expect(findAlert().props('title')).toBe(WorkItemInformation.i18n.tasksInformationTitle);
+ expect(findAlert().props('primaryButtonText')).toBe(
+ WorkItemInformation.i18n.learnTasksButtonText,
+ );
+ expect(findAlert().props('primaryButtonLink')).toBe(tasksHelpPath);
+ });
+
+ it('should have the correct link to work item link', () => {
+ expect(findHelpLink().exists()).toBe(true);
+ expect(findHelpLink().attributes('href')).toBe(workItemsHelpPath);
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js
new file mode 100644
index 00000000000..1734b901d1a
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_labels_spec.js
@@ -0,0 +1,171 @@
+import { GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui';
+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 { mountExtended } from 'helpers/vue_test_utils_helper';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
+import { i18n } from '~/work_items/constants';
+import { temporaryConfig, resolvers } from '~/work_items/graphql/provider';
+import { projectLabelsResponse, mockLabels, workItemQueryResponse } from '../mock_data';
+
+Vue.use(VueApollo);
+
+const workItemId = 'gid://gitlab/WorkItem/1';
+
+describe('WorkItemLabels component', () => {
+ let wrapper;
+
+ const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ const findEmptyState = () => wrapper.findByTestId('empty-state');
+
+ const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse);
+ const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+ const createComponent = ({
+ labels = mockLabels,
+ canUpdate = true,
+ searchQueryHandler = successSearchQueryHandler,
+ } = {}) => {
+ const apolloProvider = createMockApollo([[labelSearchQuery, searchQueryHandler]], resolvers, {
+ typePolicies: temporaryConfig.cacheConfig.typePolicies,
+ });
+
+ apolloProvider.clients.defaultClient.writeQuery({
+ query: workItemQuery,
+ variables: {
+ id: workItemId,
+ },
+ data: workItemQueryResponse.data,
+ });
+
+ wrapper = mountExtended(WorkItemLabels, {
+ provide: {
+ fullPath: 'test-project-path',
+ },
+ propsData: {
+ labels,
+ workItemId,
+ canUpdate,
+ },
+ attachTo: document.body,
+ apolloProvider,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('focuses token selector on token selector input event', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('input', [mockLabels[0]]);
+ await nextTick();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findTokenSelector().element.contains(document.activeElement)).toBe(true);
+ });
+
+ it('does not start search by default', () => {
+ createComponent();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ expect(findTokenSelector().props('dropdownItems')).toEqual([]);
+ });
+
+ it('starts search on hovering for more than 250ms', async () => {
+ createComponent();
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(true);
+ });
+
+ it('starts search on focusing token selector', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(true);
+ });
+
+ it('does not start searching if token-selector was hovered for less than 250ms', async () => {
+ createComponent();
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(100);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+
+ it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => {
+ createComponent();
+ findTokenSelector().trigger('mouseover');
+ jest.advanceTimersByTime(100);
+
+ findTokenSelector().trigger('mouseout');
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findTokenSelector().props('loading')).toBe(false);
+ });
+
+ it('shows skeleton loader on dropdown when loading', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('shows list in dropdown when loaded', async () => {
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findTokenSelector().props('dropdownItems')).toHaveLength(2);
+ });
+
+ it.each([true, false])(
+ 'passes canUpdate=%s prop to view-only of token-selector',
+ async (canUpdate) => {
+ createComponent({ canUpdate });
+
+ await waitForPromises();
+
+ expect(findTokenSelector().props('viewOnly')).toBe(!canUpdate);
+ },
+ );
+
+ it('emits error event if search query fails', async () => {
+ createComponent({ searchQueryHandler: errorHandler });
+ findTokenSelector().vm.$emit('focus');
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]);
+ });
+
+ it('should search for with correct key after text input', async () => {
+ const searchKey = 'Hello';
+
+ createComponent();
+ findTokenSelector().vm.$emit('focus');
+ findTokenSelector().vm.$emit('text-input', searchKey);
+ await waitForPromises();
+
+ expect(successSearchQueryHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ search: searchKey }),
+ );
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
new file mode 100644
index 00000000000..93bf7286aa7
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import { GlForm, GlFormCombobox } from '@gitlab/ui';
+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 WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue';
+import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import { availableWorkItemsResponse, updateWorkItemMutationResponse } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('WorkItemLinksForm', () => {
+ let wrapper;
+
+ const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
+
+ const createComponent = async ({ listResponse = availableWorkItemsResponse } = {}) => {
+ wrapper = shallowMountExtended(WorkItemLinksForm, {
+ apolloProvider: createMockApollo([
+ [projectWorkItemsQuery, jest.fn().mockResolvedValue(listResponse)],
+ [updateWorkItemMutation, updateMutationResolver],
+ ]),
+ propsData: { issuableGid: 'gid://gitlab/WorkItem/1' },
+ provide: {
+ projectPath: 'project/path',
+ },
+ });
+
+ await waitForPromises();
+ };
+
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findCombobox = () => wrapper.findComponent(GlFormCombobox);
+ const findAddChildButton = () => wrapper.findByTestId('add-child-button');
+
+ beforeEach(async () => {
+ await createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders form', () => {
+ expect(findForm().exists()).toBe(true);
+ });
+
+ it('passes available work items as prop when typing in combobox', async () => {
+ findCombobox().vm.$emit('input', 'Task');
+ await waitForPromises();
+
+ expect(findCombobox().exists()).toBe(true);
+ expect(findCombobox().props('tokenList').length).toBe(2);
+ });
+
+ it('selects and add child', async () => {
+ findCombobox().vm.$emit('input', availableWorkItemsResponse.data.workspace.workItems.edges[0]);
+
+ findAddChildButton().vm.$emit('click');
+ await waitForPromises();
+ expect(updateMutationResolver).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js
new file mode 100644
index 00000000000..f8471b7f167
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js
@@ -0,0 +1,141 @@
+import Vue from 'vue';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue';
+import changeWorkItemParentMutation from '~/work_items/graphql/change_work_item_parent_link.mutation.graphql';
+import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql';
+import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
+import { workItemHierarchyResponse, changeWorkItemParentMutationResponse } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+const PARENT_ID = 'gid://gitlab/WorkItem/1';
+const WORK_ITEM_ID = 'gid://gitlab/WorkItem/3';
+
+describe('WorkItemLinksMenu', () => {
+ let wrapper;
+ let mockApollo;
+
+ const $toast = {
+ show: jest.fn(),
+ };
+
+ const createComponent = async ({
+ data = {},
+ mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse),
+ } = {}) => {
+ mockApollo = createMockApollo([
+ [getWorkItemLinksQuery, jest.fn().mockResolvedValue(workItemHierarchyResponse)],
+ [changeWorkItemParentMutation, mutationHandler],
+ ]);
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getWorkItemLinksQuery,
+ variables: {
+ id: PARENT_ID,
+ },
+ data: workItemHierarchyResponse.data,
+ });
+
+ wrapper = shallowMountExtended(WorkItemLinksMenu, {
+ data() {
+ return {
+ ...data,
+ };
+ },
+ propsData: {
+ workItemId: WORK_ITEM_ID,
+ parentWorkItemId: PARENT_ID,
+ },
+ apolloProvider: mockApollo,
+ mocks: {
+ $toast,
+ },
+ });
+
+ await waitForPromises();
+ };
+
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findRemoveDropdownItem = () => wrapper.find(GlDropdownItem);
+
+ beforeEach(async () => {
+ await createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mockApollo = null;
+ });
+
+ it('renders dropdown and dropdown items', () => {
+ expect(findDropdown().exists()).toBe(true);
+ expect(findRemoveDropdownItem().exists()).toBe(true);
+ });
+
+ it('calls correct mutation with correct variables', async () => {
+ const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse);
+
+ createComponent({ mutationHandler });
+
+ findRemoveDropdownItem().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect(mutationHandler).toHaveBeenCalledWith({
+ id: WORK_ITEM_ID,
+ parentId: null,
+ });
+ });
+
+ it('shows toast when mutation succeeds', async () => {
+ const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse);
+
+ createComponent({ mutationHandler });
+
+ findRemoveDropdownItem().vm.$emit('click');
+
+ await waitForPromises();
+
+ expect($toast.show).toHaveBeenCalledWith('Child removed', {
+ action: { onClick: expect.anything(), text: 'Undo' },
+ });
+ });
+
+ it('updates the cache when mutation succeeds', async () => {
+ const mutationHandler = jest.fn().mockResolvedValue(changeWorkItemParentMutationResponse);
+
+ createComponent({ mutationHandler });
+
+ mockApollo.clients.defaultClient.cache.readQuery = jest.fn(
+ () => workItemHierarchyResponse.data,
+ );
+
+ mockApollo.clients.defaultClient.cache.writeQuery = jest.fn();
+
+ findRemoveDropdownItem().vm.$emit('click');
+
+ await waitForPromises();
+
+ // Remove the work item from parent's children
+ const resp = cloneDeep(workItemHierarchyResponse);
+ const index = resp.data.workItem.widgets
+ .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)
+ .children.nodes.findIndex((child) => child.id === WORK_ITEM_ID);
+ resp.data.workItem.widgets
+ .find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)
+ .children.nodes.splice(index, 1);
+
+ expect(mockApollo.clients.defaultClient.cache.writeQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ query: expect.anything(),
+ variables: { id: PARENT_ID },
+ data: resp.data,
+ }),
+ );
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
index 774e9198992..2ec9b1ec0ac 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
@@ -51,6 +51,20 @@ describe('WorkItemLinks', () => {
expect(findLinksBody().exists()).toBe(false);
});
+ describe('add link form', () => {
+ it('displays form on click add button and hides form on cancel', async () => {
+ findToggleAddFormButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findAddLinksForm().exists()).toBe(true);
+
+ findAddLinksForm().vm.$emit('cancel');
+ await nextTick();
+
+ expect(findAddLinksForm().exists()).toBe(false);
+ });
+ });
+
describe('when no child links', () => {
beforeEach(async () => {
await createComponent({ response: workItemHierarchyEmptyResponse });
@@ -59,22 +73,6 @@ describe('WorkItemLinks', () => {
it('displays empty state if there are no children', () => {
expect(findEmptyState().exists()).toBe(true);
});
-
- describe('add link form', () => {
- it('displays form on click add button and hides form on cancel', async () => {
- expect(findEmptyState().exists()).toBe(true);
-
- findToggleAddFormButton().vm.$emit('click');
- await nextTick();
-
- expect(findAddLinksForm().exists()).toBe(true);
-
- findAddLinksForm().vm.$emit('cancel');
- await nextTick();
-
- expect(findAddLinksForm().exists()).toBe(false);
- });
- });
});
it('renders all hierarchy widget children', () => {
diff --git a/spec/frontend/work_items/components/work_item_weight_spec.js b/spec/frontend/work_items/components/work_item_weight_spec.js
index 80a1d032ad7..c3bbea26cda 100644
--- a/spec/frontend/work_items/components/work_item_weight_spec.js
+++ b/spec/frontend/work_items/components/work_item_weight_spec.js
@@ -1,21 +1,51 @@
-import { shallowMount } from '@vue/test-utils';
+import { GlForm, GlFormInput } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { mockTracking } from 'helpers/tracking_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { __ } from '~/locale';
import WorkItemWeight from '~/work_items/components/work_item_weight.vue';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
+import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql';
-describe('WorkItemAssignees component', () => {
+describe('WorkItemWeight component', () => {
let wrapper;
- const createComponent = ({ weight, hasIssueWeightsFeature = true } = {}) => {
- wrapper = shallowMount(WorkItemWeight, {
+ const mutateSpy = jest.fn();
+ const workItemId = 'gid://gitlab/WorkItem/1';
+ const workItemType = 'Task';
+
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+
+ const createComponent = ({
+ canUpdate = false,
+ hasIssueWeightsFeature = true,
+ isEditing = false,
+ weight,
+ } = {}) => {
+ wrapper = mountExtended(WorkItemWeight, {
propsData: {
+ canUpdate,
weight,
+ workItemId,
+ workItemType,
},
provide: {
hasIssueWeightsFeature,
},
+ mocks: {
+ $apollo: {
+ mutate: mutateSpy,
+ },
+ },
});
+
+ if (isEditing) {
+ findInput().vm.$emit('focus');
+ }
};
- describe('weight licensed feature', () => {
+ describe('`issue_weights` licensed feature', () => {
describe.each`
description | hasIssueWeightsFeature | exists
${'when available'} | ${true} | ${true}
@@ -24,23 +54,111 @@ describe('WorkItemAssignees component', () => {
it(hasIssueWeightsFeature ? 'renders component' : 'does not render component', () => {
createComponent({ hasIssueWeightsFeature });
- expect(wrapper.find('div').exists()).toBe(exists);
+ expect(findForm().exists()).toBe(exists);
});
});
});
- describe('weight text', () => {
- describe.each`
- description | weight | text
- ${'renders 1'} | ${1} | ${'1'}
- ${'renders 0'} | ${0} | ${'0'}
- ${'renders None'} | ${null} | ${'None'}
- ${'renders None'} | ${undefined} | ${'None'}
- `('when weight is $weight', ({ description, weight, text }) => {
- it(description, () => {
- createComponent({ weight });
-
- expect(wrapper.text()).toContain(text);
+ describe('weight input', () => {
+ it('has "Weight" label', () => {
+ createComponent();
+
+ expect(wrapper.findByLabelText(__('Weight')).exists()).toBe(true);
+ });
+
+ describe('placeholder attribute', () => {
+ describe.each`
+ description | isEditing | canUpdate | value
+ ${'when not editing and cannot update'} | ${false} | ${false} | ${__('None')}
+ ${'when editing and cannot update'} | ${true} | ${false} | ${__('None')}
+ ${'when not editing and can update'} | ${false} | ${true} | ${__('None')}
+ ${'when editing and can update'} | ${true} | ${true} | ${__('Enter a number')}
+ `('$description', ({ isEditing, canUpdate, value }) => {
+ it(`has a value of "${value}"`, async () => {
+ createComponent({ canUpdate, isEditing });
+ await nextTick();
+
+ expect(findInput().attributes('placeholder')).toBe(value);
+ });
+ });
+ });
+
+ describe('readonly attribute', () => {
+ describe.each`
+ description | canUpdate | value
+ ${'when cannot update'} | ${false} | ${'readonly'}
+ ${'when can update'} | ${true} | ${undefined}
+ `('$description', ({ canUpdate, value }) => {
+ it(`renders readonly=${value}`, () => {
+ createComponent({ canUpdate });
+
+ expect(findInput().attributes('readonly')).toBe(value);
+ });
+ });
+ });
+
+ describe('type attribute', () => {
+ describe.each`
+ description | isEditing | canUpdate | type
+ ${'when not editing and cannot update'} | ${false} | ${false} | ${'text'}
+ ${'when editing and cannot update'} | ${true} | ${false} | ${'text'}
+ ${'when not editing and can update'} | ${false} | ${true} | ${'text'}
+ ${'when editing and can update'} | ${true} | ${true} | ${'number'}
+ `('$description', ({ isEditing, canUpdate, type }) => {
+ it(`has a value of "${type}"`, async () => {
+ createComponent({ canUpdate, isEditing });
+ await nextTick();
+
+ expect(findInput().attributes('type')).toBe(type);
+ });
+ });
+ });
+
+ describe('value attribute', () => {
+ describe.each`
+ weight | value
+ ${1} | ${'1'}
+ ${0} | ${'0'}
+ ${null} | ${''}
+ ${undefined} | ${''}
+ `('when `weight` prop is "$weight"', ({ weight, value }) => {
+ it(`value is "${value}"`, () => {
+ createComponent({ weight });
+
+ expect(findInput().element.value).toBe(value);
+ });
+ });
+ });
+
+ describe('when blurred', () => {
+ it('calls a mutation to update the weight', () => {
+ const weight = 0;
+ createComponent({ isEditing: true, weight });
+
+ findInput().trigger('blur');
+
+ expect(mutateSpy).toHaveBeenCalledWith({
+ mutation: localUpdateWorkItemMutation,
+ variables: {
+ input: {
+ id: workItemId,
+ weight,
+ },
+ },
+ });
+ });
+
+ it('tracks updating the weight', () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ createComponent();
+
+ findInput().trigger('blur');
+
+ expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_weight', {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_weight',
+ property: 'type_Task',
+ });
});
});
});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index bf3f4e1364d..0359caf7116 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -1,3 +1,22 @@
+export const mockAssignees = [
+ {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl: '',
+ webUrl: '',
+ name: 'John Doe',
+ username: 'doe_I',
+ },
+ {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/2',
+ avatarUrl: '',
+ webUrl: '',
+ name: 'Marcus Rutherford',
+ username: 'ruthfull',
+ },
+];
+
export const workItemQueryResponse = {
data: {
workItem: {
@@ -23,6 +42,32 @@ export const workItemQueryResponse = {
descriptionHtml:
'<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>',
},
+ {
+ __typename: 'WorkItemWidgetAssignees',
+ type: 'ASSIGNEES',
+ allowsMultipleAssignees: true,
+ assignees: {
+ nodes: mockAssignees,
+ },
+ },
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ type: 'HIERARCHY',
+ parent: {
+ id: 'gid://gitlab/Issue/1',
+ iid: '5',
+ title: 'Parent title',
+ },
+ children: {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/WorkItem/444',
+ },
+ },
+ ],
+ },
+ },
],
},
},
@@ -47,13 +92,28 @@ export const updateWorkItemMutationResponse = {
deleteWorkItem: false,
updateWorkItem: false,
},
- widgets: [],
+ widgets: [
+ {
+ children: {
+ edges: [
+ {
+ node: 'gid://gitlab/WorkItem/444',
+ },
+ ],
+ },
+ },
+ ],
},
},
},
};
-export const workItemResponseFactory = ({ canUpdate } = {}) => ({
+export const workItemResponseFactory = ({
+ canUpdate = false,
+ allowsMultipleAssignees = true,
+ assigneesWidgetPresent = true,
+ parent = null,
+} = {}) => ({
data: {
workItem: {
__typename: 'WorkItem',
@@ -78,6 +138,30 @@ export const workItemResponseFactory = ({ canUpdate } = {}) => ({
descriptionHtml:
'<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>',
},
+ assigneesWidgetPresent
+ ? {
+ __typename: 'WorkItemWidgetAssignees',
+ type: 'ASSIGNEES',
+ allowsMultipleAssignees,
+ assignees: {
+ nodes: mockAssignees,
+ },
+ }
+ : { type: 'MOCK TYPE' },
+ {
+ __typename: 'WorkItemWidgetHierarchy',
+ type: 'HIERARCHY',
+ children: {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/WorkItem/444',
+ },
+ },
+ ],
+ },
+ parent,
+ },
],
},
},
@@ -140,13 +224,45 @@ export const createWorkItemFromTaskMutationResponse = {
__typename: 'WorkItemCreateFromTaskPayload',
errors: [],
workItem: {
- descriptionHtml: '<p>New description</p>',
- id: 'gid://gitlab/WorkItem/13',
__typename: 'WorkItem',
+ description: 'New description',
+ id: 'gid://gitlab/WorkItem/1',
+ title: 'Updated title',
+ state: 'OPEN',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
+ },
userPermissions: {
deleteWorkItem: false,
updateWorkItem: false,
},
+ widgets: [
+ {
+ __typename: 'WorkItemWidgetDescription',
+ type: 'DESCRIPTION',
+ description: 'New description',
+ descriptionHtml: '<p>New description</p>',
+ },
+ ],
+ },
+ newWorkItem: {
+ __typename: 'WorkItem',
+ id: 'gid://gitlab/WorkItem/1000000',
+ title: 'Updated title',
+ state: 'OPEN',
+ description: '',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
+ },
+ userPermissions: {
+ deleteWorkItem: false,
+ updateWorkItem: false,
+ },
+ widgets: [],
},
},
},
@@ -275,3 +391,171 @@ export const workItemHierarchyResponse = {
},
},
};
+
+export const changeWorkItemParentMutationResponse = {
+ data: {
+ workItemUpdate: {
+ workItem: {
+ id: 'gid://gitlab/WorkItem/2',
+ workItemType: {
+ id: 'gid://gitlab/WorkItems::Type/5',
+ __typename: 'WorkItemType',
+ },
+ title: 'Foo',
+ state: 'OPEN',
+ __typename: 'WorkItem',
+ },
+ errors: [],
+ __typename: 'WorkItemUpdatePayload',
+ },
+ },
+};
+
+export const availableWorkItemsResponse = {
+ data: {
+ workspace: {
+ __typename: 'Project',
+ id: 'gid://gitlab/Project/2',
+ workItems: {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/WorkItem/458',
+ title: 'Task 1',
+ state: 'OPEN',
+ },
+ },
+ {
+ node: {
+ id: 'gid://gitlab/WorkItem/459',
+ title: 'Task 2',
+ state: 'OPEN',
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const projectMembersResponseWithCurrentUser = {
+ data: {
+ workspace: {
+ id: '1',
+ __typename: 'Project',
+ users: {
+ nodes: [
+ {
+ id: 'user-2',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/5',
+ avatarUrl: '/avatar2',
+ name: 'rookie',
+ username: 'rookie',
+ webUrl: 'rookie',
+ status: null,
+ },
+ },
+ {
+ id: 'user-1',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: '/root',
+ status: null,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const projectMembersResponseWithoutCurrentUser = {
+ data: {
+ workspace: {
+ id: '1',
+ __typename: 'Project',
+ users: {
+ nodes: [
+ {
+ id: 'user-2',
+ user: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/5',
+ avatarUrl: '/avatar2',
+ name: 'rookie',
+ username: 'rookie',
+ webUrl: 'rookie',
+ status: null,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const currentUserResponse = {
+ data: {
+ currentUser: {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: '/root',
+ },
+ },
+};
+
+export const currentUserNullResponse = {
+ data: {
+ currentUser: null,
+ },
+};
+
+export const mockLabels = [
+ {
+ __typename: 'Label',
+ id: 'gid://gitlab/Label/1',
+ title: 'Label 1',
+ description: '',
+ color: '#f00',
+ textColor: '#00f',
+ },
+ {
+ __typename: 'Label',
+ id: 'gid://gitlab/Label/2',
+ title: 'Label 2',
+ description: '',
+ color: '#b00',
+ textColor: '#00b',
+ },
+];
+
+export const projectLabelsResponse = {
+ data: {
+ workspace: {
+ id: '1',
+ __typename: 'Project',
+ labels: {
+ nodes: mockLabels,
+ },
+ },
+ },
+};
+
+export const mockParent = {
+ parent: {
+ id: 'gid://gitlab/Issue/1',
+ iid: '5',
+ title: 'Parent 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
index e89477ed599..fed8be3783a 100644
--- a/spec/frontend/work_items/pages/create_work_item_spec.js
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -9,11 +9,7 @@ import ItemTitle from '~/work_items/components/item_title.vue';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
-import {
- projectWorkItemTypesQueryResponse,
- createWorkItemMutationResponse,
- createWorkItemFromTaskMutationResponse,
-} from '../mock_data';
+import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data';
jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] }));
@@ -25,9 +21,6 @@ describe('Create work item component', () => {
const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
- const createWorkItemFromTaskSuccessHandler = jest
- .fn()
- .mockResolvedValue(createWorkItemFromTaskMutationResponse);
const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const findAlert = () => wrapper.findComponent(GlAlert);
@@ -122,49 +115,6 @@ describe('Create work item component', () => {
});
});
- describe('when displayed in a modal', () => {
- beforeEach(() => {
- createComponent({
- props: {
- isModal: true,
- },
- mutationHandler: createWorkItemFromTaskSuccessHandler,
- });
- });
-
- it('emits `closeModal` event on Cancel button click', () => {
- findCancelButton().vm.$emit('click');
-
- expect(wrapper.emitted('closeModal')).toEqual([[]]);
- });
-
- it('emits `onCreate` on successful mutation', async () => {
- findTitleInput().vm.$emit('title-input', 'Test title');
-
- wrapper.find('form').trigger('submit');
- await waitForPromises();
-
- expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]);
- });
-
- it('does not right margin for create button', () => {
- expect(findCreateButton().classes()).not.toContain('gl-mr-3');
- });
-
- it('adds right margin for cancel button', () => {
- expect(findCancelButton().classes()).toContain('gl-mr-3');
- });
-
- it('adds padding for content', () => {
- expect(findContent().classes('gl-px-5')).toBe(true);
- });
-
- it('defaults type to `Task`', async () => {
- await waitForPromises();
- expect(findSelect().attributes('value')).toBe('gid://gitlab/WorkItems::Type/3');
- });
- });
-
it('displays a loading icon inside dropdown when work items query is loading', () => {
createComponent();
diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js
index b9724034cb4..43869468ad0 100644
--- a/spec/frontend/work_items/pages/work_item_detail_spec.js
+++ b/spec/frontend/work_items/pages/work_item_detail_spec.js
@@ -1,26 +1,36 @@
-import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import { GlAlert, GlSkeletonLoader, GlButton } 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 waitForPromises from 'helpers/wait_for_promises';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemState from '~/work_items/components/work_item_state.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
+import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemWeight from '~/work_items/components/work_item_weight.vue';
+import WorkItemInformation from '~/work_items/components/work_item_information.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import { temporaryConfig } from '~/work_items/graphql/provider';
-import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data';
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import {
+ workItemTitleSubscriptionResponse,
+ workItemResponseFactory,
+ mockParent,
+} from '../mock_data';
describe('WorkItemDetail component', () => {
let wrapper;
+ useLocalStorageSpy();
Vue.use(VueApollo);
+ const workItemQueryResponse = workItemResponseFactory();
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
@@ -30,9 +40,17 @@ describe('WorkItemDetail component', () => {
const findWorkItemState = () => wrapper.findComponent(WorkItemState);
const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription);
const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees);
+ const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
const findWorkItemWeight = () => wrapper.findComponent(WorkItemWeight);
+ const findParent = () => wrapper.find('[data-testid="work-item-parent"]');
+ const findParentButton = () => findParent().findComponent(GlButton);
+ const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]');
+ const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]');
+ const findWorkItemInformationAlert = () => wrapper.findComponent(WorkItemInformation);
+ const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const createComponent = ({
+ isModal = false,
workItemId = workItemQueryResponse.data.workItem.id,
handler = successHandler,
subscriptionHandler = initialSubscriptionHandler,
@@ -50,7 +68,7 @@ describe('WorkItemDetail component', () => {
typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {},
},
),
- propsData: { workItemId },
+ propsData: { isModal, workItemId },
provide: {
glFeatures: {
workItemsMvc2: workItemsMvc2Enabled,
@@ -98,6 +116,36 @@ describe('WorkItemDetail component', () => {
});
});
+ describe('close button', () => {
+ describe('when isModal prop is false', () => {
+ it('does not render', async () => {
+ createComponent({ isModal: false });
+ await waitForPromises();
+
+ expect(findCloseButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when isModal prop is true', () => {
+ it('renders', async () => {
+ createComponent({ isModal: true });
+ await waitForPromises();
+
+ expect(findCloseButton().props('icon')).toBe('close');
+ expect(findCloseButton().attributes('aria-label')).toBe('Close');
+ });
+
+ it('emits `close` event when clicked', async () => {
+ createComponent({ isModal: true });
+ await waitForPromises();
+
+ findCloseButton().vm.$emit('click');
+
+ expect(wrapper.emitted('close')).toEqual([[]]);
+ });
+ });
+ });
+
describe('description', () => {
it('does not show description widget if loading description fails', () => {
createComponent();
@@ -107,13 +155,56 @@ describe('WorkItemDetail component', () => {
it('shows description widget if description loads', async () => {
createComponent();
-
await waitForPromises();
expect(findWorkItemDescription().exists()).toBe(true);
});
});
+ describe('secondary breadcrumbs', () => {
+ it('does not show secondary breadcrumbs by default', () => {
+ createComponent();
+
+ expect(findParent().exists()).toBe(false);
+ });
+
+ it('does not show secondary breadcrumbs if there is not a parent', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findParent().exists()).toBe(false);
+ });
+
+ it('shows work item type if there is not a parent', async () => {
+ createComponent();
+
+ await waitForPromises();
+ expect(findWorkItemType().exists()).toBe(true);
+ });
+
+ describe('with parent', () => {
+ beforeEach(() => {
+ const parentResponse = workItemResponseFactory(mockParent);
+ createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) });
+
+ return waitForPromises();
+ });
+
+ it('shows secondary breadcrumbs if there is a parent', () => {
+ expect(findParent().exists()).toBe(true);
+ });
+
+ it('does not show work item type', async () => {
+ expect(findWorkItemType().exists()).toBe(false);
+ });
+
+ it('sets the parent breadcrumb URL', () => {
+ expect(findParentButton().attributes().href).toBe('../../issues/5');
+ });
+ });
+ });
+
it('shows an error message when the work item query was unsuccessful', async () => {
const errorHandler = jest.fn().mockRejectedValue('Oops');
createComponent({ handler: errorHandler });
@@ -145,7 +236,6 @@ describe('WorkItemDetail component', () => {
it('renders assignees component when assignees widget is returned from the API', async () => {
createComponent({
workItemsMvc2Enabled: true,
- includeWidgets: true,
});
await waitForPromises();
@@ -155,7 +245,9 @@ describe('WorkItemDetail component', () => {
it('does not render assignees component when assignees widget is not returned from the API', async () => {
createComponent({
workItemsMvc2Enabled: true,
- includeWidgets: false,
+ handler: jest
+ .fn()
+ .mockResolvedValue(workItemResponseFactory({ assigneesWidgetPresent: false })),
});
await waitForPromises();
@@ -170,6 +262,19 @@ describe('WorkItemDetail component', () => {
expect(findWorkItemAssignees().exists()).toBe(false);
});
+ describe('labels widget', () => {
+ it.each`
+ description | includeWidgets | exists
+ ${'renders when widget is returned from API'} | ${true} | ${true}
+ ${'does not render when widget is not returned from API'} | ${false} | ${false}
+ `('$description', async ({ includeWidgets, exists }) => {
+ createComponent({ includeWidgets, workItemsMvc2Enabled: true });
+ await waitForPromises();
+
+ expect(findWorkItemLabels().exists()).toBe(exists);
+ });
+ });
+
describe('weight widget', () => {
describe('when work_items_mvc_2 feature flag is enabled', () => {
describe.each`
@@ -201,4 +306,22 @@ describe('WorkItemDetail component', () => {
});
});
});
+
+ describe('work item information', () => {
+ beforeEach(() => {
+ createComponent();
+ return waitForPromises();
+ });
+
+ it('is visible when viewed for the first time and sets localStorage value', async () => {
+ localStorage.clear();
+ expect(findWorkItemInformationAlert().exists()).toBe(true);
+ expect(findLocalStorageSync().props('value')).toBe(true);
+ });
+
+ it('is not visible after reading local storage input', async () => {
+ await findLocalStorageSync().vm.$emit('input', false);
+ expect(findWorkItemInformationAlert().exists()).toBe(false);
+ });
+ });
});
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 3c5da94114e..d9372f2bcf0 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -52,6 +52,7 @@ describe('Work items root component', () => {
createComponent();
expect(findWorkItemDetail().props()).toEqual({
+ isModal: false,
workItemId: 'gid://gitlab/WorkItem/1',
workItemParentId: null,
});