From 5afcbe03ead9ada87621888a31a62652b10a7e4f Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 20 Sep 2023 11:18:08 +0000 Subject: Add latest changes from gitlab-org/gitlab@16-4-stable-ee --- spec/components/pajamas/banner_component_spec.rb | 2 +- .../consumer/resources/graphql/pipelines.js | 4 +- .../consumer/specs/project/pipelines/show.spec.js | 4 +- .../projects/releases_controller_spec.rb | 134 + spec/controllers/admin/jobs_controller_spec.rb | 2 - spec/controllers/admin/users_controller_spec.rb | 2 +- spec/controllers/application_controller_spec.rb | 27 - .../controllers/concerns/onboarding/status_spec.rb | 28 - .../concerns/preferred_language_switcher_spec.rb | 74 +- spec/controllers/confirmations_controller_spec.rb | 22 - ...endency_proxy_for_containers_controller_spec.rb | 4 +- spec/controllers/groups/labels_controller_spec.rb | 48 +- spec/controllers/groups/runners_controller_spec.rb | 230 +- spec/controllers/groups/uploads_controller_spec.rb | 2 +- spec/controllers/invites_controller_spec.rb | 20 - .../oauth/applications_controller_spec.rb | 15 +- .../oauth/authorizations_controller_spec.rb | 33 +- .../profiles/notifications_controller_spec.rb | 3 +- .../personal_access_tokens_controller_spec.rb | 56 +- .../profiles/preferences_controller_spec.rb | 1 + spec/controllers/profiles_controller_spec.rb | 14 - .../alerting/notifications_controller_spec.rb | 27 +- .../environments/sample_metrics_controller_spec.rb | 55 - .../projects/environments_controller_spec.rb | 2 +- .../controllers/projects/issues_controller_spec.rb | 10 +- spec/controllers/projects/jobs_controller_spec.rb | 16 +- .../controllers/projects/labels_controller_spec.rb | 47 + .../projects/merge_requests_controller_spec.rb | 66 - spec/controllers/projects/notes_controller_spec.rb | 2 +- .../projects/pipelines_controller_spec.rb | 42 +- .../projects/prometheus/alerts_controller_spec.rb | 110 - .../projects/uploads_controller_spec.rb | 2 +- .../registrations/welcome_controller_spec.rb | 28 +- .../repositories/git_http_controller_spec.rb | 25 +- spec/controllers/search_controller_spec.rb | 54 +- .../sent_notifications_controller_spec.rb | 2 +- spec/controllers/uploads_controller_spec.rb | 2 +- spec/db/avoid_migration_name_collisions_spec.rb | 17 + spec/db/schema_spec.rb | 51 +- spec/experiments/application_experiment_spec.rb | 31 +- spec/factories/ci/catalog/resources.rb | 2 +- spec/factories/ci/catalog/resources/components.rb | 4 +- spec/factories/ci/catalog/resources/versions.rb | 4 +- spec/factories/ci/reports/sbom/metadatum.rb | 44 + spec/factories/ci/reports/sbom/reports.rb | 20 +- spec/factories/issues.rb | 12 + spec/factories/merge_requests.rb | 8 + spec/factories/metrics/dashboard/annotations.rb | 9 - spec/factories/metrics/users_starred_dashboards.rb | 9 - spec/factories/ml/candidate_params.rb | 2 +- spec/factories/ml/candidates.rb | 10 +- spec/factories/packages/dependency_links.rb | 20 +- spec/factories/packages/nuget/symbol.rb | 11 + .../factories/packages/package_protection_rules.rb | 10 + spec/factories/packages/packages.rb | 4 +- spec/factories/pages_domains.rb | 466 +++ spec/factories/project_alerting_settings.rb | 20 +- spec/factories/project_authorizations.rb | 8 +- spec/factories/project_metrics_settings.rb | 8 - spec/factories/projects.rb | 2 +- .../self_managed_prometheus_alert_event.rb | 12 - .../service_desk/custom_email_verification.rb | 5 + spec/factories/usage_data.rb | 5 +- spec/factories/users.rb | 4 + spec/factories/users/group_visit.rb | 12 + spec/factories/users/project_visit.rb | 12 + spec/factories/work_items.rb | 12 + spec/features/abuse_report_spec.rb | 8 +- spec/features/admin/admin_abuse_reports_spec.rb | 354 +- spec/features/admin/admin_hooks_spec.rb | 2 +- spec/features/admin/admin_jobs_spec.rb | 114 +- spec/features/admin/admin_mode/logout_spec.rb | 2 +- spec/features/admin/admin_mode/workers_spec.rb | 6 +- spec/features/admin/admin_mode_spec.rb | 2 +- spec/features/admin/admin_runners_spec.rb | 7 + .../admin/admin_sees_background_migrations_spec.rb | 2 +- spec/features/admin/admin_settings_spec.rb | 10 +- spec/features/admin/users/user_spec.rb | 10 +- spec/features/admin/users/users_spec.rb | 6 +- .../alert_management/alert_details_spec.rb | 2 +- .../alert_management/alert_management_list_spec.rb | 2 +- spec/features/boards/board_filters_spec.rb | 250 +- spec/features/boards/multiple_boards_spec.rb | 2 +- spec/features/boards/sidebar_spec.rb | 2 +- spec/features/calendar_spec.rb | 2 +- spec/features/contextual_sidebar_spec.rb | 5 +- spec/features/cycle_analytics_spec.rb | 4 +- spec/features/dashboard/activity_spec.rb | 2 +- ...ard_with_external_authorization_service_spec.rb | 2 +- spec/features/dashboard/groups_list_spec.rb | 13 +- spec/features/dashboard/issuables_counter_spec.rb | 2 +- spec/features/dashboard/issues_spec.rb | 2 +- spec/features/dashboard/merge_requests_spec.rb | 2 +- spec/features/dashboard/milestones_spec.rb | 4 +- spec/features/dashboard/navbar_spec.rb | 2 +- spec/features/dashboard/projects_spec.rb | 2 +- spec/features/dashboard/shortcuts_spec.rb | 2 +- spec/features/dashboard/snippets_spec.rb | 2 +- spec/features/dashboard/todos/todos_spec.rb | 6 +- spec/features/explore/groups_list_spec.rb | 132 +- spec/features/explore/navbar_spec.rb | 1 + .../explore/user_explores_projects_spec.rb | 4 + spec/features/global_search_spec.rb | 2 +- spec/features/groups/container_registry_spec.rb | 2 +- .../groups/dependency_proxy_for_containers_spec.rb | 12 +- spec/features/groups/dependency_proxy_spec.rb | 4 +- ...age_with_external_authorization_service_spec.rb | 2 +- spec/features/groups/group_runners_spec.rb | 301 +- spec/features/groups/labels/create_spec.rb | 6 +- spec/features/groups/labels/edit_spec.rb | 14 + .../features/groups/members/manage_members_spec.rb | 2 +- .../features/groups/members/request_access_spec.rb | 4 +- spec/features/groups/navbar_spec.rb | 5 +- spec/features/groups/new_group_page_spec.rb | 4 +- spec/features/groups/packages_spec.rb | 2 +- .../settings/packages_and_registries_spec.rb | 2 +- .../groups/user_sees_package_sidebar_spec.rb | 2 +- spec/features/groups_spec.rb | 2 +- spec/features/help_dropdown_spec.rb | 4 +- spec/features/ide/user_opens_merge_request_spec.rb | 6 +- spec/features/incidents/incident_details_spec.rb | 5 +- spec/features/invites_spec.rb | 52 +- .../filtered_search/dropdown_assignee_spec.rb | 28 - .../issues/filtered_search/visual_tokens_spec.rb | 4 +- spec/features/issues/form_spec.rb | 85 +- spec/features/issues/issue_state_spec.rb | 66 +- spec/features/issues/move_spec.rb | 2 +- spec/features/issues/note_polling_spec.rb | 16 - spec/features/issues/service_desk_spec.rb | 6 +- spec/features/issues/todo_spec.rb | 2 +- spec/features/issues/user_creates_issue_spec.rb | 2 +- spec/features/issues/user_sees_live_update_spec.rb | 22 +- .../issues/user_uses_quick_actions_spec.rb | 4 +- spec/features/jira_connect/branches_spec.rb | 4 +- spec/features/labels_hierarchy_spec.rb | 2 +- ...user_closes_reopens_merge_request_state_spec.rb | 81 +- .../user_interacts_with_batched_mr_diffs_spec.rb | 3 +- .../user_merges_merge_request_spec.rb | 4 +- .../user_merges_when_pipeline_succeeds_spec.rb | 150 - .../user_opens_checkout_branch_modal_spec.rb | 7 +- .../user_sees_check_out_branch_modal_spec.rb | 6 +- .../user_sees_deployment_widget_spec.rb | 34 +- .../user_sees_merge_request_pipelines_spec.rb | 506 +-- .../merge_request/user_sees_merge_widget_spec.rb | 29 +- ...user_sees_pipelines_from_forked_project_spec.rb | 28 +- .../merge_request/user_sees_pipelines_spec.rb | 396 +-- .../user_selects_branches_for_new_mr_spec.rb | 5 +- .../merge_request/user_sets_to_auto_merge_spec.rb | 144 + .../merge_request/user_uses_quick_actions_spec.rb | 11 +- spec/features/monitor_sidebar_link_spec.rb | 2 +- spec/features/nav/pinned_nav_items_spec.rb | 18 +- spec/features/nav/top_nav_responsive_spec.rb | 2 +- spec/features/nav/top_nav_spec.rb | 2 +- spec/features/oauth_login_spec.rb | 2 +- spec/features/profiles/user_edit_profile_spec.rb | 4 +- .../profiles/user_visits_notifications_tab_spec.rb | 8 - .../user_visits_profile_account_page_spec.rb | 2 +- .../user_visits_profile_authentication_log_spec.rb | 2 +- .../user_visits_profile_preferences_page_spec.rb | 2 +- spec/features/profiles/user_visits_profile_spec.rb | 2 +- .../user_visits_profile_ssh_keys_page_spec.rb | 2 +- spec/features/projects/active_tabs_spec.rb | 5 +- .../projects/branches/user_creates_branch_spec.rb | 2 +- spec/features/projects/ci/editor_spec.rb | 2 +- spec/features/projects/clusters/gcp_spec.rb | 2 +- spec/features/projects/clusters/user_spec.rb | 2 +- spec/features/projects/clusters_spec.rb | 2 +- .../commit/user_sees_pipelines_tab_spec.rb | 2 +- .../confluence/user_views_confluence_page_spec.rb | 2 +- .../projects/environments/environment_spec.rb | 188 +- .../user_creates_feature_flag_spec.rb | 29 +- spec/features/projects/features_visibility_spec.rb | 4 +- .../project_owner_creates_license_file_spec.rb | 4 +- .../features/projects/files/user_find_file_spec.rb | 2 +- .../projects/files/user_searches_for_files_spec.rb | 11 +- spec/features/projects/forks/fork_list_spec.rb | 2 +- spec/features/projects/graph_spec.rb | 2 +- .../projects/import_export/export_file_spec.rb | 4 + .../projects/jobs/user_browses_jobs_spec.rb | 8 +- spec/features/projects/jobs_spec.rb | 8 +- .../projects/labels/user_creates_labels_spec.rb | 2 + .../projects/labels/user_edits_labels_spec.rb | 25 +- .../projects/members/manage_members_spec.rb | 2 +- .../projects/members/user_requests_access_spec.rb | 4 +- .../milestones/user_interacts_with_labels_spec.rb | 2 +- spec/features/projects/navbar_spec.rb | 8 +- spec/features/projects/new_project_spec.rb | 14 +- .../projects/pages/user_edits_settings_spec.rb | 2 +- spec/features/projects/pipeline_schedules_spec.rb | 484 ++- spec/features/projects/pipelines/pipeline_spec.rb | 12 +- spec/features/projects/pipelines/pipelines_spec.rb | 18 +- .../projects/settings/monitor_settings_spec.rb | 5 +- .../registry_settings_cleanup_tags_spec.rb | 2 +- .../projects/settings/registry_settings_spec.rb | 2 +- .../projects/settings/service_desk_setting_spec.rb | 2 +- .../show/user_sees_collaboration_links_spec.rb | 2 +- spec/features/projects/user_sees_sidebar_spec.rb | 4 +- spec/features/projects/user_uses_shortcuts_spec.rb | 57 +- spec/features/projects/wikis_spec.rb | 2 +- .../features/projects/work_items/work_item_spec.rb | 48 +- spec/features/projects_spec.rb | 18 +- spec/features/runners_spec.rb | 489 ++- .../features/search/user_searches_for_code_spec.rb | 2 +- .../search/user_searches_for_comments_spec.rb | 2 +- .../search/user_searches_for_commits_spec.rb | 2 +- .../search/user_searches_for_issues_spec.rb | 2 +- .../user_searches_for_merge_requests_spec.rb | 2 +- .../search/user_searches_for_milestones_spec.rb | 2 +- .../search/user_searches_for_projects_spec.rb | 2 +- .../search/user_searches_for_users_spec.rb | 6 +- .../search/user_searches_for_wiki_pages_spec.rb | 2 +- .../search/user_uses_header_search_field_spec.rb | 4 +- spec/features/sentry_js_spec.rb | 1 + spec/features/signed_commits_spec.rb | 8 +- spec/features/snippets/search_snippets_spec.rb | 5 +- spec/features/snippets/show_spec.rb | 8 +- .../features/snippets/user_creates_snippet_spec.rb | 10 +- spec/features/task_lists_spec.rb | 4 +- spec/features/unsubscribe_links_spec.rb | 8 +- .../uploads/user_uploads_avatar_to_profile_spec.rb | 2 +- spec/features/usage_stats_consent_spec.rb | 2 +- spec/features/users/active_sessions_spec.rb | 8 +- spec/features/users/anonymous_sessions_spec.rb | 2 +- .../users/email_verification_on_login_spec.rb | 11 +- spec/features/users/login_spec.rb | 50 +- spec/features/users/logout_spec.rb | 2 +- spec/features/users/overview_spec.rb | 2 +- spec/features/users/rss_spec.rb | 6 +- spec/features/users/show_spec.rb | 4 + spec/features/users/signup_spec.rb | 16 +- spec/features/users/snippets_spec.rb | 6 +- spec/features/users/terms_spec.rb | 6 +- .../user_browses_projects_on_user_page_spec.rb | 6 +- spec/features/webauthn_spec.rb | 17 +- spec/features/whats_new_spec.rb | 6 +- spec/finders/abuse_reports_finder_spec.rb | 48 +- spec/finders/ci/jobs_finder_spec.rb | 281 +- spec/finders/ci/runners_finder_spec.rb | 226 +- spec/finders/ci/triggers_finder_spec.rb | 29 + spec/finders/deployments_finder_spec.rb | 16 + spec/finders/group_members_finder_spec.rb | 35 + .../accepting_group_transfers_finder_spec.rb | 21 +- spec/finders/organizations/groups_finder_spec.rb | 84 + .../organization_users_finder_spec.rb | 35 + .../packages/npm/packages_for_user_finder_spec.rb | 41 + spec/finders/packages/nuget/package_finder_spec.rb | 10 - .../schemas/entities/codequality_degradation.json | 3 + spec/fixtures/api/schemas/job/job.json | 1 - spec/fixtures/api/schemas/ml/search_runs.json | 82 + .../api/schemas/public_api/v4/integration.json | 3 + .../schemas/public_api/v4/operations/strategy.json | 3 +- .../public_api/v4/operations/user_list.json | 16 + .../api/schemas/status/ci_detailed_status.json | 2 +- spec/fixtures/ci_secure_files/sample.p12 | Bin 3352 -> 3219 bytes .../sample_event.yml | 14 +- .../sample_event_ee.yml | 10 +- spec/fixtures/packages/nuget/symbol/package.pdb | Bin 0 -> 10588 bytes .../master/gl-common-scanning-report.json | 48 +- .../__helpers__/clean_html_element_serializer.js | 142 + .../__helpers__/dom_shims/get_client_rects.js | 3 +- .../frontend/__helpers__/html_string_serializer.js | 11 + spec/frontend/__helpers__/vue_test_utils_helper.js | 121 +- .../__helpers__/vue_test_utils_helper_spec.js | 46 +- .../components/access_token_table_app_spec.js | 4 +- .../add_context_commits_modal_spec.js.snap | 4 - .../components/abuse_report_app_spec.js | 80 +- .../components/activity_events_list_spec.js | 30 + .../components/activity_history_item_spec.js | 64 + .../abuse_report/components/history_items_spec.js | 66 - .../abuse_report/components/labels_select_spec.js | 297 ++ .../abuse_report/components/report_actions_spec.js | 27 - .../abuse_report/components/report_details_spec.js | 74 + .../abuse_report/components/report_header_spec.js | 55 +- .../components/reported_content_spec.js | 11 +- .../abuse_report/components/user_details_spec.js | 62 +- spec/frontend/admin/abuse_report/mock_data.js | 88 +- .../components/abuse_report_row_spec.js | 14 + spec/frontend/admin/abuse_reports/mock_data.js | 3 + .../__snapshots__/delete_application_spec.js.snap | 1 - .../__snapshots__/remove_avatar_spec.js.snap | 1 - .../associations_list_item_spec.js.snap | 9 +- .../__snapshots__/delete_user_modal_spec.js.snap | 2 - .../admin/users/components/user_actions_spec.js | 2 +- .../__snapshots__/alerts_form_spec.js.snap | 32 +- .../components/alerts_settings_wrapper_spec.js | 2 +- .../__snapshots__/total_time_spec.js.snap | 46 +- spec/frontend/api/application_settings_api_spec.js | 45 + .../keep_latest_artifact_checkbox_spec.js.snap | 74 +- spec/frontend/avatar_helper_spec.js | 110 - .../markdown/paste_markdown_table_spec.js | 6 +- .../__snapshots__/blob_edit_header_spec.js.snap | 6 +- .../blob_header_filepath_spec.js.snap | 10 +- .../__snapshots__/blob_header_spec.js.snap | 34 - spec/frontend/blob/components/blob_header_spec.js | 61 +- spec/frontend/blob/components/mock_data.js | 15 + spec/frontend/blob/line_highlighter_spec.js | 9 + spec/frontend/blob/openapi/index_spec.js | 31 +- spec/frontend/boards/board_card_inner_spec.js | 1 + spec/frontend/boards/components/board_card_spec.js | 1 + .../boards/components/boards_selector_spec.js | 12 + .../components/issue_board_filtered_search_spec.js | 7 +- .../components/sidebar/board_sidebar_title_spec.js | 8 +- spec/frontend/boards/mock_data.js | 5 +- .../delete_merged_branches_spec.js.snap | 51 +- .../__snapshots__/divergence_graph_spec.js.snap | 8 +- .../admin/jobs_table/admin_job_table_app_spec.js | 445 +++ .../components/cancel_jobs_modal_spec.js | 66 + .../jobs_table/components/cancel_jobs_spec.js | 54 + .../components/cells/project_cell_spec.js | 32 + .../components/cells/runner_cell_spec.js | 64 + .../components/jobs_skeleton_loader_spec.js | 28 + .../admin/jobs_table/graphql/cache_config_spec.js | 106 + .../artifacts/components/feedback_banner_spec.js | 59 - .../components/job_artifacts_table_spec.js | 12 +- .../ci_variable_list/ci_variable_list_spec.js | 161 - .../native_form_variable_list_spec.js | 41 - .../components/ci_variable_drawer_spec.js | 338 +- .../components/ci_variable_settings_spec.js | 15 + .../components/ci_variable_table_spec.js | 5 +- spec/frontend/ci/common/pipelines_table_spec.js | 280 ++ .../ci/common/private/job_links_layer_spec.js | 85 + .../jobs_filtered_search_spec.js | 123 + .../tokens/job_status_token_spec.js | 58 + .../private/jobs_filtered_search/utils_spec.js | 22 + .../ci/job_details/components/empty_state_spec.js | 140 + .../components/environments_block_spec.js | 260 ++ .../ci/job_details/components/erased_block_spec.js | 59 + .../ci/job_details/components/job_header_spec.js | 154 + .../components/job_log_controllers_spec.js | 321 ++ .../components/log/collapsible_section_spec.js | 95 + .../components/log/duration_badge_spec.js | 26 + .../job_details/components/log/line_header_spec.js | 133 + .../job_details/components/log/line_number_spec.js | 35 + .../ci/job_details/components/log/line_spec.js | 256 ++ .../ci/job_details/components/log/log_spec.js | 162 + .../ci/job_details/components/log/mock_data.js | 218 ++ .../components/manual_variables_form_spec.js | 364 ++ .../components/sidebar/artifacts_block_spec.js | 193 ++ .../components/sidebar/commit_block_spec.js | 66 + .../sidebar/external_links_block_spec.js | 49 + .../components/sidebar/job_container_item_spec.js | 87 + .../job_retry_forward_deployment_modal_spec.js | 68 + .../sidebar/job_sidebar_retry_button_spec.js | 64 + .../components/sidebar/jobs_container_spec.js | 143 + .../components/sidebar/sidebar_detail_row_spec.js | 68 + .../components/sidebar/sidebar_header_spec.js | 101 + .../sidebar/sidebar_job_details_container_spec.js | 134 + .../job_details/components/sidebar/sidebar_spec.js | 222 ++ .../components/sidebar/stages_dropdown_spec.js | 192 ++ .../components/sidebar/trigger_block_spec.js | 81 + .../ci/job_details/components/stuck_block_spec.js | 94 + .../components/unmet_prerequisites_block_spec.js | 37 + spec/frontend/ci/job_details/job_app_spec.js | 343 ++ spec/frontend/ci/job_details/mock_data.js | 123 + spec/frontend/ci/job_details/store/actions_spec.js | 502 +++ spec/frontend/ci/job_details/store/getters_spec.js | 245 ++ spec/frontend/ci/job_details/store/helpers.js | 5 + .../ci/job_details/store/mutations_spec.js | 269 ++ spec/frontend/ci/job_details/store/utils_spec.js | 510 +++ spec/frontend/ci/job_details/utils_spec.js | 265 ++ spec/frontend/ci/jobs_mock_data.js | 1629 +++++++++ .../components/job_cells/actions_cell_spec.js | 240 ++ .../components/job_cells/duration_cell_spec.js | 77 + .../components/job_cells/job_cell_spec.js | 142 + .../components/job_cells/pipeline_cell_spec.js | 78 + .../components/jobs_table_empty_state_spec.js | 37 + .../ci/jobs_page/components/jobs_table_spec.js | 107 + .../jobs_page/components/jobs_table_tabs_spec.js | 81 + .../ci/jobs_page/graphql/cache_config_spec.js | 106 + spec/frontend/ci/jobs_page/job_page_app_spec.js | 338 ++ .../components/pipelines_table_wrapper_spec.js | 117 + spec/frontend/ci/merge_requests/mock_data.js | 30 + spec/frontend/ci/mixins/delayed_job_mixin_spec.js | 119 + .../__snapshots__/dag_graph_spec.js.snap | 743 ++++ .../dag/components/dag_annotations_spec.js | 98 + .../dag/components/dag_graph_spec.js | 209 ++ spec/frontend/ci/pipeline_details/dag/dag_spec.js | 168 + spec/frontend/ci/pipeline_details/dag/mock_data.js | 674 ++++ .../dag/utils/drawing_utils_spec.js | 57 + .../__snapshots__/links_inner_spec.js.snap | 110 + .../graph/components/action_component_spec.js | 116 + .../graph/components/graph_component_spec.js | 182 + .../graph/components/graph_view_selector_spec.js | 217 ++ .../graph/components/job_group_dropdown_spec.js | 84 + .../graph/components/job_item_spec.js | 492 +++ .../graph/components/job_name_component_spec.js | 30 + .../graph/components/linked_pipeline_spec.js | 464 +++ .../components/linked_pipelines_column_spec.js | 214 ++ .../graph/components/linked_pipelines_mock_data.js | 27 + .../graph/components/links_inner_spec.js | 223 ++ .../components/stage_column_component_spec.js | 228 ++ .../graph/graph_component_wrapper_spec.js | 603 ++++ .../ci/pipeline_details/graph/mock_data.js | 383 +++ .../header/pipeline_details_header_spec.js | 452 +++ .../jobs/components/failed_jobs_table_spec.js | 141 + .../pipeline_details/jobs/failed_jobs_app_spec.js | 80 + .../ci/pipeline_details/jobs/jobs_app_spec.js | 127 + .../ci/pipeline_details/linked_pipelines_mock.json | 3569 ++++++++++++++++++++ spec/frontend/ci/pipeline_details/mock_data.js | 1277 +++++++ .../ci/pipeline_details/pipeline_tabs_spec.js | 63 + .../ci/pipeline_details/pipelines_store_spec.js | 80 + .../ci/pipeline_details/tabs/pipeline_tabs_spec.js | 114 + .../test_reports/empty_state_spec.js | 45 + .../ci/pipeline_details/test_reports/mock_data.js | 31 + .../test_reports/stores/actions_spec.js | 149 + .../test_reports/stores/getters_spec.js | 171 + .../test_reports/stores/mutations_spec.js | 114 + .../test_reports/stores/utils_spec.js | 40 + .../test_reports/test_case_details_spec.js | 149 + .../test_reports/test_reports_spec.js | 125 + .../test_reports/test_suite_table_spec.js | 169 + .../test_reports/test_summary_spec.js | 106 + .../test_reports/test_summary_table_spec.js | 100 + .../ci/pipeline_details/utils/index_spec.js | 201 ++ .../pipeline_details/utils/parsing_utils_spec.js | 191 ++ .../utils/unwrapping_utils_spec.js | 127 + .../pipeline_editor/components/graph/mock_data.js | 283 ++ .../components/graph/pipeline_graph_spec.js | 100 + .../header/pipeline_editor_header_spec.js | 4 + .../header/pipeline_editor_mini_graph_spec.js | 4 +- .../components/header/pipeline_status_spec.js | 13 +- .../components/pipeline_editor_tabs_spec.js | 2 +- spec/frontend/ci/pipeline_editor/mock_data.js | 2 +- .../ci/pipeline_mini_graph/job_item_spec.js | 29 + .../legacy_pipeline_mini_graph_spec.js | 122 + .../legacy_pipeline_stage_spec.js | 247 ++ .../linked_pipelines_mini_list_spec.js | 166 + .../linked_pipelines_mock_data.js | 407 +++ spec/frontend/ci/pipeline_mini_graph/mock_data.js | 252 ++ .../pipeline_mini_graph_spec.js | 123 + .../ci/pipeline_mini_graph/pipeline_stage_spec.js | 46 + .../ci/pipeline_mini_graph/pipeline_stages_spec.js | 63 + .../components/pipeline_schedules_form_spec.js | 11 + .../table/cells/pipeline_schedule_target_spec.js | 36 +- .../components/take_ownership_modal_legacy_spec.js | 42 - spec/frontend/ci/pipeline_schedules/mock_data.js | 2 +- .../components/empty_state/ci_templates_spec.js | 107 + .../components/empty_state/ios_templates_spec.js | 133 + .../empty_state/no_ci_empty_state_spec.js | 87 + .../empty_state/pipelines_ci_templates_spec.js | 58 + .../failure_widget/failed_job_details_spec.js | 254 ++ .../failure_widget/failed_jobs_list_spec.js | 279 ++ .../components/failure_widget/mock.js | 78 + .../pipeline_failed_jobs_widget_spec.js | 139 + .../components/failure_widget/utils_spec.js | 55 + .../pipelines_page/components/nav_controls_spec.js | 80 + .../components/pipeline_labels_spec.js | 164 + .../components/pipeline_multi_actions_spec.js | 316 ++ .../components/pipeline_operations_spec.js | 77 + .../components/pipeline_stop_modal_spec.js | 27 + .../components/pipeline_triggerer_spec.js | 76 + .../pipelines_page/components/pipeline_url_spec.js | 188 ++ .../components/pipelines_artifacts_spec.js | 64 + .../components/pipelines_filtered_search_spec.js | 199 ++ .../components/pipelines_manual_actions_spec.js | 216 ++ .../ci/pipelines_page/components/time_ago_spec.js | 85 + spec/frontend/ci/pipelines_page/pipelines_spec.js | 851 +++++ .../tokens/pipeline_branch_name_token_spec.js | 142 + .../tokens/pipeline_source_token_spec.js | 53 + .../tokens/pipeline_status_token_spec.js | 58 + .../tokens/pipeline_tag_name_token_spec.js | 95 + .../tokens/pipeline_trigger_author_token_spec.js | 99 + .../__snapshots__/issue_status_icon_spec.js.snap | 7 +- .../components/cells/runner_summary_cell_spec.js | 10 - .../runner/components/runner_create_form_spec.js | 1 + .../runner/components/runner_form_fields_spec.js | 3 +- .../components/runner_managers_table_spec.js | 4 +- .../runner/components/runner_update_form_spec.js | 2 + .../metadata/__snapshots__/modal_spec.js.snap | 101 +- .../__snapshots__/new_cluster_spec.js.snap | 19 +- .../remove_cluster_confirmation_spec.js.snap | 103 +- .../components/__snapshots__/popover_spec.js.snap | 24 +- .../__snapshots__/list_item_spec.js.snap | 47 +- .../commit/commit_box_pipeline_mini_graph_spec.js | 8 +- .../legacy_pipelines_table_wrapper_spec.js | 362 ++ .../commit/pipelines/pipelines_table_spec.js | 362 -- .../__snapshots__/project_form_group_spec.js.snap | 26 +- .../__snapshots__/toolbar_button_spec.js.snap | 20 +- .../__snapshots__/table_of_contents_spec.js.snap | 54 +- .../components/wrappers/code_block_spec.js | 71 +- .../services/markdown_serializer_spec.js | 2 +- .../contribution_event_created_spec.js | 18 +- .../contribution_event_destroyed_spec.js | 32 + .../contribution_event_updated_spec.js | 31 + .../components/contribution_events_spec.js | 8 + .../components/target_link_spec.js | 2 +- spec/frontend/contribution_events/utils.js | 95 +- .../__snapshots__/contributors_spec.js.snap | 20 +- .../components/__snapshots__/list_spec.js.snap | 58 +- .../__snapshots__/design_presentation_spec.js.snap | 30 +- .../components/__snapshots__/image_spec.js.snap | 18 +- .../design_note_signed_out_spec.js.snap | 12 +- .../__snapshots__/design_reply_form_spec.js.snap | 36 +- .../list/__snapshots__/item_spec.js.snap | 66 +- .../design_management/components/list/item_spec.js | 4 +- .../toolbar/__snapshots__/index_spec.js.snap | 19 +- .../upload/__snapshots__/button_spec.js.snap | 10 +- .../pages/design/__snapshots__/index_spec.js.snap | 65 +- spec/frontend/diffs/components/app_spec.js | 23 +- .../components/diff_inline_findings_item_spec.js | 51 +- .../diffs/components/diff_inline_findings_spec.js | 6 +- spec/frontend/diffs/components/diff_row_spec.js | 3 +- .../diffs/components/inline_findings_spec.js | 6 +- .../__snapshots__/findings_drawer_spec.js.snap | 31 +- spec/frontend/diffs/mock_data/inline_findings.js | 60 +- spec/frontend/drawio/drawio_editor_spec.js | 5 +- .../ci/yaml_tests/positive_tests/include.yml | 11 + spec/frontend/emoji/index_spec.js | 17 +- .../frontend/environments/edit_environment_spec.js | 20 +- .../frontend/environments/environment_form_spec.js | 47 +- .../environments/new_environment_item_spec.js | 45 +- .../components/new_environments_dropdown_spec.js | 80 +- .../feature_flags/components/strategy_spec.js | 13 +- .../filtered_search_manager_spec.js | 2 +- spec/frontend/fixtures/abuse_reports.rb | 28 - spec/frontend/fixtures/issues.rb | 2 +- spec/frontend/fixtures/jobs.rb | 10 +- spec/frontend/fixtures/pipeline_header.rb | 2 +- spec/frontend/fixtures/pipeline_schedules.rb | 29 - spec/frontend/fixtures/pipelines.rb | 2 +- spec/frontend/fixtures/snippet.rb | 4 +- .../groups_dashboard_empty_state_spec.js | 29 + .../groups_explore_empty_state_spec.js | 27 + .../ide/components/file_templates/dropdown_spec.js | 168 - .../pipelines/__snapshots__/list_spec.js.snap | 4 +- spec/frontend/ide/init_gitlab_web_ide_spec.js | 3 + .../lib/gitlab_web_ide/setup_root_element_spec.js | 4 +- .../import_groups/components/import_table_spec.js | 2 +- .../incidents/components/incidents_list_spec.js | 2 +- .../__snapshots__/pagerduty_form_spec.js.snap | 14 +- spec/frontend/integrations/index/mock_data.js | 6 + .../components/invite_members_modal_spec.js | 145 +- .../invite_members/mock_data/api_responses.js | 6 + .../invite_members/mock_data/member_modal.js | 14 - .../invite_members/utils/member_utils_spec.js | 22 +- .../issuable/components/csv_export_modal_spec.js | 2 +- .../components/issuable_header_warnings_spec.js | 105 - .../issuable/components/status_badge_spec.js | 43 + .../issuable/components/status_box_spec.js | 50 - .../popover/components/issue_popover_spec.js | 6 +- .../components/issues_dashboard_app_spec.js | 1 - spec/frontend/issues/dashboard/mock_data.js | 1 + .../list/components/issue_card_time_info_spec.js | 125 +- .../issues/list/components/issues_list_app_spec.js | 1 - spec/frontend/issues/list/mock_data.js | 1 + .../__snapshots__/type_popover_spec.js.snap | 10 +- .../components/empty_state_with_any_issues_spec.js | 74 + .../empty_state_without_any_issues_spec.js | 90 + .../service_desk/components/info_banner_spec.js | 81 + .../components/service_desk_list_app_spec.js | 717 ++++ spec/frontend/issues/service_desk/mock_data.js | 253 ++ spec/frontend/issues/show/components/app_spec.js | 154 +- .../issues/show/components/sticky_header_spec.js | 135 + .../show/components/task_list_item_actions_spec.js | 52 +- spec/frontend/issues/show/issue_spec.js | 43 - spec/frontend/issues/show/mock_data/mock_data.js | 3 +- spec/frontend/issues/show/store_spec.js | 39 - .../components/source_branch_dropdown_spec.js | 146 +- spec/frontend/jira_connect/branches/mock_data.js | 15 + .../__snapshots__/group_item_name_spec.js.snap | 13 +- .../__snapshots__/jira_import_form_spec.js.snap | 86 +- .../filtered_search/jobs_filtered_search_spec.js | 71 - .../tokens/job_status_token_spec.js | 58 - .../jobs/components/filtered_search/utils_spec.js | 19 - .../jobs/components/job/artifacts_block_spec.js | 193 -- .../jobs/components/job/commit_block_spec.js | 66 - .../jobs/components/job/empty_state_spec.js | 140 - .../jobs/components/job/environments_block_spec.js | 260 -- .../jobs/components/job/erased_block_spec.js | 59 - spec/frontend/jobs/components/job/job_app_spec.js | 343 -- .../jobs/components/job/job_container_item_spec.js | 87 - .../components/job/job_log_controllers_spec.js | 323 -- .../job/job_retry_forward_deployment_modal_spec.js | 67 - .../job/job_sidebar_details_container_spec.js | 133 - .../job/job_sidebar_retry_button_spec.js | 64 - .../jobs/components/job/jobs_container_spec.js | 143 - .../components/job/manual_variables_form_spec.js | 364 -- spec/frontend/jobs/components/job/mock_data.js | 123 - .../jobs/components/job/sidebar_detail_row_spec.js | 68 - .../jobs/components/job/sidebar_header_spec.js | 87 - spec/frontend/jobs/components/job/sidebar_spec.js | 216 -- .../jobs/components/job/stages_dropdown_spec.js | 191 -- .../jobs/components/job/stuck_block_spec.js | 94 - .../jobs/components/job/trigger_block_spec.js | 81 - .../job/unmet_prerequisites_block_spec.js | 37 - .../components/log/collapsible_section_spec.js | 71 - .../jobs/components/log/duration_badge_spec.js | 26 - .../jobs/components/log/line_header_spec.js | 119 - .../jobs/components/log/line_number_spec.js | 35 - spec/frontend/jobs/components/log/line_spec.js | 267 -- spec/frontend/jobs/components/log/log_spec.js | 135 - spec/frontend/jobs/components/log/mock_data.js | 218 -- .../components/table/cells/actions_cell_spec.js | 240 -- .../components/table/cells/duration_cell_spec.js | 77 - .../jobs/components/table/cells/job_cell_spec.js | 142 - .../components/table/cells/pipeline_cell_spec.js | 78 - .../components/table/graphql/cache_config_spec.js | 106 - .../jobs/components/table/job_table_app_spec.js | 338 -- .../table/jobs_table_empty_state_spec.js | 37 - .../jobs/components/table/jobs_table_spec.js | 98 - .../jobs/components/table/jobs_table_tabs_spec.js | 81 - .../frontend/jobs/mixins/delayed_job_mixin_spec.js | 119 - spec/frontend/jobs/mock_data.js | 1628 --------- spec/frontend/jobs/store/actions_spec.js | 502 --- spec/frontend/jobs/store/getters_spec.js | 245 -- spec/frontend/jobs/store/helpers.js | 5 - spec/frontend/jobs/store/mutations_spec.js | 269 -- spec/frontend/jobs/store/utils_spec.js | 510 --- spec/frontend/lib/utils/array_utility_spec.js | 36 + spec/frontend/lib/utils/breadcrumbs_spec.js | 84 + spec/frontend/lib/utils/common_utils_spec.js | 39 + spec/frontend/lib/utils/datetime_range_spec.js | 382 --- spec/frontend/lib/utils/secret_detection_spec.js | 1 + spec/frontend/lib/utils/text_utility_spec.js | 17 - spec/frontend/lib/utils/url_utility_spec.js | 10 +- .../__snapshots__/member_activity_spec.js.snap | 26 +- spec/frontend/merge_request_tabs_spec.js | 16 + .../merge_requests/components/compare_app_spec.js | 54 +- .../components/header_metadata_spec.js | 93 + .../nav/components/top_nav_new_dropdown_spec.js | 3 +- .../__snapshots__/notes_app_spec.js.snap | 40 +- .../frontend/notes/components/comment_form_spec.js | 5 - spec/frontend/notes/components/notes_app_spec.js | 1 - spec/frontend/notes/stores/actions_spec.js | 174 +- spec/frontend/observability/client_spec.js | 129 +- .../groups_and_projects/components/app_spec.js | 19 +- .../components/groups_page_spec.js | 88 - .../components/projects_page_spec.js | 88 - .../organizations/groups_and_projects/mock_data.js | 252 -- .../groups_and_projects/utils_spec.js | 35 - .../shared/components/groups_view_spec.js | 146 + .../shared/components/projects_view_spec.js | 146 + spec/frontend/organizations/shared/utils_spec.js | 39 + .../organizations/show/components/app_spec.js | 49 + .../show/components/association_count_card_spec.js | 48 + .../show/components/association_counts_spec.js | 61 + .../show/components/groups_and_projects_spec.js | 106 + .../show/components/organization_avatar_spec.js | 64 + spec/frontend/organizations/show/utils_spec.js | 20 + .../__snapshots__/tags_loader_spec.js.snap | 6 - .../__snapshots__/group_empty_state_spec.js.snap | 2 +- .../__snapshots__/project_empty_state_spec.js.snap | 18 +- .../dependency_proxy/app_spec.js | 71 +- .../dependency_proxy/utils_spec.js | 25 + .../components/__snapshots__/file_sha_spec.js.snap | 11 +- .../terraform_installation_spec.js.snap | 4 - .../__snapshots__/packages_list_app_spec.js.snap | 25 +- .../__snapshots__/package_list_row_spec.js.snap | 42 +- .../__snapshots__/conan_installation_spec.js.snap | 5 +- .../__snapshots__/dependency_row_spec.js.snap | 8 +- .../details/__snapshots__/file_sha_spec.js.snap | 11 +- .../__snapshots__/maven_installation_spec.js.snap | 67 +- .../__snapshots__/npm_installation_spec.js.snap | 8 +- .../__snapshots__/nuget_installation_spec.js.snap | 5 +- .../__snapshots__/pypi_installation_spec.js.snap | 112 +- .../__snapshots__/package_list_row_spec.js.snap | 47 +- .../list/__snapshots__/publish_method_spec.js.snap | 6 +- .../components/list/packages_list_spec.js | 20 - .../components/list/packages_search_spec.js | 105 +- .../package_registry/pages/list_spec.js | 21 +- .../package_registry/utils_spec.js | 52 +- .../container_expiration_policy_form_spec.js.snap | 2 +- .../__snapshots__/publish_method_spec.js.snap | 6 +- .../__snapshots__/registry_breadcrumb_spec.js.snap | 24 +- .../shared/components/cli_commands_spec.js | 4 +- .../shared/components/persisted_search_spec.js | 4 + .../admin/abuse_reports/abuse_reports_spec.js | 48 - .../jobs/components/cancel_jobs_modal_spec.js | 66 - .../admin/jobs/components/cancel_jobs_spec.js | 9 +- .../jobs/components/jobs_skeleton_loader_spec.js | 28 - .../components/table/admin_job_table_app_spec.js | 105 +- .../components/table/cells/project_cell_spec.js | 32 - .../components/table/cells/runner_cell_spec.js | 64 - .../components/table/graphql/cache_config_spec.js | 106 - .../bitbucket_server_status_table_spec.js | 6 +- .../components/pipeline_schedule_callout_spec.js | 92 - .../dag/__snapshots__/dag_graph_spec.js.snap | 230 -- .../components/dag/dag_annotations_spec.js | 98 - .../pipelines/components/dag/dag_graph_spec.js | 209 -- spec/frontend/pipelines/components/dag/dag_spec.js | 168 - .../pipelines/components/dag/drawing_utils_spec.js | 57 - .../frontend/pipelines/components/dag/mock_data.js | 674 ---- .../components/jobs/failed_jobs_app_spec.js | 80 - .../components/jobs/failed_jobs_table_spec.js | 141 - .../pipelines/components/jobs/jobs_app_spec.js | 127 - .../pipeline_mini_graph/job_item_spec.js | 29 - .../legacy_pipeline_mini_graph_spec.js | 122 - .../legacy_pipeline_stage_spec.js | 247 -- .../linked_pipelines_mini_list_spec.js | 166 - .../linked_pipelines_mock_data.js | 407 --- .../components/pipeline_mini_graph/mock_data.js | 150 - .../pipeline_mini_graph_spec.js | 123 - .../pipeline_mini_graph/pipeline_stage_spec.js | 46 - .../pipeline_mini_graph/pipeline_stages_spec.js | 63 - .../pipelines/components/pipeline_tabs_spec.js | 114 - .../components/pipelines_filtered_search_spec.js | 199 -- .../empty_state/ci_templates_spec.js | 107 - .../empty_state/ios_templates_spec.js | 133 - .../empty_state/pipelines_ci_templates_spec.js | 58 - .../failure_widget/failed_job_details_spec.js | 254 -- .../failure_widget/failed_jobs_list_spec.js | 279 -- .../pipelines_list/failure_widget/mock.js | 78 - .../pipeline_failed_jobs_widget_spec.js | 139 - .../pipelines_list/failure_widget/utils_spec.js | 58 - .../pipelines_list/pipieline_stop_modal_spec.js | 27 - spec/frontend/pipelines/empty_state_spec.js | 87 - .../pipelines/graph/action_component_spec.js | 116 - .../pipelines/graph/graph_component_spec.js | 182 - .../graph/graph_component_wrapper_spec.js | 603 ---- .../pipelines/graph/graph_view_selector_spec.js | 217 -- .../pipelines/graph/job_group_dropdown_spec.js | 84 - spec/frontend/pipelines/graph/job_item_spec.js | 492 --- .../pipelines/graph/job_name_component_spec.js | 30 - .../pipelines/graph/linked_pipeline_spec.js | 464 --- .../graph/linked_pipelines_column_spec.js | 214 -- .../pipelines/graph/linked_pipelines_mock_data.js | 27 - spec/frontend/pipelines/graph/mock_data.js | 387 --- .../pipelines/graph/stage_column_component_spec.js | 228 -- .../__snapshots__/links_inner_spec.js.snap | 30 - .../pipelines/graph_shared/links_inner_spec.js | 223 -- .../pipelines/graph_shared/links_layer_spec.js | 85 - spec/frontend/pipelines/linked_pipelines_mock.json | 3569 -------------------- spec/frontend/pipelines/mock_data.js | 1379 -------- spec/frontend/pipelines/nav_controls_spec.js | 80 - spec/frontend/pipelines/notification/mock_data.js | 33 - .../pipelines/pipeline_details_header_spec.js | 452 --- .../frontend/pipelines/pipeline_graph/mock_data.js | 283 -- .../pipeline_graph/pipeline_graph_spec.js | 100 - .../pipelines/pipeline_graph/utils_spec.js | 197 -- spec/frontend/pipelines/pipeline_labels_spec.js | 164 - .../pipelines/pipeline_multi_actions_spec.js | 288 -- .../frontend/pipelines/pipeline_operations_spec.js | 77 - spec/frontend/pipelines/pipeline_tabs_spec.js | 63 - spec/frontend/pipelines/pipeline_triggerer_spec.js | 76 - spec/frontend/pipelines/pipeline_url_spec.js | 184 - .../frontend/pipelines/pipelines_artifacts_spec.js | 64 - .../pipelines/pipelines_manual_actions_spec.js | 216 -- spec/frontend/pipelines/pipelines_spec.js | 850 ----- spec/frontend/pipelines/pipelines_store_spec.js | 80 - spec/frontend/pipelines/pipelines_table_spec.js | 280 -- .../pipelines/test_reports/empty_state_spec.js | 45 - spec/frontend/pipelines/test_reports/mock_data.js | 31 - .../pipelines/test_reports/stores/actions_spec.js | 149 - .../pipelines/test_reports/stores/getters_spec.js | 171 - .../test_reports/stores/mutations_spec.js | 114 - .../pipelines/test_reports/stores/utils_spec.js | 40 - .../test_reports/test_case_details_spec.js | 149 - .../pipelines/test_reports/test_reports_spec.js | 125 - .../test_reports/test_suite_table_spec.js | 169 - .../pipelines/test_reports/test_summary_spec.js | 106 - .../test_reports/test_summary_table_spec.js | 100 - spec/frontend/pipelines/time_ago_spec.js | 85 - .../tokens/pipeline_branch_name_token_spec.js | 142 - .../pipelines/tokens/pipeline_source_token_spec.js | 53 - .../pipelines/tokens/pipeline_status_token_spec.js | 58 - .../tokens/pipeline_tag_name_token_spec.js | 95 - .../tokens/pipeline_trigger_author_token_spec.js | 99 - spec/frontend/pipelines/unwrapping_utils_spec.js | 127 - spec/frontend/pipelines/utils_spec.js | 191 -- .../diffs_colors_preview_spec.js.snap | 210 +- .../projects/commit/components/form_modal_spec.js | 9 +- .../components/revision_dropdown_legacy_spec.js | 136 - .../project_delete_button_spec.js.snap | 3 - .../ci_cd_analytics_area_chart_spec.js.snap | 1 - .../__snapshots__/statistics_list_spec.js.snap | 8 +- .../projects/settings/access_dropdown_spec.js | 204 -- .../components/new_access_dropdown_spec.js | 55 +- .../components/custom_email_form_spec.js | 13 + .../components/custom_email_wrapper_spec.js | 21 +- .../protected_branch_create_spec.js | 51 +- .../protected_branch_edit_spec.js | 2 +- spec/frontend/protected_tags/mock_data.js | 18 + .../protected_tags/protected_tag_edit_spec.js | 113 + .../releases/__snapshots__/util_spec.js.snap | 48 +- .../__snapshots__/issuable_stats_spec.js.snap | 67 +- .../release_block_milestone_info_spec.js | 6 +- .../directory_download_links_spec.js.snap | 18 +- .../__snapshots__/last_commit_spec.js.snap | 44 +- .../components/blob_content_viewer_spec.js | 81 +- .../table/__snapshots__/row_spec.js.snap | 114 +- spec/frontend/repository/mock_data.js | 11 - spec/frontend/search/mock_data.js | 2 +- .../frontend/search/sidebar/components/app_spec.js | 117 +- .../sidebar/components/blobs_filters_spec.js | 85 +- .../sidebar/components/commits_filters_spec.js | 28 + .../sidebar/components/issues_filters_spec.js | 98 +- .../components/merge_requests_filters_spec.js | 123 +- .../sidebar/components/notes_filters_spec.js | 28 + .../sidebar/components/projects_filters_spec.js | 28 + .../sidebar/components/projects_filters_specs.js | 28 - .../small_screen_drawer_navigation_spec.js | 68 + spec/frontend/search/store/actions_spec.js | 16 + .../continuous_vulnerability_scan_spec.js | 124 + .../components/feature_card_spec.js | 18 + spec/frontend/security_configuration/utils_spec.js | 64 + spec/frontend/sentry/index_spec.js | 104 - spec/frontend/sentry/init_sentry_spec.js | 177 + spec/frontend/sentry/legacy_index_spec.js | 6 - spec/frontend/sentry/sentry_config_spec.js | 103 - .../components/empty_state_with_any_issues_spec.js | 74 - .../empty_state_without_any_issues_spec.js | 86 - .../service_desk/components/info_banner_spec.js | 81 - .../components/service_desk_list_app_spec.js | 376 --- spec/frontend/service_desk/mock_data.js | 236 -- .../sidebar/components/assignees/assignees_spec.js | 2 +- .../assignees/sidebar_invite_members_spec.js | 2 +- .../assignees/uncollapsed_assignee_list_spec.js | 2 +- .../sidebar_confidentiality_form_spec.js | 86 +- .../incidents/sidebar_escalation_status_spec.js | 7 +- .../dropdown_contents_create_view_spec.js | 27 +- .../labels_select_widget/dropdown_footer_spec.js | 37 +- .../lock/__snapshots__/edit_form_spec.js.snap | 4 - .../components/time_tracking/time_tracker_spec.js | 6 +- .../todo_toggle/__snapshots__/todo_spec.js.snap | 4 +- spec/frontend/sidebar/mock_data.js | 80 + .../silent_mode_settings/components/app_spec.js | 133 + .../__snapshots__/snippet_blob_edit_spec.js.snap | 3 +- .../snippet_description_edit_spec.js.snap | 26 +- .../snippet_description_view_spec.js.snap | 2 +- .../snippet_visibility_edit_spec.js.snap | 25 +- .../snippets/components/embed_dropdown_spec.js | 48 +- .../components/context_header_spec.js | 50 - .../components/context_switcher_spec.js | 302 -- .../components/context_switcher_toggle_spec.js | 39 - .../super_sidebar/components/create_menu_spec.js | 21 +- .../super_sidebar/components/flyout_menu_spec.js | 16 +- .../components/frequent_items_list_spec.js | 85 - .../__snapshots__/search_item_spec.js.snap | 27 +- .../command_palette/command_palette_items_spec.js | 42 +- .../global_search/command_palette/mock_data.js | 17 + .../global_search/command_palette/utils_spec.js | 4 + .../global_search_default_places_spec.js | 16 + .../global_search/components/global_search_spec.js | 63 +- .../components/global_search/mock_data.js | 44 + .../components/global_search/utils_spec.js | 88 +- .../super_sidebar/components/groups_list_spec.js | 90 - .../super_sidebar/components/items_list_spec.js | 63 - .../super_sidebar/components/menu_section_spec.js | 36 +- .../super_sidebar/components/nav_item_spec.js | 97 +- .../components/pinned_section_spec.js | 29 + .../super_sidebar/components/projects_list_spec.js | 85 - .../components/search_results_spec.js | 69 - .../components/sidebar_hover_peek_behavior_spec.js | 213 ++ .../super_sidebar/components/sidebar_menu_spec.js | 69 +- .../components/sidebar_peek_behavior_spec.js | 25 +- .../super_sidebar/components/super_sidebar_spec.js | 111 +- .../components/super_sidebar_toggle_spec.js | 23 +- .../super_sidebar/components/user_bar_spec.js | 20 +- .../super_sidebar/components/user_menu_spec.js | 21 - spec/frontend/super_sidebar/mock_data.js | 46 +- spec/frontend/super_sidebar/mocks.js | 24 + spec/frontend/super_sidebar/utils_spec.js | 78 +- .../time_tracking/components/timelogs_app_spec.js | 25 +- .../tracing/components/tracing_details_spec.js | 103 - .../tracing/components/tracing_empty_state_spec.js | 39 - .../tracing_list_filtered_search_spec.js | 38 - .../tracing/components/tracing_list_spec.js | 216 -- .../tracing/components/tracing_table_list_spec.js | 77 - spec/frontend/tracing/details_index_spec.js | 42 - spec/frontend/tracing/filters_spec.js | 141 - spec/frontend/tracing/list_index_spec.js | 37 - .../tracking/dispatch_snowplow_event_spec.js | 76 + spec/frontend/tracking/internal_events_spec.js | 147 +- spec/frontend/tracking/mock_data.js | 17 + .../tracking/tracking_initialization_spec.js | 29 +- .../storage/components/project_storage_app_spec.js | 29 +- .../storage/components/usage_graph_spec.js | 125 - .../user_lists/components/user_list_spec.js | 2 +- spec/frontend/users_select/test_helper.js | 3 +- .../components/action_buttons.js | 43 - .../components/action_buttons_spec.js | 61 + .../components/mr_widget_pipeline_spec.js | 2 +- .../__snapshots__/new_ready_to_merge_spec.js.snap | 18 +- .../components/states/mr_widget_merged_spec.js | 6 +- .../__snapshots__/dynamic_content_spec.js.snap | 154 +- .../components/widget/app_spec.js | 45 +- .../deployment/deployment_actions_spec.js | 72 +- .../deployment/deployment_mock_data.js | 7 +- .../deployment/deployment_spec.js | 31 +- .../extensions/test_report/index_spec.js | 16 +- .../frontend/vue_merge_request_widget/mock_data.js | 168 +- .../mr_widget_options_spec.js | 811 ++--- .../__snapshots__/expand_button_spec.js.snap | 32 +- .../integration_help_text_spec.js.snap | 6 +- .../__snapshots__/source_editor_spec.js.snap | 4 +- .../__snapshots__/split_button_spec.js.snap | 59 - .../badges/__snapshots__/beta_badge_spec.js.snap | 6 - .../__snapshots__/simple_viewer_spec.js.snap | 29 +- .../components/blob_viewers/rich_viewer_spec.js | 77 +- .../vue_shared/components/ci_badge_link_spec.js | 2 +- .../components/code_block_highlighted_spec.js | 2 +- .../vue_shared/components/code_block_spec.js | 56 +- .../components/confidentiality_badge_spec.js | 42 +- .../confirm_danger/confirm_danger_modal_spec.js | 13 + .../date_time_picker_input_spec.js | 62 - .../date_time_picker/date_time_picker_lib_spec.js | 190 -- .../date_time_picker/date_time_picker_spec.js | 326 -- .../__snapshots__/design_note_pin_spec.js.snap | 12 +- .../components/entity_select/utils_spec.js | 12 +- .../components/filtered_search_bar/mock_data.js | 16 +- .../tokens/milestone_token_spec.js | 45 +- .../__snapshots__/form_footer_actions_spec.js.snap | 2 +- .../vue_shared/components/gl_modal_vuex_spec.js | 10 +- .../groups_list/groups_list_item_spec.js | 69 + .../components/groups_list/groups_list_spec.js | 14 + .../vue_shared/components/groups_list/mock_data.js | 6 + .../components/header_ci_component_spec.js | 165 - .../components/list_actions/list_actions_spec.js | 135 + .../__snapshots__/suggestion_diff_spec.js.snap | 3 +- .../components/markdown/apply_suggestion_spec.js | 23 +- .../markdown/comment_templates_dropdown_spec.js | 35 +- .../components/markdown/field_view_spec.js | 22 +- .../components/markdown/markdown_editor_spec.js | 5 + .../markdown/suggestion_diff_header_spec.js | 7 +- .../__snapshots__/metric_images_table_spec.js.snap | 13 +- .../__snapshots__/noteable_warning_spec.js.snap | 18 +- .../__snapshots__/placeholder_note_spec.js.snap | 15 +- .../placeholder_system_note_spec.js.snap | 2 +- .../paginated_table_with_search_and_tabs_spec.js | 2 +- .../pagination_bar/pagination_bar_spec.js | 12 +- .../projects_list/projects_list_item_spec.js | 33 +- .../__snapshots__/code_instruction_spec.js.snap | 14 +- .../__snapshots__/history_item_spec.js.snap | 6 +- .../__snapshots__/skeleton_loader_spec.js.snap | 12 +- .../__snapshots__/settings_block_spec.js.snap | 15 +- .../__snapshots__/chunk_new_spec.js.snap | 13 +- .../source_viewer/components/chunk_new_spec.js | 1 + .../source_viewer/source_viewer_new_spec.js | 3 +- .../components/source_viewer/source_viewer_spec.js | 35 +- .../vue_shared/components/split_button_spec.js | 117 - .../__snapshots__/upload_dropzone_spec.js.snap | 328 +- .../vue_shared/components/user_select_spec.js | 28 +- .../issuable_blocked_icon_spec.js.snap | 30 - .../create/components/issuable_form_spec.js | 2 +- .../issuable/issuable_blocked_icon_spec.js | 4 - .../issuable/list/components/issuable_item_spec.js | 33 +- .../frontend/vue_shared/issuable/list/mock_data.js | 2 +- .../show/components/issuable_header_spec.js | 12 +- .../show/components/issuable_title_spec.js | 4 +- .../frontend/vue_shared/issuable/show/mock_data.js | 1 + .../new_namespace/new_namespace_page_spec.js | 20 +- .../security_report_download_dropdown_spec.js | 64 +- .../__snapshots__/push_events_spec.js.snap | 168 +- .../__snapshots__/work_item_note_body_spec.js.snap | 40 +- .../work_item_note_replying_spec.js.snap | 12 +- .../notes/work_item_activity_sort_filter_spec.js | 27 +- .../components/notes/work_item_add_note_spec.js | 14 + .../components/notes/work_item_note_spec.js | 7 + .../shared/work_item_link_child_contents_spec.js | 14 +- .../components/shared/work_item_links_menu_spec.js | 8 +- .../shared/work_item_token_input_spec.js | 81 + .../components/work_item_actions_spec.js | 21 +- .../work_items/components/work_item_detail_spec.js | 84 + .../work_item_links/work_item_link_child_spec.js | 2 - .../work_item_links/work_item_links_form_spec.js | 50 +- .../work_items/components/work_item_notes_spec.js | 12 + .../work_item_relationship_list_spec.js.snap | 29 + .../work_item_relationship_list_spec.js | 41 + .../work_item_relationships_spec.js | 93 + .../components/work_item_state_badge_spec.js | 5 +- .../list/components/work_items_list_app_spec.js | 18 +- spec/frontend/work_items/mock_data.js | 192 +- spec/frontend/work_items/utils_spec.js | 13 +- spec/graphql/mutations/base_mutation_spec.rb | 2 +- .../mutations/design_management/delete_spec.rb | 35 +- .../mutations/work_items/linked_items/base_spec.rb | 3 +- spec/graphql/resolvers/base_resolver_spec.rb | 2 +- spec/graphql/resolvers/blame_resolver_spec.rb | 81 + .../resolvers/branch_commit_resolver_spec.rb | 2 +- .../graphql/resolvers/ci/all_jobs_resolver_spec.rb | 117 +- .../resolvers/ci/group_runners_resolver_spec.rb | 20 +- .../resolvers/ci/project_runners_resolver_spec.rb | 16 +- spec/graphql/resolvers/ci/runners_resolver_spec.rb | 30 +- .../metrics/dashboards/annotation_resolver_spec.rb | 16 +- spec/graphql/resolvers/work_items_resolver_spec.rb | 5 - spec/graphql/types/base_argument_spec.rb | 2 +- spec/graphql/types/base_edge_spec.rb | 2 +- spec/graphql/types/base_enum_spec.rb | 2 +- spec/graphql/types/base_field_spec.rb | 2 +- spec/graphql/types/base_object_spec.rb | 2 +- spec/graphql/types/blame/blame_type_spec.rb | 16 + spec/graphql/types/blame/commit_data_type_spec.rb | 21 + spec/graphql/types/blame/groups_type_spec.rb | 19 + spec/graphql/types/ci/job_base_field_spec.rb | 143 + spec/graphql/types/ci/job_kind_enum_spec.rb | 2 +- spec/graphql/types/ci/job_trace_type_spec.rb | 184 +- spec/graphql/types/ci/job_type_spec.rb | 1 + spec/graphql/types/issue_type_spec.rb | 2 +- spec/graphql/types/label_type_spec.rb | 1 + spec/graphql/types/merge_request_type_spec.rb | 2 +- .../types/organizations/group_sort_enum_spec.rb | 26 + .../types/organizations/organization_type_spec.rb | 11 + .../organizations/organization_user_type_spec.rb | 11 + .../types/permission_types/work_item_spec.rb | 2 +- spec/graphql/types/project_type_spec.rb | 11 +- spec/graphql/types/query_type_spec.rb | 10 + spec/graphql/types/repository/blob_type_spec.rb | 1 + .../degradation_type_spec.rb | 13 + .../report_type_spec.rb | 13 + .../status_enum_spec.rb | 11 + .../summary_type_spec.rb | 13 + .../codequality_reports_comparer_type_spec.rb | 11 + spec/helpers/admin/abuse_reports_helper_spec.rb | 8 +- spec/helpers/application_helper_spec.rb | 100 +- spec/helpers/artifacts_helper_spec.rb | 3 +- spec/helpers/button_helper_spec.rb | 98 +- spec/helpers/ci/status_helper_spec.rb | 23 - spec/helpers/environment_helper_spec.rb | 9 + spec/helpers/environments_helper_spec.rb | 12 - spec/helpers/icons_helper_spec.rb | 11 + spec/helpers/integrations_helper_spec.rb | 20 + spec/helpers/invite_members_helper_spec.rb | 56 - spec/helpers/issuables_helper_spec.rb | 325 +- spec/helpers/issues_helper_spec.rb | 84 +- spec/helpers/members_helper_spec.rb | 6 - spec/helpers/nav/new_dropdown_helper_spec.rb | 2 +- spec/helpers/nav_helper_spec.rb | 9 +- .../organizations/organization_helper_spec.rb | 65 + spec/helpers/projects/observability_helper_spec.rb | 37 - spec/helpers/projects/pipeline_helper_spec.rb | 2 +- spec/helpers/projects_helper_spec.rb | 16 - spec/helpers/registrations_helper_spec.rb | 2 +- spec/helpers/sidebars_helper_spec.rb | 229 +- spec/helpers/sidekiq_helper_spec.rb | 2 +- spec/helpers/vite_helper_spec.rb | 59 + spec/helpers/webpack_helper_spec.rb | 18 + spec/helpers/work_items_helper_spec.rb | 14 + ...n_cable_subscription_adapter_identifier_spec.rb | 3 +- spec/initializers/mail_starttls_patch_spec.rb | 31 + spec/initializers/sidekiq_spec.rb | 2 +- spec/lib/api/ci/helpers/runner_spec.rb | 34 +- spec/lib/api/entities/merge_request_basic_spec.rb | 2 +- spec/lib/api/entities/merge_request_diff_spec.rb | 44 + spec/lib/api/entities/ml/mlflow/get_run_spec.rb | 63 + spec/lib/api/entities/ml/mlflow/run_info_spec.rb | 2 +- spec/lib/api/entities/ml/mlflow/run_spec.rb | 20 +- .../lib/api/entities/ml/mlflow/search_runs_spec.rb | 37 + spec/lib/api/entities/project_spec.rb | 2 +- spec/lib/api/helpers/packages_helpers_spec.rb | 4 +- spec/lib/api/helpers_spec.rb | 222 +- spec/lib/api/ml/mlflow/api_helpers_spec.rb | 24 + spec/lib/backup/database_model_spec.rb | 82 + spec/lib/backup/database_spec.rb | 92 +- spec/lib/backup/gitaly_backup_spec.rb | 14 +- spec/lib/backup/repositories_spec.rb | 46 +- .../lib/banzai/filter/code_language_filter_spec.rb | 36 +- spec/lib/banzai/filter/inline_diff_filter_spec.rb | 2 +- .../bitbucket/representation/pull_request_spec.rb | 51 + .../common/graphql/get_members_query_spec.rb | 24 +- .../common/pipelines/entity_finisher_spec.rb | 6 +- .../member_attributes_transformer_spec.rb | 53 +- .../file_downloads/validations_spec.rb | 2 +- .../groups/loaders/group_loader_spec.rb | 27 + spec/lib/bulk_imports/network_error_spec.rb | 30 +- spec/lib/bulk_imports/pipeline/runner_spec.rb | 2 +- .../projects/pipelines/issues_pipeline_spec.rb | 34 + .../projects/pipelines/references_pipeline_spec.rb | 125 +- spec/lib/bulk_imports/users_mapper_spec.rb | 26 +- spec/lib/click_house/bind_index_manager_spec.rb | 33 - spec/lib/click_house/query_builder_spec.rb | 26 +- spec/lib/click_house/record_sync_context_spec.rb | 32 + spec/lib/click_house/sync_cursor_spec.rb | 35 + .../constraints/activity_pub_constrainer_spec.rb | 39 + .../my_batched_migration_spec_matcher.txt | 2 +- .../analytics/internal_events_generator_spec.rb | 6 +- .../partitioning/foreign_keys_generator_spec.rb | 6 +- .../snowplow_event_definition_generator_spec.rb | 5 +- spec/lib/gitlab/auth/o_auth/provider_spec.rb | 14 +- .../gitlab/auth/user_access_denied_reason_spec.rb | 2 +- spec/lib/gitlab/auth_spec.rb | 24 +- ...as_merge_request_of_vulnerability_reads_spec.rb | 101 + .../backfill_nuget_normalized_version_spec.rb | 74 + ...tatistics_storage_size_with_recent_size_spec.rb | 165 + .../backfill_snippet_repositories_spec.rb | 4 +- ...backfill_user_preferences_with_defaults_spec.rb | 66 + .../backfill_users_with_defaults_spec.rb | 68 + ...t_credit_card_validation_data_to_hashes_spec.rb | 81 + .../rebalance_partition_id_spec.rb | 46 - ...e_users_set_external_if_service_account_spec.rb | 42 + spec/lib/gitlab/bitbucket_import/importer_spec.rb | 22 +- .../importers/pull_request_importer_spec.rb | 166 + .../importers/pull_requests_importer_spec.rb | 71 + .../importers/repository_importer_spec.rb | 49 + .../bitbucket_import/parallel_importer_spec.rb | 43 + .../gitlab/bitbucket_import/user_finder_spec.rb | 75 + .../bitbucket_server_import/importer_spec.rb | 653 ---- .../gitlab/checks/matching_merge_request_spec.rb | 45 +- .../lib/gitlab/ci/build/artifacts/metadata_spec.rb | 206 +- spec/lib/gitlab/ci/build/duration_parser_spec.rb | 6 +- .../lib/gitlab/ci/components/instance_path_spec.rb | 251 +- spec/lib/gitlab/ci/config/entry/bridge_spec.rb | 2 +- spec/lib/gitlab/ci/config/entry/default_spec.rb | 2 +- .../ci/config/entry/include/rules/rule_spec.rb | 38 +- .../gitlab/ci/config/entry/include/rules_spec.rb | 35 +- spec/lib/gitlab/ci/config/entry/job_spec.rb | 20 +- .../lib/gitlab/ci/config/entry/processable_spec.rb | 33 + spec/lib/gitlab/ci/config/external/context_spec.rb | 6 +- .../ci/config/external/file/component_spec.rb | 35 + .../ci/config/external/mapper/verifier_spec.rb | 26 - .../gitlab/ci/config/external/processor_spec.rb | 14 +- spec/lib/gitlab/ci/config/external/rules_spec.rb | 218 +- .../ci/config/interpolation/interpolator_spec.rb | 3 +- .../gitlab/ci/config/yaml/tags/reference_spec.rb | 4 +- .../gitlab/ci/config/yaml/tags/resolver_spec.rb | 4 +- spec/lib/gitlab/ci/parsers/sbom/cyclonedx_spec.rb | 65 +- spec/lib/gitlab/ci/parsers/security/common_spec.rb | 6 +- spec/lib/gitlab/ci/pipeline/seed/build_spec.rb | 120 + spec/lib/gitlab/ci/reports/sbom/component_spec.rb | 12 + spec/lib/gitlab/ci/reports/sbom/metadata_spec.rb | 54 + spec/lib/gitlab/ci/templates/MATLAB_spec.rb | 2 +- spec/lib/gitlab/ci/trace/stream_spec.rb | 50 + .../gitlab/ci/variables/builder/pipeline_spec.rb | 7 +- spec/lib/gitlab/ci/variables/builder_spec.rb | 23 +- spec/lib/gitlab/ci/yaml_processor_spec.rb | 122 + spec/lib/gitlab/composer/version_index_spec.rb | 115 +- .../content_security_policy/config_loader_spec.rb | 56 + spec/lib/gitlab/current_settings_spec.rb | 34 +- spec/lib/gitlab/data_builder/deployment_spec.rb | 9 + .../async_indexes/migration_helpers_spec.rb | 8 + .../lib/gitlab/database/click_house_client_spec.rb | 191 +- spec/lib/gitlab/database/gitlab_schema_spec.rb | 2 +- .../gitlab/database/load_balancing/host_spec.rb | 33 +- .../database/load_balancing/load_balancer_spec.rb | 51 +- .../load_balancing/rack_middleware_spec.rb | 119 +- .../load_balancing/service_discovery_spec.rb | 127 +- .../sidekiq_server_middleware_spec.rb | 44 +- .../database/load_balancing/sticking_spec.rb | 353 +- .../database/migrations/instrumentation_spec.rb | 2 + .../database/no_cross_db_foreign_keys_spec.rb | 7 +- .../no_overrides_for_through_associations_spec.rb | 80 + .../partitioning/ci_sliding_list_strategy_spec.rb | 26 + .../database/partitioning/monthly_strategy_spec.rb | 30 +- .../partitioning/partition_manager_spec.rb | 155 +- .../partitioning/sliding_list_strategy_spec.rb | 26 + spec/lib/gitlab/database/partitioning_spec.rb | 91 +- spec/lib/gitlab/database/reindexing_spec.rb | 20 + spec/lib/gitlab/database/tables_truncate_spec.rb | 278 +- spec/lib/gitlab/database_spec.rb | 53 - spec/lib/gitlab/database_warnings_spec.rb | 96 + .../email/handler/create_note_handler_spec.rb | 4 +- .../email/handler/service_desk_handler_spec.rb | 8 +- .../in_product_marketing/admin_verify_spec.rb | 45 - .../message/in_product_marketing/base_spec.rb | 108 - .../message/in_product_marketing/create_spec.rb | 28 - .../in_product_marketing/team_short_spec.rb | 47 - .../message/in_product_marketing/team_spec.rb | 82 - .../in_product_marketing/trial_short_spec.rb | 45 - .../message/in_product_marketing/trial_spec.rb | 48 - .../message/in_product_marketing/verify_spec.rb | 54 - .../email/message/in_product_marketing_spec.rb | 35 - .../gitlab/email/service_desk/custom_email_spec.rb | 37 + spec/lib/gitlab/etag_caching/middleware_spec.rb | 16 +- spec/lib/gitlab/etag_caching/router/rails_spec.rb | 14 - spec/lib/gitlab/etag_caching/store_spec.rb | 2 +- spec/lib/gitlab/event_store/store_spec.rb | 20 +- spec/lib/gitlab/experiment/rollout/feature_spec.rb | 2 +- spec/lib/gitlab/git/blame_spec.rb | 10 +- spec/lib/gitlab/git/diff_spec.rb | 25 + spec/lib/gitlab/git/repository_spec.rb | 31 + spec/lib/gitlab/git_access_snippet_spec.rb | 2 +- .../gitlab/gitaly_client/operation_service_spec.rb | 105 +- spec/lib/gitlab/gitaly_client/ref_service_spec.rb | 110 + .../gitaly_client/repository_service_spec.rb | 13 +- .../gitaly_client/with_feature_flag_actors_spec.rb | 23 +- .../github_import/attachments_downloader_spec.rb | 51 + spec/lib/gitlab/github_import/client_spec.rb | 22 +- .../importer/note_attachments_importer_spec.rb | 41 +- .../pull_requests/merged_by_importer_spec.rb | 4 + .../importer/pull_requests/review_importer_spec.rb | 4 + .../github_import/markdown/attachment_spec.rb | 24 +- .../gitlab/github_import/object_counter_spec.rb | 26 +- spec/lib/gitlab/github_import/user_finder_spec.rb | 269 +- spec/lib/gitlab/github_import_spec.rb | 4 +- spec/lib/gitlab/gl_repository/identifier_spec.rb | 6 +- spec/lib/gitlab/gl_repository/repo_type_spec.rb | 24 +- spec/lib/gitlab/gl_repository_spec.rb | 11 +- spec/lib/gitlab/gon_helper_spec.rb | 88 +- .../graphql/deprecations/deprecation_spec.rb | 2 +- spec/lib/gitlab/group_search_results_spec.rb | 13 +- spec/lib/gitlab/http_spec.rb | 9 +- spec/lib/gitlab/import/errors_spec.rb | 1 + spec/lib/gitlab/import_export/all_models.yml | 8 +- .../import_export/attributes_permitter_spec.rb | 1 - .../base/relation_object_saver_spec.rb | 31 +- .../gitlab/import_export/command_line_util_spec.rb | 2 +- .../decompressed_archive_size_validator_spec.rb | 14 +- .../lib/gitlab/import_export/file_importer_spec.rb | 3 +- .../import_export/import_test_coverage_spec.rb | 1 - .../import_export/json/ndjson_writer_spec.rb | 11 +- .../json/streaming_serializer_spec.rb | 3 +- .../import_export/project/export_task_spec.rb | 2 +- .../import_export/project/tree_restorer_spec.rb | 2 +- spec/lib/gitlab/import_sources_spec.rb | 54 +- .../instrumentation/redis_interceptor_spec.rb | 1 + spec/lib/gitlab/job_waiter_spec.rb | 40 +- spec/lib/gitlab/manifest_import/metadata_spec.rb | 18 - spec/lib/gitlab/metrics/dashboard/cache_spec.rb | 88 - .../lib/gitlab/metrics/dashboard/processor_spec.rb | 30 - .../dashboard/repo_dashboard_finder_spec.rb | 54 - .../metrics/dashboard/stages/url_validator_spec.rb | 101 - spec/lib/gitlab/metrics/dashboard/url_spec.rb | 106 - .../metrics/samplers/database_sampler_spec.rb | 78 +- .../middleware/webhook_recursion_detection_spec.rb | 2 +- spec/lib/gitlab/observability_spec.rb | 29 +- spec/lib/gitlab/other_markup_spec.rb | 41 +- spec/lib/gitlab/pages/cache_control_spec.rb | 88 - spec/lib/gitlab/pages/virtual_host_finder_spec.rb | 58 - spec/lib/gitlab/pages_spec.rb | 87 +- .../gitlab/pagination/cursor_based_keyset_spec.rb | 102 +- spec/lib/gitlab/patch/redis_cache_store_spec.rb | 66 +- .../lib/gitlab/patch/sidekiq_scheduled_enq_spec.rb | 89 + .../prometheus/additional_metrics_parser_spec.rb | 248 -- .../additional_metrics_deployment_query_spec.rb | 23 - .../additional_metrics_environment_query_spec.rb | 45 - spec/lib/gitlab/rack_attack/request_spec.rb | 33 + spec/lib/gitlab/redis/chat_spec.rb | 2 +- spec/lib/gitlab/redis/etag_cache_spec.rb | 56 - spec/lib/gitlab/redis/multi_store_spec.rb | 100 + spec/lib/gitlab/redis/pubsub_spec.rb | 8 + spec/lib/gitlab/redis/queues_metadata_spec.rb | 43 + spec/lib/gitlab/redis/workhorse_spec.rb | 44 + spec/lib/gitlab/regex_spec.rb | 25 - spec/lib/gitlab/repo_path_spec.rb | 14 +- spec/lib/gitlab/search_results_spec.rb | 54 +- .../lib/gitlab/security/scan_configuration_spec.rb | 10 + spec/lib/gitlab/setup_helper/workhorse_spec.rb | 10 +- .../duplicate_jobs/client_spec.rb | 2 +- .../duplicate_jobs/duplicate_job_spec.rb | 65 +- .../sidekiq_middleware/server_metrics_spec.rb | 6 +- spec/lib/gitlab/sidekiq_migrate_jobs_spec.rb | 3 +- spec/lib/gitlab/sidekiq_queue_spec.rb | 2 +- spec/lib/gitlab/sql/cte_spec.rb | 3 +- spec/lib/gitlab/sql/pattern_spec.rb | 46 +- spec/lib/gitlab/time_tracking_formatter_spec.rb | 8 + .../destinations/database_events_snowplow_spec.rb | 4 + .../gitlab/tracking/service_ping_context_spec.rb | 24 +- spec/lib/gitlab/tracking/standard_context_spec.rb | 3 +- spec/lib/gitlab/url_builder_spec.rb | 3 + spec/lib/gitlab/url_sanitizer_spec.rb | 19 + spec/lib/gitlab/usage/metric_definition_spec.rb | 60 +- ...background_migration_failed_jobs_metric_spec.rb | 16 +- .../count_connected_agents_metric_spec.rb | 12 + spec/lib/gitlab/usage/metrics/query_spec.rb | 2 +- spec/lib/gitlab/usage/time_series_storable_spec.rb | 40 + .../ci_template_unique_counter_spec.rb | 2 +- .../issue_activity_unique_counter_spec.rb | 186 +- .../kubernetes_agent_counter_spec.rb | 6 +- spec/lib/gitlab/usage_data_queries_spec.rb | 2 +- spec/lib/gitlab/usage_data_spec.rb | 8 +- spec/lib/gitlab/user_access_snippet_spec.rb | 2 +- spec/lib/gitlab/utils/markdown_spec.rb | 35 +- spec/lib/gitlab/workhorse_spec.rb | 145 +- spec/lib/gitlab/x509/certificate_spec.rb | 2 +- spec/lib/gitlab/x509/commit_sigstore_spec.rb | 53 + spec/lib/gitlab/x509/commit_spec.rb | 6 +- spec/lib/gitlab/x509/signature_sigstore_spec.rb | 453 +++ spec/lib/gitlab/x509/signature_spec.rb | 2 +- spec/lib/gitlab/x509/tag_sigstore_spec.rb | 45 + spec/lib/gitlab/x509/tag_spec.rb | 27 +- spec/lib/peek/views/click_house_spec.rb | 13 +- spec/lib/sidebars/admin/panel_spec.rb | 8 +- spec/lib/sidebars/concerns/has_avatar_spec.rb | 29 + spec/lib/sidebars/explore/panel_spec.rb | 17 + .../groups/menus/packages_registries_menu_spec.rb | 39 +- spec/lib/sidebars/groups/menus/scope_menu_spec.rb | 5 +- .../sidebars/groups/super_sidebar_panel_spec.rb | 8 +- spec/lib/sidebars/menu_item_spec.rb | 9 +- spec/lib/sidebars/menu_spec.rb | 12 + .../organizations/menus/scope_menu_spec.rb | 4 +- spec/lib/sidebars/organizations/panel_spec.rb | 1 + .../organizations/super_sidebar_panel_spec.rb | 7 +- spec/lib/sidebars/panel_spec.rb | 18 +- .../sidebars/projects/menus/issues_menu_spec.rb | 1 + .../sidebars/projects/menus/monitor_menu_spec.rb | 14 - .../menus/packages_registries_menu_spec.rb | 25 +- .../lib/sidebars/projects/menus/scope_menu_spec.rb | 5 +- .../sidebars/projects/super_sidebar_panel_spec.rb | 8 +- spec/lib/sidebars/search/panel_spec.rb | 7 +- spec/lib/sidebars/static_menu_spec.rb | 4 + .../user_profile/menus/overview_menu_spec.rb | 5 +- spec/lib/sidebars/user_profile/panel_spec.rb | 7 +- spec/lib/sidebars/user_settings/panel_spec.rb | 3 +- .../your_work/menus/organizations_menu_spec.rb | 42 + spec/lib/sidebars/your_work/panel_spec.rb | 3 +- .../system_check/app/table_truncate_check_spec.rb | 75 + spec/lib/unnested_in_filters/rewriter_spec.rb | 251 +- spec/lib/users/internal_spec.rb | 97 + spec/mailers/emails/in_product_marketing_spec.rb | 69 - spec/mailers/emails/profile_spec.rb | 73 +- spec/mailers/emails/service_desk_spec.rb | 90 +- spec/mailers/notify_spec.rb | 72 +- ...3723_rebalance_partition_id_ci_pipeline_spec.rb | 58 - ...5093840_rebalance_partition_id_ci_build_spec.rb | 58 - ..._partition_ids_for_ci_pipeline_variable_spec.rb | 58 - ...9_fix_partition_ids_for_ci_job_artifact_spec.rb | 58 - ...08132608_fix_partition_ids_for_ci_stage_spec.rb | 58 - ...artition_ids_for_ci_build_report_result_spec.rb | 60 - ...rtition_ids_for_ci_build_trace_metadata_spec.rb | 60 - ...fix_partition_ids_for_ci_build_metadata_spec.rb | 60 - ..._fix_partition_ids_for_ci_job_variables_spec.rb | 51 - ...x_partition_ids_on_ci_sources_pipelines_spec.rb | 45 - ...t_backfill_is_finished_for_self_managed_spec.rb | 35 + ...wap_notes_id_to_bigint_for_self_managed_spec.rb | 120 + ...ser_todos_widget_to_epic_work_item_type_spec.rb | 25 + ...t_backfill_is_finished_for_self_managed_spec.rb | 35 + ...data_note_id_to_bigint_for_self_managed_spec.rb | 121 + ...t_backfill_is_finished_for_self_managed_spec.rb | 35 + ...ions_note_id_to_bigint_for_self_managed_spec.rb | 127 + ..._files_note_id_to_bigint_for_self_hosts_spec.rb | 156 + ...ng_namespace_ids_of_vulnerability_reads_spec.rb | 27 + ...queue_backfill_nuget_normalized_version_spec.rb | 26 + ...normalized_columns_for_sbom_occurrences_spec.rb | 26 + ...ame_plans_titles_with_legacy_plan_names_spec.rb | 23 + ...t_backfill_is_finished_for_self_managed_spec.rb | 36 + ...ons_note_id_to_big_int_for_self_managed_spec.rb | 122 + ...ents_target_id_to_bigint_for_self_hosts_spec.rb | 121 + ..._emoji_note_id_to_bigint_for_self_hosts_spec.rb | 121 + ...3610_queue_backfill_users_with_defaults_spec.rb | 27 + ...backfill_user_preferences_with_defaults_spec.rb | 27 + ...e_create_compliance_standards_adherence_spec.rb | 50 + ...t_credit_card_validation_data_to_hashes_spec.rb | 26 + ...0822104028_delete_project_callout_three_spec.rb | 21 + ...1454_remove_free_user_cap_email_workers_spec.rb | 24 + ...tatistics_storage_size_with_recent_size_spec.rb | 26 + ...d_items_widget_to_ticket_work_item_type_spec.rb | 29 + ...e_users_set_external_if_service_account_spec.rb | 26 + ...1084632_queue_sync_scan_result_policies_spec.rb | 26 + ...ed_sent_notifications_bigint_conversion_spec.rb | 144 + ...self_hosted_sent_notifications_backfill_spec.rb | 162 + ...as_merge_request_of_vulnerability_reads_spec.rb | 26 + ...lert_management_prometheus_integrations_spec.rb | 126 + ...t_backfill_is_finished_for_self_managed_spec.rb | 35 + ...ions_note_id_to_bigint_for_self_managed_spec.rb | 135 + spec/models/ability_spec.rb | 5 + spec/models/abuse_report_spec.rb | 27 +- spec/models/active_session_spec.rb | 18 +- .../alert_management/http_integration_spec.rb | 2 +- .../alerting/project_alerting_setting_spec.rb | 29 +- .../cycle_analytics/runtime_limiter_spec.rb | 55 + spec/models/application_setting_spec.rb | 12 + spec/models/award_emoji_spec.rb | 33 +- spec/models/bulk_imports/entity_spec.rb | 8 + spec/models/ci/build_spec.rb | 179 +- spec/models/ci/catalog/listing_spec.rb | 12 +- spec/models/ci/catalog/resource_spec.rb | 6 +- spec/models/ci/catalog/resources/component_spec.rb | 2 +- spec/models/ci/runner_spec.rb | 7 +- spec/models/clusters/agent_token_spec.rb | 39 +- spec/models/commit_status_spec.rb | 40 +- spec/models/concerns/as_cte_spec.rb | 2 +- spec/models/concerns/each_batch_spec.rb | 20 + spec/models/concerns/expirable_spec.rb | 7 +- spec/models/concerns/has_user_type_spec.rb | 80 +- spec/models/concerns/issuable_spec.rb | 31 + spec/models/concerns/prometheus_adapter_spec.rb | 22 - .../concerns/require_email_verification_spec.rb | 2 +- spec/models/concerns/resolvable_discussion_spec.rb | 8 +- spec/models/concerns/routable_spec.rb | 71 +- spec/models/concerns/transitionable_spec.rb | 40 + spec/models/deploy_key_spec.rb | 2 +- spec/models/design_management/design_spec.rb | 10 - spec/models/doorkeeper/application_spec.rb | 11 + spec/models/environment_status_spec.rb | 12 - spec/models/group_spec.rb | 21 +- spec/models/hooks/web_hook_log_spec.rb | 38 + spec/models/integration_spec.rb | 6 +- .../integrations/base_chat_notification_spec.rb | 21 +- .../chat_message/deployment_message_spec.rb | 36 +- spec/models/integrations/confluence_spec.rb | 6 + spec/models/integrations/mattermost_spec.rb | 2 +- spec/models/integrations/prometheus_spec.rb | 28 + spec/models/integrations/shimo_spec.rb | 8 +- spec/models/integrations/slack_spec.rb | 2 +- spec/models/integrations/zentao_spec.rb | 8 +- spec/models/issue_spec.rb | 7 +- .../modification_tracker_spec.rb | 14 +- .../turbo_modification_tracker_spec.rb | 23 + spec/models/member_spec.rb | 7 + spec/models/members/group_member_spec.rb | 16 +- spec/models/merge_request_spec.rb | 160 +- spec/models/metrics/dashboard/annotation_spec.rb | 73 - .../models/metrics/users_starred_dashboard_spec.rb | 39 - spec/models/ml/model_version_spec.rb | 10 +- spec/models/namespace_spec.rb | 326 +- spec/models/note_spec.rb | 111 +- spec/models/notification_setting_spec.rb | 7 + spec/models/oauth_access_token_spec.rb | 6 +- spec/models/organizations/organization_spec.rb | 1 + spec/models/packages/dependency_link_spec.rb | 68 +- spec/models/packages/ml_model/package_spec.rb | 67 + spec/models/packages/nuget/metadatum_spec.rb | 23 +- spec/models/packages/nuget/symbol_spec.rb | 70 + spec/models/packages/package_spec.rb | 43 +- spec/models/packages/protection/rule_spec.rb | 40 + spec/models/pages/virtual_domain_spec.rb | 53 +- spec/models/pages_deployment_spec.rb | 51 + spec/models/pages_domain_spec.rb | 11 + .../prometheus_metric_spec.rb | 67 - .../prometheus_panel_group_spec.rb | 62 - .../prometheus_panel_spec.rb | 85 - spec/models/plan_spec.rb | 12 + spec/models/pool_repository_spec.rb | 46 +- spec/models/project_authorization_spec.rb | 37 + spec/models/project_authorizations/changes_spec.rb | 10 +- spec/models/project_ci_cd_setting_spec.rb | 2 +- spec/models/project_feature_spec.rb | 20 + spec/models/project_import_state_spec.rb | 8 + spec/models/project_metrics_setting_spec.rb | 63 - spec/models/project_spec.rb | 113 +- spec/models/repository_spec.rb | 31 +- spec/models/resource_label_event_spec.rb | 17 +- spec/models/resource_state_event_spec.rb | 16 +- spec/models/review_spec.rb | 19 + spec/models/route_spec.rb | 10 + spec/models/snippet_repository_spec.rb | 2 +- spec/models/user_custom_attribute_spec.rb | 29 +- spec/models/user_preference_spec.rb | 14 + spec/models/user_spec.rb | 125 +- spec/models/users/credit_card_validation_spec.rb | 143 +- spec/models/users/group_visit_spec.rb | 25 + spec/models/users/project_visit_spec.rb | 25 + spec/models/work_item_spec.rb | 81 +- .../work_items/related_work_item_link_spec.rb | 40 + spec/models/work_items/widgets/description_spec.rb | 2 +- .../models/work_items/widgets/linked_items_spec.rb | 4 +- spec/models/x509_certificate_spec.rb | 1 - spec/models/x509_issuer_spec.rb | 2 - spec/policies/ci/bridge_policy_spec.rb | 34 +- spec/policies/ci/pipeline_policy_spec.rb | 25 + spec/policies/global_policy_spec.rb | 16 +- spec/policies/group_policy_spec.rb | 22 +- spec/policies/issue_policy_spec.rb | 4 +- .../organizations/organization_policy_spec.rb | 14 +- .../packages/policies/project_policy_spec.rb | 2 +- spec/policies/project_policy_spec.rb | 4 +- spec/presenters/blob_presenter_spec.rb | 12 +- spec/presenters/event_presenter_spec.rb | 100 + spec/presenters/gitlab/blame_presenter_spec.rb | 29 + spec/presenters/issue_presenter_spec.rb | 2 +- .../packages/composer/packages_presenter_spec.rb | 2 +- .../security/configuration_presenter_spec.rb | 3 +- spec/presenters/snippet_blob_presenter_spec.rb | 2 +- spec/rake_helper.rb | 2 +- .../admin/abuse_reports_controller_spec.rb | 79 +- spec/requests/admin/users_controller_spec.rb | 23 + spec/requests/api/bulk_imports_spec.rb | 24 +- spec/requests/api/ci/jobs_spec.rb | 8 + .../api/ci/runner/jobs_request_post_spec.rb | 61 +- spec/requests/api/commit_statuses_spec.rb | 30 +- spec/requests/api/commits_spec.rb | 2 +- spec/requests/api/discussions_spec.rb | 11 + spec/requests/api/feature_flags_spec.rb | 162 +- spec/requests/api/features_spec.rb | 2 +- spec/requests/api/graphql/ci/jobs_spec.rb | 103 +- spec/requests/api/graphql/ci/runner_spec.rb | 166 +- .../api/graphql/ci/runner_web_url_edge_spec.rb | 20 +- spec/requests/api/graphql/ci/runners_spec.rb | 10 +- .../graphql/group/dependency_proxy_blobs_spec.rb | 5 +- spec/requests/api/graphql/group/work_item_spec.rb | 71 + spec/requests/api/graphql/group/work_items_spec.rb | 32 +- spec/requests/api/graphql/group_query_spec.rb | 2 +- spec/requests/api/graphql/groups_query_spec.rb | 2 +- spec/requests/api/graphql/jobs_query_spec.rb | 2 +- .../codequality_reports_comparer_spec.rb | 185 + .../admin/abuse_report_labels/create_spec.rb | 55 + .../mutations/ci/pipeline_schedule/create_spec.rb | 25 +- .../mutations/ci/pipeline_schedule/delete_spec.rb | 23 +- .../mutations/ci/pipeline_schedule/play_spec.rb | 23 +- .../mutations/ci/pipeline_schedule/update_spec.rb | 25 +- .../mutations/ci/pipeline_trigger/create_spec.rb | 23 +- .../mutations/merge_requests/update_spec.rb | 32 + .../metrics/dashboard/annotations/create_spec.rb | 41 +- .../metrics/dashboard/annotations/delete_spec.rb | 22 +- .../mutations/work_items/linked_items/add_spec.rb | 9 +- .../work_items/linked_items/remove_spec.rb | 120 + .../graphql/mutations/work_items/update_spec.rb | 14 + .../organizations/organization_query_spec.rb | 178 + spec/requests/api/graphql/packages/package_spec.rb | 2 +- .../api/graphql/project/merge_request_spec.rb | 7 +- spec/requests/api/graphql/project/runners_spec.rb | 14 +- .../api/graphql/project/work_items_spec.rb | 38 +- spec/requests/api/graphql/project_query_spec.rb | 18 +- spec/requests/api/graphql/work_item_spec.rb | 44 +- spec/requests/api/groups_spec.rb | 36 +- spec/requests/api/internal/base_spec.rb | 15 +- spec/requests/api/internal/kubernetes_spec.rb | 162 +- spec/requests/api/merge_requests_spec.rb | 28 +- spec/requests/api/metadata_spec.rb | 18 +- .../api/metrics/dashboard/annotations_spec.rb | 3 +- .../api/metrics/user_starred_dashboards_spec.rb | 5 - spec/requests/api/ml/mlflow/experiments_spec.rb | 4 +- spec/requests/api/ml/mlflow/runs_spec.rb | 138 +- spec/requests/api/npm_group_packages_spec.rb | 43 +- spec/requests/api/npm_instance_packages_spec.rb | 29 +- spec/requests/api/nuget_project_packages_spec.rb | 68 + spec/requests/api/project_attributes.yml | 3 + spec/requests/api/project_import_spec.rb | 10 +- spec/requests/api/project_packages_spec.rb | 6 + spec/requests/api/projects_spec.rb | 8 +- spec/requests/api/search_spec.rb | 45 + spec/requests/api/settings_spec.rb | 6 +- spec/requests/api/usage_data_queries_spec.rb | 6 +- spec/requests/api/users_spec.rb | 47 +- .../clusters/agents/dashboard_controller_spec.rb | 76 + spec/requests/content_security_policy_spec.rb | 29 + .../groups/email_campaigns_controller_spec.rb | 127 - .../settings/access_tokens_controller_spec.rb | 22 + spec/requests/groups/work_items_controller_spec.rb | 44 +- spec/requests/openid_connect_spec.rb | 2 +- .../organizations/organizations_controller_spec.rb | 70 +- spec/requests/projects/noteable_notes_spec.rb | 78 - .../settings/access_tokens_controller_spec.rb | 22 + spec/requests/projects/tracing_controller_spec.rb | 104 - spec/requests/rack_attack_global_spec.rb | 114 + spec/requests/search_controller_spec.rb | 1 + spec/requests/sessions_spec.rb | 42 +- .../users/namespace_visits_controller_spec.rb | 72 + spec/requests/verifies_with_email_spec.rb | 6 +- .../organizations_controller_routing_spec.rb | 10 + spec/rubocop/cop/capybara/testid_finders_spec.rb | 50 + .../rubocop/cop/lint/last_keyword_argument_spec.rb | 168 - .../migration/versioned_migration_class_spec.rb | 9 + .../generate_message_to_run_e2e_pipeline_spec.rb | 2 +- spec/scripts/trigger-build_spec.rb | 12 + .../activity_streams_serializer_spec.rb | 157 + .../activity_pub/project_entity_spec.rb | 32 + .../activity_pub/release_entity_spec.rb | 48 + .../activity_pub/releases_actor_entity_spec.rb | 39 + .../activity_pub/releases_actor_serializer_spec.rb | 16 + .../releases_outbox_serializer_spec.rb | 34 + spec/serializers/activity_pub/user_entity_spec.rb | 28 + .../admin/abuse_report_details_entity_spec.rb | 113 +- .../admin/abuse_report_details_serializer_spec.rb | 5 +- spec/serializers/admin/abuse_report_entity_spec.rb | 15 +- .../admin/reported_content_entity_spec.rb | 50 + spec/serializers/build_details_entity_spec.rb | 16 + spec/serializers/ci/job_annotation_entity_spec.rb | 30 + .../codequality_degradation_entity_spec.rb | 17 +- ...codequality_reports_comparer_serializer_spec.rb | 4 +- spec/serializers/deployment_entity_spec.rb | 89 +- .../import/github_realtime_repo_entity_spec.rb | 4 +- .../import/github_realtime_repo_serializer_spec.rb | 2 +- spec/serializers/profile/event_entity_spec.rb | 11 + .../abuse_report_labels/create_service_spec.rb | 51 + .../abuse_reports/moderate_user_service_spec.rb | 17 + .../admin/abuse_reports/update_service_spec.rb | 85 + .../application_settings/update_service_spec.rb | 8 +- spec/services/auto_merge/base_service_spec.rb | 5 + .../create_pipeline_trackers_service_spec.rb | 176 - spec/services/bulk_imports/create_service_spec.rb | 22 +- .../bulk_imports/file_download_service_spec.rb | 14 +- spec/services/ci/components/fetch_service_spec.rb | 37 +- .../ci/create_commit_status_service_spec.rb | 461 +++ .../cross_project_pipeline_spec.rb | 17 + .../ci/create_pipeline_service/environment_spec.rb | 22 + .../ci/create_pipeline_service/logger_spec.rb | 69 - .../parent_child_pipeline_spec.rb | 32 +- .../ci/create_pipeline_service/rules_spec.rb | 40 + .../ci/create_pipeline_service/variables_spec.rb | 33 + spec/services/ci/create_pipeline_service_spec.rb | 174 - .../cancel_redundant_pipelines_service_spec.rb | 14 - spec/services/ci/register_job_service_spec.rb | 15 +- .../set_runner_associated_projects_service_spec.rb | 4 +- .../services/concerns/rate_limited_service_spec.rb | 2 +- .../services/return_service_responses_spec.rb | 32 + .../deployments/update_environment_service_spec.rb | 21 + .../delete_designs_service_spec.rb | 8 +- .../design_management/save_designs_service_spec.rb | 27 +- spec/services/discussions/resolve_service_spec.rb | 8 +- spec/services/draft_notes/publish_service_spec.rb | 9 +- spec/services/environments/stop_service_spec.rb | 3 +- .../environments/stop_stale_service_spec.rb | 6 +- spec/services/files/delete_service_spec.rb | 7 +- spec/services/files/update_service_spec.rb | 6 +- spec/services/git/branch_push_service_spec.rb | 24 +- .../create_cloudsql_instance_service_spec.rb | 30 +- .../fetch_google_ip_list_service_spec.rb | 2 +- .../google_cloud/generate_pipeline_service_spec.rb | 48 +- .../get_cloudsql_instances_service_spec.rb | 38 +- spec/services/gpg_keys/destroy_service_spec.rb | 22 +- spec/services/groups/destroy_service_spec.rb | 11 +- .../groups/group_links/create_service_spec.rb | 3 +- spec/services/groups/update_service_spec.rb | 72 - .../import_export_clean_up_service_spec.rb | 16 +- .../incidents/create_service_spec.rb | 2 +- .../create_incident_issue_service_spec.rb | 4 +- spec/services/issuable/process_assignees_spec.rb | 80 +- spec/services/issue_links/destroy_service_spec.rb | 9 +- spec/services/issue_links/list_service_spec.rb | 55 +- spec/services/issues/close_service_spec.rb | 4 +- spec/services/issues/create_service_spec.rb | 8 +- spec/services/issues/export_csv_service_spec.rb | 32 +- spec/services/issues/move_service_spec.rb | 3 +- spec/services/issues/resolve_discussions_spec.rb | 26 +- spec/services/issues/update_service_spec.rb | 13 +- .../labels/available_labels_service_spec.rb | 24 +- spec/services/labels/update_service_spec.rb | 8 + .../batch_cleaner_service_spec.rb | 20 +- .../process_deleted_records_service_spec.rb | 4 +- .../invitation_reminder_email_service_spec.rb | 2 +- .../merge_requests/approval_service_spec.rb | 45 + spec/services/merge_requests/base_service_spec.rb | 28 + .../merge_requests/create_ref_service_spec.rb | 183 +- .../merge_requests/ff_merge_service_spec.rb | 144 - spec/services/merge_requests/merge_service_spec.rb | 895 +++-- .../merge_requests/refresh_service_spec.rb | 7 +- .../services/merge_requests/update_service_spec.rb | 52 +- .../metrics/global_metrics_update_service_spec.rb | 14 - .../metrics/sample_metrics_service_spec.rb | 45 - .../in_product_marketing_emails_service_spec.rb | 216 -- spec/services/note_summary_spec.rb | 9 +- spec/services/notes/create_service_spec.rb | 5 +- spec/services/notes/destroy_service_spec.rb | 7 +- spec/services/notes/quick_actions_service_spec.rb | 39 + spec/services/notes/update_service_spec.rb | 12 +- spec/services/notification_service_spec.rb | 134 +- .../ml_model/create_package_file_service_spec.rb | 4 +- .../packages/npm/generate_metadata_service_spec.rb | 11 + .../nuget/check_duplicates_service_spec.rb | 155 + .../nuget/extract_metadata_file_service_spec.rb | 14 +- .../extract_remote_metadata_file_service_spec.rb | 126 + .../nuget/metadata_extraction_service_spec.rb | 4 +- .../nuget/odata_package_entry_service_spec.rb | 69 + ...te_legacy_storage_to_deployment_service_spec.rb | 24 +- ...obtain_lets_encrypt_certificate_service_spec.rb | 3 +- spec/services/preview_markdown_service_spec.rb | 21 +- .../gitlab/delete_tags_service_spec.rb | 19 +- spec/services/projects/create_service_spec.rb | 26 +- spec/services/projects/import_service_spec.rb | 102 +- ...oduct_marketing_campaign_emails_service_spec.rb | 54 +- .../prometheus/alerts/notify_service_spec.rb | 4 +- .../services/projects/update_pages_service_spec.rb | 128 +- .../update_repository_storage_service_spec.rb | 47 + spec/services/projects/update_service_spec.rb | 74 +- .../protected_branches/api_service_spec.rb | 20 +- spec/services/push_event_payload_service_spec.rb | 4 +- .../quick_actions/interpret_service_spec.rb | 33 +- spec/services/releases/create_service_spec.rb | 2 +- spec/services/releases/destroy_service_spec.rb | 28 +- .../resource_access_tokens/revoke_service_spec.rb | 3 +- .../resource_events/change_labels_service_spec.rb | 12 +- .../merge_into_notes_service_spec.rb | 3 +- .../dependency_scanning_create_service_spec.rb | 2 +- .../security/merge_reports_service_spec.rb | 105 +- .../create_service_spec.rb | 23 + .../update_service_spec.rb | 34 + .../custom_emails/create_service_spec.rb | 13 + .../custom_emails/destroy_service_spec.rb | 7 + .../service_desk_settings/update_service_spec.rb | 25 +- spec/services/spam/spam_action_service_spec.rb | 11 + .../system_notes/alert_management_service_spec.rb | 4 +- .../system_notes/issuables_service_spec.rb | 13 +- .../system_notes/time_tracking_service_spec.rb | 21 +- .../users/authorized_build_service_spec.rb | 8 + spec/services/users/build_service_spec.rb | 51 + .../migrate_records_to_ghost_user_service_spec.rb | 10 +- .../upsert_credit_card_validation_service_spec.rb | 11 +- .../destroy_service_spec.rb | 82 + spec/services/work_items/update_service_spec.rb | 5 +- spec/spec_helper.rb | 48 +- spec/support/before_all_adapter.rb | 11 - spec/support/capybara.rb | 6 +- spec/support/capybara_wait_for_all_requests.rb | 20 +- spec/support/database/auto_explain.rb | 15 +- spec/support/database/click_house/hooks.rb | 8 +- .../prevent_cross_database_modification.rb | 48 +- spec/support/database_cleaner.rb | 10 - spec/support/db_cleaner.rb | 3 + spec/support/factory_bot.rb | 14 + spec/support/finder_collection_allowlist.yml | 6 +- .../gitlab_stubs/gitlab_ci_dast_includes.yml | 7 +- spec/support/helpers/database/duplicate_indexes.rb | 77 + .../support/helpers/database/duplicate_indexes.yml | 265 ++ .../helpers/features/admin_users_helpers.rb | 2 +- .../helpers/features/highlight_content_helper.rb | 19 + spec/support/helpers/features/runners_helpers.rb | 10 +- spec/support/helpers/filtered_search_helpers.rb | 2 +- spec/support/helpers/loose_foreign_keys_helper.rb | 11 + spec/support/helpers/sign_up_helpers.rb | 27 + spec/support/helpers/stub_gitlab_calls.rb | 14 +- spec/support/helpers/x509_helpers.rb | 181 + spec/support/matchers/pagination_matcher.rb | 10 + spec/support/migration.rb | 14 - spec/support/multiple_databases.rb | 2 - spec/support/protected_branch_helpers.rb | 2 +- spec/support/rspec.rb | 10 +- spec/support/rspec_order_todo.yml | 17 +- .../dependency_proxy_shared_context.rb | 14 + .../shared_contexts/email_shared_context.rb | 2 +- .../group_integrations_shared_context.rb | 2 +- .../instance_integrations_shared_context.rb | 2 +- .../project_integrations_shared_context.rb | 4 +- .../finders/users_finder_shared_contexts.rb | 2 +- .../load_balancing/wal_tracking_shared_context.rb | 8 +- .../shared_contexts/navbar_structure_context.rb | 20 +- .../policies/group_policy_shared_context.rb | 2 +- .../requests/api/npm_packages_shared_context.rb | 2 +- .../noteable/notes_channel_shared_examples.rb | 12 - ...pipeline_service_environment_shared_examples.rb | 166 + .../ci/deployable_shared_examples.rb | 56 +- .../every_metric_definition_shared_examples.rb | 3 +- ...ntegrations_hook_log_actions_shared_examples.rb | 9 + .../issuable_notes_filter_shared_examples.rb | 17 - .../labels_controller_shared_examples.rb | 38 + .../search_rate_limit_shared_examples.rb | 21 + .../snowplow_event_tracking_examples.rb | 2 +- .../features/2fa_shared_examples.rb | 4 +- .../features/content_editor_shared_examples.rb | 3 +- ..._features_apply_to_issuables_shared_examples.rb | 4 +- ...d_branches_access_control_ce_shared_examples.rb | 21 +- .../protected_tags_with_deploy_keys_examples.rb | 4 +- .../features/runners_shared_examples.rb | 18 + .../sidebar/sidebar_labels_shared_examples.rb | 9 +- .../features/snippets_shared_examples.rb | 2 +- .../variable_list_pagination_shared_examples.rb | 10 +- .../features/variable_list_shared_examples.rb | 34 +- .../features/work_items_shared_examples.rb | 39 + .../finders/issues_finder_shared_examples.rb | 8 +- .../update_time_estimate_shared_examples.rb | 20 +- .../gitlab_style_deprecations_shared_examples.rb | 10 +- .../harbor/artifacts_controller_shared_examples.rb | 12 +- .../repositories_controller_shared_examples.rb | 24 +- .../harbor/tags_controller_shared_examples.rb | 12 +- .../lib/api/ai_workhorse_shared_examples.rb | 45 - .../object_import_shared_examples.rb | 100 + .../stage_methods_shared_examples.rb | 29 + .../object_import_shared_examples.rb | 4 +- .../lib/gitlab/ci/ci_trace_shared_examples.rb | 8 +- .../database/cte_materialized_shared_examples.rb | 37 +- .../gitlab/import/advance_stage_shared_examples.rb | 109 + .../lib/gitlab/repo_type_shared_examples.rb | 2 +- .../search_archived_filter_shared_examples.rb | 33 +- .../issuable_activity_shared_examples.rb | 87 - .../shared_examples/lib/menus_shared_examples.rb | 7 + .../user_profile_menus_shared_examples.rb | 14 +- .../loose_foreign_keys/have_loose_foreign_key.rb | 6 +- .../mailers/notify_shared_examples.rb | 8 + .../add_work_item_widget_shared_examples.rb | 107 + .../concerns/linkable_items_shared_examples.rb | 11 + .../models/group_shared_examples.rb | 45 +- .../models/members_notifications_shared_example.rb | 8 +- .../models/users/pages_visits_shared_examples.rb | 27 + .../shared_examples/redis/redis_shared_examples.rb | 84 + .../access_tokens_controller_shared_examples.rb | 6 +- .../api/graphql/issue_list_shared_examples.rb | 33 + .../api/graphql/work_item_list_shared_examples.rb | 98 + .../api/ml/mlflow/mlflow_shared_examples.rb | 15 +- .../requests/api/npm_packages_shared_examples.rb | 69 +- .../requests/api/nuget_packages_shared_examples.rb | 42 +- .../api_keyset_pagination_shared_examples.rb | 50 + .../requests/rack_attack_shared_examples.rb | 38 +- .../services/incident_shared_examples.rb | 2 +- .../issuable_update_service_shared_examples.rb | 71 + .../destroyable_issuable_links_shared_examples.rb | 11 +- .../services/metrics/dashboard_shared_examples.rb | 193 -- ...igrate_to_ghost_user_service_shared_examples.rb | 4 +- .../services/pages_size_limit_shared_examples.rb | 32 - .../services/protected_branches_shared_examples.rb | 12 + .../users/build_service_shared_examples.rb | 51 - ...ecords_to_ghost_user_service_shared_examples.rb | 2 +- .../users/pages_visits_shared_examples.rb | 63 + .../background_migration_worker_shared_examples.rb | 36 + ...nd_migration_execution_worker_shared_example.rb | 21 + ..._background_migration_worker_shared_examples.rb | 22 + spec/support/sidekiq.rb | 2 + .../capybara_wait_for_all_requests_spec.rb | 16 +- .../database/duplicate_indexes_spec.rb | 108 + .../database/multiple_databases_helpers_spec.rb | 2 +- .../helpers/redis_commands/recorder_spec.rb | 6 - .../audit_event_types/check_docs_task_spec.rb | 12 +- .../audit_event_types/compile_docs_task_spec.rb | 8 +- spec/tasks/gitlab/backup_rake_spec.rb | 24 +- .../gitlab/ci_secure_files/check_rake_spec.rb | 2 +- .../gitlab/ci_secure_files/migrate_rake_spec.rb | 4 +- spec/tasks/gitlab/container_registry_rake_spec.rb | 4 +- .../db/cells/bump_cell_sequences_rake_spec.rb | 2 +- .../decomposition/connection_status_rake_spec.rb | 2 +- .../rollback/bump_ci_sequences_rake_spec.rb | 2 +- spec/tasks/gitlab/db/lock_writes_rake_spec.rb | 2 +- .../gitlab/db/migration_fix_15_11_rake_spec.rb | 2 +- .../gitlab/db/truncate_legacy_tables_rake_spec.rb | 2 +- spec/tasks/gitlab/db/validate_config_rake_spec.rb | 2 +- spec/tasks/gitlab/db_rake_spec.rb | 32 +- .../gitlab/dependency_proxy/migrate_rake_spec.rb | 2 +- .../generate_sample_prometheus_data_rake_spec.rb | 34 - spec/tasks/gitlab/gitaly_rake_spec.rb | 2 +- spec/tasks/gitlab/lfs/migrate_rake_spec.rb | 2 +- spec/tasks/gitlab/packages/migrate_rake_spec.rb | 2 +- spec/tasks/gitlab/password_rake_spec.rb | 4 +- ...ct_statistics_build_artifacts_size_rake_spec.rb | 6 +- spec/tasks/gitlab/snippets_rake_spec.rb | 6 +- spec/tasks/gitlab/terraform/migrate_rake_spec.rb | 4 +- spec/tasks/gitlab/web_hook_rake_spec.rb | 8 +- spec/tasks/gitlab/workhorse_rake_spec.rb | 2 +- spec/tasks/gitlab/x509/update_rake_spec.rb | 2 +- spec/tasks/migrate/schema_check_rake_spec.rb | 2 +- spec/tooling/danger/clickhouse_spec.rb | 70 + spec/tooling/danger/ignored_model_columns_spec.rb | 145 + .../fixtures/cleanup_conversion_migration.txt | 44 + spec/tooling/fixtures/remove_column_migration.txt | 84 + spec/tooling/fixtures/rename_column_migration.txt | 45 + .../packages/nuget/symbol_uploader_spec.rb | 28 + .../application_settings/general.html.haml_spec.rb | 1 - .../views/admin/identities/index.html.haml_spec.rb | 9 +- .../devise/shared/_signin_box.html.haml_spec.rb | 2 +- .../shared/_signup_omniauth_provider_list_spec.rb | 50 + spec/views/events/event/_push.html.haml_spec.rb | 13 +- spec/views/layouts/_page.html.haml_spec.rb | 38 + .../_super_sidebar_logged_out.html.haml_spec.rb | 4 + spec/views/layouts/organization.html.haml_spec.rb | 59 + spec/views/layouts/snippets.html.haml_spec.rb | 2 +- spec/views/projects/empty.html.haml_spec.rb | 2 +- .../issues/service_desk/_issue.html.haml_spec.rb | 2 +- .../pages/_pages_settings.html.haml_spec.rb | 30 +- .../_pipeline_schedule.html.haml_spec.rb | 49 - .../registrations/welcome/show.html.haml_spec.rb | 1 - spec/workers/bulk_import_worker_spec.rb | 169 + .../finish_project_import_worker_spec.rb | 28 + .../bulk_imports/pipeline_batch_worker_spec.rb | 10 + .../workers/click_house/events_sync_worker_spec.rb | 145 +- .../gitlab/github_import/object_importer_spec.rb | 3 +- .../github_import/rescheduling_methods_spec.rb | 2 +- .../gitlab/import/notify_upon_death_spec.rb | 51 + .../concerns/gitlab/notify_upon_death_spec.rb | 51 - .../concerns/limited_capacity/worker_spec.rb | 18 +- spec/workers/database/lock_tables_worker_spec.rb | 136 + .../database/monitor_locked_tables_worker_spec.rb | 55 +- .../environments/stop_job_success_worker_spec.rb | 55 +- spec/workers/every_sidekiq_worker_spec.rb | 14 +- .../bitbucket_import/advance_stage_worker_spec.rb | 115 + .../import_pull_request_worker_spec.rb | 9 + .../stage/finish_import_worker_spec.rb | 27 + .../stage/import_pull_requests_worker_spec.rb | 77 + .../stage/import_repository_worker_spec.rb | 21 + .../advance_stage_worker_spec.rb | 7 + .../import_pull_request_worker_spec.rb | 6 +- .../github_gists_import/import_gist_worker_spec.rb | 8 +- .../github_import/advance_stage_worker_spec.rb | 112 +- .../jira_import/advance_stage_worker_spec.rb | 7 + .../gitlab/jira_import/import_issue_worker_spec.rb | 2 +- .../close_incident_worker_spec.rb | 2 +- .../process_alert_worker_v2_spec.rb | 4 +- .../loose_foreign_keys/cleanup_worker_spec.rb | 62 + .../merge_requests/ensure_prepared_worker_spec.rb | 59 + .../metrics/global_metrics_update_worker_spec.rb | 30 - .../in_product_marketing_emails_worker_spec.rb | 32 - spec/workers/new_merge_request_worker_spec.rb | 28 +- spec/workers/new_note_worker_spec.rb | 2 +- .../pages/invalidate_domain_cache_worker_spec.rb | 267 -- .../personal_access_tokens/expiring_worker_spec.rb | 27 + spec/workers/post_receive_spec.rb | 2 +- .../record_target_platforms_worker_spec.rb | 2 +- ...records_to_ghost_user_in_batches_worker_spec.rb | 4 +- .../users/track_namespace_visits_worker_spec.rb | 27 + 1854 files changed, 68811 insertions(+), 53805 deletions(-) create mode 100644 spec/controllers/activity_pub/projects/releases_controller_spec.rb delete mode 100644 spec/controllers/projects/environments/sample_metrics_controller_spec.rb delete mode 100644 spec/controllers/projects/prometheus/alerts_controller_spec.rb create mode 100644 spec/db/avoid_migration_name_collisions_spec.rb create mode 100644 spec/factories/ci/reports/sbom/metadatum.rb delete mode 100644 spec/factories/metrics/dashboard/annotations.rb delete mode 100644 spec/factories/metrics/users_starred_dashboards.rb create mode 100644 spec/factories/packages/nuget/symbol.rb create mode 100644 spec/factories/packages/package_protection_rules.rb delete mode 100644 spec/factories/project_metrics_settings.rb delete mode 100644 spec/factories/self_managed_prometheus_alert_event.rb create mode 100644 spec/factories/users/group_visit.rb create mode 100644 spec/factories/users/project_visit.rb delete mode 100644 spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb create mode 100644 spec/features/merge_request/user_sets_to_auto_merge_spec.rb create mode 100644 spec/finders/ci/triggers_finder_spec.rb create mode 100644 spec/finders/organizations/groups_finder_spec.rb create mode 100644 spec/finders/organizations/organization_users_finder_spec.rb create mode 100644 spec/finders/packages/npm/packages_for_user_finder_spec.rb create mode 100644 spec/fixtures/api/schemas/ml/search_runs.json create mode 100644 spec/fixtures/api/schemas/public_api/v4/operations/user_list.json create mode 100644 spec/fixtures/packages/nuget/symbol/package.pdb create mode 100644 spec/frontend/__helpers__/clean_html_element_serializer.js create mode 100644 spec/frontend/__helpers__/html_string_serializer.js create mode 100644 spec/frontend/admin/abuse_report/components/activity_events_list_spec.js create mode 100644 spec/frontend/admin/abuse_report/components/activity_history_item_spec.js delete mode 100644 spec/frontend/admin/abuse_report/components/history_items_spec.js create mode 100644 spec/frontend/admin/abuse_report/components/labels_select_spec.js create mode 100644 spec/frontend/admin/abuse_report/components/report_details_spec.js create mode 100644 spec/frontend/api/application_settings_api_spec.js delete mode 100644 spec/frontend/avatar_helper_spec.js delete mode 100644 spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap create mode 100644 spec/frontend/ci/admin/jobs_table/admin_job_table_app_spec.js create mode 100644 spec/frontend/ci/admin/jobs_table/components/cancel_jobs_modal_spec.js create mode 100644 spec/frontend/ci/admin/jobs_table/components/cancel_jobs_spec.js create mode 100644 spec/frontend/ci/admin/jobs_table/components/cells/project_cell_spec.js create mode 100644 spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js create mode 100644 spec/frontend/ci/admin/jobs_table/components/jobs_skeleton_loader_spec.js create mode 100644 spec/frontend/ci/admin/jobs_table/graphql/cache_config_spec.js delete mode 100644 spec/frontend/ci/artifacts/components/feedback_banner_spec.js delete mode 100644 spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js delete mode 100644 spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js create mode 100644 spec/frontend/ci/common/pipelines_table_spec.js create mode 100644 spec/frontend/ci/common/private/job_links_layer_spec.js create mode 100644 spec/frontend/ci/common/private/jobs_filtered_search/jobs_filtered_search_spec.js create mode 100644 spec/frontend/ci/common/private/jobs_filtered_search/tokens/job_status_token_spec.js create mode 100644 spec/frontend/ci/common/private/jobs_filtered_search/utils_spec.js create mode 100644 spec/frontend/ci/job_details/components/empty_state_spec.js create mode 100644 spec/frontend/ci/job_details/components/environments_block_spec.js create mode 100644 spec/frontend/ci/job_details/components/erased_block_spec.js create mode 100644 spec/frontend/ci/job_details/components/job_header_spec.js create mode 100644 spec/frontend/ci/job_details/components/job_log_controllers_spec.js create mode 100644 spec/frontend/ci/job_details/components/log/collapsible_section_spec.js create mode 100644 spec/frontend/ci/job_details/components/log/duration_badge_spec.js create mode 100644 spec/frontend/ci/job_details/components/log/line_header_spec.js create mode 100644 spec/frontend/ci/job_details/components/log/line_number_spec.js create mode 100644 spec/frontend/ci/job_details/components/log/line_spec.js create mode 100644 spec/frontend/ci/job_details/components/log/log_spec.js create mode 100644 spec/frontend/ci/job_details/components/log/mock_data.js create mode 100644 spec/frontend/ci/job_details/components/manual_variables_form_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/commit_block_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/external_links_block_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/job_retry_forward_deployment_modal_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/jobs_container_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/sidebar_detail_row_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/sidebar_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js create mode 100644 spec/frontend/ci/job_details/components/sidebar/trigger_block_spec.js create mode 100644 spec/frontend/ci/job_details/components/stuck_block_spec.js create mode 100644 spec/frontend/ci/job_details/components/unmet_prerequisites_block_spec.js create mode 100644 spec/frontend/ci/job_details/job_app_spec.js create mode 100644 spec/frontend/ci/job_details/mock_data.js create mode 100644 spec/frontend/ci/job_details/store/actions_spec.js create mode 100644 spec/frontend/ci/job_details/store/getters_spec.js create mode 100644 spec/frontend/ci/job_details/store/helpers.js create mode 100644 spec/frontend/ci/job_details/store/mutations_spec.js create mode 100644 spec/frontend/ci/job_details/store/utils_spec.js create mode 100644 spec/frontend/ci/job_details/utils_spec.js create mode 100644 spec/frontend/ci/jobs_mock_data.js create mode 100644 spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js create mode 100644 spec/frontend/ci/jobs_page/components/job_cells/duration_cell_spec.js create mode 100644 spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js create mode 100644 spec/frontend/ci/jobs_page/components/job_cells/pipeline_cell_spec.js create mode 100644 spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js create mode 100644 spec/frontend/ci/jobs_page/components/jobs_table_spec.js create mode 100644 spec/frontend/ci/jobs_page/components/jobs_table_tabs_spec.js create mode 100644 spec/frontend/ci/jobs_page/graphql/cache_config_spec.js create mode 100644 spec/frontend/ci/jobs_page/job_page_app_spec.js create mode 100644 spec/frontend/ci/merge_requests/components/pipelines_table_wrapper_spec.js create mode 100644 spec/frontend/ci/merge_requests/mock_data.js create mode 100644 spec/frontend/ci/mixins/delayed_job_mixin_spec.js create mode 100644 spec/frontend/ci/pipeline_details/dag/components/__snapshots__/dag_graph_spec.js.snap create mode 100644 spec/frontend/ci/pipeline_details/dag/components/dag_annotations_spec.js create mode 100644 spec/frontend/ci/pipeline_details/dag/components/dag_graph_spec.js create mode 100644 spec/frontend/ci/pipeline_details/dag/dag_spec.js create mode 100644 spec/frontend/ci/pipeline_details/dag/mock_data.js create mode 100644 spec/frontend/ci/pipeline_details/dag/utils/drawing_utils_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/__snapshots__/links_inner_spec.js.snap create mode 100644 spec/frontend/ci/pipeline_details/graph/components/action_component_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/graph_view_selector_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/job_group_dropdown_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_column_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_mock_data.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/links_inner_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/graph_component_wrapper_spec.js create mode 100644 spec/frontend/ci/pipeline_details/graph/mock_data.js create mode 100644 spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js create mode 100644 spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js create mode 100644 spec/frontend/ci/pipeline_details/jobs/failed_jobs_app_spec.js create mode 100644 spec/frontend/ci/pipeline_details/jobs/jobs_app_spec.js create mode 100644 spec/frontend/ci/pipeline_details/linked_pipelines_mock.json create mode 100644 spec/frontend/ci/pipeline_details/mock_data.js create mode 100644 spec/frontend/ci/pipeline_details/pipeline_tabs_spec.js create mode 100644 spec/frontend/ci/pipeline_details/pipelines_store_spec.js create mode 100644 spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/empty_state_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/mock_data.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/stores/actions_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/stores/getters_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/stores/mutations_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/test_case_details_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/test_summary_spec.js create mode 100644 spec/frontend/ci/pipeline_details/test_reports/test_summary_table_spec.js create mode 100644 spec/frontend/ci/pipeline_details/utils/index_spec.js create mode 100644 spec/frontend/ci/pipeline_details/utils/parsing_utils_spec.js create mode 100644 spec/frontend/ci/pipeline_details/utils/unwrapping_utils_spec.js create mode 100644 spec/frontend/ci/pipeline_editor/components/graph/mock_data.js create mode 100644 spec/frontend/ci/pipeline_editor/components/graph/pipeline_graph_spec.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/job_item_spec.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mock_data.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/mock_data.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/pipeline_mini_graph_spec.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/pipeline_stage_spec.js create mode 100644 spec/frontend/ci/pipeline_mini_graph/pipeline_stages_spec.js delete mode 100644 spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/failure_widget/mock.js create mode 100644 spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/nav_controls_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js create mode 100644 spec/frontend/ci/pipelines_page/components/time_ago_spec.js create mode 100644 spec/frontend/ci/pipelines_page/pipelines_spec.js create mode 100644 spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js create mode 100644 spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js create mode 100644 spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js create mode 100644 spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js create mode 100644 spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js create mode 100644 spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js delete mode 100644 spec/frontend/commit/pipelines/pipelines_table_spec.js create mode 100644 spec/frontend/contribution_events/components/contribution_event/contribution_event_destroyed_spec.js create mode 100644 spec/frontend/contribution_events/components/contribution_event/contribution_event_updated_spec.js delete mode 100644 spec/frontend/fixtures/abuse_reports.rb create mode 100644 spec/frontend/groups/components/empty_states/groups_dashboard_empty_state_spec.js create mode 100644 spec/frontend/groups/components/empty_states/groups_explore_empty_state_spec.js delete mode 100644 spec/frontend/ide/components/file_templates/dropdown_spec.js delete mode 100644 spec/frontend/issuable/components/issuable_header_warnings_spec.js create mode 100644 spec/frontend/issuable/components/status_badge_spec.js delete mode 100644 spec/frontend/issuable/components/status_box_spec.js create mode 100644 spec/frontend/issues/service_desk/components/empty_state_with_any_issues_spec.js create mode 100644 spec/frontend/issues/service_desk/components/empty_state_without_any_issues_spec.js create mode 100644 spec/frontend/issues/service_desk/components/info_banner_spec.js create mode 100644 spec/frontend/issues/service_desk/components/service_desk_list_app_spec.js create mode 100644 spec/frontend/issues/service_desk/mock_data.js create mode 100644 spec/frontend/issues/show/components/sticky_header_spec.js delete mode 100644 spec/frontend/issues/show/issue_spec.js delete mode 100644 spec/frontend/issues/show/store_spec.js delete mode 100644 spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js delete mode 100644 spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js delete mode 100644 spec/frontend/jobs/components/filtered_search/utils_spec.js delete mode 100644 spec/frontend/jobs/components/job/artifacts_block_spec.js delete mode 100644 spec/frontend/jobs/components/job/commit_block_spec.js delete mode 100644 spec/frontend/jobs/components/job/empty_state_spec.js delete mode 100644 spec/frontend/jobs/components/job/environments_block_spec.js delete mode 100644 spec/frontend/jobs/components/job/erased_block_spec.js delete mode 100644 spec/frontend/jobs/components/job/job_app_spec.js delete mode 100644 spec/frontend/jobs/components/job/job_container_item_spec.js delete mode 100644 spec/frontend/jobs/components/job/job_log_controllers_spec.js delete mode 100644 spec/frontend/jobs/components/job/job_retry_forward_deployment_modal_spec.js delete mode 100644 spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js delete mode 100644 spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js delete mode 100644 spec/frontend/jobs/components/job/jobs_container_spec.js delete mode 100644 spec/frontend/jobs/components/job/manual_variables_form_spec.js delete mode 100644 spec/frontend/jobs/components/job/mock_data.js delete mode 100644 spec/frontend/jobs/components/job/sidebar_detail_row_spec.js delete mode 100644 spec/frontend/jobs/components/job/sidebar_header_spec.js delete mode 100644 spec/frontend/jobs/components/job/sidebar_spec.js delete mode 100644 spec/frontend/jobs/components/job/stages_dropdown_spec.js delete mode 100644 spec/frontend/jobs/components/job/stuck_block_spec.js delete mode 100644 spec/frontend/jobs/components/job/trigger_block_spec.js delete mode 100644 spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js delete mode 100644 spec/frontend/jobs/components/log/collapsible_section_spec.js delete mode 100644 spec/frontend/jobs/components/log/duration_badge_spec.js delete mode 100644 spec/frontend/jobs/components/log/line_header_spec.js delete mode 100644 spec/frontend/jobs/components/log/line_number_spec.js delete mode 100644 spec/frontend/jobs/components/log/line_spec.js delete mode 100644 spec/frontend/jobs/components/log/log_spec.js delete mode 100644 spec/frontend/jobs/components/log/mock_data.js delete mode 100644 spec/frontend/jobs/components/table/cells/actions_cell_spec.js delete mode 100644 spec/frontend/jobs/components/table/cells/duration_cell_spec.js delete mode 100644 spec/frontend/jobs/components/table/cells/job_cell_spec.js delete mode 100644 spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js delete mode 100644 spec/frontend/jobs/components/table/graphql/cache_config_spec.js delete mode 100644 spec/frontend/jobs/components/table/job_table_app_spec.js delete mode 100644 spec/frontend/jobs/components/table/jobs_table_empty_state_spec.js delete mode 100644 spec/frontend/jobs/components/table/jobs_table_spec.js delete mode 100644 spec/frontend/jobs/components/table/jobs_table_tabs_spec.js delete mode 100644 spec/frontend/jobs/mixins/delayed_job_mixin_spec.js delete mode 100644 spec/frontend/jobs/mock_data.js delete mode 100644 spec/frontend/jobs/store/actions_spec.js delete mode 100644 spec/frontend/jobs/store/getters_spec.js delete mode 100644 spec/frontend/jobs/store/helpers.js delete mode 100644 spec/frontend/jobs/store/mutations_spec.js delete mode 100644 spec/frontend/jobs/store/utils_spec.js create mode 100644 spec/frontend/lib/utils/breadcrumbs_spec.js delete mode 100644 spec/frontend/lib/utils/datetime_range_spec.js create mode 100644 spec/frontend/merge_requests/components/header_metadata_spec.js delete mode 100644 spec/frontend/organizations/groups_and_projects/components/groups_page_spec.js delete mode 100644 spec/frontend/organizations/groups_and_projects/components/projects_page_spec.js delete mode 100644 spec/frontend/organizations/groups_and_projects/mock_data.js delete mode 100644 spec/frontend/organizations/groups_and_projects/utils_spec.js create mode 100644 spec/frontend/organizations/shared/components/groups_view_spec.js create mode 100644 spec/frontend/organizations/shared/components/projects_view_spec.js create mode 100644 spec/frontend/organizations/shared/utils_spec.js create mode 100644 spec/frontend/organizations/show/components/app_spec.js create mode 100644 spec/frontend/organizations/show/components/association_count_card_spec.js create mode 100644 spec/frontend/organizations/show/components/association_counts_spec.js create mode 100644 spec/frontend/organizations/show/components/groups_and_projects_spec.js create mode 100644 spec/frontend/organizations/show/components/organization_avatar_spec.js create mode 100644 spec/frontend/organizations/show/utils_spec.js create mode 100644 spec/frontend/packages_and_registries/dependency_proxy/utils_spec.js delete mode 100644 spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js delete mode 100644 spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js delete mode 100644 spec/frontend/pages/admin/jobs/components/jobs_skeleton_loader_spec.js delete mode 100644 spec/frontend/pages/admin/jobs/components/table/cells/project_cell_spec.js delete mode 100644 spec/frontend/pages/admin/jobs/components/table/cells/runner_cell_spec.js delete mode 100644 spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js delete mode 100644 spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js delete mode 100644 spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap delete mode 100644 spec/frontend/pipelines/components/dag/dag_annotations_spec.js delete mode 100644 spec/frontend/pipelines/components/dag/dag_graph_spec.js delete mode 100644 spec/frontend/pipelines/components/dag/dag_spec.js delete mode 100644 spec/frontend/pipelines/components/dag/drawing_utils_spec.js delete mode 100644 spec/frontend/pipelines/components/dag/mock_data.js delete mode 100644 spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js delete mode 100644 spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js delete mode 100644 spec/frontend/pipelines/components/jobs/jobs_app_spec.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/job_item_spec.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage_spec.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mock_data.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js delete mode 100644 spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js delete mode 100644 spec/frontend/pipelines/components/pipeline_tabs_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_filtered_search_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_job_details_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_jobs_list_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js delete mode 100644 spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js delete mode 100644 spec/frontend/pipelines/empty_state_spec.js delete mode 100644 spec/frontend/pipelines/graph/action_component_spec.js delete mode 100644 spec/frontend/pipelines/graph/graph_component_spec.js delete mode 100644 spec/frontend/pipelines/graph/graph_component_wrapper_spec.js delete mode 100644 spec/frontend/pipelines/graph/graph_view_selector_spec.js delete mode 100644 spec/frontend/pipelines/graph/job_group_dropdown_spec.js delete mode 100644 spec/frontend/pipelines/graph/job_item_spec.js delete mode 100644 spec/frontend/pipelines/graph/job_name_component_spec.js delete mode 100644 spec/frontend/pipelines/graph/linked_pipeline_spec.js delete mode 100644 spec/frontend/pipelines/graph/linked_pipelines_column_spec.js delete mode 100644 spec/frontend/pipelines/graph/linked_pipelines_mock_data.js delete mode 100644 spec/frontend/pipelines/graph/mock_data.js delete mode 100644 spec/frontend/pipelines/graph/stage_column_component_spec.js delete mode 100644 spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap delete mode 100644 spec/frontend/pipelines/graph_shared/links_inner_spec.js delete mode 100644 spec/frontend/pipelines/graph_shared/links_layer_spec.js delete mode 100644 spec/frontend/pipelines/linked_pipelines_mock.json delete mode 100644 spec/frontend/pipelines/mock_data.js delete mode 100644 spec/frontend/pipelines/nav_controls_spec.js delete mode 100644 spec/frontend/pipelines/notification/mock_data.js delete mode 100644 spec/frontend/pipelines/pipeline_details_header_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_graph/mock_data.js delete mode 100644 spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_graph/utils_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_labels_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_multi_actions_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_operations_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_tabs_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_triggerer_spec.js delete mode 100644 spec/frontend/pipelines/pipeline_url_spec.js delete mode 100644 spec/frontend/pipelines/pipelines_artifacts_spec.js delete mode 100644 spec/frontend/pipelines/pipelines_manual_actions_spec.js delete mode 100644 spec/frontend/pipelines/pipelines_spec.js delete mode 100644 spec/frontend/pipelines/pipelines_store_spec.js delete mode 100644 spec/frontend/pipelines/pipelines_table_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/empty_state_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/mock_data.js delete mode 100644 spec/frontend/pipelines/test_reports/stores/actions_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/stores/getters_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/stores/mutations_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/stores/utils_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/test_case_details_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/test_reports_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/test_suite_table_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/test_summary_spec.js delete mode 100644 spec/frontend/pipelines/test_reports/test_summary_table_spec.js delete mode 100644 spec/frontend/pipelines/time_ago_spec.js delete mode 100644 spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js delete mode 100644 spec/frontend/pipelines/tokens/pipeline_source_token_spec.js delete mode 100644 spec/frontend/pipelines/tokens/pipeline_status_token_spec.js delete mode 100644 spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js delete mode 100644 spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js delete mode 100644 spec/frontend/pipelines/unwrapping_utils_spec.js delete mode 100644 spec/frontend/pipelines/utils_spec.js delete mode 100644 spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js delete mode 100644 spec/frontend/projects/settings/access_dropdown_spec.js create mode 100644 spec/frontend/protected_tags/mock_data.js create mode 100644 spec/frontend/protected_tags/protected_tag_edit_spec.js create mode 100644 spec/frontend/search/sidebar/components/commits_filters_spec.js create mode 100644 spec/frontend/search/sidebar/components/notes_filters_spec.js create mode 100644 spec/frontend/search/sidebar/components/projects_filters_spec.js delete mode 100644 spec/frontend/search/sidebar/components/projects_filters_specs.js create mode 100644 spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js create mode 100644 spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js delete mode 100644 spec/frontend/sentry/index_spec.js create mode 100644 spec/frontend/sentry/init_sentry_spec.js delete mode 100644 spec/frontend/sentry/sentry_config_spec.js delete mode 100644 spec/frontend/service_desk/components/empty_state_with_any_issues_spec.js delete mode 100644 spec/frontend/service_desk/components/empty_state_without_any_issues_spec.js delete mode 100644 spec/frontend/service_desk/components/info_banner_spec.js delete mode 100644 spec/frontend/service_desk/components/service_desk_list_app_spec.js delete mode 100644 spec/frontend/service_desk/mock_data.js create mode 100644 spec/frontend/silent_mode_settings/components/app_spec.js delete mode 100644 spec/frontend/super_sidebar/components/context_header_spec.js delete mode 100644 spec/frontend/super_sidebar/components/context_switcher_spec.js delete mode 100644 spec/frontend/super_sidebar/components/context_switcher_toggle_spec.js delete mode 100644 spec/frontend/super_sidebar/components/frequent_items_list_spec.js delete mode 100644 spec/frontend/super_sidebar/components/groups_list_spec.js delete mode 100644 spec/frontend/super_sidebar/components/items_list_spec.js delete mode 100644 spec/frontend/super_sidebar/components/projects_list_spec.js delete mode 100644 spec/frontend/super_sidebar/components/search_results_spec.js create mode 100644 spec/frontend/super_sidebar/components/sidebar_hover_peek_behavior_spec.js create mode 100644 spec/frontend/super_sidebar/mocks.js delete mode 100644 spec/frontend/tracing/components/tracing_details_spec.js delete mode 100644 spec/frontend/tracing/components/tracing_empty_state_spec.js delete mode 100644 spec/frontend/tracing/components/tracing_list_filtered_search_spec.js delete mode 100644 spec/frontend/tracing/components/tracing_list_spec.js delete mode 100644 spec/frontend/tracing/components/tracing_table_list_spec.js delete mode 100644 spec/frontend/tracing/details_index_spec.js delete mode 100644 spec/frontend/tracing/filters_spec.js delete mode 100644 spec/frontend/tracing/list_index_spec.js create mode 100644 spec/frontend/tracking/dispatch_snowplow_event_spec.js create mode 100644 spec/frontend/tracking/mock_data.js delete mode 100644 spec/frontend/usage_quotas/storage/components/usage_graph_spec.js delete mode 100644 spec/frontend/vue_merge_request_widget/components/action_buttons.js create mode 100644 spec/frontend/vue_merge_request_widget/components/action_buttons_spec.js delete mode 100644 spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap delete mode 100644 spec/frontend/vue_shared/components/date_time_picker/date_time_picker_input_spec.js delete mode 100644 spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js delete mode 100644 spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js delete mode 100644 spec/frontend/vue_shared/components/header_ci_component_spec.js create mode 100644 spec/frontend/vue_shared/components/list_actions/list_actions_spec.js delete mode 100644 spec/frontend/vue_shared/components/split_button_spec.js delete mode 100644 spec/frontend/vue_shared/issuable/__snapshots__/issuable_blocked_icon_spec.js.snap create mode 100644 spec/frontend/work_items/components/shared/work_item_token_input_spec.js create mode 100644 spec/frontend/work_items/components/work_item_relationships/__snapshots__/work_item_relationship_list_spec.js.snap create mode 100644 spec/frontend/work_items/components/work_item_relationships/work_item_relationship_list_spec.js create mode 100644 spec/frontend/work_items/components/work_item_relationships/work_item_relationships_spec.js create mode 100644 spec/graphql/resolvers/blame_resolver_spec.rb create mode 100644 spec/graphql/types/blame/blame_type_spec.rb create mode 100644 spec/graphql/types/blame/commit_data_type_spec.rb create mode 100644 spec/graphql/types/blame/groups_type_spec.rb create mode 100644 spec/graphql/types/ci/job_base_field_spec.rb create mode 100644 spec/graphql/types/organizations/group_sort_enum_spec.rb create mode 100644 spec/graphql/types/organizations/organization_type_spec.rb create mode 100644 spec/graphql/types/organizations/organization_user_type_spec.rb create mode 100644 spec/graphql/types/security/codequality_reports_comparer/degradation_type_spec.rb create mode 100644 spec/graphql/types/security/codequality_reports_comparer/report_type_spec.rb create mode 100644 spec/graphql/types/security/codequality_reports_comparer/status_enum_spec.rb create mode 100644 spec/graphql/types/security/codequality_reports_comparer/summary_type_spec.rb create mode 100644 spec/graphql/types/security/codequality_reports_comparer_type_spec.rb create mode 100644 spec/helpers/organizations/organization_helper_spec.rb delete mode 100644 spec/helpers/projects/observability_helper_spec.rb create mode 100644 spec/helpers/vite_helper_spec.rb create mode 100644 spec/lib/api/entities/merge_request_diff_spec.rb create mode 100644 spec/lib/api/entities/ml/mlflow/get_run_spec.rb create mode 100644 spec/lib/api/entities/ml/mlflow/search_runs_spec.rb create mode 100644 spec/lib/backup/database_model_spec.rb delete mode 100644 spec/lib/click_house/bind_index_manager_spec.rb create mode 100644 spec/lib/click_house/record_sync_context_spec.rb create mode 100644 spec/lib/click_house/sync_cursor_spec.rb create mode 100644 spec/lib/constraints/activity_pub_constrainer_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_has_merge_request_of_vulnerability_reads_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_nuget_normalized_version_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_project_statistics_storage_size_with_recent_size_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_user_preferences_with_defaults_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_users_with_defaults_spec.rb create mode 100644 spec/lib/gitlab/background_migration/convert_credit_card_validation_data_to_hashes_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/rebalance_partition_id_spec.rb create mode 100644 spec/lib/gitlab/background_migration/update_users_set_external_if_service_account_spec.rb create mode 100644 spec/lib/gitlab/bitbucket_import/importers/pull_request_importer_spec.rb create mode 100644 spec/lib/gitlab/bitbucket_import/importers/pull_requests_importer_spec.rb create mode 100644 spec/lib/gitlab/bitbucket_import/importers/repository_importer_spec.rb create mode 100644 spec/lib/gitlab/bitbucket_import/parallel_importer_spec.rb create mode 100644 spec/lib/gitlab/bitbucket_import/user_finder_spec.rb delete mode 100644 spec/lib/gitlab/bitbucket_server_import/importer_spec.rb create mode 100644 spec/lib/gitlab/ci/reports/sbom/metadata_spec.rb create mode 100644 spec/lib/gitlab/database/no_overrides_for_through_associations_spec.rb create mode 100644 spec/lib/gitlab/database_warnings_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/admin_verify_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/create_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/team_short_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/team_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/trial_short_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/trial_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/verify_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing_spec.rb create mode 100644 spec/lib/gitlab/email/service_desk/custom_email_spec.rb delete mode 100644 spec/lib/gitlab/metrics/dashboard/cache_spec.rb delete mode 100644 spec/lib/gitlab/metrics/dashboard/processor_spec.rb delete mode 100644 spec/lib/gitlab/metrics/dashboard/repo_dashboard_finder_spec.rb delete mode 100644 spec/lib/gitlab/metrics/dashboard/stages/url_validator_spec.rb delete mode 100644 spec/lib/gitlab/metrics/dashboard/url_spec.rb delete mode 100644 spec/lib/gitlab/pages/cache_control_spec.rb create mode 100644 spec/lib/gitlab/patch/sidekiq_scheduled_enq_spec.rb delete mode 100644 spec/lib/gitlab/prometheus/additional_metrics_parser_spec.rb delete mode 100644 spec/lib/gitlab/prometheus/queries/additional_metrics_deployment_query_spec.rb delete mode 100644 spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb delete mode 100644 spec/lib/gitlab/redis/etag_cache_spec.rb create mode 100644 spec/lib/gitlab/redis/pubsub_spec.rb create mode 100644 spec/lib/gitlab/redis/queues_metadata_spec.rb create mode 100644 spec/lib/gitlab/redis/workhorse_spec.rb create mode 100644 spec/lib/gitlab/usage/metrics/instrumentations/count_connected_agents_metric_spec.rb create mode 100644 spec/lib/gitlab/usage/time_series_storable_spec.rb create mode 100644 spec/lib/gitlab/x509/commit_sigstore_spec.rb create mode 100644 spec/lib/gitlab/x509/signature_sigstore_spec.rb create mode 100644 spec/lib/gitlab/x509/tag_sigstore_spec.rb create mode 100644 spec/lib/sidebars/concerns/has_avatar_spec.rb create mode 100644 spec/lib/sidebars/explore/panel_spec.rb create mode 100644 spec/lib/sidebars/your_work/menus/organizations_menu_spec.rb create mode 100644 spec/lib/system_check/app/table_truncate_check_spec.rb create mode 100644 spec/lib/users/internal_spec.rb delete mode 100644 spec/migrations/20230125093723_rebalance_partition_id_ci_pipeline_spec.rb delete mode 100644 spec/migrations/20230125093840_rebalance_partition_id_ci_build_spec.rb delete mode 100644 spec/migrations/20230208100917_fix_partition_ids_for_ci_pipeline_variable_spec.rb delete mode 100644 spec/migrations/20230208103009_fix_partition_ids_for_ci_job_artifact_spec.rb delete mode 100644 spec/migrations/20230208132608_fix_partition_ids_for_ci_stage_spec.rb delete mode 100644 spec/migrations/20230209090702_fix_partition_ids_for_ci_build_report_result_spec.rb delete mode 100644 spec/migrations/20230209092204_fix_partition_ids_for_ci_build_trace_metadata_spec.rb delete mode 100644 spec/migrations/20230209140102_fix_partition_ids_for_ci_build_metadata_spec.rb delete mode 100644 spec/migrations/20230214122717_fix_partition_ids_for_ci_job_variables_spec.rb delete mode 100644 spec/migrations/20230214154101_fix_partition_ids_on_ci_sources_pipelines_spec.rb create mode 100644 spec/migrations/20230726142555_ensure_notes_bigint_backfill_is_finished_for_self_managed_spec.rb create mode 100644 spec/migrations/20230726144458_swap_notes_id_to_bigint_for_self_managed_spec.rb create mode 100644 spec/migrations/20230802212443_add_current_user_todos_widget_to_epic_work_item_type_spec.rb create mode 100644 spec/migrations/20230809170822_ensure_system_note_metadata_bigint_backfill_is_finished_for_self_managed_spec.rb create mode 100644 spec/migrations/20230809174702_swap_system_note_metadata_note_id_to_bigint_for_self_managed_spec.rb create mode 100644 spec/migrations/20230809203254_ensure_issue_user_mentions_bigint_backfill_is_finished_for_self_managed_spec.rb create mode 100644 spec/migrations/20230809210550_swap_issue_user_mentions_note_id_to_bigint_for_self_managed_spec.rb create mode 100644 spec/migrations/20230810113227_swap_note_diff_files_note_id_to_bigint_for_self_hosts_spec.rb create mode 100644 spec/migrations/20230810124545_schedule_fixing_namespace_ids_of_vulnerability_reads_spec.rb create mode 100644 spec/migrations/20230811103457_queue_backfill_nuget_normalized_version_spec.rb create mode 100644 spec/migrations/20230815140656_queue_populate_denormalized_columns_for_sbom_occurrences_spec.rb create mode 100644 spec/migrations/20230815160428_rename_plans_titles_with_legacy_plan_names_spec.rb create mode 100644 spec/migrations/20230816152540_ensure_dum_note_id_bigint_backfill_is_finished_for_self_managed_spec.rb create mode 100644 spec/migrations/20230816152639_swap_design_user_mentions_note_id_to_big_int_for_self_managed_spec.rb create mode 100644 spec/migrations/20230817111938_swap_events_target_id_to_bigint_for_self_hosts_spec.rb create mode 100644 spec/migrations/20230817143637_swap_award_emoji_note_id_to_bigint_for_self_hosts_spec.rb create mode 100644 spec/migrations/20230818083610_queue_backfill_users_with_defaults_spec.rb create mode 100644 spec/migrations/20230818085219_queue_backfill_user_preferences_with_defaults_spec.rb create mode 100644 spec/migrations/20230818142801_queue_create_compliance_standards_adherence_spec.rb create mode 100644 spec/migrations/20230821081603_queue_convert_credit_card_validation_data_to_hashes_spec.rb create mode 100644 spec/migrations/20230822104028_delete_project_callout_three_spec.rb create mode 100644 spec/migrations/20230822151454_remove_free_user_cap_email_workers_spec.rb create mode 100644 spec/migrations/20230823090001_queue_backfill_project_statistics_storage_size_with_recent_size_spec.rb create mode 100644 spec/migrations/20230823140934_add_linked_items_widget_to_ticket_work_item_type_spec.rb create mode 100644 spec/migrations/20230830121830_queue_update_users_set_external_if_service_account_spec.rb create mode 100644 spec/migrations/20230831084632_queue_sync_scan_result_policies_spec.rb create mode 100644 spec/migrations/20230906204934_restart_self_hosted_sent_notifications_bigint_conversion_spec.rb create mode 100644 spec/migrations/20230906204935_restart_self_hosted_sent_notifications_backfill_spec.rb create mode 100644 spec/migrations/20230907155247_queue_backfill_has_merge_request_of_vulnerability_reads_spec.rb create mode 100644 spec/migrations/backfill_alert_management_prometheus_integrations_spec.rb create mode 100644 spec/migrations/ensure_mr_user_mentions_note_id_bigint_backfill_is_finished_for_self_managed_spec.rb create mode 100644 spec/migrations/swap_merge_request_user_mentions_note_id_to_bigint_for_self_managed_spec.rb create mode 100644 spec/models/analytics/cycle_analytics/runtime_limiter_spec.rb create mode 100644 spec/models/concerns/transitionable_spec.rb create mode 100644 spec/models/doorkeeper/application_spec.rb create mode 100644 spec/models/loose_foreign_keys/turbo_modification_tracker_spec.rb delete mode 100644 spec/models/metrics/dashboard/annotation_spec.rb delete mode 100644 spec/models/metrics/users_starred_dashboard_spec.rb create mode 100644 spec/models/packages/ml_model/package_spec.rb create mode 100644 spec/models/packages/nuget/symbol_spec.rb create mode 100644 spec/models/packages/protection/rule_spec.rb delete mode 100644 spec/models/performance_monitoring/prometheus_metric_spec.rb delete mode 100644 spec/models/performance_monitoring/prometheus_panel_group_spec.rb delete mode 100644 spec/models/performance_monitoring/prometheus_panel_spec.rb delete mode 100644 spec/models/project_metrics_setting_spec.rb create mode 100644 spec/models/users/group_visit_spec.rb create mode 100644 spec/models/users/project_visit_spec.rb create mode 100644 spec/requests/api/graphql/group/work_item_spec.rb create mode 100644 spec/requests/api/graphql/merge_requests/codequality_reports_comparer_spec.rb create mode 100644 spec/requests/api/graphql/mutations/admin/abuse_report_labels/create_spec.rb create mode 100644 spec/requests/api/graphql/mutations/merge_requests/update_spec.rb create mode 100644 spec/requests/api/graphql/mutations/work_items/linked_items/remove_spec.rb create mode 100644 spec/requests/api/graphql/organizations/organization_query_spec.rb create mode 100644 spec/requests/clusters/agents/dashboard_controller_spec.rb delete mode 100644 spec/requests/groups/email_campaigns_controller_spec.rb delete mode 100644 spec/requests/projects/noteable_notes_spec.rb delete mode 100644 spec/requests/projects/tracing_controller_spec.rb create mode 100644 spec/requests/users/namespace_visits_controller_spec.rb create mode 100644 spec/rubocop/cop/capybara/testid_finders_spec.rb delete mode 100644 spec/rubocop/cop/lint/last_keyword_argument_spec.rb create mode 100644 spec/serializers/activity_pub/activity_streams_serializer_spec.rb create mode 100644 spec/serializers/activity_pub/project_entity_spec.rb create mode 100644 spec/serializers/activity_pub/release_entity_spec.rb create mode 100644 spec/serializers/activity_pub/releases_actor_entity_spec.rb create mode 100644 spec/serializers/activity_pub/releases_actor_serializer_spec.rb create mode 100644 spec/serializers/activity_pub/releases_outbox_serializer_spec.rb create mode 100644 spec/serializers/activity_pub/user_entity_spec.rb create mode 100644 spec/serializers/admin/reported_content_entity_spec.rb create mode 100644 spec/serializers/ci/job_annotation_entity_spec.rb create mode 100644 spec/services/admin/abuse_report_labels/create_service_spec.rb create mode 100644 spec/services/admin/abuse_reports/update_service_spec.rb delete mode 100644 spec/services/bulk_imports/create_pipeline_trackers_service_spec.rb create mode 100644 spec/services/ci/create_commit_status_service_spec.rb create mode 100644 spec/services/concerns/services/return_service_responses_spec.rb delete mode 100644 spec/services/merge_requests/ff_merge_service_spec.rb delete mode 100644 spec/services/metrics/global_metrics_update_service_spec.rb delete mode 100644 spec/services/metrics/sample_metrics_service_spec.rb delete mode 100644 spec/services/namespaces/in_product_marketing_emails_service_spec.rb create mode 100644 spec/services/packages/nuget/check_duplicates_service_spec.rb create mode 100644 spec/services/packages/nuget/extract_remote_metadata_file_service_spec.rb create mode 100644 spec/services/packages/nuget/odata_package_entry_service_spec.rb create mode 100644 spec/services/work_items/related_work_item_links/destroy_service_spec.rb create mode 100644 spec/support/helpers/database/duplicate_indexes.rb create mode 100644 spec/support/helpers/database/duplicate_indexes.yml create mode 100644 spec/support/helpers/features/highlight_content_helper.rb create mode 100644 spec/support/helpers/loose_foreign_keys_helper.rb create mode 100644 spec/support/helpers/sign_up_helpers.rb create mode 100644 spec/support/shared_contexts/dependency_proxy_shared_context.rb create mode 100644 spec/support/shared_examples/ci/create_pipeline_service_environment_shared_examples.rb create mode 100644 spec/support/shared_examples/controllers/labels_controller_shared_examples.rb create mode 100644 spec/support/shared_examples/controllers/search_rate_limit_shared_examples.rb delete mode 100644 spec/support/shared_examples/lib/api/ai_workhorse_shared_examples.rb create mode 100644 spec/support/shared_examples/lib/gitlab/bitbucket_import/object_import_shared_examples.rb create mode 100644 spec/support/shared_examples/lib/gitlab/bitbucket_import/stage_methods_shared_examples.rb create mode 100644 spec/support/shared_examples/lib/gitlab/import/advance_stage_shared_examples.rb delete mode 100644 spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb create mode 100644 spec/support/shared_examples/models/users/pages_visits_shared_examples.rb create mode 100644 spec/support/shared_examples/requests/api/graphql/work_item_list_shared_examples.rb create mode 100644 spec/support/shared_examples/requests/api_keyset_pagination_shared_examples.rb delete mode 100644 spec/support/shared_examples/services/metrics/dashboard_shared_examples.rb delete mode 100644 spec/support/shared_examples/services/pages_size_limit_shared_examples.rb create mode 100644 spec/support/shared_examples/services/protected_branches_shared_examples.rb create mode 100644 spec/support/shared_examples/users/pages_visits_shared_examples.rb create mode 100644 spec/support_specs/database/duplicate_indexes_spec.rb delete mode 100644 spec/tasks/gitlab/generate_sample_prometheus_data_rake_spec.rb create mode 100644 spec/tooling/danger/clickhouse_spec.rb create mode 100644 spec/tooling/danger/ignored_model_columns_spec.rb create mode 100644 spec/tooling/fixtures/cleanup_conversion_migration.txt create mode 100644 spec/tooling/fixtures/remove_column_migration.txt create mode 100644 spec/tooling/fixtures/rename_column_migration.txt create mode 100644 spec/uploaders/packages/nuget/symbol_uploader_spec.rb create mode 100644 spec/views/devise/shared/_signup_omniauth_provider_list_spec.rb create mode 100644 spec/views/layouts/_page.html.haml_spec.rb create mode 100644 spec/views/layouts/organization.html.haml_spec.rb delete mode 100644 spec/views/projects/pipeline_schedules/_pipeline_schedule.html.haml_spec.rb create mode 100644 spec/workers/bulk_imports/finish_project_import_worker_spec.rb create mode 100644 spec/workers/concerns/gitlab/import/notify_upon_death_spec.rb delete mode 100644 spec/workers/concerns/gitlab/notify_upon_death_spec.rb create mode 100644 spec/workers/database/lock_tables_worker_spec.rb create mode 100644 spec/workers/gitlab/bitbucket_import/advance_stage_worker_spec.rb create mode 100644 spec/workers/gitlab/bitbucket_import/import_pull_request_worker_spec.rb create mode 100644 spec/workers/gitlab/bitbucket_import/stage/finish_import_worker_spec.rb create mode 100644 spec/workers/gitlab/bitbucket_import/stage/import_pull_requests_worker_spec.rb create mode 100644 spec/workers/gitlab/bitbucket_import/stage/import_repository_worker_spec.rb create mode 100644 spec/workers/gitlab/bitbucket_server_import/advance_stage_worker_spec.rb create mode 100644 spec/workers/gitlab/jira_import/advance_stage_worker_spec.rb create mode 100644 spec/workers/merge_requests/ensure_prepared_worker_spec.rb delete mode 100644 spec/workers/metrics/global_metrics_update_worker_spec.rb delete mode 100644 spec/workers/namespaces/in_product_marketing_emails_worker_spec.rb delete mode 100644 spec/workers/pages/invalidate_domain_cache_worker_spec.rb create mode 100644 spec/workers/users/track_namespace_visits_worker_spec.rb (limited to 'spec') diff --git a/spec/components/pajamas/banner_component_spec.rb b/spec/components/pajamas/banner_component_spec.rb index 6b99b4c1d76..c9d9a9176e8 100644 --- a/spec/components/pajamas/banner_component_spec.rb +++ b/spec/components/pajamas/banner_component_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Pajamas::BannerComponent, type: :component do end it 'renders a close button' do - expect(page).to have_css "button.gl-banner-close" + expect(page).to have_css "button.gl-button.gl-banner-close" end describe 'button_text and button_link' do diff --git a/spec/contracts/consumer/resources/graphql/pipelines.js b/spec/contracts/consumer/resources/graphql/pipelines.js index 48724e15eb8..201045e011f 100644 --- a/spec/contracts/consumer/resources/graphql/pipelines.js +++ b/spec/contracts/consumer/resources/graphql/pipelines.js @@ -5,7 +5,7 @@ import { extractGraphQLQuery } from '../../helpers/graphql_query_extractor'; export async function getPipelineHeaderDataRequest(endpoint) { const { url } = endpoint; const query = await extractGraphQLQuery( - 'app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql', + 'app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql', ); const graphqlQuery = { query, @@ -27,7 +27,7 @@ export async function getPipelineHeaderDataRequest(endpoint) { export async function deletePipeline(endpoint) { const { url } = endpoint; const query = await extractGraphQLQuery( - 'app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql', + 'app/assets/javascripts/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql', ); const graphqlQuery = { query, diff --git a/spec/contracts/consumer/specs/project/pipelines/show.spec.js b/spec/contracts/consumer/specs/project/pipelines/show.spec.js index 97ad9dbbc9d..d2743b1037f 100644 --- a/spec/contracts/consumer/specs/project/pipelines/show.spec.js +++ b/spec/contracts/consumer/specs/project/pipelines/show.spec.js @@ -27,7 +27,7 @@ pactWith( describe(GET_PIPELINE_HEADER_DATA_PROVIDER_NAME, () => { beforeEach(async () => { const query = await extractGraphQLQuery( - 'app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql', + 'app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql', ); const graphqlQuery = new GraphQLInteraction() .given(PipelineHeaderData.scenario.state) @@ -64,7 +64,7 @@ pactWith( describe(DELETE_PIPELINE_PROVIDER_NAME, () => { beforeEach(async () => { const query = await extractGraphQLQuery( - 'app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql', + 'app/assets/javascripts/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql', ); const graphqlQuery = new GraphQLInteraction() .given(DeletePipeline.scenario.state) diff --git a/spec/controllers/activity_pub/projects/releases_controller_spec.rb b/spec/controllers/activity_pub/projects/releases_controller_spec.rb new file mode 100644 index 00000000000..8719756b260 --- /dev/null +++ b/spec/controllers/activity_pub/projects/releases_controller_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ActivityPub::Projects::ReleasesController, feature_category: :groups_and_projects do + include AccessMatchersForController + + let_it_be(:project) { create(:project, :repository, :public) } + let_it_be(:private_project) { create(:project, :repository, :private) } + let_it_be(:developer) { create(:user) } + let_it_be(:release_1) { create(:release, project: project, released_at: Time.zone.parse('2018-10-18')) } + let_it_be(:release_2) { create(:release, project: project, released_at: Time.zone.parse('2019-10-19')) } + + before_all do + project.add_developer(developer) + end + + shared_examples 'common access controls' do + it 'renders a 200' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when the project is private' do + let(:project) { private_project } + + context 'when user is not logged in' do + it 'renders a 404' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is a developer' do + before do + sign_in(developer) + end + + it 'still renders a 404' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when activity_pub feature flag is disabled' do + before do + stub_feature_flags(activity_pub: false) + end + + it 'renders a 404' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when activity_pub_project feature flag is disabled' do + before do + stub_feature_flags(activity_pub_project: false) + end + + it 'renders a 404' do + get(action, params: params) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + shared_examples_for 'ActivityPub response' do + it 'returns an application/activity+json content_type' do + expect(response.media_type).to eq 'application/activity+json' + end + + it 'is formated as an ActivityStream document' do + expect(json_response['@context']).to eq 'https://www.w3.org/ns/activitystreams' + end + end + + describe 'GET #index' do + before do + get(action, params: params) + end + + let(:action) { :index } + let(:params) { { namespace_id: project.namespace, project_id: project } } + + it_behaves_like 'common access controls' + it_behaves_like 'ActivityPub response' + + it "returns the project's releases actor profile data" do + expect(json_response['id']).to include project_releases_path(project) + end + end + + describe 'GET #outbox' do + before do + get(action, params: params) + end + + let(:action) { :outbox } + let(:params) { { namespace_id: project.namespace, project_id: project, page: page } } + + context 'with no page parameter' do + let(:page) { nil } + + it_behaves_like 'common access controls' + it_behaves_like 'ActivityPub response' + + it "returns the project's releases collection index" do + expect(json_response['id']).to include outbox_project_releases_path(project) + expect(json_response['totalItems']).to eq 2 + end + end + + context 'with a page parameter' do + let(:page) { 1 } + + it_behaves_like 'common access controls' + it_behaves_like 'ActivityPub response' + + it "returns the project's releases list" do + expect(json_response['id']).to include outbox_project_releases_path(project, page: 1) + + names = json_response['orderedItems'].map { |release| release['object']['name'] } + expect(names).to match_array([release_2.name, release_1.name]) + end + end + end +end diff --git a/spec/controllers/admin/jobs_controller_spec.rb b/spec/controllers/admin/jobs_controller_spec.rb index 2d1482f40d4..c99bb6ff695 100644 --- a/spec/controllers/admin/jobs_controller_spec.rb +++ b/spec/controllers/admin/jobs_controller_spec.rb @@ -14,8 +14,6 @@ RSpec.describe Admin::JobsController do get :index expect(response).to have_gitlab_http_status(:ok) - expect(assigns(:builds)).to be_a(Kaminari::PaginatableWithoutCount) - expect(assigns(:builds).count).to be(1) end end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 399b7c02c52..f83b98d7a51 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -416,7 +416,7 @@ RSpec.describe Admin::UsersController do context 'for an internal user' do it 'does not deactivate the user' do - internal_user = User.alert_bot + internal_user = Users::Internal.alert_bot put :deactivate, params: { id: internal_user.username } diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 58125f3a831..b7ee01ce6b3 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -865,33 +865,6 @@ RSpec.describe ApplicationController, feature_category: :shared do end end - describe '#required_signup_info' do - controller(described_class) do - def index; end - end - - let(:user) { create(:user) } - - context 'user with required role' do - before do - user.set_role_required! - sign_in(user) - get :index - end - - it { is_expected.to redirect_to users_sign_up_welcome_path } - end - - context 'user without a required role' do - before do - sign_in(user) - get :index - end - - it { is_expected.not_to redirect_to users_sign_up_welcome_path } - end - end - describe 'rescue_from Gitlab::Auth::IpBlocked' do controller(described_class) do skip_before_action :authenticate_user! diff --git a/spec/controllers/concerns/onboarding/status_spec.rb b/spec/controllers/concerns/onboarding/status_spec.rb index b14346dc052..fe7c5ac6346 100644 --- a/spec/controllers/concerns/onboarding/status_spec.rb +++ b/spec/controllers/concerns/onboarding/status_spec.rb @@ -75,32 +75,4 @@ RSpec.describe Onboarding::Status, feature_category: :onboarding do it { is_expected.to eq(last_member_source) } end end - - describe '#invite_with_tasks_to_be_done?' do - subject { described_class.new(nil, nil, user).invite_with_tasks_to_be_done? } - - context 'when there are tasks_to_be_done with one member' do - let_it_be(:member) { create(:group_member, user: user, tasks_to_be_done: tasks_to_be_done) } - - it { is_expected.to eq(true) } - end - - context 'when there are multiple members and the tasks_to_be_done is on only one of them' do - before do - create(:group_member, user: user, tasks_to_be_done: tasks_to_be_done) - end - - it { is_expected.to eq(true) } - end - - context 'when there are no tasks_to_be_done' do - it { is_expected.to eq(false) } - end - - context 'when there are no members' do - let_it_be(:user) { build_stubbed(:user) } - - it { is_expected.to eq(false) } - end - end end diff --git a/spec/controllers/concerns/preferred_language_switcher_spec.rb b/spec/controllers/concerns/preferred_language_switcher_spec.rb index 40d6ac10c37..4ceb6fa312e 100644 --- a/spec/controllers/concerns/preferred_language_switcher_spec.rb +++ b/spec/controllers/concerns/preferred_language_switcher_spec.rb @@ -13,13 +13,79 @@ RSpec.describe PreferredLanguageSwitcher, type: :controller do end end + subject { cookies[:preferred_language] } + context 'when first visit' do + let(:glm_source) { 'about.gitlab.com' } + let(:accept_language_header) { nil } + before do - get :new + request.env['HTTP_ACCEPT_LANGUAGE'] = accept_language_header + + get :new, params: { glm_source: glm_source } end it 'sets preferred_language to default' do - expect(cookies[:preferred_language]).to eq Gitlab::CurrentSettings.default_preferred_language + expect(subject).to eq Gitlab::CurrentSettings.default_preferred_language + end + + context 'when language param is valid' do + let(:glm_source) { 'about.gitlab.com/fr-fr/' } + + it 'sets preferred_language accordingly' do + expect(subject).to eq 'fr' + end + + context 'when language param is invalid' do + let(:glm_source) { 'about.gitlab.com/ko-ko/' } + + it 'sets preferred_language to default' do + expect(subject).to eq Gitlab::CurrentSettings.default_preferred_language + end + end + end + + context 'when browser preferred language is not english' do + context 'with selectable language' do + let(:accept_language_header) { 'zh-CN,zh;q=0.8,zh-TW;q=0.7' } + + it 'sets preferred_language accordingly' do + expect(subject).to eq 'zh_CN' + end + end + + context 'with unselectable language' do + let(:accept_language_header) { 'nl-NL;q=0.8' } + + it 'sets preferred_language to default' do + expect(subject).to eq Gitlab::CurrentSettings.default_preferred_language + end + end + + context 'with empty string in language header' do + let(:accept_language_header) { '' } + + it 'sets preferred_language to default' do + expect(subject).to eq Gitlab::CurrentSettings.default_preferred_language + end + end + + context 'with language header without dashes' do + let(:accept_language_header) { 'fr;q=8' } + + it 'sets preferred_language accordingly' do + expect(subject).to eq 'fr' + end + end + end + + context 'when language params and language header are both valid' do + let(:accept_language_header) { 'zh-CN,zh;q=0.8,zh-TW;q=0.7' } + let(:glm_source) { 'about.gitlab.com/fr-fr/' } + + it 'sets preferred_language according to language params' do + expect(subject).to eq 'fr' + end end end @@ -36,7 +102,7 @@ RSpec.describe PreferredLanguageSwitcher, type: :controller do let(:user_preferred_language) { 'zh_CN' } it 'keeps preferred language unchanged' do - expect(cookies[:preferred_language]).to eq user_preferred_language + expect(subject).to eq user_preferred_language end end @@ -44,7 +110,7 @@ RSpec.describe PreferredLanguageSwitcher, type: :controller do let(:user_preferred_language) { 'xxx' } it 'sets preferred_language to default' do - expect(cookies[:preferred_language]).to eq Gitlab::CurrentSettings.default_preferred_language + expect(subject).to eq Gitlab::CurrentSettings.default_preferred_language end end end diff --git a/spec/controllers/confirmations_controller_spec.rb b/spec/controllers/confirmations_controller_spec.rb index fea43894f1c..cbe0ec5d126 100644 --- a/spec/controllers/confirmations_controller_spec.rb +++ b/spec/controllers/confirmations_controller_spec.rb @@ -19,17 +19,6 @@ RSpec.describe ConfirmationsController, feature_category: :system_access do get :show, params: { confirmation_token: confirmation_token } end - context 'when signup info is required' do - before do - allow(controller).to receive(:current_user) { user } - user.set_role_required! - end - - it 'does not redirect' do - expect(perform_request).not_to redirect_to(users_sign_up_welcome_path) - end - end - context 'user is already confirmed' do before do user.confirm @@ -137,17 +126,6 @@ RSpec.describe ConfirmationsController, feature_category: :system_access do stub_feature_flags(identity_verification: false) end - context 'when signup info is required' do - before do - allow(controller).to receive(:current_user) { user } - user.set_role_required! - end - - it 'does not redirect' do - expect(perform_request).not_to redirect_to(users_sign_up_welcome_path) - end - end - context "when `email_confirmation_setting` is set to `soft`" do before do stub_application_setting_enum('email_confirmation_setting', 'soft') diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb index 3fb5e08f065..6bb791d2fd4 100644 --- a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb +++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb @@ -244,7 +244,7 @@ RSpec.describe Groups::DependencyProxyForContainersController, feature_category: subject send_data_type, send_data = workhorse_send_data - header, url = send_data.values_at('Header', 'Url') + header, url = send_data.values_at('Headers', 'Url') expect(send_data_type).to eq('send-dependency') expect(header).to eq( @@ -312,7 +312,7 @@ RSpec.describe Groups::DependencyProxyForContainersController, feature_category: subject send_data_type, send_data = workhorse_send_data - header, url = send_data.values_at('Header', 'Url') + header, url = send_data.values_at('Headers', 'Url') expect(send_data_type).to eq('send-dependency') expect(header).to eq("Authorization" => ["Bearer abcd1234"]) diff --git a/spec/controllers/groups/labels_controller_spec.rb b/spec/controllers/groups/labels_controller_spec.rb index f9f1fc21538..3dcf41941bb 100644 --- a/spec/controllers/groups/labels_controller_spec.rb +++ b/spec/controllers/groups/labels_controller_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe Groups::LabelsController, feature_category: :team_planning do - let_it_be(:group) { create(:group) } + let_it_be(:root_group) { create(:group) } + let_it_be(:group) { create(:group, parent: root_group) } let_it_be(:user) { create(:user) } let_it_be(:another_user) { create(:user) } let_it_be(:project) { create(:project, namespace: group) } @@ -162,5 +163,50 @@ RSpec.describe Groups::LabelsController, feature_category: :team_planning do let(:group_request) { put :update, params: { group_id: group.to_param, id: label.to_param, label: { title: 'Test' } } } let(:sub_group_request) { put :update, params: { group_id: sub_group.to_param, id: label.to_param, label: { title: 'Test' } } } end + + context 'when updating lock_on_merge' do + let_it_be(:params) { { lock_on_merge: true } } + let_it_be_with_reload(:label) { create(:group_label, group: group) } + + subject(:update_request) { put :update, params: { group_id: group.to_param, id: label.to_param, label: params } } + + context 'when feature flag is disabled' do + before do + stub_feature_flags(enforce_locked_labels_on_merge: false) + end + + it 'does not allow setting lock_on_merge' do + update_request + + expect(response).to redirect_to(group_labels_path) + expect(label.reload.lock_on_merge).to be_falsey + end + end + + shared_examples 'allows setting lock_on_merge' do + it do + update_request + + expect(response).to redirect_to(group_labels_path) + expect(label.reload.lock_on_merge).to be_truthy + end + end + + context 'when feature flag for group is enabled' do + before do + stub_feature_flags(enforce_locked_labels_on_merge: group) + end + + it_behaves_like 'allows setting lock_on_merge' + end + + context 'when feature flag for ancestor group is enabled' do + before do + stub_feature_flags(enforce_locked_labels_on_merge: root_group) + end + + it_behaves_like 'allows setting lock_on_merge' + end + end end end diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb index 37242bce6bf..a4e55a89f41 100644 --- a/spec/controllers/groups/runners_controller_spec.rb +++ b/spec/controllers/groups/runners_controller_spec.rb @@ -3,65 +3,88 @@ require 'spec_helper' RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do - let_it_be(:user) { create(:user) } - let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:namespace_settings) { create(:namespace_settings, runner_registration_enabled: true) } + let_it_be(:group) { create(:group, namespace_settings: namespace_settings) } let_it_be(:project) { create(:project, group: group) } let_it_be(:runner) { create(:ci_runner, :group, groups: [group]) } let!(:project_runner) { create(:ci_runner, :project, projects: [project]) } let!(:instance_runner) { create(:ci_runner, :instance) } - let(:params_runner_project) { { group_id: group, id: project_runner } } - let(:params_runner_instance) { { group_id: group, id: instance_runner } } - let(:params) { { group_id: group, id: runner } } - before do sign_in(user) end describe '#index', :snowplow do - context 'when user is owner' do - before do - group.add_owner(user) - end + subject(:execute_get_request) { get :index, params: { group_id: group } } - it 'renders show with 200 status code' do - get :index, params: { group_id: group } + shared_examples 'can access the page' do + it 'renders index with 200 status code' do + execute_get_request expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template(:index) end it 'tracks the event' do - get :index, params: { group_id: group } + execute_get_request expect_snowplow_event(category: described_class.name, action: 'index', user: user, namespace: group) end + end + + shared_examples 'cannot access the page' do + it 'renders 404' do + execute_get_request + + expect(response).to have_gitlab_http_status(:not_found) + end - it 'assigns variables' do - get :index, params: { group_id: group } + it 'does not track the event' do + execute_get_request - expect(assigns(:group_new_runner_path)).to eq(new_group_runner_path(group)) + expect_no_snowplow_event end end - context 'when user is not owner' do + context 'when the user is a maintainer' do before do group.add_maintainer(user) end - it 'renders a 404' do - get :index, params: { group_id: group } + include_examples 'can access the page' - expect(response).to have_gitlab_http_status(:not_found) + it 'does not expose runner creation and registration variables' do + execute_get_request + + expect(assigns(:group_runner_registration_token)).to be_nil + expect(assigns(:group_new_runner_path)).to be_nil end + end - it 'does not track the event' do - get :index, params: { group_id: group } + context 'when the user is an owner' do + before do + group.add_owner(user) + end - expect_no_snowplow_event + include_examples 'can access the page' + + it 'exposes runner creation and registration variables' do + execute_get_request + + expect(assigns(:group_runner_registration_token)).not_to be_nil + expect(assigns(:group_new_runner_path)).to eq(new_group_runner_path(group)) end end + + context 'when user is not maintainer' do + before do + group.add_developer(user) + end + + include_examples 'cannot access the page' + end end describe '#new' do @@ -139,9 +162,9 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do end describe '#show' do - context 'when user is owner' do + context 'when user is maintainer' do before do - group.add_owner(user) + group.add_maintainer(user) end it 'renders show with 200 status code' do @@ -166,9 +189,9 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do end end - context 'when user is not owner' do + context 'when user is not maintainer' do before do - group.add_maintainer(user) + group.add_developer(user) end it 'renders a 404' do @@ -197,20 +220,26 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do group.add_owner(user) end - it 'renders edit with 200 status code' do + it 'renders 200 for group runner' do get :edit, params: { group_id: group, id: runner } expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template(:edit) end - it 'renders a 404 instance runner' do + it 'renders 404 for non-existing runner' do + get :edit, params: { group_id: group, id: non_existing_record_id } + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'renders 404 for instance runner' do get :edit, params: { group_id: group, id: instance_runner } expect(response).to have_gitlab_http_status(:not_found) end - it 'renders edit with 200 status code project runner' do + it 'renders 200 for project runner' do get :edit, params: { group_id: group, id: project_runner } expect(response).to have_gitlab_http_status(:ok) @@ -218,18 +247,49 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do end end - context 'when user is not owner' do + context 'when user is maintainer' do before do group.add_maintainer(user) end - it 'renders a 404' do + it 'renders 404 for group runner' do get :edit, params: { group_id: group, id: runner } expect(response).to have_gitlab_http_status(:not_found) end - it 'renders a 404 project runner' do + it 'renders 404 for instance runner' do + get :edit, params: { group_id: group, id: instance_runner } + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'renders 200 for project runner' do + get :edit, params: { group_id: group, id: project_runner } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:edit) + end + end + + context 'when user is not maintainer' do + before do + group.add_developer(user) + end + + it 'renders 404 for group runner' do + get :edit, params: { group_id: group, id: runner } + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'renders 404 for instance runner' do + get :edit, params: { group_id: group, id: instance_runner } + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'renders 404 for project runner' do get :edit, params: { group_id: group, id: project_runner } expect(response).to have_gitlab_http_status(:not_found) @@ -238,83 +298,105 @@ RSpec.describe Groups::RunnersController, feature_category: :runner_fleet do end describe '#update' do - let!(:runner) { create(:ci_runner, :group, groups: [group]) } - - context 'when user is an owner' do - before do - group.add_owner(user) - end + let!(:group_runner) { create(:ci_runner, :group, groups: [group]) } + shared_examples 'updates the runner' do it 'updates the runner, ticks the queue, and redirects' do new_desc = runner.description.swapcase expect do - post :update, params: params.merge(runner: { description: new_desc }) + post :update, params: { group_id: group, id: runner, runner: { description: new_desc } } + runner.reload end.to change { runner.ensure_runner_queue_value } expect(response).to have_gitlab_http_status(:found) expect(runner.reload.description).to eq(new_desc) end + end - it 'does not update the instance runner' do - new_desc = instance_runner.description.swapcase + shared_examples 'rejects the update' do + it 'does not update the runner' do + new_desc = runner.description.swapcase expect do - post :update, params: params_runner_instance.merge(runner: { description: new_desc }) - end.to not_change { instance_runner.ensure_runner_queue_value } - .and not_change { instance_runner.description } + post :update, params: { group_id: group, id: runner, runner: { description: new_desc } } + runner.reload + end.to not_change { runner.ensure_runner_queue_value } + .and not_change { runner.description } expect(response).to have_gitlab_http_status(:not_found) end + end + + context 'when user is owner' do + before do + group.add_owner(user) + end - it 'updates the project runner, ticks the queue, and redirects project runner' do - new_desc = project_runner.description.swapcase + context 'with group runner' do + let(:runner) { group_runner } - expect do - post :update, params: params_runner_project.merge(runner: { description: new_desc }) - end.to change { project_runner.ensure_runner_queue_value } + it_behaves_like 'updates the runner' + end - expect(response).to have_gitlab_http_status(:found) - expect(project_runner.reload.description).to eq(new_desc) + context 'with instance runner' do + let(:runner) { instance_runner } + + it_behaves_like 'rejects the update' + end + + context 'with project runner' do + let(:runner) { project_runner } + + it_behaves_like 'updates the runner' end end - context 'when user is not an owner' do + context 'when user is maintainer' do before do group.add_maintainer(user) end - it 'rejects the update and responds 404' do - old_desc = runner.description + context 'with group runner' do + let(:runner) { group_runner } - expect do - post :update, params: params.merge(runner: { description: old_desc.swapcase }) - end.not_to change { runner.ensure_runner_queue_value } + it_behaves_like 'rejects the update' + end - expect(response).to have_gitlab_http_status(:not_found) - expect(runner.reload.description).to eq(old_desc) + context 'with instance runner' do + let(:runner) { instance_runner } + + it_behaves_like 'rejects the update' end - it 'rejects the update and responds 404 instance runner' do - old_desc = instance_runner.description + context 'with project runner' do + let(:runner) { project_runner } - expect do - post :update, params: params_runner_instance.merge(runner: { description: old_desc.swapcase }) - end.not_to change { instance_runner.ensure_runner_queue_value } + it_behaves_like 'updates the runner' + end + end - expect(response).to have_gitlab_http_status(:not_found) - expect(instance_runner.reload.description).to eq(old_desc) + context 'when user is not maintainer' do + before do + group.add_developer(user) end - it 'rejects the update and responds 404 project runner' do - old_desc = project_runner.description + context 'with group runner' do + let(:runner) { group_runner } - expect do - post :update, params: params_runner_project.merge(runner: { description: old_desc.swapcase }) - end.not_to change { project_runner.ensure_runner_queue_value } + it_behaves_like 'rejects the update' + end - expect(response).to have_gitlab_http_status(:not_found) - expect(project_runner.reload.description).to eq(old_desc) + context 'with instance runner' do + let(:runner) { instance_runner } + + it_behaves_like 'rejects the update' + end + + context 'with project runner' do + let(:runner) { project_runner } + + it_behaves_like 'rejects the update' end end end diff --git a/spec/controllers/groups/uploads_controller_spec.rb b/spec/controllers/groups/uploads_controller_spec.rb index 7795fff5541..94bb9c9aa02 100644 --- a/spec/controllers/groups/uploads_controller_spec.rb +++ b/spec/controllers/groups/uploads_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Groups::UploadsController do +RSpec.describe Groups::UploadsController, feature_category: :portfolio_management do include WorkhorseHelpers let(:model) { create(:group, :public) } diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index f3b21e191c4..b3b7753df61 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -192,26 +192,6 @@ RSpec.describe InvitesController do expect(session[:invite_email]).to eq(member.invite_email) end - context 'with stored location for user' do - it 'stores the correct path for user' do - request - - expect(controller.stored_location_for(:user)).to eq(activity_project_path(member.source)) - end - - context 'with relative root' do - before do - stub_default_url_options(script_name: '/gitlab') - end - - it 'stores the correct path for user' do - request - - expect(controller.stored_location_for(:user)).to eq(activity_project_path(member.source)) - end - end - end - context 'when it is part of our invite email experiment' do let(:extra_params) { { invite_type: 'initial_email' } } diff --git a/spec/controllers/oauth/applications_controller_spec.rb b/spec/controllers/oauth/applications_controller_spec.rb index 5b9fd192ad4..44deeb6c47e 100644 --- a/spec/controllers/oauth/applications_controller_spec.rb +++ b/spec/controllers/oauth/applications_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Oauth::ApplicationsController do +RSpec.describe Oauth::ApplicationsController, feature_category: :system_access do let(:user) { create(:user) } let(:application) { create(:oauth_application, owner: user) } @@ -86,10 +86,10 @@ RSpec.describe Oauth::ApplicationsController do it_behaves_like 'redirects to login page when the user is not signed in' it_behaves_like 'redirects to 2fa setup page when the user requires it' - it 'returns the secret in json format' do + it 'returns the prefixed secret in json format' do subject - expect(json_response['secret']).not_to be_nil + expect(json_response['secret']).to match(/gloas-\h{64}/) end context 'when renew fails' do @@ -153,6 +153,15 @@ RSpec.describe Oauth::ApplicationsController do expect(response).to render_template :show end + context 'the secret' do + render_views + + it 'is in the response' do + subject + expect(response.body).to match(/gloas-\h{64}/) + end + end + it 'redirects back to profile page if OAuth applications are disabled' do disable_user_oauth diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb index 4772c3f3487..cfb512afc91 100644 --- a/spec/controllers/oauth/authorizations_controller_spec.rb +++ b/spec/controllers/oauth/authorizations_controller_spec.rb @@ -5,9 +5,15 @@ require 'spec_helper' RSpec.describe Oauth::AuthorizationsController do let(:user) { create(:user) } let(:application_scopes) { 'api read_user' } + let(:confidential) { true } let!(:application) do - create(:oauth_application, scopes: application_scopes, redirect_uri: 'http://example.com') + create( + :oauth_application, + scopes: application_scopes, + redirect_uri: 'http://example.com', + confidential: confidential + ) end let(:params) do @@ -68,12 +74,27 @@ RSpec.describe Oauth::AuthorizationsController do create(:oauth_access_token, application: application, resource_owner_id: user.id, scopes: scopes) end - it 'authorizes the request and shows the user a page that redirects' do - subject + context 'when application is confidential' do + let(:confidential) { true } - expect(request.session['user_return_to']).to be_nil - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template('doorkeeper/authorizations/redirect') + it 'authorizes the request and shows the user a page that redirects' do + subject + + expect(request.session['user_return_to']).to be_nil + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('doorkeeper/authorizations/redirect') + end + end + + context 'when application is not confidential' do + let(:confidential) { false } + + it 'returns 200 code and renders view' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('doorkeeper/authorizations/new') + end end end diff --git a/spec/controllers/profiles/notifications_controller_spec.rb b/spec/controllers/profiles/notifications_controller_spec.rb index 36ec36fb6f1..22c0a62a6a1 100644 --- a/spec/controllers/profiles/notifications_controller_spec.rb +++ b/spec/controllers/profiles/notifications_controller_spec.rb @@ -149,11 +149,10 @@ RSpec.describe Profiles::NotificationsController do it 'updates only permitted attributes' do sign_in(user) - put :update, params: { user: { notification_email: 'new@example.com', email_opted_in: true, notified_of_own_activity: true, admin: true } } + put :update, params: { user: { notification_email: 'new@example.com', notified_of_own_activity: true, admin: true } } user.reload expect(user.notification_email).to eq('new@example.com') - expect(user.email_opted_in).to eq(true) expect(user.notified_of_own_activity).to eq(true) expect(user.admin).to eq(false) expect(controller).to set_flash[:notice].to('Notification settings saved') diff --git a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb index 044ce8f397a..14f3f5c23cd 100644 --- a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb +++ b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb @@ -73,14 +73,14 @@ RSpec.describe Profiles::PersonalAccessTokensController do get :index end - it "only includes details of the active personal access token" do + it "only includes details of active personal access tokens" do active_personal_access_tokens_detail = ::PersonalAccessTokenSerializer.new.represent([active_personal_access_token]) expect(assigns(:active_access_tokens).to_json).to eq(active_personal_access_tokens_detail.to_json) end - it "sets PAT name and scopes" do + it "builds a PAT with name and scopes from params" do name = 'My PAT' scopes = 'api,read_user' @@ -105,5 +105,57 @@ RSpec.describe Profiles::PersonalAccessTokensController do expect(json_response.count).to eq(1) end + + it 'sets available scopes' do + expect(assigns(:scopes)).to eq(Gitlab::Auth.available_scopes_for(access_token_user)) + end + + context 'with feature flag k8s_proxy_pat disabled' do + before do + stub_feature_flags(k8s_proxy_pat: false) + # Impersonation and inactive personal tokens are ignored + create(:personal_access_token, :impersonation, user: access_token_user) + create(:personal_access_token, :revoked, user: access_token_user) + get :index + end + + it "only includes details of active personal access tokens" do + active_personal_access_tokens_detail = + ::PersonalAccessTokenSerializer.new.represent([active_personal_access_token]) + + expect(assigns(:active_access_tokens).to_json).to eq(active_personal_access_tokens_detail.to_json) + end + + it "builds a PAT with name and scopes from params" do + name = 'My PAT' + scopes = 'api,read_user' + + get :index, params: { name: name, scopes: scopes } + + expect(assigns(:personal_access_token)).to have_attributes( + name: eq(name), + scopes: contain_exactly(:api, :read_user) + ) + end + + it 'returns 404 when personal access tokens are disabled' do + allow(::Gitlab::CurrentSettings).to receive_messages(personal_access_tokens_disabled?: true) + + get :index + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns tokens for json format' do + get :index, params: { format: :json } + + expect(json_response.count).to eq(1) + end + + it 'sets available scopes' do + expect(assigns(:scopes)) + .to eq(Gitlab::Auth.available_scopes_for(access_token_user) - [Gitlab::Auth::K8S_PROXY_SCOPE]) + end + end end end diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index b4ffe0bc844..aaf169cd42b 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -54,6 +54,7 @@ RSpec.describe Profiles::PreferencesController do preferred_language: 'jp', tab_width: '5', project_shortcut_buttons: 'true', + keyboard_shortcuts_enabled: 'true', render_whitespace_in_code: 'true' }.with_indifferent_access diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb index b1c43a33386..2bcb47f97ab 100644 --- a/spec/controllers/profiles_controller_spec.rb +++ b/spec/controllers/profiles_controller_spec.rb @@ -193,20 +193,6 @@ RSpec.describe ProfilesController, :request_store do .to raise_error(ActionController::ParameterMissing) end - context 'with legacy storage' do - it 'moves dependent projects to new namespace' do - project = create(:project_empty_repo, :legacy_storage, namespace: namespace) - - put :update_username, - params: { user: { username: new_username } } - - user.reload - - expect(response).to have_gitlab_http_status(:found) - expect(gitlab_shell.repository_exists?(project.repository_storage, "#{new_username}/#{project.path}.git")).to be_truthy - end - end - context 'with hashed storage' do it 'keeps repository location unchanged on disk' do project = create(:project_empty_repo, namespace: namespace) diff --git a/spec/controllers/projects/alerting/notifications_controller_spec.rb b/spec/controllers/projects/alerting/notifications_controller_spec.rb index 5ce2950f95f..6a8c57e4abd 100644 --- a/spec/controllers/projects/alerting/notifications_controller_spec.rb +++ b/spec/controllers/projects/alerting/notifications_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Projects::Alerting::NotificationsController do +RSpec.describe Projects::Alerting::NotificationsController, feature_category: :incident_management do include HttpBasicAuthHelpers let_it_be(:project) { create(:project) } @@ -68,6 +68,31 @@ RSpec.describe Projects::Alerting::NotificationsController do make_request end + context 'with a corresponding project_alerting_setting' do + let_it_be_with_reload(:setting) { create(:project_alerting_setting, :with_http_integration, project: project) } + let_it_be_with_reload(:integration) { project.alert_management_http_integrations.last! } + + context 'and a migrated or synced HTTP integration' do + it 'extracts and finds the integration' do + expect(notify_service).to receive(:execute).with('some token', integration) + + make_request + end + end + + context 'and no migrated or synced HTTP integration' do + before do + integration.destroy! + end + + it 'does not find an integration' do + expect(notify_service).to receive(:execute).with('some token', nil) + + make_request + end + end + end + context 'with a corresponding integration' do context 'with integration parameters specified' do let_it_be_with_reload(:integration) { create(:alert_management_http_integration, project: project) } diff --git a/spec/controllers/projects/environments/sample_metrics_controller_spec.rb b/spec/controllers/projects/environments/sample_metrics_controller_spec.rb deleted file mode 100644 index b266c569edd..00000000000 --- a/spec/controllers/projects/environments/sample_metrics_controller_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Projects::Environments::SampleMetricsController do - include StubENV - - let_it_be(:project) { create(:project) } - let_it_be(:environment) { create(:environment, project: project) } - let_it_be(:user) { create(:user) } - - before do - project.add_reporter(user) - sign_in(user) - end - - describe 'GET #query' do - context 'when the file is not found' do - before do - get :query, params: environment_params - end - - it 'returns a 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when the sample data is found' do - before do - allow_next_instance_of(Metrics::SampleMetricsService) do |service| - allow(service).to receive(:query).and_return([]) - end - get :query, params: environment_params - end - - it 'returns JSON with a message and a 200 status code' do - expect(json_response.keys).to contain_exactly('status', 'data') - expect(response).to have_gitlab_http_status(:ok) - end - end - end - - private - - def environment_params(params = {}) - { - id: environment.id.to_s, - namespace_id: project.namespace.full_path, - project_id: project.path, - identifier: 'sample_metric_query_result', - start: '2019-12-02T23:31:45.000Z', - end: '2019-12-03T00:01:45.000Z' - }.merge(params) - end -end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 4b091e9221e..c421aee88f8 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -398,7 +398,7 @@ RSpec.describe Projects::EnvironmentsController, feature_category: :continuous_d expect(response).to have_gitlab_http_status(:ok) expect(json_response).to eq( { 'redirect_url' => - project_pipeline_url(project, action.pipeline_id) }) + project_job_url(project, action) }) end it 'returns environment url for multiple stop actions' do diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 7b576533ae5..d4f04105605 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -120,11 +120,11 @@ RSpec.describe Projects::IssuesController, :request_store, feature_category: :te allow(Kaminari.config).to receive(:default_per_page).and_return(1) end - it 'redirects to last page when out of bounds on non-html requests' do + it 'does not redirect when out of bounds on non-html requests' do get :index, params: params.merge(page: last_page + 1), format: 'atom' - expect(response).to have_gitlab_http_status(:redirect) - expect(response).to redirect_to(action: 'index', format: 'atom', page: last_page, state: 'opened') + expect(response).to have_gitlab_http_status(:ok) + expect(assigns(:issues).size).to eq(0) end end @@ -1725,7 +1725,7 @@ RSpec.describe Projects::IssuesController, :request_store, feature_category: :te describe 'GET service_desk' do let_it_be(:project) { create(:project_empty_repo, :public) } - let_it_be(:support_bot) { User.support_bot } + let_it_be(:support_bot) { Users::Internal.support_bot } let_it_be(:other_user) { create(:user) } let_it_be(:service_desk_issue_1) { create(:issue, project: project, author: support_bot) } let_it_be(:service_desk_issue_2) { create(:issue, project: project, author: support_bot, assignees: [other_user]) } @@ -1756,7 +1756,7 @@ RSpec.describe Projects::IssuesController, :request_store, feature_category: :te it 'allows an assignee to be specified by id' do get_service_desk(assignee_id: other_user.id) - expect(assigns(:users)).to contain_exactly(other_user, support_bot) + expect(assigns(:issues)).to contain_exactly(service_desk_issue_2) end end diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index ede26ebd032..9851153bd39 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -47,7 +47,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu it 'has only pending builds' do expect(response).to have_gitlab_http_status(:ok) - expect(assigns(:builds).first.status).to eq('pending') end end @@ -60,7 +59,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu it 'has only running jobs' do expect(response).to have_gitlab_http_status(:ok) - expect(assigns(:builds).first.status).to eq('running') end end @@ -73,7 +71,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu it 'has only finished jobs' do expect(response).to have_gitlab_http_status(:ok) - expect(assigns(:builds).first.status).to eq('success') end end @@ -89,7 +86,6 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu it 'redirects to the page' do expect(response).to have_gitlab_http_status(:ok) - expect(assigns(:builds).current_page).to eq(last_page) end end end @@ -156,6 +152,18 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state, featu expect(response).to have_gitlab_http_status(:not_found) end end + + context 'when the job is a bridge' do + let!(:downstream_pipeline) { create(:ci_pipeline, child_of: pipeline) } + let(:job) { downstream_pipeline.source_job } + + it 'redirects to the downstream pipeline page' do + get_show(id: job.id) + + expect(response).to have_gitlab_http_status(:found) + expect(response).to redirect_to(namespace_project_pipeline_path(id: downstream_pipeline.id)) + end + end end context 'when requesting JSON' do diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb index 74c16621fc5..db8cac8bb4a 100644 --- a/spec/controllers/projects/labels_controller_spec.rb +++ b/spec/controllers/projects/labels_controller_spec.rb @@ -297,6 +297,53 @@ RSpec.describe Projects::LabelsController, feature_category: :team_planning do end end + describe 'PUT #update' do + context 'when updating lock_on_merge' do + let_it_be(:params) { { lock_on_merge: true } } + let_it_be_with_reload(:label) { create(:label, project: project) } + + subject(:update_request) { put :update, params: { namespace_id: project.namespace, project_id: project, id: label.to_param, label: params } } + + context 'when feature flag is disabled' do + before do + stub_feature_flags(enforce_locked_labels_on_merge: false) + end + + it 'does not allow setting lock_on_merge' do + update_request + + expect(response).to redirect_to(namespace_project_labels_path) + expect(label.reload.lock_on_merge).to be_falsey + end + end + + shared_examples 'allows setting lock_on_merge' do + it do + update_request + + expect(response).to redirect_to(namespace_project_labels_path) + expect(label.reload.lock_on_merge).to be_truthy + end + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(enforce_locked_labels_on_merge: project) + end + + it_behaves_like 'allows setting lock_on_merge' + end + + context 'when feature flag for ancestor group is enabled' do + before do + stub_feature_flags(enforce_locked_labels_on_merge: group) + end + + it_behaves_like 'allows setting lock_on_merge' + end + end + end + describe 'DELETE #destroy' do context 'when current user has ability to destroy the label' do before do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 0e3e3f31783..a47bb98770c 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -68,72 +68,6 @@ RSpec.describe Projects::MergeRequestsController, feature_category: :code_review end end - context 'when add_prepared_state_to_mr feature flag on' do - before do - stub_feature_flags(add_prepared_state_to_mr: true) - end - - context 'when the merge request is not prepared' do - before do - merge_request.update!(prepared_at: nil, created_at: 10.minutes.ago) - end - - it 'prepares the merge request' do - expect(NewMergeRequestWorker).to receive(:perform_async) - - go - end - - context 'when the merge request was created less than 5 minutes ago' do - it 'does not prepare the merge request again' do - travel_to(4.minutes.from_now) do - merge_request.update!(created_at: Time.current - 4.minutes) - - expect(NewMergeRequestWorker).not_to receive(:perform_async) - - go - end - end - end - - context 'when the merge request was created 5 minutes ago' do - it 'prepares the merge request' do - travel_to(6.minutes.from_now) do - merge_request.update!(created_at: Time.current - 6.minutes) - - expect(NewMergeRequestWorker).to receive(:perform_async) - - go - end - end - end - end - - context 'when the merge request is prepared' do - before do - merge_request.update!(prepared_at: Time.current, created_at: 10.minutes.ago) - end - - it 'prepares the merge request' do - expect(NewMergeRequestWorker).not_to receive(:perform_async) - - go - end - end - end - - context 'when add_prepared_state_to_mr feature flag is off' do - before do - stub_feature_flags(add_prepared_state_to_mr: false) - end - - it 'does not prepare the merge request again' do - expect(NewMergeRequestWorker).not_to receive(:perform_async) - - go - end - end - describe 'as html' do it 'sets the endpoint_metadata_url' do go diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 500fab471ef..35aa01cdfad 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -40,7 +40,7 @@ RSpec.describe Projects::NotesController, type: :controller, feature_category: : request.headers['X-Last-Fetched-At'] = microseconds(last_fetched_at) end - specify { expect(get(:index, params: request_params)).to have_request_urgency(:medium) } + specify { expect(get(:index, params: request_params)).to have_request_urgency(:low) } it 'sets the correct feature category' do get :index, params: request_params diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index a5542a2b825..43e7bafc206 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -767,6 +767,33 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte describe 'GET #charts' do let(:pipeline) { create(:ci_pipeline, project: project) } + [ + { + chart_param: 'time-to-restore-service', + event: 'p_analytics_ci_cd_time_to_restore_service' + }, + { + chart_param: 'change-failure-rate', + event: 'p_analytics_ci_cd_change_failure_rate' + } + ].each do |tab| + it_behaves_like 'tracking unique visits', :charts do + let(:request_params) { { namespace_id: project.namespace, project_id: project, id: pipeline.id, chart: tab[:chart_param] } } + let(:target_id) { ['p_analytics_pipelines', tab[:event]] } + end + + it_behaves_like 'Snowplow event tracking with RedisHLL context' do + subject { get :charts, params: request_params, format: :html } + + let(:request_params) { { namespace_id: project.namespace, project_id: project, id: pipeline.id, chart: tab[:chart_param] } } + let(:category) { described_class.name } + let(:action) { 'perform_analytics_usage_action' } + let(:namespace) { project.namespace } + let(:label) { 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly' } + let(:property) { 'p_analytics_pipelines' } + end + end + [ { chart_param: '', @@ -783,14 +810,6 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte { chart_param: 'lead-time', event: 'p_analytics_ci_cd_lead_time' - }, - { - chart_param: 'time-to-restore-service', - event: 'p_analytics_ci_cd_time_to_restore_service' - }, - { - chart_param: 'change-failure-rate', - event: 'p_analytics_ci_cd_change_failure_rate' } ].each do |tab| it_behaves_like 'tracking unique visits', :charts do @@ -798,15 +817,12 @@ RSpec.describe Projects::PipelinesController, feature_category: :continuous_inte let(:target_id) { ['p_analytics_pipelines', tab[:event]] } end - it_behaves_like 'Snowplow event tracking with RedisHLL context' do + it_behaves_like 'internal event tracking' do subject { get :charts, params: request_params, format: :html } let(:request_params) { { namespace_id: project.namespace, project_id: project, id: pipeline.id, chart: tab[:chart_param] } } - let(:category) { described_class.name } - let(:action) { 'perform_analytics_usage_action' } + let(:action) { tab[:event] } let(:namespace) { project.namespace } - let(:label) { 'redis_hll_counters.analytics.analytics_total_unique_counts_monthly' } - let(:property) { 'p_analytics_pipelines' } end end end diff --git a/spec/controllers/projects/prometheus/alerts_controller_spec.rb b/spec/controllers/projects/prometheus/alerts_controller_spec.rb deleted file mode 100644 index 3e64631fbf1..00000000000 --- a/spec/controllers/projects/prometheus/alerts_controller_spec.rb +++ /dev/null @@ -1,110 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Projects::Prometheus::AlertsController, feature_category: :incident_management do - let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project) } - let_it_be(:environment) { create(:environment, project: project) } - - before do - project.add_maintainer(user) - sign_in(user) - end - - shared_examples 'unprivileged' do - before do - project.add_developer(user) - end - - it 'returns not_found' do - make_request - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - shared_examples 'project non-specific environment' do |status| - let(:other) { create(:environment) } - - it "returns #{status}" do - make_request(environment_id: other) - - expect(response).to have_gitlab_http_status(status) - end - - if status == :ok - it 'returns no prometheus alerts' do - make_request(environment_id: other) - - expect(json_response).to be_empty - end - end - end - - describe 'POST #notify' do - let(:alert_1) { build(:alert_management_alert, :prometheus, project: project) } - let(:alert_2) { build(:alert_management_alert, :prometheus, project: project) } - let(:service_response) { ServiceResponse.success(http_status: :created) } - let(:notify_service) { instance_double(Projects::Prometheus::Alerts::NotifyService, execute: service_response) } - - before do - sign_out(user) - - expect(Projects::Prometheus::Alerts::NotifyService) - .to receive(:new) - .with(project, duck_type(:permitted?)) - .and_return(notify_service) - end - - it 'returns created if notification succeeds' do - expect(notify_service).to receive(:execute).and_return(service_response) - - post :notify, params: project_params, session: { as: :json } - - expect(response).to have_gitlab_http_status(:created) - end - - it 'returns unprocessable entity if notification fails' do - expect(notify_service).to receive(:execute).and_return( - ServiceResponse.error(message: 'Unprocessable Entity', http_status: :unprocessable_entity) - ) - - post :notify, params: project_params, session: { as: :json } - - expect(response).to have_gitlab_http_status(:unprocessable_entity) - end - - context 'bearer token' do - context 'when set' do - it 'extracts bearer token' do - request.headers['HTTP_AUTHORIZATION'] = 'Bearer some token' - - expect(notify_service).to receive(:execute).with('some token') - - post :notify, params: project_params, as: :json - end - - it 'pass nil if cannot extract a non-bearer token' do - request.headers['HTTP_AUTHORIZATION'] = 'some token' - - expect(notify_service).to receive(:execute).with(nil) - - post :notify, params: project_params, as: :json - end - end - - context 'when missing' do - it 'passes nil' do - expect(notify_service).to receive(:execute).with(nil) - - post :notify, params: project_params, as: :json - end - end - end - end - - def project_params(opts = {}) - opts.reverse_merge(namespace_id: project.namespace, project_id: project) - end -end diff --git a/spec/controllers/projects/uploads_controller_spec.rb b/spec/controllers/projects/uploads_controller_spec.rb index 01635f2e158..9f20856fa68 100644 --- a/spec/controllers/projects/uploads_controller_spec.rb +++ b/spec/controllers/projects/uploads_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Projects::UploadsController do +RSpec.describe Projects::UploadsController, feature_category: :team_planning do include WorkhorseHelpers let(:model) { create(:project, :public) } diff --git a/spec/controllers/registrations/welcome_controller_spec.rb b/spec/controllers/registrations/welcome_controller_spec.rb index 5a3feefc1ba..0bac52c8dca 100644 --- a/spec/controllers/registrations/welcome_controller_spec.rb +++ b/spec/controllers/registrations/welcome_controller_spec.rb @@ -12,21 +12,12 @@ RSpec.describe Registrations::WelcomeController, feature_category: :system_acces it { is_expected.to redirect_to new_user_registration_path } end - context 'when role or setup_for_company is not set' do + context 'when setup_for_company is not set' do before do sign_in(user) end it { is_expected.to render_template(:show) } - end - - context 'when role is required and setup_for_company is not set' do - before do - user.set_role_required! - sign_in(user) - end - - it { is_expected.to render_template(:show) } render_views @@ -37,7 +28,7 @@ RSpec.describe Registrations::WelcomeController, feature_category: :system_acces end end - context 'when role and setup_for_company is set' do + context 'when setup_for_company is set' do before do user.update!(setup_for_company: false) sign_in(user) @@ -46,15 +37,6 @@ RSpec.describe Registrations::WelcomeController, feature_category: :system_acces it { is_expected.to redirect_to(dashboard_projects_path) } end - context 'when role is set and setup_for_company is not set' do - before do - user.update!(role: :software_developer) - sign_in(user) - end - - it { is_expected.to render_template(:show) } - end - context 'when 2FA is required from group' do before do user = create(:user, require_two_factor_authentication_from_group: true) @@ -131,12 +113,6 @@ RSpec.describe Registrations::WelcomeController, feature_category: :system_acces expect(subject).to redirect_to(dashboard_projects_path) end end - - context 'when tasks to be done are assigned' do - let!(:member1) { create(:group_member, user: user, tasks_to_be_done: %w[ci code]) } - - it { is_expected.to redirect_to(issues_dashboard_path(assignee_username: user.username)) } - end end end end diff --git a/spec/controllers/repositories/git_http_controller_spec.rb b/spec/controllers/repositories/git_http_controller_spec.rb index 88af7d1fe45..602c9c0a2ce 100644 --- a/spec/controllers/repositories/git_http_controller_spec.rb +++ b/spec/controllers/repositories/git_http_controller_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Repositories::GitHttpController, feature_category: :source_code_management do - let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:project) { create(:project_with_design, :public, :repository) } let_it_be(:personal_snippet) { create(:personal_snippet, :public, :repository) } let_it_be(:project_snippet) { create(:project_snippet, :public, :repository, project: project) } @@ -177,4 +177,27 @@ RSpec.describe Repositories::GitHttpController, feature_category: :source_code_m it_behaves_like 'handles logging git receive pack operation' end end + + context 'when repository container is a design_management_repository' do + let(:container) { project.design_management_repository } + let(:access_checker_class) { Gitlab::GitAccessDesign } + let(:repository_path) { "#{container.full_path}.git" } + let(:params) { { repository_path: repository_path, service: 'git-upload-pack' } } + + describe 'GET #info_refs' do + it 'calls the right access checker class with the right object' do + allow(controller).to receive(:verify_workhorse_api!).and_return(true) + + access_double = double + + expect(access_checker_class).to receive(:new) + .with(nil, container, 'http', hash_including({ repository_path: repository_path })) + .and_return(access_double) + + allow(access_double).to receive(:check).and_return(false) + + get :info_refs, params: params + end + end + end end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 57ae1d5a1db..9771141a955 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -41,21 +41,25 @@ RSpec.describe SearchController, feature_category: :global_search do describe 'rate limit scope' do it 'uses current_user and search scope' do %w[projects blobs users issues merge_requests].each do |scope| - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user, scope]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user, scope], users_allowlist: []) get :show, params: { search: 'hello', scope: scope } end end it 'uses just current_user when no search scope is used' do - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user], users_allowlist: []) get :show, params: { search: 'hello' } end it 'uses just current_user when search scope is abusive' do - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user], users_allowlist: []) get(:show, params: { search: 'hello', scope: 'hack-the-mainframe' }) - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user], users_allowlist: []) get :show, params: { search: 'hello', scope: 'blobs' * 1000 } end end @@ -298,6 +302,14 @@ RSpec.describe SearchController, feature_category: :global_search do end end + it_behaves_like 'search request exceeding rate limit', :clean_gitlab_redis_cache do + let(:current_user) { user } + + def request + get(:show, params: { search: 'foo@bar.com', scope: 'users' }) + end + end + it 'increments the custom search sli apdex' do expect(Gitlab::Metrics::GlobalSearchSlis).to receive(:record_apdex).with( elapsed: a_kind_of(Numeric), @@ -370,16 +382,19 @@ RSpec.describe SearchController, feature_category: :global_search do describe 'rate limit scope' do it 'uses current_user and search scope' do %w[projects blobs users issues merge_requests].each do |scope| - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user, scope]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user, scope], users_allowlist: []) get :count, params: { search: 'hello', scope: scope } end end it 'uses just current_user when search scope is abusive' do - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user], users_allowlist: []) get :count, params: { search: 'hello', scope: 'hack-the-mainframe' } - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user], users_allowlist: []) get :count, params: { search: 'hello', scope: 'blobs' * 1000 } end end @@ -432,6 +447,14 @@ RSpec.describe SearchController, feature_category: :global_search do get(:count, params: { search: 'foo@bar.com', scope: 'users' }) end end + + it_behaves_like 'search request exceeding rate limit', :clean_gitlab_redis_cache do + let(:current_user) { user } + + def request + get(:count, params: { search: 'foo@bar.com', scope: 'users' }) + end + end end describe 'GET #autocomplete' do @@ -454,16 +477,19 @@ RSpec.describe SearchController, feature_category: :global_search do describe 'rate limit scope' do it 'uses current_user and search scope' do %w[projects blobs users issues merge_requests].each do |scope| - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user, scope]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user, scope], users_allowlist: []) get :autocomplete, params: { term: 'hello', scope: scope } end end it 'uses just current_user when search scope is abusive' do - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user], users_allowlist: []) get :autocomplete, params: { term: 'hello', scope: 'hack-the-mainframe' } - expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, scope: [user]) + expect(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).with(:search_rate_limit, + scope: [user], users_allowlist: []) get :autocomplete, params: { term: 'hello', scope: 'blobs' * 1000 } end end @@ -476,6 +502,14 @@ RSpec.describe SearchController, feature_category: :global_search do end end + it_behaves_like 'search request exceeding rate limit', :clean_gitlab_redis_cache do + let(:current_user) { user } + + def request + get(:autocomplete, params: { term: 'foo@bar.com', scope: 'users' }) + end + end + it 'can be filtered with params[:filter]' do get :autocomplete, params: { term: 'setting', filter: 'generic' } expect(response).to have_gitlab_http_status(:ok) diff --git a/spec/controllers/sent_notifications_controller_spec.rb b/spec/controllers/sent_notifications_controller_spec.rb index e60cf37aad6..190c00092b6 100644 --- a/spec/controllers/sent_notifications_controller_spec.rb +++ b/spec/controllers/sent_notifications_controller_spec.rb @@ -299,7 +299,7 @@ RSpec.describe SentNotificationsController do end context 'when support bot is the notification recipient' do - let(:sent_notification) { create(:sent_notification, project: target_project, noteable: noteable, recipient: User.support_bot) } + let(:sent_notification) { create(:sent_notification, project: target_project, noteable: noteable, recipient: Users::Internal.support_bot) } it 'deletes the external author on the issue' do expect { unsubscribe }.to change { issue.issue_email_participants.count }.by(-1) diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 8015136d1e0..8ae78c5ee35 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -19,7 +19,7 @@ RSpec.shared_examples 'content publicly cached' do end end -RSpec.describe UploadsController do +RSpec.describe UploadsController, feature_category: :groups_and_projects do include WorkhorseHelpers let!(:user) { create(:user, avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) } diff --git a/spec/db/avoid_migration_name_collisions_spec.rb b/spec/db/avoid_migration_name_collisions_spec.rb new file mode 100644 index 00000000000..f5fa3da0c81 --- /dev/null +++ b/spec/db/avoid_migration_name_collisions_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Avoid Migration Name Collisions', feature_category: :database do + subject(:duplicated_migration_class_names) do + class_names = migration_files.map { |path| class_name_regex.match(File.read(path))[1] } + class_names.select { |class_name| class_names.count(class_name) > 1 } + end + + let(:class_name_regex) { /^\s*class\s+:*([A-Z][A-Za-z0-9_]+\S+)/ } + let(:migration_files) { Dir['db/migrate/*.rb', 'db/post_migrate/*.rb', 'ee/elastic/migrate/*.rb'] } + + it 'loads all database and search migrations without name collisions' do + expect(duplicated_migration_class_names).to be_empty + end +end diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 3c99393b14b..cfd6bbf3094 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -42,7 +42,7 @@ RSpec.describe 'Database schema', feature_category: :database do aws_roles: %w[role_external_id], boards: %w[milestone_id iteration_id], broadcast_messages: %w[namespace_id], - chat_names: %w[chat_id team_id user_id integration_id], + chat_names: %w[chat_id team_id user_id], chat_teams: %w[team_id], ci_builds: %w[project_id runner_id user_id erased_by_id trigger_request_id partition_id], ci_namespace_monthly_usages: %w[namespace_id], @@ -187,6 +187,44 @@ RSpec.describe 'Database schema', feature_category: :database do expect(ignored_columns).to match_array(ignored_columns - foreign_keys) end end + + context 'btree indexes' do + it 'only has existing indexes in the ignored duplicate indexes duplicate_indexes.yml' do + table_ignored_indexes = (ignored_indexes[table] || {}).to_a.flatten.uniq + indexes_by_name = indexes.map(&:name) + expect(indexes_by_name).to include(*table_ignored_indexes) + end + + it 'does not have any duplicated indexes' do + duplicate_indexes = Database::DuplicateIndexes.new(table, indexes).duplicate_indexes + expect(duplicate_indexes).to be_an_instance_of Hash + + table_ignored_indexes = ignored_indexes[table] || {} + + # We ignore all the indexes that are explicitly ignored in duplicate_indexes.yml + duplicate_indexes.each do |index, matching_indexes| + duplicate_indexes[index] = matching_indexes.reject do |matching_index| + table_ignored_indexes.fetch(index.name, []).include?(matching_index.name) || + table_ignored_indexes.fetch(matching_index.name, []).include?(index.name) + end + + duplicate_indexes.delete(index) if duplicate_indexes[index].empty? + end + + if duplicate_indexes.present? + btree_index = duplicate_indexes.each_key.first + matching_indexes = duplicate_indexes[btree_index] + + error_message = <<~ERROR + Duplicate index: #{btree_index.name} with #{matching_indexes.map(&:name)} + #{btree_index.name} : #{btree_index.columns.inspect} + #{matching_indexes.first.name} : #{matching_indexes.first.columns.inspect}. + Consider dropping the indexes #{matching_indexes.map(&:name).join(', ')} + ERROR + raise error_message + end + end + end end end end @@ -196,23 +234,18 @@ RSpec.describe 'Database schema', feature_category: :database do IGNORED_LIMIT_ENUMS = { 'Analytics::CycleAnalytics::Stage' => %w[start_event_identifier end_event_identifier], 'Ci::Bridge' => %w[failure_reason], - 'Ci::Bridge::Partitioned' => %w[failure_reason], 'Ci::Build' => %w[failure_reason], - 'Ci::Build::Partitioned' => %w[failure_reason], 'Ci::BuildMetadata' => %w[timeout_source], 'Ci::BuildTraceChunk' => %w[data_store], 'Ci::DailyReportResult' => %w[param_type], 'Ci::JobArtifact' => %w[file_type], 'Ci::Pipeline' => %w[source config_source failure_reason], 'Ci::Processable' => %w[failure_reason], - 'Ci::Processable::Partitioned' => %w[failure_reason], 'Ci::Runner' => %w[access_level], 'Ci::Stage' => %w[status], 'Clusters::Cluster' => %w[platform_type provider_type], 'CommitStatus' => %w[failure_reason], - 'CommitStatus::Partitioned' => %w[failure_reason], 'GenericCommitStatus' => %w[failure_reason], - 'GenericCommitStatus::Partitioned' => %w[failure_reason], 'InternalId' => %w[usage], 'List' => %w[list_type], 'NotificationSetting' => %w[level], @@ -244,7 +277,6 @@ RSpec.describe 'Database schema', feature_category: :database do "ApplicationSetting" => %w[repository_storages_weighted], "AlertManagement::Alert" => %w[payload], "Ci::BuildMetadata" => %w[config_options config_variables], - "Ci::BuildMetadata::Partitioned" => %w[config_options config_variables id_tokens runtime_runner_features secrets], "ExperimentSubject" => %w[context], "ExperimentUser" => %w[context], "Geo::Event" => %w[payload], @@ -409,4 +441,9 @@ RSpec.describe 'Database schema', feature_category: :database do def ignored_jsonb_columns(model) IGNORED_JSONB_COLUMNS.fetch(model, []) end + + def ignored_indexes + duplicate_indexes_file_path = "spec/support/helpers/database/duplicate_indexes.yml" + @ignored_indexes ||= YAML.load_file(Rails.root.join(duplicate_indexes_file_path)) || {} + end end diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb index 461a6390a33..8a65c219f5d 100644 --- a/spec/experiments/application_experiment_spec.rb +++ b/spec/experiments/application_experiment_spec.rb @@ -211,7 +211,7 @@ RSpec.describe ApplicationExperiment, :experiment, feature_category: :experiment application_experiment.variant(:variant1) {} application_experiment.variant(:variant2) {} - expect(application_experiment.assigned.name).to eq('variant2') + expect(application_experiment.assigned.name).to eq(:variant2) end end @@ -248,7 +248,7 @@ RSpec.describe ApplicationExperiment, :experiment, feature_category: :experiment end it "caches the variant determined by the variant resolver" do - expect(application_experiment.assigned.name).to eq('candidate') # we should be in the experiment + expect(application_experiment.assigned.name).to eq(:candidate) # we should be in the experiment application_experiment.run @@ -263,7 +263,7 @@ RSpec.describe ApplicationExperiment, :experiment, feature_category: :experiment # the control. stub_feature_flags(namespaced_stub: false) # simulate being not rolled out - expect(application_experiment.assigned.name).to eq('control') # if we ask, it should be control + expect(application_experiment.assigned.name).to eq(:control) # if we ask, it should be control application_experiment.run @@ -299,29 +299,4 @@ RSpec.describe ApplicationExperiment, :experiment, feature_category: :experiment end end end - - context "with deprecation warnings" do - before do - Gitlab::Experiment::Configuration.instance_variable_set(:@__dep_versions, nil) # clear the internal memoization - - allow(ActiveSupport::Deprecation).to receive(:new).and_call_original - end - - it "doesn't warn on non dev/test environments" do - allow(Gitlab).to receive(:dev_or_test_env?).and_return(false) - - expect { experiment(:example) { |e| e.use {} } }.not_to raise_error - expect(ActiveSupport::Deprecation).not_to have_received(:new).with(anything, 'Gitlab::Experiment') - end - - it "warns on dev and test environments" do - allow(Gitlab).to receive(:dev_or_test_env?).and_return(true) - - # This will eventually raise an ActiveSupport::Deprecation exception, - # it's ok to change it when that happens. - expect { experiment(:example) { |e| e.use {} } }.not_to raise_error - - expect(ActiveSupport::Deprecation).to have_received(:new).with(anything, 'Gitlab::Experiment') - end - end end diff --git a/spec/factories/ci/catalog/resources.rb b/spec/factories/ci/catalog/resources.rb index 66c2e58cdd9..c663164d449 100644 --- a/spec/factories/ci/catalog/resources.rb +++ b/spec/factories/ci/catalog/resources.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :catalog_resource, class: 'Ci::Catalog::Resource' do + factory :ci_catalog_resource, class: 'Ci::Catalog::Resource' do project factory: :project end end diff --git a/spec/factories/ci/catalog/resources/components.rb b/spec/factories/ci/catalog/resources/components.rb index 3eeb2f4251a..8feecc695bc 100644 --- a/spec/factories/ci/catalog/resources/components.rb +++ b/spec/factories/ci/catalog/resources/components.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true FactoryBot.define do - factory :catalog_resource_component, class: 'Ci::Catalog::Resources::Component' do - version factory: :catalog_resource_version + factory :ci_catalog_resource_component, class: 'Ci::Catalog::Resources::Component' do + version factory: :ci_catalog_resource_version catalog_resource { version.catalog_resource } project { version.project } name { catalog_resource.name } diff --git a/spec/factories/ci/catalog/resources/versions.rb b/spec/factories/ci/catalog/resources/versions.rb index d5057969273..520708d9d58 100644 --- a/spec/factories/ci/catalog/resources/versions.rb +++ b/spec/factories/ci/catalog/resources/versions.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true FactoryBot.define do - factory :catalog_resource_version, class: 'Ci::Catalog::Resources::Version' do - catalog_resource + factory :ci_catalog_resource_version, class: 'Ci::Catalog::Resources::Version' do + catalog_resource factory: :ci_catalog_resource project { catalog_resource.project } release { association :release, project: project } end diff --git a/spec/factories/ci/reports/sbom/metadatum.rb b/spec/factories/ci/reports/sbom/metadatum.rb new file mode 100644 index 00000000000..f05ace8754f --- /dev/null +++ b/spec/factories/ci/reports/sbom/metadatum.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ci_reports_sbom_metadata, class: '::Gitlab::Ci::Reports::Sbom::Metadata' do + transient do + vendor { generate(:name) } + author_name { generate(:name) } + end + + tools do + [ + { + vendor: vendor, + name: "Gemnasium", + version: "2.34.0" + } + ] + end + authors do + [ + { + name: author_name, + email: "support@gitlab.com" + } + ] + end + properties do + [ + { + name: "gitlab:dependency_scanning:input_file:path", + value: "package-lock.json" + }, + { + name: "gitlab:dependency_scanning:package_manager:name", + value: "npm" + } + ] + end + + skip_create + + initialize_with { new(tools: tools, authors: authors, properties: properties) } + end +end diff --git a/spec/factories/ci/reports/sbom/reports.rb b/spec/factories/ci/reports/sbom/reports.rb index 7a076282915..3698b0f17eb 100644 --- a/spec/factories/ci/reports/sbom/reports.rb +++ b/spec/factories/ci/reports/sbom/reports.rb @@ -3,6 +3,14 @@ FactoryBot.define do factory :ci_reports_sbom_report, class: '::Gitlab::Ci::Reports::Sbom::Report' do transient do + sbom_attributes do + { + bom_format: 'CycloneDX', + spec_version: '1.4', + serial_number: "urn:uuid:aec33827-20ae-40d0-ae83-18ee846364d2", + version: 1 + } + end num_components { 5 } components { build_list :ci_reports_sbom_component, num_components } source { association :ci_reports_sbom_source } @@ -14,8 +22,18 @@ FactoryBot.define do end end + trait(:with_metadata) do + transient do + metadata { association(:ci_reports_sbom_metadata) } + end + + after(:build) do |report, options| + report.metadata = options.metadata + end + end + after(:build) do |report, options| - options.components.each { |component| report.add_component(component) } + options.components.each { |component| report.add_component(component) } if options.components report.set_source(options.source) end diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 3f17d4d5a97..7d044c4aa92 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -65,6 +65,18 @@ FactoryBot.define do end end + trait :group_level do + project { nil } + association :namespace, factory: :group + association :author, factory: :user + end + + trait :user_namespace_level do + project { nil } + association :namespace, factory: :user_namespace + association :author, factory: :user + end + trait :issue do association :work_item_type, :default, :issue end diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index 390db24dde8..3b37d6cf8ad 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -56,6 +56,14 @@ FactoryBot.define do state_id { MergeRequest.available_states[:merged] } end + trait :unprepared do + prepared_at { nil } + end + + trait :prepared do + prepared_at { Time.now } + end + trait :with_merged_metrics do merged diff --git a/spec/factories/metrics/dashboard/annotations.rb b/spec/factories/metrics/dashboard/annotations.rb deleted file mode 100644 index 50c9ed01fd8..00000000000 --- a/spec/factories/metrics/dashboard/annotations.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :metrics_dashboard_annotation, class: '::Metrics::Dashboard::Annotation' do - description { "Dashbaord annoation description" } - dashboard_path { "custom_dashbaord.yml" } - starting_at { Time.current } - end -end diff --git a/spec/factories/metrics/users_starred_dashboards.rb b/spec/factories/metrics/users_starred_dashboards.rb deleted file mode 100644 index 06fe7735e9a..00000000000 --- a/spec/factories/metrics/users_starred_dashboards.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :metrics_users_starred_dashboard, class: '::Metrics::UsersStarredDashboard' do - dashboard_path { "custom_dashboard.yml" } - user - project - end -end diff --git a/spec/factories/ml/candidate_params.rb b/spec/factories/ml/candidate_params.rb index 73cb0c54089..e3af8ab834b 100644 --- a/spec/factories/ml/candidate_params.rb +++ b/spec/factories/ml/candidate_params.rb @@ -4,7 +4,7 @@ FactoryBot.define do factory :ml_candidate_params, class: '::Ml::CandidateParam' do association :candidate, factory: :ml_candidates - sequence(:name) { |n| "metric#{n}" } + sequence(:name) { |n| "params#{n}" } sequence(:value) { |n| "value#{n}" } end end diff --git a/spec/factories/ml/candidates.rb b/spec/factories/ml/candidates.rb index b9a2320138a..9bfb78066bd 100644 --- a/spec/factories/ml/candidates.rb +++ b/spec/factories/ml/candidates.rb @@ -7,16 +7,12 @@ FactoryBot.define do experiment { association :ml_experiments, project_id: project.id } trait :with_metrics_and_params do - after(:create) do |candidate| - candidate.metrics = FactoryBot.create_list(:ml_candidate_metrics, 2, candidate: candidate ) - candidate.params = FactoryBot.create_list(:ml_candidate_params, 2, candidate: candidate ) - end + metrics { Array.new(2) { association(:ml_candidate_metrics, candidate: instance) } } + params { Array.new(2) { association(:ml_candidate_params, candidate: instance) } } end trait :with_metadata do - after(:create) do |candidate| - candidate.metadata = FactoryBot.create_list(:ml_candidate_metadata, 2, candidate: candidate ) - end + metadata { Array.new(2) { association(:ml_candidate_metadata, candidate: instance) } } end trait :with_artifact do diff --git a/spec/factories/packages/dependency_links.rb b/spec/factories/packages/dependency_links.rb index 6470cbdc9a6..d28263efe05 100644 --- a/spec/factories/packages/dependency_links.rb +++ b/spec/factories/packages/dependency_links.rb @@ -6,15 +6,31 @@ FactoryBot.define do dependency { association(:packages_dependency) } dependency_type { :dependencies } - trait(:with_nuget_metadatum) do + trait :with_nuget_metadatum do after :build do |link| link.nuget_metadatum = build(:nuget_dependency_link_metadatum) end end - trait(:rubygems) do + trait :rubygems do package { association(:rubygems_package) } dependency { association(:packages_dependency, :rubygems) } end + + trait :dependencies do + dependency_type { :dependencies } + end + + trait :dev_dependencies do + dependency_type { :devDependencies } + end + + trait :bundle_dependencies do + dependency_type { :bundleDependencies } + end + + trait :peer_dependencies do + dependency_type { :peerDependencies } + end end end diff --git a/spec/factories/packages/nuget/symbol.rb b/spec/factories/packages/nuget/symbol.rb new file mode 100644 index 00000000000..7ab1e026cda --- /dev/null +++ b/spec/factories/packages/nuget/symbol.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :nuget_symbol, class: 'Packages::Nuget::Symbol' do + package { association(:nuget_package) } + file { fixture_file_upload('spec/fixtures/packages/nuget/symbol/package.pdb') } + file_path { 'lib/net7.0/package.pdb' } + size { 100.bytes } + sequence(:signature) { |n| "b91a152048fc4b3883bf3cf73fbc03f#{n}FFFFFFFF" } + end +end diff --git a/spec/factories/packages/package_protection_rules.rb b/spec/factories/packages/package_protection_rules.rb new file mode 100644 index 00000000000..3038fb847e7 --- /dev/null +++ b/spec/factories/packages/package_protection_rules.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :package_protection_rule, class: 'Packages::Protection::Rule' do + project + package_name_pattern { '@my_scope/my_package' } + package_type { :npm } + push_protected_up_to_access_level { Gitlab::Access::DEVELOPER } + end +end diff --git a/spec/factories/packages/packages.rb b/spec/factories/packages/packages.rb index 132152bf028..caec7580e46 100644 --- a/spec/factories/packages/packages.rb +++ b/spec/factories/packages/packages.rb @@ -301,9 +301,9 @@ FactoryBot.define do end end - factory :ml_model_package do + factory :ml_model_package, class: 'Packages::MlModel::Package' do sequence(:name) { |n| "mlmodel-package-#{n}" } - sequence(:version) { |n| "v1.0.#{n}" } + sequence(:version) { |n| "1.0.#{n}" } package_type { :ml_model } end end diff --git a/spec/factories/pages_domains.rb b/spec/factories/pages_domains.rb index 2ba5cbb48bf..d91037e803f 100644 --- a/spec/factories/pages_domains.rb +++ b/spec/factories/pages_domains.rb @@ -349,6 +349,472 @@ x6zG6WoibsbsJMj70nwseUnPTBQNDP+j61RJjC/r end end + trait :extra_long_key do + certificate do + <<~CERT + -----BEGIN CERTIFICATE----- + MIIRLzCCCRegAwIBAgIULB+G07cadoQD0Sh7NOq6jio5SaowDQYJKoZIhvcNAQEL + BQAwJzELMAkGA1UEBhMCZGUxGDAWBgNVBAMMD2xvY2FsaG9zdC5sb2NhbDAeFw0y + MzA4MTQxNjIxMzdaFw0yMzA5MTMxNjIxMzdaMCcxCzAJBgNVBAYTAmRlMRgwFgYD + VQQDDA9sb2NhbGhvc3QubG9jYWwwgggiMA0GCSqGSIb3DQEBAQUAA4IIDwAwgggK + AoIIAQDhfq6cKgjogJYFGRuokm7MAyUHwMBzkprL1wSemGquI2i1DkjzbDHSa2iR + qTTiNgr8NHlYXhmqn6Km7T4DNaWBqrWLsYVusGBtKKIl6EbE+dVjV/7iqn1lgUF2 + RI77S7t6tXYKYwG1CiboUi+Dyz/eJB408KY8ruHkSkuqdMRV6XXkkytU3DRd6FKj + mdw8S7A0IcY8I/r8Sj81CifAuI4BkSqrh210o01RwYZVjcXiq5R+qIXbT51H6MRV + pSMTPRMQ2yvJ997OTR3UopZWv5WeGc0wyQSqMUBBL82wvpNeOWc5GYLLGx1uilh1 + zWr+MnCYebaDOfP1a4GnHB2KwCY9RUVw6tAKcLxBMWbcd7JN5ijObkhk3TmPexol + XmkB72+5q6cytwgdj1Wc2udg746kkPwkKeOmJt1789Jaqlvn4Emez/g3N2hXO3s9 + DJZuTY3NXesmraq9oGmlWSZNF5up2sZ4811ci1cMEl9p8GSNpTcMy98ZdXCUhrrS + g3fPbaK6abcRx5xhbXqzuI6QExBie+6x9aPPO7VR3ibwdk2rae24f2fnquS6sFLa + Oa7Spl0eFdS0nySvlMhII2kB4ZBaa1dzZYsVmJgOMKfBKsh7k1EZPOYcnKAyyiWS + RAhzgPIC9TULZtnEJ9RBW6th4gUvA2aa1YM8PPERW+kYXBfNsPOqKkW5mK9FI+9S + at/og1vQQHY2GFXy5pyQDlgX6UArdnF6grAOOwZJFhCXg0FMaMFy6FEdohNnCOZm + iUNk3JE+FyI50UeA6rR5J/x11+kfwmAxpo5+E7zIpIe5MTCdvCdqk7QklAVbIWK6 + JLY/nqWj0pyhpGSRPJ0U44/ildZt3+tj5IdyqNnuwQwbLCpVYu4o6qhd2WtctfL1 + L/fXuR3BuhzULmAzatmzJQd5+ewd1e5gH8aQsHMD0OMXJnKK1zrdj/FmbvvTfT69 + aelyQvFORCqvTZo/b+zXKF6MRd7OblJoqeRVwjxoQWHv5n+FbfLzqUuy+XDleBTp + dXSdkQIK5rII23vKoo25gp6uZ99dqMI5RTUN1h0GLHwkCrIACOF3FMuAuqjugP/9 + sIZK1fXpNnQ1qmZN17phpRDra/tYdoX3YlLYBs/1W5IIauBPJKpz/TYxa2vlisKz + yfvkV5CYqUz2ax2mb5bGHeyYYkbPfF6tV986GhEIZTQRBi10BM5eIU/l1WTJUUqn + Ld2RF2T2AiFgaavSqiBIUzj5mpVyVjeDs96yik0oCgx31OUUgqV5oSgwnUJYf/1l + 1Z5Yg/VhnENo1NN4HHdPWMPLK18dWvY/Ui3GAL6s/6LCLTWS9+mV1zW2IhDyvapq + vuWMmQdfbKKvwsD8gFQtxO09CkWa8JOjTYt7VQaISnl039Y/L3vAwy7q9sE9fbNt + BiNKxLeULx6TBumHLbSJPUesqKSkM3Iz10seyXD+dZX3Z5dULV8ME16/lAs5UUPt + g4SKGnhoyoxciWRB0YYGq8MW9RceppUkn8sG/zF4xsffvdft4KAMBWbzZKOFsO5S + pjKFyLBIg68cXmgyqTrWODS6HaBagiTjrKyI+EUl6riFhHjClWtGRTAmwWiif/jF + dav0C4GMrF3jpnfQYmz9mE4G/PgHTvb4gXaOQsHFuxUPmWjOz07Ba0mo8GR492jD + 3I9ffIjOA1UBA8tRMBAbBzKavQrX6qy9XKHopXC5vrB5l3zBquX8X0I9CZmjrvZt + vdj/7Lu+p6wU7RbFr5C3b+obFZN57qj/uf+7GfrjuZnfrxkxb0LxLAgrirUi6RkW + rHJ3aL+dQlGd7vzZKsLmgJv34PtproTfIeFgVq3q2nz3uKBCdBwLQRs0scKvbwSu + VresQWQfwoy7viMI2964pDl9KBmt8dsVYm0TGx6AYtB2XhzHnF5wgr82anEswbBp + n3fo7wgI9lbmwrwXpS5LgCIvOIcqGtH2izXcqt+45fqazsPDj3b3pEyT6FcqwlLC + T/1p9kjUJ//mg8DBTXcyWuVDdtbdGpGJHtKftU0tWr3X84k53HGaJFDdUJWuJp8v + 87hZMc2IsgjtJ5gBAvpW23BVZf27VFBTJBZqTt/pWMiEdfyMlZeqnUv7o6TlBEfO + BNl6BupT9SYQMahE9GDl/mq+QRN0x9qzncDKlQSKZsiocO3Q8eQheTkQY0TVWTUy + 9Wgqj845nGTJfM+w4xto8cbkB61fcKd4u1iiDWeYmnTkKOo5Ny0+47bHVrBiaumU + 5JsV/qs32+BZBQKIh/mRK/FE/pxXOX0ouZlM3bq3nuDCd7BwqMstI1zx2eKNAjN9 + G22ZFs9RteI0JbHndJvGIv72Zo7ERGM7+D6rGDARuolPKcgdKWiBH1bTiGv9WSxo + fer6hCPNkGl4Z1Upa/pe85P1DL0Yz4mVJteFd+Tn+oJwEyP8XJp6jQj5kYgMDAuG + sGq3STyLwnDPe6R+dkCxmQ6kAuZFBNEuduWFZfQJJGxib1vCjnfAsMK0BqW2jFF7 + cID0A0upiDjgcjloJ+FYF2VLCXUE6dmtgujlNLhyMWcyKDNzKlNsMVB7swkPLnOK + 6MFVYQ9dXI72IpI73LKXoREsOcEyItS2pvDhu9TfGcQLBkVTYWllsuhpmsg9xwYJ + ajf4ewKP0Yxa6XbkDlxNtyFbRIu8m6AhoRo3sPBPUIb02Dri4qBId7RVBRe+B8M9 + NuNE5o88QHA21R2u7S7cr67Zw26HSNGEN+9HGY4Xpy66ijW84wIDAQABo1MwUTAd + BgNVHQ4EFgQULhLmAennkk5+BcYJY1cU6OYXFokwHwYDVR0jBBgwFoAULhLmAenn + kk5+BcYJY1cU6OYXFokwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC + CAEAphitB4iOhDwsKy21Ut2c3C0/gg/rlyyUhoD1H2BAYdJTlRFdCpoH0F2dXFOh + rFfh4U3G8sRYm/TwhP9lJ4/TCdYH7WQBIU0dvMectYd2KWyYkNb8eBh4fC1gLZ5I + 5zxAigc242Pjft9NsTgcbDI8+xjtSXc0cwaZTD5kxZyQm1BnONoZvF0/s7dsv+n0 + kU1tB9n0PlcAphQTq311Vk99HW1SqrA9njQftJr/tbOy2nyHqFjhQxAqr9/CYE3t + 6v4itH04n3eHgYDlmi0MqrFtqGobLhRp+zVAVxy7o9+nh0Z9wUikknPapV1GH1Vs + TO8fhr9eunXsTHQVP2EK77tJK7pNdfBvwHOq595iCbSFp0TF5sG4zCGZReB0TvtS + nROSKwq+jsV4xSGlTnqbf4EIoD4HdrWw7BvLOlz55oPK+Og/w4X0DwR6zx3rCAT8 + nrDm5ekNBTVlAhbD2g284mZ0G5F+c3lnhbju66RoHvrzyEQ+6avdhUB9BTKzZ7Pj + CkRLsTlXtOnO5E5/rX1+mKXRwCNmPdK4vYfPucl/vgdlcpvzJunrQMPlvfM2UKeg + z2yyT0rW1sK9IcvlqwApWwPuS1mC1o3WrwzZo0qgDwQSzjQh6UkQPSBTmkM9Fv4I + AzGQoWAcXw/QbRpvjEm8H3U60B0fCwPyG7Z7eGjf4am2RYs+viRT/4ewJ22i9imx + E5jxIpMLeb0CITI594sRQXbLEsISq9Hvrt4kr5lSfZ9IRRv+AiYjjHQVSMRo641j + JExXZRuQgagL6Pg4wElyRR1tAsy1aDdenV0hLJ7eSaQf3Z9Bs4honDv97UUVLbQW + hebIGnJEPY4w15hUTKzp6eIz+V2rpG2Jcf2G5UXRWnw+Gai3CyBMJ7NnqvIis8qP + OUbGYrbqRWOmrjphOgaX9hvLD7nbK1wXMFE3V7cP5py594qN8vg8EGSpsCATXAk3 + A8aSW/Kl1jVCn1WVzez+E9bbcTf06eUc1M0X5HI8NH7uhw1ECn/jYA9Q5UJ32SNK + 4G14vsPtbtG99nAKy3fbA1Qn3MOu5anA2rl0NNICNEc89Q6fMeyFV8ctwyF7qqYq + M439B6R8jzz0ESLZdp7r9f8Ve3TlBvs+42knRBkjUqfNHSf06/wG+AUOmNiGF8jt + O2e4mXxLotqIAT5OpNpZIQyZY1Sr4uvp3zsvKOnHU3GBwsB4nhHqRzpRqkK6DIaw + TnOC/dOKzWpUy9iNzEGCNaJVWkQBCaFMJb968h2cZQzpj15XhAVlKfhh2KHCoDGt + WnEPgchVzBQwvhZra3gP7ynkGxSRYYPzLWt7b6oZSMB/JWyU+2fSqRPAXvUue7Ns + peHKPuGETVMR8jTuUghQvQDyTpoH9GZzyNQ2CUOfgAoA5cc9XuI+KGcsQuWqQvLv + zpxeHM8d1+vAu8WjnTs0E0MZk7Vi+N5DuhsTT7kP9fyz+rQzgQ7+bcanOgBABIcc + dsbTdfJApeFwN874s020M11Q+RwsXm40xDZHTYWe+r2Yq/+kd2N0rM40TS/Zv6KA + /1Ag5XC7dq3Uqp0Vk06LzZ7qP8gNiN813/qvN2PW/phq4A/OFtCnEGfom4I2MimB + SEpYuPTgiRo//y1tqq5D8994J546LdQ2Y1VmlK/7CYHZN7Sq7yoA0gVZh31QC7Wh + huX/bjhpmmehMbE8f0//6jEqvJxA6qV8f2XJNGa8ctE0Tf7kwku0JSPSqW4hMdml + udZivHIpgANbYgTVeVR4LFnkZO2tVeNXDj+jaQJ8piyZ4V2HVASTWM/0JieUuQU/ + btvNFQ0iMRrjVKjgK9OZxo3f5O/CNTrKYQDPMyyJEvs2+oZHwj7T7srlgEm0RmX0 + PkXsR2kmCnjSdgIrwbW2FB+QS2U/N0jUQfFxfv8s0zor9RyL3PuHjtfYPbr8v0wS + 5b81CafxumhOw93DWhwZoyK+IcYrZ904tqmJFraaX9odJ1AzZS+vf1AzlwiVTT1+ + xN0Thf/WnXzQb9JGQA0Ix005ekZbxbjOa1jQ8kIuZ4qQe+/figpTf8AusvxnwG7N + T2iP8qVnd/ovxIDrPJ+nssiolQwDK7ocSk6ZCQLYj3fSCSgGmobY8XFlFrMfW+oM + TQG4vWvcLuIrrTcnp5aIl4XyvVuvkJoYZD7AXBng5CoGA1AaJEK1He5i6+OeNHIJ + HvNMsUPmHaOdYa2iVU1aJ4DUbO8zydXPPNtI6hMvnvqp5oq7beNX8hXkIPIhO1y3 + Nc09nzD4nLCHH96GVpCwvWuvlbLGK2fHdj/bP0PVRn1ql4O3AbXD0tEY4nacq7Ex + AS5oPtHdosrQrTv0ZG2D+H5x7u02f0hraMubSyjruZ+TA9phgQjXm+D+JrcCrDr/ + oE1L1uBKnQOT/8AsYh2t2JhuV7Ry0cg6Jt+AQAmLCzaBpxIGYWWNIbjDifn9lZi+ + lZW9Ny+sWVNa4VzB8V9V9rXGWqDnNag1j87JTmS3NTqsECiaP4QJML/A8zjoI7e8 + QFwKPChCfZHhKA/yAcY7GX7Gwj/ljMrS6ZvYirH0dI+v00rQ7LFA9REplCVLxpBT + iboycHkVNdni8H4xqiMpBYw83bX5B5syLS744+QX2kUkhIO8ILSiOJ+gutbDRDi8 + Vmi1NgacnawjwRBzfKZd3r2JCZ47n3o9j8kbxQlgdOtY8PmttzQH8jUk22rjyWJs + O+Y4I/T3OE9g24Ei+b4kwgBFXaoajzWj+/xKOI+Oy+EUPg0= + -----END CERTIFICATE----- + CERT + end + + key do + <<~KEY + -----BEGIN PRIVATE KEY----- + MIIkRAIBADANBgkqhkiG9w0BAQEFAASCJC4wgiQqAgEAAoIIAQDhfq6cKgjogJYF + GRuokm7MAyUHwMBzkprL1wSemGquI2i1DkjzbDHSa2iRqTTiNgr8NHlYXhmqn6Km + 7T4DNaWBqrWLsYVusGBtKKIl6EbE+dVjV/7iqn1lgUF2RI77S7t6tXYKYwG1Cibo + Ui+Dyz/eJB408KY8ruHkSkuqdMRV6XXkkytU3DRd6FKjmdw8S7A0IcY8I/r8Sj81 + CifAuI4BkSqrh210o01RwYZVjcXiq5R+qIXbT51H6MRVpSMTPRMQ2yvJ997OTR3U + opZWv5WeGc0wyQSqMUBBL82wvpNeOWc5GYLLGx1uilh1zWr+MnCYebaDOfP1a4Gn + HB2KwCY9RUVw6tAKcLxBMWbcd7JN5ijObkhk3TmPexolXmkB72+5q6cytwgdj1Wc + 2udg746kkPwkKeOmJt1789Jaqlvn4Emez/g3N2hXO3s9DJZuTY3NXesmraq9oGml + WSZNF5up2sZ4811ci1cMEl9p8GSNpTcMy98ZdXCUhrrSg3fPbaK6abcRx5xhbXqz + uI6QExBie+6x9aPPO7VR3ibwdk2rae24f2fnquS6sFLaOa7Spl0eFdS0nySvlMhI + I2kB4ZBaa1dzZYsVmJgOMKfBKsh7k1EZPOYcnKAyyiWSRAhzgPIC9TULZtnEJ9RB + W6th4gUvA2aa1YM8PPERW+kYXBfNsPOqKkW5mK9FI+9Sat/og1vQQHY2GFXy5pyQ + DlgX6UArdnF6grAOOwZJFhCXg0FMaMFy6FEdohNnCOZmiUNk3JE+FyI50UeA6rR5 + J/x11+kfwmAxpo5+E7zIpIe5MTCdvCdqk7QklAVbIWK6JLY/nqWj0pyhpGSRPJ0U + 44/ildZt3+tj5IdyqNnuwQwbLCpVYu4o6qhd2WtctfL1L/fXuR3BuhzULmAzatmz + JQd5+ewd1e5gH8aQsHMD0OMXJnKK1zrdj/FmbvvTfT69aelyQvFORCqvTZo/b+zX + KF6MRd7OblJoqeRVwjxoQWHv5n+FbfLzqUuy+XDleBTpdXSdkQIK5rII23vKoo25 + gp6uZ99dqMI5RTUN1h0GLHwkCrIACOF3FMuAuqjugP/9sIZK1fXpNnQ1qmZN17ph + pRDra/tYdoX3YlLYBs/1W5IIauBPJKpz/TYxa2vlisKzyfvkV5CYqUz2ax2mb5bG + HeyYYkbPfF6tV986GhEIZTQRBi10BM5eIU/l1WTJUUqnLd2RF2T2AiFgaavSqiBI + Uzj5mpVyVjeDs96yik0oCgx31OUUgqV5oSgwnUJYf/1l1Z5Yg/VhnENo1NN4HHdP + WMPLK18dWvY/Ui3GAL6s/6LCLTWS9+mV1zW2IhDyvapqvuWMmQdfbKKvwsD8gFQt + xO09CkWa8JOjTYt7VQaISnl039Y/L3vAwy7q9sE9fbNtBiNKxLeULx6TBumHLbSJ + PUesqKSkM3Iz10seyXD+dZX3Z5dULV8ME16/lAs5UUPtg4SKGnhoyoxciWRB0YYG + q8MW9RceppUkn8sG/zF4xsffvdft4KAMBWbzZKOFsO5SpjKFyLBIg68cXmgyqTrW + ODS6HaBagiTjrKyI+EUl6riFhHjClWtGRTAmwWiif/jFdav0C4GMrF3jpnfQYmz9 + mE4G/PgHTvb4gXaOQsHFuxUPmWjOz07Ba0mo8GR492jD3I9ffIjOA1UBA8tRMBAb + BzKavQrX6qy9XKHopXC5vrB5l3zBquX8X0I9CZmjrvZtvdj/7Lu+p6wU7RbFr5C3 + b+obFZN57qj/uf+7GfrjuZnfrxkxb0LxLAgrirUi6RkWrHJ3aL+dQlGd7vzZKsLm + gJv34PtproTfIeFgVq3q2nz3uKBCdBwLQRs0scKvbwSuVresQWQfwoy7viMI2964 + pDl9KBmt8dsVYm0TGx6AYtB2XhzHnF5wgr82anEswbBpn3fo7wgI9lbmwrwXpS5L + gCIvOIcqGtH2izXcqt+45fqazsPDj3b3pEyT6FcqwlLCT/1p9kjUJ//mg8DBTXcy + WuVDdtbdGpGJHtKftU0tWr3X84k53HGaJFDdUJWuJp8v87hZMc2IsgjtJ5gBAvpW + 23BVZf27VFBTJBZqTt/pWMiEdfyMlZeqnUv7o6TlBEfOBNl6BupT9SYQMahE9GDl + /mq+QRN0x9qzncDKlQSKZsiocO3Q8eQheTkQY0TVWTUy9Wgqj845nGTJfM+w4xto + 8cbkB61fcKd4u1iiDWeYmnTkKOo5Ny0+47bHVrBiaumU5JsV/qs32+BZBQKIh/mR + K/FE/pxXOX0ouZlM3bq3nuDCd7BwqMstI1zx2eKNAjN9G22ZFs9RteI0JbHndJvG + Iv72Zo7ERGM7+D6rGDARuolPKcgdKWiBH1bTiGv9WSxofer6hCPNkGl4Z1Upa/pe + 85P1DL0Yz4mVJteFd+Tn+oJwEyP8XJp6jQj5kYgMDAuGsGq3STyLwnDPe6R+dkCx + mQ6kAuZFBNEuduWFZfQJJGxib1vCjnfAsMK0BqW2jFF7cID0A0upiDjgcjloJ+FY + F2VLCXUE6dmtgujlNLhyMWcyKDNzKlNsMVB7swkPLnOK6MFVYQ9dXI72IpI73LKX + oREsOcEyItS2pvDhu9TfGcQLBkVTYWllsuhpmsg9xwYJajf4ewKP0Yxa6XbkDlxN + tyFbRIu8m6AhoRo3sPBPUIb02Dri4qBId7RVBRe+B8M9NuNE5o88QHA21R2u7S7c + r67Zw26HSNGEN+9HGY4Xpy66ijW84wIDAQABAoIIAQDcVXF+TCB6NrLf9mGtPLAg + jm4PfktOYpD43ne4FAwhbZ3xVCz6Fd000xjRQ3nWE6J2PzvWmdQQgX1oCGbQsgmv + gsNz5RkRSCxgXRTbX3RPIiNct+3pQ1fV6A+z5VekuqJNS6Q0j/tqD6pm1W9yIxac + E8SkTATTRLqa2/HFc+UoYT9+AkOT3rsYi1q8Wyn0jKx2tA3EVA/5lv7d77daO7se + Ut9TzbepAawaV7PQQwB59NfbTwXEfq2bRxkY6ow0Tzgi/1VxOs8t2/JrBBdMWlVy + r5lssu7o8cjsKS6eJglPR13SUFgZ57vBeFLpgLer/FNC2aL55JW5V7vPMsy29/wl + YFty8y4nFXMNbJ0qjZbfQSbcVqxMSlHlHg81NmP6rSAJV22/Q1MdtyGba9YsRMen + i7ekCn5TqqQ+asc/Kjk1gFXPZT0PjwdYPVm1FGilDQijA8My/vzX3zd7hnnDWG8U + 8B2Ar6OpOsnqlMVAedF3ClmZGlg7wyInLuK7shROzbz00zk7mUT3egcsNwiuRMJ8 + yMY6g1/1rU0F2sFHswE/nfjXjz5TAwwOUx4R980YLdDNBd3aQ6qQGhv9SQRg/yuS + /lHsAut9RaZGL0qrmAdfoFndBEGA8ZYjKpy9p9ZuLi/LrhePtYbRgW2IE2+J7FTO + VE9cuYZLROz03k8MK2hi5yWgPz/0Evon3+4IJT/2LOx4t5QKVYseFjIjHLD9ZD/8 + d/Z4E9y9evUwUuwRcAJNDAsCIXipMOYuhmbDCBqfIlqVRft+bTyl/jAsNmMcLsWu + 77oYqbuP++86SnIIBcWQSvpkzEB4gV4eZqfWZOrjjTwisDe2RjCyLXz7nUPJzklB + AUw7RmEHK3APN/iBUI1o84rs1iV/1mNuqqbk52MQGeS2mAl1Vn9Pnndr8aG1kPwj + RxduO35FgPRRZTmQNFQ10ArH1c+2HHnadAXrBONDb5/jrv3aX0R5+f59WgfQnrEQ + GoJRnLftCCcIY+KzjBFMqlt7tQ+vqMakocolOEyjbb6GMlcCCpySKnW7L6OnnP2H + wc9OMI6fn3iqwKroeL7nA8ZzGhGjDkDlE42PMH53/0sS/s9cZM0kAMgwgx7eOpvV + G7LZP+zdAwMOptQxf2UAUD5xqZjbfzBlkUmgbZvAycMTOFJocc/+AglcOn8lgtnY + AZltXXBUkIXWIzVV8ShWth+DoJ82X2XkxJbidhGKpUZUj05Xq8llxjBXG2KPmnmu + yAnkmcvfvv2XQwJd0NuqR+Iyz8K6hd7/JMjQSYQ4z2/kWdEQTOz47y+xi3V9Pzro + LypwQvdRAwdNeVhqzcwMeEt4y1nDRtQyrBspxK/9ysWGe1sXzH/P+gDG3CZHv5Kz + 9b82pNI8mGuSSzZqKtZtHnysb6bt/h16EnLNZQ3+SxGa6nA8Vk90Un+qPDHye8lh + FC0Pdkp1bqWY1Gok+xSWm0jeT+1PKcGNaePxV4/3+NL5AR3hhPRaVg0pbiuFXz71 + JgWKGkj7eHFxQtineq/F1OxsdYzB66xBNI9f7gB3KCFyVkE5PVebNV82fV080IQ0 + Cy5yQ6XMU+lxuEFas9ZVb2Z9/mkXpQBOFY+9p9nP0wHdwmS/SeH5TuJnquBh31kt + FkDnAQyVAHcLPdKihWnSsNPlFDeY/Vb2bTA8ppPGXZuj8oh+CSDA2P8u3BeUeEcj + U3gjD6ottWIpOcqXNHwql9asLuVERGUDd7K9ALsK3DzjRsDFwQMSFaFCtG4v1ZZa + WtURi8RmZBUP47///9DkwV46/m0TTP5ax9vxjJLkiZOGlQes98KQ1Oyl9b37cds5 + gb3SbXz65VWYW3/4+h3GecZ6xynfsfavD0d0fBAJN48YaKb3k/9p6qfw3cxr/A4I + Y75m9Au9OXZdgncCT19kpC2uXlXu9XlGXcv/VkNt1p0jMta6A1n6kJvyG/hrjUpH + WSYibXEW/qod7za4oYBv+3bjNSSaBqjx5n/1U8lj6xp36ilHhHQ7QkIH3O4RqkPj + 5oXKezm2CUqxYJ7Lo84f7cLbHXcG9Qp+wmCy97E/NPlK+8yQZyz0e6i8KtxEocsQ + u/xqHlCSi+JCR8vNA0ndsWJzI2/dDF/7Q0pcOvIxNARSmyHADabRBen+FPUEE0AN + KWaspqrf78hAqLbCXskUZf0T9S9DDMDHQj9bZs0H48OvCVFFb5TzBndG2kJIOZSg + OnzOOELcZBPY/rv9g26Q8CrhGnh4GoEEbNP8imPJ8Kmpd1Vk5auVYD9qfygm21Bw + uxj1/O0cdnZfT16X9h0JnwjP9MZ4tlRH4t77uswHuaStRTe/dn/kA9Tn0cox4z3q + 6txM4uakB+st4LgMhnDTGvMMD+I3TSaReexr82hOZJUwc95VzW4lWrIEB4Yv69uA + Lt/5kbJUY44kAgdORPHLVsFhngHilGyD/3m/XyHUxRiNdj6/CDANtWwYN9089Jky + gauvpCOT6D2SJVCx/AZIWmDwNAorXVNC6Rl3aySzIK81aqYejm3D6S774ciOG1HC + lbmVPtLr1kh/ZbM+VrnpzIu+svm4hFZA6dhAYnx+50ZJUISX4a9i/vkTNBK0puwz + yj64bwKxdoRnT7tzJAmz5pMRn6K69ur2mEvW/4KolJeZp3V8YYmPZPjYMLPdeJDc + Lf/t6Ff1WoERTpe6IepnPlKxuSgpn9JDzZ+V3hVnQdu4kHjSheZMXei3Bv0fb/eA + /DR1vGi6zCGGyGh7lSaWCQKCBAEA+aiesAPcwyZyjGn7u1c1qBOUtUex+EroyO+D + JXgKIUbNvKxNcD7+vWD7mq+uDHEsYeKwcK2ulV5t6pD4HYaHROg+qtaGm9jK1Tbu + O9zPyQpOOEG/tqHxasO+fuDiQOO22xwy2+oc1BkpPn+q4yDLrGtV60k5mIEFusdB + fDYLQIKBRa5mIrXOJ4+DMumAqXAHeB9rjlll4AyQLHt0n3oRok1GXuL5tQJvt1E2 + HsU1HPMcwM/cX3xuFDMRNJ0PP9GGFmXGHIrWGqYbsnIIgZHUjbSShAzsk0xK2OKQ + S10CXP4VFkB2C94WYu6f4aVKk1ia9DYdDLvzVG5pvx8gmolccCk59cquE0SVZXoq + 607tHXspYgCBhJZShrvLvxX8hxbstsoVa4E7AkjWQUMbD/5Kpt1V22F2EHWLIty1 + UXSTdlNGTntfoS4PEkxQg32kF17x02fpWpvR1gINrZbpVUgWaiISs7XbAYsTukwV + BlUka1/yZMrZzB5q8GN2RXyT0bV1U5P1SqiF3M2Y5ffzVP/OTW/+bahnICvIeDlm + aXary2SCLFtpQ0UBbmGV35JtgjxPV3LjARYiNv1kpozKD4goX/q45JMmefHIf0NK + m90Jbk8ezxyMIYanrQ6rNLK6nUm68+mJdR+okNXuosCWaWG7uAl8yZ+Aw6yY8aRa + fBhX5HHY328gCbCPwf6W1gkfmdgTNIq339sIngyNlQAxQwW3DRf38P9U3PniZX6z + wqNNqGaE8+9OjHyJzSXCFiYHFuerfVYYmkm3zvuPaya+CLd+xqj2jV4P+GUW/3vJ + UNtNM4nUITgws2hNQS+oWEbcFiI7Z3M7JIAI1P2BsWvB6YCP63+p4Ij5Mij7IQso + 2B6j5/dohXKzQvSFt+r94bSjYfMGE5F3TGWERwmubGifap0hsEuxLdECdzC8yo6W + fie4V7AW+ssBRhyHf/FKUwix358xECRBYa/hm7gLMtGYU/Pt5wuK1APJGVbTizaC + y9LSEDkOTqGu/S8waJJkdgkethbMj7Z2uhpBRxlRKWr9I2GvUn+9kYnfuIINB9Iz + mMnUeK6L9mhu/+e2+8VSfr3VHASd9+9m4cVWqSEjPxgg91dHl5LkPvnQyj6gu7UC + IdIUziFEeEBp+7t7bqhyq+9ofxachn502tWeuureyzscoFrbfKlgb2yXg0K9TQLu + UFXGboRE9xS8xI6svAwKgWrjdbUlg+MLTVVLK5oA5srGLuzYc2bqu5KIL2ViWaeI + pnvYDWBIBtyqvbYUbb88DFTl35rcufpwXE9SBvWk8FO5pEvtncYDfU8bhdGg3esJ + s4uTg2BBb97kViYttyR0YWnWh2B63M+rqb4Kt0v2dGalUmHoTwKCBAEA5zjwfagb + Yg7tT4eN5MAvCRzeMKVKSKhPKmXVJWtul3SzcypUj8uB4wIU2HQwRUB6IIW7fbKt + 2vfJRCYKYMUtaavltQR1Vp6at5xkswIEeNfxe5vpOk7EiFuE+Im/MVnjvkTyKmYi + emO0Sv5reTHaQ0KbE+cgoNO85SHS+XXxm9VCdMbhGiYbhVNPzeIKX8Kwg/wiAP1N + hO1WgT/fXtD5/xtGjh6/IKruJ/CtQXvecT7+/QbT7rQ2Xx0YBu2/5W9XRobzIo6K + gLyUkRIIvnVC+sfnuFBMtmMT6e2P9FqMe3f9sIyqyJlQdjaBZeGM2S4sDNMZQEAX + uOjUK4sG+7KwQxVdNlOolrn1QTKGXIsbRH9cWbn3KM6qC4LkCEthoHB8oY9aC/Lm + gux4kJ4KM7cxXuHIDfcp2Zg8Cd2pUuH3U21HSpPkzEXEK0U7G5O8E24KpKfBUco9 + CE+bReo1OKRgqpVyYiFbtC5xAbwIqd7+WFkEK8rwwMIwB/Yqhl+10T2KsA5vcvz/ + fhjH4voEc4VWZGjjfyqkHhFTlJFpqaWchBI1EHhNezpIQj86eIGvzbgzZfGHg6I1 + B+HQo3bqLvO0sO5XKJ/bid1Alm/RC4RfWIqSk6lNzM9yaMiHJ0LUrTf9bjpk30+y + nz5wbLpkxNZYP6LE7Zhm+um/TBCMRJ4eW0Oc5rcIEl+Q+h+dMeI6o26Nb3H7gQSf + L3L+VqxboT2tXxUKoGwadMqNrsoxHb87xX5E8l10DGdWgSer6bH44bVba7kz8Zny + XqNad8ZpBAUzXMbNmzBxe+q1jCcUI7simu7CO0UZgkeJhFY4tUFq07B63YRmP90y + fvoGp0MC5m+UlawIrRgq3oNOXhl4b6S6ow4JiDAndGz51KGRQLxZcqP3yBmjXwOb + GL2zlnGhoIe56qTuPBibmk50NLjsQIDo4heYG/h1MKa183yo1uwBxJfNFmcDQh/d + C27wiAF5fiZvU7yJ7d2EFgvyNOrcMaCq+1Yb4Au3gYTe5qpRNcl1kwFI+1DO87e6 + rryA67YBDatghqhVuXwRgNqgpq3UcCDkczQPm9Hb+u9/kOz8vF9EwSBthivKpTxL + TD0u26vdJ+syiyzTPvHShgyyT5u5Zl1sAjzaCQte+WgsAtojgTrrYUDKDb/IhhQK + SzRit+0m/yXo4bAgHFklRdOs1OcIyTA9NzHke7JsCGgErqDTbCI5xqVp7dxAQcwJ + ybaX1tkRar66tS7DDsKw9PeHdW9LzUNCm4TGyT0SMbOz8PdVCb1TnbkHIJclLjz2 + 4llQyPGEsqNWUF7FzNuybWSFl25/EhlT3lMMmH2T4yRA+K4Hc298gxofKgsnHEzh + CosyHu+csHDpLQKCBAEAqfQ2AtC+SkM0G45Shdf6eO7LfxTNfJ9SFOenuawcCUcv + 607IcK8Rr04EOet6apHoisJNJoe1n41m+hWyMjdQgoIvlxDvFczhV4BLcYkCEnPn + h7iKkANyWyHh3nGs1EuwQTzTCo43DdQLFbbHWFMNE9UF6mQwxzad9eaLF8mao1G0 + OwFcGij1rEywHcqDgdT34LhS+da12W3z/7QTUjVBJ+G/E/0jzCtabcrlMtFBNPHz + EvbtqDsGnM2e2thId0NlKn4h/XAuDHojxLiIPdxOfCD+1NIPgr6e/UJOxF8Oqst1 + A27ibXXEe5jCUlO5jtD0u2bTI8YXAdUgO7Eu+sSjnt8Ry9cr5YX8xdYCvak/FaCw + LTz27pF+oKXbL7wB6tyaTF0Jc+PHjeiTol3SYHLV0v494lhYjR/XleX1sPvRHu3V + oLuwAANg0y4MaVbwi9Bgg2/rlXkZwbwoH5HqSdoHGD0Vyiz0Z/qLdXkxntv7LPVm + B2NoHOJgHkE3VFpYLpx+wGSqySYr6oIzoenHRofVozWoWHIZsfbcQ6ufog/dJ1rG + mvenktm4/bGE22vNDKmNwZQ+IJE2vYSGLjMNosEn6x69Gy1pNf54ZNokQjKYpvVJ + nehrJK+MGe0wc3FwRH7avAyxPIBOujpId5bvTdHwfnpG7uKcP5iRjX468tuHicZO + wtvdTXtagc+UUyRm1M4ZVN1SCxwKo70b7ODyqBON52X3raHD3aJmkn1MViXhSdBw + lbbFHDHzhSo9E+LTVK5lOa+QlAe3DzqFCYaYO0rfDNIc9WOhL5FxtH5KL7b6uSkM + tYiQ7rEEVmnhCidCz/aBxgzVqCVY3dWtomAe45xXXRPNS0MzkQgA3R/BsE47ekAc + cSwCCIR5OxjHuAzGZHmSG2QdeG5rPAjFKpuWWneZZXBBr1Tnfsg43RNwM3VKsrb3 + DceAmH/3ZguWcywqGnc+aSSlNaELznvdc7znG8+klnJvEaF6FrvaypxTMfnUcqLE + sJa0jzq+k5GEvi27MG4Y14R5EnupEIOVksJ4jMuFFH5NSHQ5TluKD1bzNQHAmF8K + fLXfSmotUPulCw6jsq0Z9JyOxwcV1ZDvc5Yzau2JmQ+wPYbGsccsmFvClc9zxlcz + S0FeZLXecxhM5+rUkh+McqpHVmmx4sDc5jDZbfgsDpMnSPL9uaeHQpPKM/oQWU/F + uwXs80nFIUZ5KFzhd1HXtg6rtPtpbscp8fL8MxmcyAK5rPM1rj4wU6QPDHamP4TZ + w4IY4YjAI23ZrPNmgW/k7t4j+1MsHfy/SbNVXxkpKwyPd5CQxepMvoWwVv+fbgHq + ygNMIbFf0ZsJdv8bwZDWUtc0nxr2JI2buuXdiVWJVQKCBAEA1tsADaOCHnJEbdxG + K8OxcURT6twM1MshFQKfNzBHCZG1llRFU4EFZs3uVNxSZmdtlH7wI/M+vfP2H89B + YX6XnlPPFY/ZAO5MUkWPBQ/g2/G9QOE1raq30QVJ4DEPampex9UFOgTCEPxI8k7L + y0hZypo/xBTHKurV4gy2IHxKUEWwhRaw4T174T3zMBrVDPq6T0qgxk6aE+T+tweF + JnQFedn8i99iNpbeylpIhEr3/j9Nbg1ELdFjnKpKQ1X1NNtrO+v2TawqY0nYu50I + ZwJLhQDw/0IOpoQWYw8O7z6cv7ZWFBICOHjOXap0PxmBaeYPpLMcCaoE4Rvo27VK + feQjCZL2lJ7UT4rorPaoB6Jzagj25aF6W37+X8f24QY653zfMrkkMWo6bHoT5j4U + uM2HoOUoomGDj+B4GarRxmSXD/zBfDlFJ9PEX3jrXcq/v0ZHuYzwhHHqmKhwXl0t + qz6DXL+WFD1vG1T0SWpSmpbNvYap64+ee192hk9mYIrbRl1rXAFt6mnRd3jLdMxi + Cn5iMteMXgRfkFkFU05z4uIzOD469Nz1Eoar0nMyf/vyQrThfd8bz2OQ54wb9Wlw + XsSyqJ4we11gARGJDMFGfO86MepCHdf6pVA2vctoW0EsovEeG6lDRoamMncwvLfP + H2EVi7xSRX2SY6GE0selr7VF/AQt7e0yIPCQpPtvdIUFfAwkfORrkg2bZdnzINL0 + KjZHvcytnTgWtWPql/rl/QBQKEoXAyd3yHbV2RnmEzf/TqzZEJZ+AAjPQMWGMTo7 + JzM18QYC1CwFp+IHZP6DJlij5VfrQGwLMhYLYN9FvpfVDnQ1F1YKNVnzrC3ktNP+ + A+a3KQU84qtMWouk7Ke6U/O8QfuvO8+TOgpxc/XWJVNfwrk+a7/3ITkWi7zq/ecF + C0hTqAguH8W2AYLZVIxpa97diAnonEUZkGW5OVIjCeMwGV/9gM2kJ3O4UQF7nMXS + ATjxxduyR0fJjzr2i9mZVrw3ZWk0adI5aK7w+WJWKCbVjA5rpKwIQkv9upULLvxm + qi8PeNE/JyZ0lUmSco+gkbjez3YW8vHk+Z5G6YJtrxTPrK3XWA+lNDl8tpE704A1 + 9vwEcXLrsNfAijOOFY9cjhRNYx7sc+8PB66XBudwiosXYb10g6YsTPqePheli8dg + r0Kozd59WBo2GlaBiSxN67VZjMpdx9uZq44Mm8Bx9U8wZLgcYJyDUSCqD7gOC+SU + 3J3ynJ2hPzwGdvrz8lnDFC9l22Fb3m9TUr/rewQ5Dt3QrwTZ7JzGPdsEhnv8J1zV + s7E3aWNHZf7YI/J+eKKCjWzfk/2T/LbkDvMHNI1x+wAjsSc6wjSu2QtPKh8CKeD5 + trKU2QKCBAA3IgjiRuX5dCkgoq6T+nqCMy5/vrbMRMCMLfGsOwk9wNQk/NtsuCoP + fyFLWPD3RwWv17904oFWiSA+kfCQ8QAlLHJuweCUybbklh+RN8PYHs/llxt0G7Uf + sk1VV9XlWINE9x5uxoOQq8+aMFDvWjbq7H1kbJkVWSYJjQgjqk1Afp02FIQ6kWKJ + CBKcMT/AvONsFjjyBTUtCGop3ftytEFedw3zlqhn9y6HITTvmjSyZBoPZox+vacI + DDArlo5EC/BZf0zpaIgDJccGLJI/eOPqX4VqhX+EWJErVdX0dlLDQcoAlM6lIRer + OHHdvyKN5Dvv0eSlgp68jjrLTwEr46DcHrB/W3L5/S23rJ3G6IObvS6SljPYFQHo + l/hi3ZGM4wSazjQIfNPk4mlE/fB5jmFWolijGLc89hGhpmyVJ6bqISUowM2OqydV + NhK6oZhyd32ebCHp2XQ1CKPdfrEhdsEreLT9E5pMNVptsZ7ptxIhZ9ILuK9fCRC0 + Mz1bQ8ZX/VgCvb6qcU/7aGZasqDMQopbwz6bVg8FXAy2Vx1vyy+GXyZqBRdi7Lep + 6XzspzM5AJ2Bpk2PkMbiCRBnjrxDHpXcokq+J5sTYQug599v6TXqNf0u6QHd3aJK + t0NRtiiOtErp8HZ2yWcocwcNnTTdLQ43bJX7jevzQ6KVZwdavMOebHz/XY/HTUzZ + taq3/vRx2EWmsBxqpkmu3FOKopkAB38j6ulUjMebamopw0vicLJj5cxjqAcOLrTB + 4Wgr0sF/sdlzSCyEyAAoQMPfbBTIXwErJylHEPLYncJG9QrH+GKDWbw5mDW63wYY + bHKjLJ62/rkJldyWGTsSMeHmKbt7SZUv3ntcOjeugQubZL2olZwzyAGqUtQ/tnJV + rBPuzRddPWA2Jq3ZOfpmLmmAlKX0n/udTNBbK+eBht31DZWZv3thqkp2hlcn70tL + jrJcQv9hpqCR3u96Xfqf+Fm2qx3IjhIzhtE6hlGBaDgl1+4Np2kNwwboWtT11Duv + kcwZ5dskO3i3KGEESyi3j/qzv6HVWEjq8ex/H23l9QigAN9ikPRdDuSmKPxMgr76 + CmXc4AtLZA4iiosHgHZ2GxDmlPB+yohBy3AGuemv6sL4EU2C1Z6wDdOUapKJtgSu + M++J8FOtBW+yuySCIBCAYGVbjm/dVi3ay2VhMiW7uq1aBMYh8Dg9DppIPJ0KS8dB + dsyQz5OTBDRoQ1kT9vMYIRZrjbeCNqQnfkWAIxejT2TXQxPVJf+efVqrotrzOivb + AdoggNoChG89dOwEJqDRJ4OX4kvuNuJa1M9kvfdoEc9ToKXXY4m7GSaf4JyGEqe8 + NtjUPwBEHYJ9bcJ/fgqxWtoc23l/1OfP + -----END PRIVATE KEY----- + KEY + end + end + + trait :key_length_8192 do + certificate do + <<~CERT + -----BEGIN CERTIFICATE----- + MIIJLzCCBRegAwIBAgIUB6932CbRBXCmSbrDR1L1AeVQsq0wDQYJKoZIhvcNAQEL + BQAwJzELMAkGA1UEBhMCZGUxGDAWBgNVBAMMD2xvY2FsaG9zdC5sb2NhbDAeFw0y + MzA4MTcxOTA0MDlaFw0yMzA5MTYxOTA0MDlaMCcxCzAJBgNVBAYTAmRlMRgwFgYD + VQQDDA9sb2NhbGhvc3QubG9jYWwwggQiMA0GCSqGSIb3DQEBAQUAA4IEDwAwggQK + AoIEAQC5qEZ5QBj+nPYQjpbOBwRWIsNJVPgB54ADkjYSfL6kbPyeQsgAP2vU8zWA + Z1lIyiV3M5qa7KZ4oK4uin7O3rwho2XoWaaf1Z6ifpNeVl8Favyn/IeVJp+jchtl + VyDAEaX0GozOfo5X+xOAQE7twUNSlB8+6YIqxTQf28zB7ks/2w2qrJvXO3cSgQyB + 74tTUn/25hwATNlEzSBRdLzoNeK7ZInayLAvxwR5krIm+yjH0EGsI7XZQMLjZOwL + 95FrhfBLYODwPam5lAzPB6paeY+oGGkNwGhzECqmHLnXMSlY6lpeUs1vOCjVcE13 + v5jb+nIwLaBWvpF4fnskgwI5+hiBcfsRuByKH+x5pW7ofCnqAOgkq8m1ErqRIvN8 + SG54ySlA/+RoPI+G/1/x/AdQtdQxL5X80ZeUjpLGTGy3kPVEQ8feP8Ny3rSklsPt + nIMk6fRWgiw2ba2jt500F+FuntCj3PWDZfQ+LBBswuLpQCoB124RobXwFCCfqSd1 + 8+S8PfGJQ4gDwc4K17lm/WJq/X3mUDmxJLhEIvpZUxi2App7b3KqdCMthOUxUFoa + IZiWtbK3h0A10c/Qr29BI3soKxD30dw/DylR42dTGZUX9fdEkPOulVaTXmApnGkv + HRomQ2VSJQq8wccceXvte/ZV2duehDk7032LhlNCeosfaJs4L5BXdB+xGfxDzQ65 + p2xo/nA3k4eJhWhIejzOmJ1zUcsAl9rDUK312hajRh1UFDIr53yYnpAjcxyDYZqv + EcuFrjNZKi7zPsZC/17f48hOEHXC5PcFJrm7dvJa2z3+Vswy7IVljVhxu5CQQwk/ + aIWDAqQXjXYL9L9y8kGspczucVhthOuDKad1Hopy3E5HpaNhXWOLqglWQihjRviG + Llt1DU5RnMRJJUp2JW8Ic+SAgtBpS39xFyJRj+gBrcWopdDWr/leTWY6NEKijTRg + SzxmvFr9uRlrAExO+FNafeTslYoStTQPysBwNC0HWa3SV04GU/LBRVko6f/ee5WQ + aPjGeRiSqBDZBL5HkJ90UMAXrj+2lv+yW16T9upD0H8gv/GpZZIPoZ9fcanSWMz4 + 1BloOglzUC+AWe/8j6egcpwDbVMbOq51XxP5BkXp/wc2vzxMF8OEMLm6pu6arAup + 8JFtFwrS7Cn85iWIppWxNiuSptpBnYdCd+e51uKjigvA+KGyarnPyJOOHynLVwpg + hofK8hw0EzDgkQ2ysh9rwqvKtMJdXr9Vi4LGlacnTvO3modvk2B6zv4AsSsR88gP + HMTM+Y6J8pJVs8OkY8S/utlOXKrTv3Y1+PRueVWtyVggfvOaXy2UKkAp8e6zBZ6V + 5D0BheSOW99ImH63c0AXQ8JV6j1TAgMBAAGjUzBRMB0GA1UdDgQWBBQlK1yjMe+/ + 2592Ei6EiNHBMnzMMjAfBgNVHSMEGDAWgBQlK1yjMe+/2592Ei6EiNHBMnzMMjAP + BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IEAQBYie/7NeBW6jzXhL73 + qTfubvtpYkNSCSYj5OvunG5sCzHEpWv31yEWbhRxEoLlh4rZxGtsn7hqsa6D4u6U + /OXydn8NNPIJH+bOLkn2QLz9Rn8N3YLTAnbjtYIIPDSy2VXMnJAP99eBH/+p+V0V + ZIxgSVBNljs34DJsLzOaiDz5js8z/ZPuJbiNjmUOpPxFj2TS7zuVWvpmrei8yGLC + PPxe4+LzIRTSgOJAdfyVzgBBD4vjFyPyNi6z2e5t0PrZ2jMJIXtjJQ98CMDEmn+m + EYrB3RoSTIGAv1Y3MZfkcOY+SzkqEvb8ojqODX+6axso3Vw1ylVQnGhmOesSVV7Z + Fnf2AtaRdjOJD3rhsZw80fjv/PW8NKoS1CdDiylJ0t1GQycSTOs7aCZx0aVc9iDY + OAKZU6rBWbWBQx9Nddsll/oeTkRhd3tW3lZH6r8tXweDERQffrHpAv6GiAnqmQKQ + s5t8cp8iB7mkCH8YmONdaoR+e5jGwEAVgax1BDedt1Ryz85gG6udAN2xHy9NAxPH + z0tRSpcvBztkw5jTSUxT1cVyYt+fDQpB8LX085Vg07bniCZ3oZmq0QO0M3mGrrva + FX9VnsakfTJ7lMlaEE7qMfE58S5sLkbXfWG649MFs6lK0e3vkU81iy+DiAA8wA7D + t/pswj0DLAQHdrNNvpRTMrl4edHAZawQFMDuTO4EFMYygcYu7s/Q4RMZaoknYbWk + 57JMXg5iC4/GcwpMZ33zA/zIuf6TMXAS2p9yjCQOaPhiwWVCa4awtCZl4iVI5Q78 + yxWN6mLmqH/zHiIBf4rtcwYmSzLyg8rz1FRjBR50BpC3Vt/F80rIe46QgBkyxwT3 + FhtOJkGAyw6yko0JinA/14KmeJnpf3g0YZig+jhG1omhigms6U18KYdeVy6N/5wf + 35ut0cGvXE+Ii4XKRZuVx0UxszaS3aBxLvsQBOOKEBRJljCHimWGPqGYECgvb7BK + 20UYkYmxX/l/hYjlmE9/wI7Wozd3ho2PcPfI5kSBHvo8qVg/VouLAq1cxvHPWjz8 + FOOgukZKnLRnVHz1wqH0HxoQKrW4iGZINHDBVh6R1zgEBWAm1X3xNQ6Sww+xIoUz + lYoDi58waKHs6ph82qoPfRtielxdZGLz7JhijQqWG+QYB45d55GG8FC15byH0PTx + JOlCjbDbHKShYdcB/UK7nr7fNTWgjsaF7gaLy0GOZS4zDE6/TtDNLC6zrwipcYOm + dqd6UrgdkMHccm7qCJpBdD09cTH8sJIgGSEC3YuHvqJ1/bUMaXAF5em2cQBcH+Ng + q98r/+7CUPADtb/H+OjIWhW2vLGISMbr7xgWqkETsrh+XZI3QOuz2/n38PZBcwhm + O5R7 + -----END CERTIFICATE----- + CERT + end + + key do + <<~KEY + -----BEGIN PRIVATE KEY----- + MIISQwIBADANBgkqhkiG9w0BAQEFAASCEi0wghIpAgEAAoIEAQC5qEZ5QBj+nPYQ + jpbOBwRWIsNJVPgB54ADkjYSfL6kbPyeQsgAP2vU8zWAZ1lIyiV3M5qa7KZ4oK4u + in7O3rwho2XoWaaf1Z6ifpNeVl8Favyn/IeVJp+jchtlVyDAEaX0GozOfo5X+xOA + QE7twUNSlB8+6YIqxTQf28zB7ks/2w2qrJvXO3cSgQyB74tTUn/25hwATNlEzSBR + dLzoNeK7ZInayLAvxwR5krIm+yjH0EGsI7XZQMLjZOwL95FrhfBLYODwPam5lAzP + B6paeY+oGGkNwGhzECqmHLnXMSlY6lpeUs1vOCjVcE13v5jb+nIwLaBWvpF4fnsk + gwI5+hiBcfsRuByKH+x5pW7ofCnqAOgkq8m1ErqRIvN8SG54ySlA/+RoPI+G/1/x + /AdQtdQxL5X80ZeUjpLGTGy3kPVEQ8feP8Ny3rSklsPtnIMk6fRWgiw2ba2jt500 + F+FuntCj3PWDZfQ+LBBswuLpQCoB124RobXwFCCfqSd18+S8PfGJQ4gDwc4K17lm + /WJq/X3mUDmxJLhEIvpZUxi2App7b3KqdCMthOUxUFoaIZiWtbK3h0A10c/Qr29B + I3soKxD30dw/DylR42dTGZUX9fdEkPOulVaTXmApnGkvHRomQ2VSJQq8wccceXvt + e/ZV2duehDk7032LhlNCeosfaJs4L5BXdB+xGfxDzQ65p2xo/nA3k4eJhWhIejzO + mJ1zUcsAl9rDUK312hajRh1UFDIr53yYnpAjcxyDYZqvEcuFrjNZKi7zPsZC/17f + 48hOEHXC5PcFJrm7dvJa2z3+Vswy7IVljVhxu5CQQwk/aIWDAqQXjXYL9L9y8kGs + pczucVhthOuDKad1Hopy3E5HpaNhXWOLqglWQihjRviGLlt1DU5RnMRJJUp2JW8I + c+SAgtBpS39xFyJRj+gBrcWopdDWr/leTWY6NEKijTRgSzxmvFr9uRlrAExO+FNa + feTslYoStTQPysBwNC0HWa3SV04GU/LBRVko6f/ee5WQaPjGeRiSqBDZBL5HkJ90 + UMAXrj+2lv+yW16T9upD0H8gv/GpZZIPoZ9fcanSWMz41BloOglzUC+AWe/8j6eg + cpwDbVMbOq51XxP5BkXp/wc2vzxMF8OEMLm6pu6arAup8JFtFwrS7Cn85iWIppWx + NiuSptpBnYdCd+e51uKjigvA+KGyarnPyJOOHynLVwpghofK8hw0EzDgkQ2ysh9r + wqvKtMJdXr9Vi4LGlacnTvO3modvk2B6zv4AsSsR88gPHMTM+Y6J8pJVs8OkY8S/ + utlOXKrTv3Y1+PRueVWtyVggfvOaXy2UKkAp8e6zBZ6V5D0BheSOW99ImH63c0AX + Q8JV6j1TAgMBAAECggQAOlfCRco50JGc1hkpFPepijQEcKAOC/MnDHg/G9Itytgh + Ds7nsQQ9K79+OarAqRo1ad9Cn5rsuY2tDx0gunvOXTfPB5Rcw2/LGT9zqjq0Q6ya + V2QJa3qmwiNSrqcRuKoTH8HUK/QjYUyalTwgUaDhOisoIooZCL3OIpDdKLhs11VM + Vy1FD/807RC20IJpozaS1hD8DbAYuwFHPbHUx5hfdwoiNCnLDEibhGTwLUXSS/CL + IsBaHjq2w+TsNNqIzWRa3iVEqtqF4ra+y7SZ+TKoTWfWY6bqa/ZRoL/4OsLNPo7u + 9SNKQcBBPMm83nvMWpy6k59S+s+KQXZl1lSBN5z7ZHpgLvJPraxYkOXHE7IpLcs5 + KIT/rzKChKeaIp1UcgqtNyrzKTqW1BKeoRnVZqytUQOmO7vVya6AO2a653jbSqeO + QK6DCi8oT2y9h4cew1PuH91qbXRME93Yvg0fH7cy07vVP4Sjm4IXa0ZXLnumd8uu + YEYUOazpj6MFrpCFeg5xP/SD4sJdsJSYQ+AutHaSwPTHHH7wlSD00WtGobPxvgaI + 3z397AkOSU/58KpMHFhfIEOVjxQvHWJ0MOEoi7f07hv5/asTDhPLXZb1foEiQl7W + 5S8y9L68s3beqxqXJB0b0xOm6yhuHOmkYz4IbHQ5Cvh8T+unUVhWA9ckrysdVCs9 + Kcgs2knAuNpSXGCoDW0zGqXRQg11WaHJ2S0woLyrDfhk14tBwhojZblwN2d8nqsR + 3JxM5Dcc5tS722ousXqxiF9DuSA0ekwvp4mljwkw/9mJYpbk1568aE4aavjMZnfb + r0RGGwXhbIHG+41j1izxKIS0au8H8BsqEJbJubWxHAVFODdVlAckuRXV4ZzL9Nro + MPrJYdwTh8dVcFVtAZQk0cSlWmg7UqbsCZqZ5Kxk4HJhK3xbzF/cNPnY7zL2l3mo + 7qYONaRKRdelWZqcB9z+ZuGVMUfMxRhaN9InubRlpqoOTXK22GbmhcIApsXi38GU + Z3cgrBrGhG42Tqpp3Ub+goGPw8cnQM9+OItxm0hcL5BJOeC63uqavVemLhs9JM2g + 7pkxRPeTagdulHRWEFVTtpt1AN1Am9fivLSEXa6Oz5Fab4b36qOJx9wdCFYC0nh9 + v/BEBJmlsxuVA+gL0e4tYcxMt67AMdgMyhKzT5A76Jxp3AcJ9QoHZODHkfgIcvAo + sCPkBcBNiRZ4EJyrB27fSCxSqqfIGo+ZttZ/K3+g412+2ALfA+TM4LpCmLN441Rb + XPa+Gz0ZAroXa+RVO3M4Jr9aJeDRKMSty5wIXavA43L372A+6WuwGKOVqiP1svzV + Bs6rhoKhoPsxjIPwFJluH9XMKgBhvWFdf4lLtrGDaQKCAgEA41NZ0VeOW+2D13w2 + SUF2oq1pbZcloExPN1GKqBf61l/VzmJxrNiEHXm7oQlnX7f6KI9FlpC+Po3U59Wo + OhTe6t7eF+e3vvUObBssrbe6dkiwN7deJxq6W4bdddjH1MLBEpNu4qswgC8v17GA + hAG001DfYnphicZiRBayP3mgNM1xgnwW4YoyCL7+rBKYnE4HEfhNcgJxDiKBsx2y + sR4ARe8RORqaH3AkJh/M7IcsRdrtTEqovaaWGiiF5XRx0xUEj2MtnYaq4j0Cp2jl + vPVhZu63kxFmFZbz4G0qmuCp3KzH0JMSWMqM4zFV6ZLi9g5F/6mWCV6RJ2aK9hnD + JybaqN1tnriwBx2k1nclfMjTAGunfg8gFT+V9Zwqb9kSOmIaQonbKj9/Zu4OkiWh + QGJQKdQ7L0PBb82XyPEaltxrKJRf3rxc2xVjstcaSw75EukRP95jFV0IzYRbgzYX + NC/0eojriqR4K3JxlKTAYJTw+0WciCWFqnxD54w77SetUkbnV5kMmc8kDLyBli4a + Gtwu0tO7PR9M37hW2/ND1U1PWpV1II5aQNtRf8P/ZMA1A6lFa9R8AyxbCz7om5FH + vYjYsW8D+pGfPpFsXlye7kwG7dNx8HrENhUu+j34PqKVpdyNOxzuYOo8PQQDhOmf + +DDDqqQA8D4gcifPj1I4wsM9tocCggIBANETZm7i5EDnInSsfj7vVJV4DfxD245e + s1ZlqRCd/Jpgc3RbmXdPFTF8N12Q8ls4bXPDKiTiy1Oy4OeZ5+6lgkpRCz2Ptgtj + Q1+is9VhPLoXaqRHbZ2Y7F7auN9KcthFt6WYurDNiqXybmZ6X7EDM8uDLFsL+2nk + QswBNqJrfTZyvPe4ZUy+WkS3ma2Zo9xujTwV0SHXbzwW/o8pIMWDLR9hxMVUYuXG + uEYORT+n0TVDFmUyHxjyR5j1tQ8jyLigcMaR1ysjzMyM2LWY/VZXuVH1OdUr7xYn + Kq4q0BtGAWNzOzO8jJjhtPmacVJA1wgI53nik42nE7an4vcinFDJzwqFFdK0abWv + XMZ3E36XT/+QY8GZ9Y5fEBOegfS1DyQHoSgqbJONS/cfRe8NSTc1+e7JfeRNUtYD + SEk6sham0RXnTHLCOhP59DxX5RWY9oNgIBSXRa5ZS9AOx0PIMx0ZaSBBh2vsAe9j + rnOkI8k/X1aqD1wo+t2wR7xFG7jPnnzqNOqbT/FSop6NCZ8eEPGWJi9Sc5Yks85J + N6XJwvFdLfY+VvQjqgqbh13mdcosA0JVAzqnEZI6wj02KSqAvm3Bfmfp5qnmE8LY + TEEBhYzXcwEkGoQ5Em6/+zhXnoFACdkSgB5yw0UmQDCNE3I94WQ+qQI/7PnoSqHg + KTRufnjHrGnVAoICAQDdfcYCyeukOD0AhT8ji0w7XvldVSrND+0TOjj+ZTb7Dy90 + Qsj9n4zCZ2zgkBgP1GNCh65G8MrcijcKmEusI8+7SuFcq2KGBaFCxgt3S4+7VkGU + V+697TXsnfBDta+m5wdVwR8GbcP48YENCR7t//ee+apd+l307r2qF+8fF7N4H0Bc + 4ektYgg0K1xablgR25jZ8nQLBMQBALAcxG/qUQ/1E+VVHU1UGmCuYMe7Ik2J1rDl + Z80X1CtmW1ty4U1SXKUvzHOSi7cObmGamgNWZEO+FhP5kLdFi+odHmCnvQTkRdj+ + qX3z048IgnZx+bN4CRo865CLmn+VwzzcYueZyyq749u+Dbc9h62nZTm6ZrXoL/xn + P/eDnIvRXpKengM7rYBmmolXlbzdnk/GKDIAWIpA50+vUrYz6D7fA8Rjf2pNhJwQ + mrlioWmdxCYTQgh/W2V6NIWYOCiujirYIqjjKWJszeGqGWwY8Q4nxYrHz/co7H+C + zAR7w04qWqG9Ba7DfuBDopT7fC9k1XrxyAOZbjWVJ8XE3S16wdKnxlOujgAmg382 + 9FyN2uOCuIasNPaylYhVcxhNwzcGMwpTIW+kBaUU5NUcnCxruye6nUYhayRJL39R + z1xEUcmO+zhYVvO2Qrm9Aghll3SQAswnAbbjDShoqBld+zqD37RFsdgqNC96GwKC + AgEAr615eNs1qEOO9DKssf0wOZfzSHFMX0i7sHEjqk7WHnHFEZSWU2YkDLyvWPOe + cX/smET5eJ0I9H9t862i8SgpXoDSzRugf9kcl5ODQFzARi2+8eMC/FWu59UpWpaY + AZozQfYfiMhtJBudIIbbOUXTk8HY13gt/UBL0FeErN1dDQ9EMXLDy8R23R7ZBsH+ + qg5Kpp4+aA057mfz5h9M5infFGt2h8jsgN6FoHgFQAOnCvYgL0/6SV/rQV/Uj7Al + zN0jZfbNsfYW9Bm1ToIK/S4hDfjca37LGvY2KrrWutQL/qCoskRQb3XYN5PKfK73 + AE1bE1OLYI9vRR+02qw+ZLPuQIyrVa061etQLYOI4eoK0ldlOxw+9S5zt8iMsi4h + VskCZVmgeitUFYY1oTSsvLOiGz87hUZjwGhpqP6k/duV/K2p0xPY8UgqLTo9x/QL + z0BKNIMXjfSCe4SvcwkZye28I9psDAb3aUt9HrZhS4zwc0XaOjpE8VpaLJx1Osla + BuRVKnzuo3woIMmpuAXvftAHreO+M/8LBt8G30u1flIpeKvRLLt6+gbNq90mRIbP + BkGgwPv5C8JLzFtiI9CiMl9P88jahRBKsoJFMKoyqbGvdNn9XfUGxACU+zbEfR5u + J/Qfq3YLFmOZtDIWkPvmE/GC2d0VJrhFXdeZR/FAXASLnzECggIAGcuDDxovnC5W + fxNR3xAHx7iLlxmAvYMJ8+zul6kPhymyJ5ib36RyhbdV+53sKssC9r2xesOivI1D + y4gUafqzEhZfNf3cvedCh5CVqWbAIjasYSSa/ZCqbZW7/wA1Zs4pG8xFXZKdLybo + jwDzidZ+NokW7mXZL9TTFqtOm3ShhfGZOMdx8TtAnn2iiemYTHjo3xuRROU3M0xa + ADbzpJl6/+pYNOt4VxghHNqcUFdQwqnnnueHCVRwCq3rtxsXPFULUZKF6KhZlu99 + nmO3zVn85Os7lGa383RZHcu43LwxuXsAMHgay1Def83kwnKda7lKel/eWw7Cdj2v + uVr3V27aM1ZKOWYivm3aodlYLrhwcnqczkzAU1uP5PHZhJrUY3okZb/cPFShi4ok + ExpntdKzXVXg2zDdB8GyeqZ5ba6zpkoFxBBwMFWgd+PajLFvN7lHEC9BrFg817hT + vjPpz7M6hZmLLCkrIPA8lgM2r3AJF0Uu2IgshwTMP4TmLwMPDkDfctp3qWR4EA92 + DH76UVaDfcfE0WSCh1Znk/2DPxVv8yYVK0elqpM+JiS0xsLvzksTk39rbv7qdy6f + 1EoJJFfrfqvrowyGG0f946bb5k2nsNFjKwHexQMpYx35pGbSp1CCOHqzLIS+LjOb + c5RerXbZTDEafdHsyGlkhu+nOjYvyGM= + -----END PRIVATE KEY----- + KEY + end + end + trait :instance_serverless do wildcard { true } scope { :instance } diff --git a/spec/factories/project_alerting_settings.rb b/spec/factories/project_alerting_settings.rb index 2c8ca7c70a8..ef0beb6b98a 100644 --- a/spec/factories/project_alerting_settings.rb +++ b/spec/factories/project_alerting_settings.rb @@ -3,6 +3,24 @@ FactoryBot.define do factory :project_alerting_setting, class: 'Alerting::ProjectAlertingSetting' do project - token { 'access_token_123' } + token { SecureRandom.hex } + + # Remove in next required stop after %16.4 + # https://gitlab.com/gitlab-org/gitlab/-/issues/338838 + transient do + sync_http_integrations { false } + end + + trait :with_http_integration do + sync_http_integrations { true } + end + + # for simplicity, let factory exclude the AlertManagement::HttpIntegration + # created in after_commit callback on model + after(:create) do |setting, evaluator| + next if evaluator.sync_http_integrations + + setting.project.alert_management_http_integrations.last!.destroy! + end end end diff --git a/spec/factories/project_authorizations.rb b/spec/factories/project_authorizations.rb index 1726da55c99..4e4330b37a6 100644 --- a/spec/factories/project_authorizations.rb +++ b/spec/factories/project_authorizations.rb @@ -7,7 +7,9 @@ FactoryBot.define do access_level { Gitlab::Access::REPORTER } end - trait :owner do - access_level { Gitlab::Access::OWNER } - end + trait(:guest) { access_level { Gitlab::Access::GUEST } } + trait(:reporter) { access_level { Gitlab::Access::REPORTER } } + trait(:developer) { access_level { Gitlab::Access::DEVELOPER } } + trait(:maintainer) { access_level { Gitlab::Access::MAINTAINER } } + trait(:owner) { access_level { Gitlab::Access::OWNER } } end diff --git a/spec/factories/project_metrics_settings.rb b/spec/factories/project_metrics_settings.rb deleted file mode 100644 index b5c0fd88a6c..00000000000 --- a/spec/factories/project_metrics_settings.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :project_metrics_setting, class: 'ProjectMetricsSetting' do - project - external_dashboard_url { 'https://grafana.com' } - end -end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 0111083298c..443bca6030c 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -535,7 +535,7 @@ FactoryBot.define do factory :project_with_design, parent: :project do after(:create) do |project| issue = create(:issue, project: project) - create(:design, project: project, issue: issue) + create(:design, :with_file, project: project, issue: issue) end end diff --git a/spec/factories/self_managed_prometheus_alert_event.rb b/spec/factories/self_managed_prometheus_alert_event.rb deleted file mode 100644 index 3a48aba5f54..00000000000 --- a/spec/factories/self_managed_prometheus_alert_event.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :self_managed_prometheus_alert_event do - project - sequence(:payload_key) { |n| "hash payload key #{n}" } - status { SelfManagedPrometheusAlertEvent.status_value_for(:firing) } - title { 'alert' } - query_expression { 'vector(2)' } - started_at { Time.now } - end -end diff --git a/spec/factories/service_desk/custom_email_verification.rb b/spec/factories/service_desk/custom_email_verification.rb index a3b72da2e9e..4c3322169fa 100644 --- a/spec/factories/service_desk/custom_email_verification.rb +++ b/spec/factories/service_desk/custom_email_verification.rb @@ -11,5 +11,10 @@ FactoryBot.define do trait :overdue do triggered_at { (ServiceDesk::CustomEmailVerification::TIMEFRAME + 1).minutes.ago } end + + trait :finished do + state { 'finished' } + token { nil } + end end end diff --git a/spec/factories/usage_data.rb b/spec/factories/usage_data.rb index 0e944b90d0c..1084891b07f 100644 --- a/spec/factories/usage_data.rb +++ b/spec/factories/usage_data.rb @@ -35,8 +35,8 @@ FactoryBot.define do create(:custom_issue_tracker_integration, project: projects[2], active: true) create(:project_error_tracking_setting, project: projects[0]) create(:project_error_tracking_setting, project: projects[1], enabled: false) - alert_bot_issues = create_list(:incident, 2, project: projects[0], author: User.alert_bot) - create_list(:incident, 2, project: projects[1], author: User.alert_bot) + alert_bot_issues = create_list(:incident, 2, project: projects[0], author: Users::Internal.alert_bot) + create_list(:incident, 2, project: projects[1], author: Users::Internal.alert_bot) issues = create_list(:issue, 4, project: projects[0]) create_list(:prometheus_alert, 2, project: projects[0]) create(:prometheus_alert, project: projects[1]) @@ -62,7 +62,6 @@ FactoryBot.define do # Alert Issues create(:alert_management_alert, issue: issues[0], project: projects[0]) create(:alert_management_alert, issue: alert_bot_issues[0], project: projects[0]) - create(:self_managed_prometheus_alert_event, related_issues: [issues[1]], project: projects[0]) # Kubernetes agents create(:cluster_agent, project: projects[0]) diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 67c857165fc..d61d5cc2d78 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -126,6 +126,10 @@ FactoryBot.define do end end + trait :no_super_sidebar do + use_new_navigation { false } + end + trait :two_factor_via_webauthn do transient { registrations_count { 5 } } diff --git a/spec/factories/users/group_visit.rb b/spec/factories/users/group_visit.rb new file mode 100644 index 00000000000..a98ee61332a --- /dev/null +++ b/spec/factories/users/group_visit.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :group_visit, class: 'Users::GroupVisit' do + transient { target_user { association(:user) } } + transient { target_group { association(:group) } } + + user_id { target_user.id } + entity_id { target_group.id } + visited_at { Time.now } + end +end diff --git a/spec/factories/users/project_visit.rb b/spec/factories/users/project_visit.rb new file mode 100644 index 00000000000..40ead720061 --- /dev/null +++ b/spec/factories/users/project_visit.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :project_visit, class: 'Users::ProjectVisit' do + transient { target_user { association(:user) } } + transient { target_project { association(:project) } } + + user_id { target_user.id } + entity_id { target_project.id } + visited_at { Time.now } + end +end diff --git a/spec/factories/work_items.rb b/spec/factories/work_items.rb index 4a2186f2fcf..1827f2a590b 100644 --- a/spec/factories/work_items.rb +++ b/spec/factories/work_items.rb @@ -26,6 +26,18 @@ FactoryBot.define do closed_at { Time.now } end + trait :group_level do + project { nil } + association :namespace, factory: :group + association :author, factory: :user + end + + trait :user_namespace_level do + project { nil } + association :namespace, factory: :user_namespace + association :author, factory: :user + end + trait :issue do association :work_item_type, :default, :issue end diff --git a/spec/features/abuse_report_spec.rb b/spec/features/abuse_report_spec.rb index f934736ced9..f1df5c2d6f0 100644 --- a/spec/features/abuse_report_spec.rb +++ b/spec/features/abuse_report_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' RSpec.describe 'Abuse reports', :js, feature_category: :insider_threat do - let_it_be(:abusive_user) { create(:user) } + let_it_be(:abusive_user) { create(:user, :no_super_sidebar) } - let_it_be(:reporter1) { create(:user) } + let_it_be(:reporter1) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, :public, :repository) } let_it_be(:issue) { create(:issue, project: project, author: abusive_user) } @@ -57,7 +57,7 @@ RSpec.describe 'Abuse reports', :js, feature_category: :insider_threat do describe 'when user_profile_overflow_menu FF turned on' do context 'when reporting a user profile for abuse' do - let_it_be(:reporter2) { create(:user) } + let_it_be(:reporter2) { create(:user, :no_super_sidebar) } before do visit user_path(abusive_user) @@ -108,7 +108,7 @@ RSpec.describe 'Abuse reports', :js, feature_category: :insider_threat do describe 'when user_profile_overflow_menu FF turned off' do context 'when reporting a user profile for abuse' do - let_it_be(:reporter2) { create(:user) } + let_it_be(:reporter2) { create(:user, :no_super_sidebar) } before do stub_feature_flags(user_profile_overflow_menu_vue: false) diff --git a/spec/features/admin/admin_abuse_reports_spec.rb b/spec/features/admin/admin_abuse_reports_spec.rb index 18bc851558d..973988560b3 100644 --- a/spec/features/admin/admin_abuse_reports_spec.rb +++ b/spec/features/admin/admin_abuse_reports_spec.rb @@ -11,291 +11,195 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :insider_threat do let_it_be(:closed_report) { create(:abuse_report, :closed, user: user, category: 'spam') } describe 'as an admin' do + include FilteredSearchHelpers + before do sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) - end - - context 'when abuse_reports_list feature flag is enabled' do - include FilteredSearchHelpers - - before do - visit admin_abuse_reports_path - end - - let(:abuse_report_row_selector) { '[data-testid="abuse-report-row"]' } - - it 'only includes open reports by default' do - expect_displayed_reports_count(2) - - expect_report_shown(open_report, open_report2) - - within '[data-testid="abuse-reports-filtered-search-bar"]' do - expect(page).to have_content 'Status = Open' - end - end - - it 'can be filtered by status, user, reporter, and category', :aggregate_failures do - # filter by status - filter %w[Status Closed] - expect_displayed_reports_count(1) - expect_report_shown(closed_report) - expect_report_not_shown(open_report, open_report2) - - filter %w[Status Open] - expect_displayed_reports_count(2) - expect_report_shown(open_report, open_report2) - expect_report_not_shown(closed_report) - - # filter by user - filter(['User', open_report2.user.username]) - expect_displayed_reports_count(1) - expect_report_shown(open_report2) - expect_report_not_shown(open_report, closed_report) + visit admin_abuse_reports_path + end - # filter by reporter - filter(['Reporter', open_report.reporter.username]) + let(:abuse_report_row_selector) { '[data-testid="abuse-report-row"]' } - expect_displayed_reports_count(1) - expect_report_shown(open_report) - expect_report_not_shown(open_report2, closed_report) + it 'only includes open reports by default' do + expect_displayed_reports_count(2) - # filter by category - filter(['Category', open_report2.category]) + expect_report_shown(open_report, open_report2) - expect_displayed_reports_count(1) - expect_report_shown(open_report2) - expect_report_not_shown(open_report, closed_report) + within_testid('abuse-reports-filtered-search-bar') do + expect(page).to have_content 'Status = Open' end + end - it 'can be sorted by created_at and updated_at in desc and asc order', :aggregate_failures do - sort_by 'Created date' - # created_at desc - expect(report_rows[0].text).to include(report_text(open_report2)) - expect(report_rows[1].text).to include(report_text(open_report)) - - # created_at asc - toggle_sort_direction - - expect(report_rows[0].text).to include(report_text(open_report)) - expect(report_rows[1].text).to include(report_text(open_report2)) + it 'can be filtered by status, user, reporter, and category', :aggregate_failures do + # filter by status + filter %w[Status Closed] + expect_displayed_reports_count(1) + expect_report_shown(closed_report) + expect_report_not_shown(open_report, open_report2) - # updated_at asc - sort_by 'Updated date' + filter %w[Status Open] + expect_displayed_reports_count(2) + expect_report_shown(open_report, open_report2) + expect_report_not_shown(closed_report) - expect(report_rows[0].text).to include(report_text(open_report2)) - expect(report_rows[1].text).to include(report_text(open_report)) + # filter by user + filter(['User', open_report2.user.username]) - # updated_at desc - toggle_sort_direction + expect_displayed_reports_count(1) + expect_report_shown(open_report2) + expect_report_not_shown(open_report, closed_report) - expect(report_rows[0].text).to include(report_text(open_report)) - expect(report_rows[1].text).to include(report_text(open_report2)) - end + # filter by reporter + filter(['Reporter', open_report.reporter.username]) - context 'when multiple reports for the same user are created' do - let_it_be(:open_report3) { create(:abuse_report, category: 'spam', user: user) } - let_it_be(:closed_report2) { create(:abuse_report, :closed, user: user, category: 'spam') } + expect_displayed_reports_count(1) + expect_report_shown(open_report) + expect_report_not_shown(open_report2, closed_report) - it 'aggregates open reports by user & category', :aggregate_failures do - expect_displayed_reports_count(2) + # filter by category + filter(['Category', open_report2.category]) - expect_aggregated_report_shown(open_report, 2) - expect_report_shown(open_report2) - end + expect_displayed_reports_count(1) + expect_report_shown(open_report2) + expect_report_not_shown(open_report, closed_report) + end - it 'can sort aggregated reports by number_of_reports in desc order only', :aggregate_failures do - sort_by 'Number of Reports' + it 'can be sorted by created_at and updated_at in desc and asc order', :aggregate_failures do + sort_by 'Created date' + # created_at desc + expect(report_rows[0].text).to include(report_text(open_report2)) + expect(report_rows[1].text).to include(report_text(open_report)) - expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) - expect(report_rows[1].text).to include(report_text(open_report2)) + # created_at asc + toggle_sort_direction - toggle_sort_direction + expect(report_rows[0].text).to include(report_text(open_report)) + expect(report_rows[1].text).to include(report_text(open_report2)) - expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) - expect(report_rows[1].text).to include(report_text(open_report2)) - end + # updated_at asc + sort_by 'Updated date' - it 'can sort aggregated reports by created_at and updated_at in desc and asc order', :aggregate_failures do - # number_of_reports desc (default) - expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) - expect(report_rows[1].text).to include(report_text(open_report2)) + expect(report_rows[0].text).to include(report_text(open_report2)) + expect(report_rows[1].text).to include(report_text(open_report)) - # created_at desc - sort_by 'Created date' + # updated_at desc + toggle_sort_direction - expect(report_rows[0].text).to include(report_text(open_report2)) - expect(report_rows[1].text).to include(aggregated_report_text(open_report, 2)) - - # created_at asc - toggle_sort_direction + expect(report_rows[0].text).to include(report_text(open_report)) + expect(report_rows[1].text).to include(report_text(open_report2)) + end - expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) - expect(report_rows[1].text).to include(report_text(open_report2)) + context 'when multiple reports for the same user are created' do + let_it_be(:open_report3) { create(:abuse_report, category: 'spam', user: user) } + let_it_be(:closed_report2) { create(:abuse_report, :closed, user: user, category: 'spam') } - sort_by 'Updated date' + it 'aggregates open reports by user & category', :aggregate_failures do + expect_displayed_reports_count(2) - # updated_at asc - expect(report_rows[0].text).to include(report_text(open_report2)) - expect(report_rows[1].text).to include(aggregated_report_text(open_report, 2)) + expect_aggregated_report_shown(open_report, 2) + expect_report_shown(open_report2) + end - # updated_at desc - toggle_sort_direction + it 'can sort aggregated reports by number_of_reports in desc order only', :aggregate_failures do + sort_by 'Number of Reports' - expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) - expect(report_rows[1].text).to include(report_text(open_report2)) - end + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) - it 'does not aggregate closed reports', :aggregate_failures do - filter %w[Status Closed] + toggle_sort_direction - expect_displayed_reports_count(2) - expect_report_shown(closed_report, closed_report2) - end + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) end - def report_rows - page.all(abuse_report_row_selector) - end + it 'can sort aggregated reports by created_at and updated_at in desc and asc order', :aggregate_failures do + # number_of_reports desc (default) + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) - def report_text(report) - "#{report.user.name} reported for #{report.category} by #{report.reporter.name}" - end + # created_at desc + sort_by 'Created date' - def aggregated_report_text(report, count) - "#{report.user.name} reported for #{report.category} by #{count} users" - end + expect(report_rows[0].text).to include(report_text(open_report2)) + expect(report_rows[1].text).to include(aggregated_report_text(open_report, 2)) - def expect_report_shown(*reports) - reports.each do |r| - expect(page).to have_content(report_text(r)) - end - end + # created_at asc + toggle_sort_direction - def expect_report_not_shown(*reports) - reports.each do |r| - expect(page).not_to have_content(report_text(r)) - end - end + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) - def expect_aggregated_report_shown(*reports, count) - reports.each do |r| - expect(page).to have_content(aggregated_report_text(r, count)) - end - end + sort_by 'Updated date' - def expect_displayed_reports_count(count) - expect(page).to have_css(abuse_report_row_selector, count: count) - end + # updated_at asc + expect(report_rows[0].text).to include(report_text(open_report2)) + expect(report_rows[1].text).to include(aggregated_report_text(open_report, 2)) - def filter(tokens) - # remove all existing filters first - page.find_all('.gl-token-close').each(&:click) + # updated_at desc + toggle_sort_direction - select_tokens(*tokens, submit: true, input_text: 'Filter reports') + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) end - def sort_by(sort) - page.within('.vue-filtered-search-bar-container .sort-dropdown-container') do - page.find('.gl-dropdown-toggle').click + it 'does not aggregate closed reports', :aggregate_failures do + filter %w[Status Closed] - page.within('.dropdown-menu') do - click_button sort - wait_for_requests - end - end + expect_displayed_reports_count(2) + expect_report_shown(closed_report, closed_report2) end end - context 'when abuse_reports_list feature flag is disabled' do - before do - stub_feature_flags(abuse_reports_list: false) - - visit admin_abuse_reports_path - end + def report_rows + page.all(abuse_report_row_selector) + end - it 'displays all abuse reports', :aggregate_failures do - expect_report_shown(open_report) - expect_report_actions_shown(open_report) + def report_text(report) + "#{report.user.name} reported for #{report.category} by #{report.reporter.name}" + end - expect_report_shown(open_report2) - expect_report_actions_shown(open_report2) + def aggregated_report_text(report, count) + "#{report.user.name} reported for #{report.category} by #{count} users" + end - expect_report_shown(closed_report) - expect_report_actions_shown(closed_report) + def expect_report_shown(*reports) + reports.each do |r| + expect(page).to have_content(report_text(r)) end + end - context 'when an admin has been reported for abuse' do - let_it_be(:admin_abuse_report) { create(:abuse_report, user: admin) } - - it 'displays the abuse report without actions' do - expect_report_shown(admin_abuse_report) - expect_report_actions_not_shown(admin_abuse_report) - end + def expect_report_not_shown(*reports) + reports.each do |r| + expect(page).not_to have_content(report_text(r)) end + end - context 'when multiple users have been reported for abuse' do - let(:report_count) { AbuseReport.default_per_page + 3 } - - before do - report_count.times do - create(:abuse_report, user: create(:user)) - end - end - - context 'in the abuse report view', :aggregate_failures do - it 'adds pagination' do - visit admin_abuse_reports_path - - expect(page).to have_selector('.pagination') - expect(page).to have_selector('.pagination .js-pagination-page', count: (report_count.to_f / AbuseReport.default_per_page).ceil) - end - end + def expect_aggregated_report_shown(*reports, count) + reports.each do |r| + expect(page).to have_content(aggregated_report_text(r, count)) end + end - context 'when filtering reports' do - it 'can be filtered by reported-user', :aggregate_failures do - visit admin_abuse_reports_path - - page.within '.filter-form' do - click_button 'User' - wait_for_requests - - page.within '.dropdown-menu-user' do - click_link user.name - end - - wait_for_requests - end + def expect_displayed_reports_count(count) + expect(page).to have_css(abuse_report_row_selector, count: count) + end - expect_report_shown(open_report) - expect_report_shown(closed_report) - end - end + def filter(tokens) + # remove all existing filters first + page.find_all('.gl-token-close').each(&:click) - def expect_report_shown(report) - page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do - expect(page).to have_content(report.user.name) - expect(page).to have_content(report.reporter.name) - expect(page).to have_content(report.message) - expect(page).to have_link(report.user.name, href: user_path(report.user)) - end - end + select_tokens(*tokens, submit: true, input_text: 'Filter reports') + end - def expect_report_actions_shown(report) - page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do - expect(page).to have_link('Remove user & report') - expect(page).to have_link('Block user') - expect(page).to have_link('Remove user') - end - end + def sort_by(sort) + page.within('.vue-filtered-search-bar-container .sort-dropdown-container') do + page.find('.gl-dropdown-toggle').click - def expect_report_actions_not_shown(report) - page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do - expect(page).not_to have_link('Remove user & report') - expect(page).not_to have_link('Block user') - expect(page).not_to have_link('Remove user') + page.within('.dropdown-menu') do + click_button sort + wait_for_requests end end end diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index b4f64cbfa7b..a5acba1fe4a 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Admin::Hooks', feature_category: :webhooks do include Spec::Support::Helpers::ModalHelpers - let_it_be(:user) { create(:admin) } + let_it_be(:user) { create(:admin, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/admin/admin_jobs_spec.rb b/spec/features/admin/admin_jobs_spec.rb index d46b314c144..b305bec6493 100644 --- a/spec/features/admin/admin_jobs_spec.rb +++ b/spec/features/admin/admin_jobs_spec.rb @@ -2,9 +2,8 @@ require 'spec_helper' -RSpec.describe 'Admin Jobs', feature_category: :continuous_integration do +RSpec.describe 'Admin Jobs', :js, feature_category: :continuous_integration do before do - stub_feature_flags(admin_jobs_vue: false) admin = create(:admin) sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) @@ -23,12 +22,13 @@ RSpec.describe 'Admin Jobs', feature_category: :continuous_integration do visit admin_jobs_path - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'All') - expect(page).to have_selector('.row-content-block', text: 'All jobs') - expect(page.all('.build-link').size).to eq(4) - expect(page).to have_button 'Stop all jobs' + wait_for_requests - click_button 'Stop all jobs' + expect(page).to have_selector('[data-testid="jobs-all-tab"]') + expect(page.all('[data-testid="jobs-table-row"]').size).to eq(4) + expect(page).to have_button 'Cancel all jobs' + + click_button 'Cancel all jobs' expect(page).to have_button 'Yes, proceed' expect(page).to have_content 'Are you sure?' end @@ -38,73 +38,11 @@ RSpec.describe 'Admin Jobs', feature_category: :continuous_integration do it 'shows a message' do visit admin_jobs_path - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'All') - expect(page).to have_content 'No jobs to show' - expect(page).not_to have_button 'Stop all jobs' - end - end - end - - context 'Pending tab' do - context 'when have pending jobs' do - it 'shows pending jobs' do - build1 = create(:ci_build, pipeline: pipeline, status: :pending) - build2 = create(:ci_build, pipeline: pipeline, status: :running) - build3 = create(:ci_build, pipeline: pipeline, status: :success) - build4 = create(:ci_build, pipeline: pipeline, status: :failed) - - visit admin_jobs_path(scope: :pending) - - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Pending') - expect(page.find('.build-link')).to have_content(build1.id) - expect(page.find('.build-link')).not_to have_content(build2.id) - expect(page.find('.build-link')).not_to have_content(build3.id) - expect(page.find('.build-link')).not_to have_content(build4.id) - expect(page).to have_button 'Stop all jobs' - end - end - - context 'when have no jobs pending' do - it 'shows a message' do - create(:ci_build, pipeline: pipeline, status: :success) - - visit admin_jobs_path(scope: :pending) - - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Pending') - expect(page).to have_content 'No jobs to show' - expect(page).not_to have_button 'Stop all jobs' - end - end - end - - context 'Running tab' do - context 'when have running jobs' do - it 'shows running jobs' do - build1 = create(:ci_build, pipeline: pipeline, status: :running) - build2 = create(:ci_build, pipeline: pipeline, status: :success) - build3 = create(:ci_build, pipeline: pipeline, status: :failed) - build4 = create(:ci_build, pipeline: pipeline, status: :pending) - - visit admin_jobs_path(scope: :running) - - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Running') - expect(page.find('.build-link')).to have_content(build1.id) - expect(page.find('.build-link')).not_to have_content(build2.id) - expect(page.find('.build-link')).not_to have_content(build3.id) - expect(page.find('.build-link')).not_to have_content(build4.id) - expect(page).to have_button 'Stop all jobs' - end - end - - context 'when have no jobs running' do - it 'shows a message' do - create(:ci_build, pipeline: pipeline, status: :success) - - visit admin_jobs_path(scope: :running) + wait_for_requests - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Running') - expect(page).to have_content 'No jobs to show' - expect(page).not_to have_button 'Stop all jobs' + expect(page).to have_selector('[data-testid="jobs-all-tab"]') + expect(page).to have_selector('[data-testid="jobs-empty-state"]') + expect(page).not_to have_button 'Cancel all jobs' end end end @@ -116,13 +54,19 @@ RSpec.describe 'Admin Jobs', feature_category: :continuous_integration do build2 = create(:ci_build, pipeline: pipeline, status: :running) build3 = create(:ci_build, pipeline: pipeline, status: :success) - visit admin_jobs_path(scope: :finished) + visit admin_jobs_path + + wait_for_requests + + find_by_testid('jobs-finished-tab').click + + wait_for_requests - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Finished') - expect(page.find('.build-link')).not_to have_content(build1.id) - expect(page.find('.build-link')).not_to have_content(build2.id) - expect(page.find('.build-link')).to have_content(build3.id) - expect(page).to have_button 'Stop all jobs' + expect(page).to have_selector('[data-testid="jobs-finished-tab"]') + expect(find_by_testid('job-id-link')).not_to have_content(build1.id) + expect(find_by_testid('job-id-link')).not_to have_content(build2.id) + expect(find_by_testid('job-id-link')).to have_content(build3.id) + expect(page).to have_button 'Cancel all jobs' end end @@ -130,11 +74,17 @@ RSpec.describe 'Admin Jobs', feature_category: :continuous_integration do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :running) - visit admin_jobs_path(scope: :finished) + visit admin_jobs_path + + wait_for_requests + + find_by_testid('jobs-finished-tab').click + + wait_for_requests - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Finished') + expect(page).to have_selector('[data-testid="jobs-finished-tab"]') expect(page).to have_content 'No jobs to show' - expect(page).to have_button 'Stop all jobs' + expect(page).to have_button 'Cancel all jobs' end end end diff --git a/spec/features/admin/admin_mode/logout_spec.rb b/spec/features/admin/admin_mode/logout_spec.rb index a64d3f241f6..5d9106fea02 100644 --- a/spec/features/admin/admin_mode/logout_spec.rb +++ b/spec/features/admin/admin_mode/logout_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Admin Mode Logout', :js, feature_category: :system_access do include UserLoginHelper include Features::TopNavSpecHelpers - let(:user) { create(:admin) } + let(:user) { create(:admin, :no_super_sidebar) } before do # TODO: This used to use gitlab_sign_in, instead of sign_in, but that is buggy. See diff --git a/spec/features/admin/admin_mode/workers_spec.rb b/spec/features/admin/admin_mode/workers_spec.rb index 124c43eef9d..2a862c750d7 100644 --- a/spec/features/admin/admin_mode/workers_spec.rb +++ b/spec/features/admin/admin_mode/workers_spec.rb @@ -6,8 +6,8 @@ require 'spec_helper' RSpec.describe 'Admin mode for workers', :request_store, feature_category: :system_access do include Features::AdminUsersHelpers - let(:user) { create(:user) } - let(:user_to_delete) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } + let(:user_to_delete) { create(:user, :no_super_sidebar) } before do sign_in(user) @@ -22,7 +22,7 @@ RSpec.describe 'Admin mode for workers', :request_store, feature_category: :syst end context 'as an admin user' do - let(:user) { create(:admin) } + let(:user) { create(:admin, :no_super_sidebar) } context 'when admin mode disabled' do it 'cannot delete user', :js do diff --git a/spec/features/admin/admin_mode_spec.rb b/spec/features/admin/admin_mode_spec.rb index 65249fa0235..edfa58567ad 100644 --- a/spec/features/admin/admin_mode_spec.rb +++ b/spec/features/admin/admin_mode_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Admin mode', :js, feature_category: :shared do include Features::TopNavSpecHelpers include StubENV - let(:admin) { create(:admin) } + let(:admin) { create(:admin, :no_super_sidebar) } before do stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index af6ba318ac6..e0f4473c80c 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -54,6 +54,11 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do let(:runner) { instance_runner } end + it_behaves_like 'shows runner details from list' do + let(:runner) { instance_runner } + let(:runner_page_path) { admin_runner_path(instance_runner) } + end + it_behaves_like 'pauses, resumes and deletes a runner' do let(:runner) { instance_runner } end @@ -575,6 +580,8 @@ RSpec.describe "Admin Runners", feature_category: :runner_fleet do let(:runner_page_path) { admin_runner_path(project_runner) } end + it_behaves_like 'shows locked field' + describe 'breadcrumbs' do it 'contains the current runner id and token' do page.within '[data-testid="breadcrumb-links"]' do diff --git a/spec/features/admin/admin_sees_background_migrations_spec.rb b/spec/features/admin/admin_sees_background_migrations_spec.rb index 7d4d3deb6d8..7423e74bf3a 100644 --- a/spec/features/admin/admin_sees_background_migrations_spec.rb +++ b/spec/features/admin/admin_sees_background_migrations_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe "Admin > Admin sees background migrations", feature_category: :database do include ListboxHelpers - let_it_be(:admin) { create(:admin) } + let_it_be(:admin) { create(:admin, :no_super_sidebar) } let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob } let_it_be(:active_migration) { create(:batched_background_migration, :active, table_name: 'active') } diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index b78d6777a1a..e87f47e5234 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Admin updates settings', feature_category: :shared do include TermsHelper include UsageDataHelpers - let_it_be(:admin) { create(:admin) } + let_it_be(:admin) { create(:admin, :no_super_sidebar) } context 'application setting :admin_mode is enabled', :request_store do before do @@ -53,7 +53,7 @@ RSpec.describe 'Admin updates settings', feature_category: :shared do it 'modify import sources' do expect(current_settings.import_sources).to be_empty - page.within('[data-testid="admin-visibility-access-settings"]') do + page.within('[data-testid="admin-import-export-settings"]') do check "Repository by URL" click_button 'Save changes' end @@ -63,7 +63,7 @@ RSpec.describe 'Admin updates settings', feature_category: :shared do end it 'change Visibility and Access Controls' do - page.within('[data-testid="admin-visibility-access-settings"]') do + page.within('[data-testid="admin-import-export-settings"]') do page.within('[data-testid="project-export"]') do uncheck 'Enabled' end @@ -113,7 +113,7 @@ RSpec.describe 'Admin updates settings', feature_category: :shared do end it 'change Maximum export size' do - page.within(find('[data-testid="account-limit"]')) do + page.within(find('[data-testid="admin-import-export-settings"]')) do fill_in 'Maximum export size (MiB)', with: 25 click_button 'Save changes' end @@ -123,7 +123,7 @@ RSpec.describe 'Admin updates settings', feature_category: :shared do end it 'change Maximum import size' do - page.within(find('[data-testid="account-limit"]')) do + page.within(find('[data-testid="admin-import-export-settings"]')) do fill_in 'Maximum import size (MiB)', with: 15 click_button 'Save changes' end diff --git a/spec/features/admin/users/user_spec.rb b/spec/features/admin/users/user_spec.rb index a95fd133133..7dc329e6909 100644 --- a/spec/features/admin/users/user_spec.rb +++ b/spec/features/admin/users/user_spec.rb @@ -6,8 +6,8 @@ RSpec.describe 'Admin::Users::User', feature_category: :user_management do include Features::AdminUsersHelpers include Spec::Support::Helpers::ModalHelpers - let_it_be(:user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') } - let_it_be(:current_user) { create(:admin) } + let_it_be(:user) { create(:omniauth_user, :no_super_sidebar, provider: 'twitter', extern_uid: '123456') } + let_it_be(:current_user) { create(:admin, :no_super_sidebar) } before do sign_in(current_user) @@ -145,7 +145,7 @@ RSpec.describe 'Admin::Users::User', feature_category: :user_management do end describe 'Impersonation' do - let_it_be(:another_user) { create(:user) } + let_it_be(:another_user) { create(:user, :no_super_sidebar) } context 'before impersonating' do subject { visit admin_user_path(user_to_visit) } @@ -156,7 +156,7 @@ RSpec.describe 'Admin::Users::User', feature_category: :user_management do it 'disables impersonate button' do subject - impersonate_btn = find('[data-testid="impersonate_user_link"]') + impersonate_btn = find('[data-testid="impersonate-user-link"]') expect(impersonate_btn).not_to be_nil expect(impersonate_btn['disabled']).not_to be_nil @@ -174,7 +174,7 @@ RSpec.describe 'Admin::Users::User', feature_category: :user_management do subject expect(page).to have_content('Impersonate') - impersonate_btn = find('[data-testid="impersonate_user_link"]') + impersonate_btn = find('[data-testid="impersonate-user-link"]') expect(impersonate_btn['disabled']).to be_nil end end diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb index 8e80ce5edd9..8ee30c50a7d 100644 --- a/spec/features/admin/users/users_spec.rb +++ b/spec/features/admin/users/users_spec.rb @@ -350,7 +350,8 @@ RSpec.describe 'Admin::Users', feature_category: :user_management do let_it_be(:ghost_user) { create(:user, :ghost) } it 'does not render actions dropdown' do - expect(page).not_to have_css("[data-testid='user-actions-#{ghost_user.id}'] [data-testid='dropdown-toggle']") + expect(page).not_to have_css( + "[data-testid='user-actions-#{ghost_user.id}'] [data-testid='user-actions-dropdown-toggle']") end end @@ -358,7 +359,8 @@ RSpec.describe 'Admin::Users', feature_category: :user_management do let_it_be(:bot_user) { create(:user, user_type: :alert_bot) } it 'does not render actions dropdown' do - expect(page).not_to have_css("[data-testid='user-actions-#{bot_user.id}'] [data-testid='dropdown-toggle']") + expect(page).not_to have_css( + "[data-testid='user-actions-#{bot_user.id}'] [data-testid='user-actions-dropdown-toggle']") end end end diff --git a/spec/features/alert_management/alert_details_spec.rb b/spec/features/alert_management/alert_details_spec.rb index 45fa4d810aa..b377d3a092b 100644 --- a/spec/features/alert_management/alert_details_spec.rb +++ b/spec/features/alert_management/alert_details_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'Alert details', :js, feature_category: :incident_management do let_it_be(:project) { create(:project) } - let_it_be(:developer) { create(:user) } + let_it_be(:developer) { create(:user, :no_super_sidebar) } let_it_be(:alert) { create(:alert_management_alert, project: project, status: 'triggered', title: 'Alert') } before_all do diff --git a/spec/features/alert_management/alert_management_list_spec.rb b/spec/features/alert_management/alert_management_list_spec.rb index 6ed3bdec5f5..cc54af249e1 100644 --- a/spec/features/alert_management/alert_management_list_spec.rb +++ b/spec/features/alert_management/alert_management_list_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'Alert Management index', :js, feature_category: :incident_management do let_it_be(:project) { create(:project) } - let_it_be(:developer) { create(:user) } + let_it_be(:developer) { create(:user, :no_super_sidebar) } before_all do project.add_developer(developer) diff --git a/spec/features/boards/board_filters_spec.rb b/spec/features/boards/board_filters_spec.rb index 006b7ce45d4..1ee02de9a66 100644 --- a/spec/features/boards/board_filters_spec.rb +++ b/spec/features/boards/board_filters_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' RSpec.describe 'Issue board filters', :js, feature_category: :team_planning do - let_it_be(:project) { create(:project, :repository) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, group: group) } let_it_be(:user) { create(:user) } - let_it_be(:board) { create(:board, project: project) } let_it_be(:project_label) { create(:label, project: project, title: 'Label') } let_it_be(:milestone_1) { create(:milestone, project: project, due_date: 3.days.from_now) } let_it_be(:milestone_2) { create(:milestone, project: project, due_date: Date.tomorrow) } @@ -21,166 +21,195 @@ RSpec.describe 'Issue board filters', :js, feature_category: :team_planning do let(:filter_first_suggestion) { find('.gl-filtered-search-suggestion-list').first('.gl-filtered-search-suggestion') } let(:filter_submit) { find('.gl-search-box-by-click-search-button') } - before do - stub_feature_flags(apollo_boards: false) - project.add_maintainer(user) - sign_in(user) + context 'for a project board' do + let_it_be(:board) { create(:board, project: project) } - visit_project_board - end - - shared_examples 'loads all the users when opened' do - it 'and submit one as filter', :aggregate_failures do - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) + before do + stub_feature_flags(apollo_boards: false) + project.add_maintainer(user) + sign_in(user) + visit project_board_path(project, board) wait_for_requests + end - expect_filtered_search_dropdown_results(filter_dropdown, 4) + shared_examples 'loads all the users when opened' do + it 'and submit one as filter', :aggregate_failures do + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) - click_on user.username - filter_submit.click + wait_for_requests - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) - expect(find('.board-card')).to have_content(issue.title) - end - end + expect_filtered_search_dropdown_results(filter_dropdown, 3) - describe 'filters by assignee' do - before do - set_filter('assignee') - end + click_on user.username + filter_submit.click - it_behaves_like 'loads all the users when opened', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/351426' do - let(:issue) { issue_2 } + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) + expect(find('.board-card')).to have_content(issue.title) + end end - end - describe 'filters by author' do - before do - set_filter('author') - end + describe 'filters by assignee' do + before do + set_filter('assignee') + end - it_behaves_like 'loads all the users when opened' do - let(:issue) { issue_1 } + it_behaves_like 'loads all the users when opened', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/351426' do + let(:issue) { issue_2 } + end end - end - describe 'filters by label' do - before do - set_filter('label') + describe 'filters by author' do + before do + set_filter('author') + end + + it_behaves_like 'loads all the users when opened' do + let(:issue) { issue_1 } + end end - it 'loads all the labels when opened and submit one as filter', :aggregate_failures do - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) + describe 'filters by label' do + before do + set_filter('label') + end - expect_filtered_search_dropdown_results(filter_dropdown, 3) + it 'loads all the labels when opened and submit one as filter', :aggregate_failures do + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) - filter_dropdown.click_on project_label.title - filter_submit.click + expect_filtered_search_dropdown_results(filter_dropdown, 3) - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) - expect(find('.board-card')).to have_content(issue_2.title) - end - end + filter_dropdown.click_on project_label.title + filter_submit.click - describe 'filters by releases' do - before do - set_filter('release') + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) + expect(find('.board-card')).to have_content(issue_2.title) + end end - it 'loads all the releases when opened and submit one as filter', :aggregate_failures do - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) + describe 'filters by releases' do + before do + set_filter('release') + end - expect_filtered_search_dropdown_results(filter_dropdown, 2) + it 'loads all the releases when opened and submit one as filter', :aggregate_failures do + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) - click_on release.tag - filter_submit.click + expect_filtered_search_dropdown_results(filter_dropdown, 2) - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) - expect(find('.board-card')).to have_content(issue_1.title) - end - end + click_on release.tag + filter_submit.click - describe 'filters by confidentiality' do - before do - filter_input.click - filter_input.set("confidential:") + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) + expect(find('.board-card')).to have_content(issue_1.title) + end end - it 'loads all the confidentiality options when opened and submit one as filter', :aggregate_failures do - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) + describe 'filters by confidentiality' do + before do + filter_input.click + filter_input.set("confidential:") + end - expect_filtered_search_dropdown_results(filter_dropdown, 2) + it 'loads all the confidentiality options when opened and submit one as filter', :aggregate_failures do + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) - filter_dropdown.click_on 'Yes' - filter_submit.click + expect_filtered_search_dropdown_results(filter_dropdown, 2) - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) - expect(find('.board-card')).to have_content(issue_2.title) - end - end + filter_dropdown.click_on 'Yes' + filter_submit.click - describe 'filters by milestone' do - before do - set_filter('milestone') + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) + expect(find('.board-card')).to have_content(issue_2.title) + end end - it 'loads all the milestones when opened and submit one as filter', :aggregate_failures do - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) + describe 'filters by milestone' do + before do + set_filter('milestone') + end + + it 'loads all the milestones when opened and submit one as filter', :aggregate_failures do + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) - expect_filtered_search_dropdown_results(filter_dropdown, 6) - expect(filter_dropdown).to have_content('None') - expect(filter_dropdown).to have_content('Any') - expect(filter_dropdown).to have_content('Started') - expect(filter_dropdown).to have_content('Upcoming') + expect_filtered_search_dropdown_results(filter_dropdown, 6) + expect(filter_dropdown).to have_content('None') + expect(filter_dropdown).to have_content('Any') + expect(filter_dropdown).to have_content('Started') + expect(filter_dropdown).to have_content('Upcoming') - dropdown_nodes = page.find_all('.gl-filtered-search-suggestion-list > .gl-filtered-search-suggestion') + dropdown_nodes = page.find_all('.gl-filtered-search-suggestion-list > .gl-filtered-search-suggestion') - expect(dropdown_nodes[4]).to have_content(milestone_2.title) - expect(dropdown_nodes.last).to have_content(milestone_1.title) + expect(dropdown_nodes[4]).to have_content(milestone_2.title) + expect(dropdown_nodes.last).to have_content(milestone_1.title) - click_on milestone_1.title - filter_submit.click + click_on milestone_1.title + filter_submit.click - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) + end end - end - describe 'filters by reaction emoji' do - before do - set_filter('my-reaction') + describe 'filters by reaction emoji' do + before do + set_filter('my-reaction') + end + + it 'loads all the emojis when opened and submit one as filter', :aggregate_failures do + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) + + expect_filtered_search_dropdown_results(filter_dropdown, 3) + + click_on 'thumbsup' + filter_submit.click + + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) + expect(find('.board-card')).to have_content(issue_1.title) + end end - it 'loads all the emojis when opened and submit one as filter', :aggregate_failures do - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 2) + describe 'filters by type' do + let_it_be(:incident) { create(:incident, project: project) } + + before do + set_filter('type') + end - expect_filtered_search_dropdown_results(filter_dropdown, 3) + it 'loads all the types when opened and submit one as filter', :aggregate_failures do + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 3) - click_on 'thumbsup' - filter_submit.click + expect_filtered_search_dropdown_results(filter_dropdown, 2) - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) - expect(find('.board-card')).to have_content(issue_1.title) + click_on 'Incident' + filter_submit.click + + expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) + expect(find('.board-card')).to have_content(incident.title) + end end end - describe 'filters by type' do - let_it_be(:incident) { create(:incident, project: project) } + context 'for a group board' do + let_it_be(:board) { create(:board, group: group) } + + let_it_be(:child_project_member) { create(:user).tap { |u| project.add_maintainer(u) } } before do - set_filter('type') - end + stub_feature_flags(apollo_boards: false) - it 'loads all the types when opened and submit one as filter', :aggregate_failures do - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 3) + group.add_maintainer(user) + sign_in(user) + end - expect_filtered_search_dropdown_results(filter_dropdown, 2) + context 'when filtering by assignee' do + it 'includes descendant project members in autocomplete' do + visit group_board_path(group, board) + wait_for_requests - click_on 'Incident' - filter_submit.click + set_filter('assignee') - expect(find('.board:nth-child(1)')).to have_selector('.board-card', count: 1) - expect(find('.board-card')).to have_content(incident.title) + expect(page).to have_css('.gl-filtered-search-suggestion', text: child_project_member.name) + end end end @@ -193,9 +222,4 @@ RSpec.describe 'Issue board filters', :js, feature_category: :team_planning do def expect_filtered_search_dropdown_results(filter_dropdown, count) expect(filter_dropdown).to have_selector('.gl-dropdown-item', count: count) end - - def visit_project_board - visit project_board_path(project, board) - wait_for_requests - end end diff --git a/spec/features/boards/multiple_boards_spec.rb b/spec/features/boards/multiple_boards_spec.rb index e9d34c6f87f..9d59d3dd02a 100644 --- a/spec/features/boards/multiple_boards_spec.rb +++ b/spec/features/boards/multiple_boards_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Multiple Issue Boards', :js, feature_category: :team_planning do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, :public) } let_it_be(:planning) { create(:label, project: project, name: 'Planning') } let_it_be(:board) { create(:board, name: 'board1', project: project) } diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 4807b691e4f..358da1e1279 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe 'Project issue boards sidebar', :js, feature_category: :team_planning, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/416414' do +RSpec.describe 'Project issue boards sidebar', :js, feature_category: :team_planning do include BoardHelpers let_it_be(:user) { create(:user) } diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb index 8ad27b65f11..e22ae4f51fb 100644 --- a/spec/features/calendar_spec.rb +++ b/spec/features/calendar_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Contributions Calendar', :js, feature_category: :user_profile do include MobileHelpers - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:contributed_project) { create(:project, :public, :repository) } let(:issue_note) { create(:note, project: contributed_project) } diff --git a/spec/features/contextual_sidebar_spec.rb b/spec/features/contextual_sidebar_spec.rb index 132c8eb7192..ab322f18240 100644 --- a/spec/features/contextual_sidebar_spec.rb +++ b/spec/features/contextual_sidebar_spec.rb @@ -4,9 +4,8 @@ require 'spec_helper' RSpec.describe 'Contextual sidebar', :js, feature_category: :remote_development do context 'when context is a project' do - let_it_be(:project) { create(:project) } - - let(:user) { project.first_owner } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :repository, namespace: user.namespace) } before do sign_in(user) diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index 56272f58e0d..4fe05abd73b 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -60,8 +60,8 @@ RSpec.describe 'Value Stream Analytics', :js, feature_category: :value_stream_ma # NOTE: in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68595 travel back # 5 days in time before we create data for these specs, to mitigate some flakiness # So setting the date range to be the last 2 days should skip past the existing data - from = 2.days.ago.strftime("%Y-%m-%d") - to = 1.day.ago.strftime("%Y-%m-%d") + from = 2.days.ago.to_date.iso8601 + to = 1.day.ago.to_date.iso8601 max_items_per_page = 20 around do |example| diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb index 60621f57bde..61631d28aa9 100644 --- a/spec/features/dashboard/activity_spec.rb +++ b/spec/features/dashboard/activity_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Dashboard > Activity', feature_category: :user_profile do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb b/spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb index c1849cbee83..a00666c2376 100644 --- a/spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb +++ b/spec/features/dashboard/group_dashboard_with_external_authorization_service_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'The group dashboard', :js, feature_category: :groups_and_project include ExternalAuthorizationServiceHelpers include Features::TopNavSpecHelpers - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in user diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb index b077b554773..e1da163cdf5 100644 --- a/spec/features/dashboard/groups_list_spec.rb +++ b/spec/features/dashboard/groups_list_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Dashboard Groups page', :js, feature_category: :groups_and_projects do - let(:user) { create :user } + let(:user) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } let(:nested_group) { create(:group, :nested) } let(:another_group) { create(:group) } @@ -237,4 +237,15 @@ RSpec.describe 'Dashboard Groups page', :js, feature_category: :groups_and_proje expect(page).to have_link("Explore groups", href: explore_groups_path) end + + context 'when there are no groups to display' do + before do + sign_in(user) + visit dashboard_groups_path + end + + it 'shows empty state' do + expect(page).to have_content(s_('GroupsEmptyState|A group is a collection of several projects')) + end + end end diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index 5e6ec007569..501405c5662 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Navigation bar counter', :use_clean_rails_memory_store_caching, feature_category: :team_planning do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, namespace: user.namespace) } let(:issue) { create(:issue, project: project) } let(:merge_request) { create(:merge_request, source_project: project) } diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index 70d9f7e5137..69b32113bba 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Dashboard Issues', feature_category: :team_planning do include FilteredSearchHelpers - let_it_be(:current_user) { create :user } + let_it_be(:current_user) { create(:user, :no_super_sidebar) } let_it_be(:user) { current_user } # Shared examples depend on this being available let_it_be(:public_project) { create(:project, :public) } let_it_be(:project) { create(:project) } diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 624f3530f81..4bb04f4ff80 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Dashboard Merge Requests', feature_category: :code_review_workfl include FilteredSearchHelpers include ProjectForksHelper - let(:current_user) { create :user } + let(:current_user) { create(:user, :no_super_sidebar) } let(:user) { current_user } let(:project) { create(:project) } diff --git a/spec/features/dashboard/milestones_spec.rb b/spec/features/dashboard/milestones_spec.rb index 0dd25ffaa94..38637115246 100644 --- a/spec/features/dashboard/milestones_spec.rb +++ b/spec/features/dashboard/milestones_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'Dashboard > Milestones', feature_category: :team_planning do end describe 'as logged-in user' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } let(:project) { create(:project, namespace: user.namespace) } let!(:milestone) { create(:milestone, project: project) } @@ -50,7 +50,7 @@ RSpec.describe 'Dashboard > Milestones', feature_category: :team_planning do end describe 'with merge requests disabled' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } let(:project) { create(:project, :merge_requests_disabled, namespace: user.namespace) } let!(:milestone) { create(:milestone, project: project) } diff --git a/spec/features/dashboard/navbar_spec.rb b/spec/features/dashboard/navbar_spec.rb index ff0ff899fc2..30e7f2d2e4e 100644 --- a/spec/features/dashboard/navbar_spec.rb +++ b/spec/features/dashboard/navbar_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe '"Your work" navbar', feature_category: :navigation do include_context 'dashboard navbar structure' - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } it_behaves_like 'verified navigation bar' do before do diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index 747d09f5d08..e5ad9808f83 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Dashboard Projects', feature_category: :groups_and_projects do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project, reload: true) { create(:project, :repository, creator: build(:user)) } # ensure creator != owner to avoid N+1 false-positive let_it_be(:project2) { create(:project, :public) } diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb index 2e01c1304de..c8013d364e3 100644 --- a/spec/features/dashboard/shortcuts_spec.rb +++ b/spec/features/dashboard/shortcuts_spec.rb @@ -50,13 +50,13 @@ RSpec.describe 'Dashboard shortcuts', :js, feature_category: :shared do context 'logged out' do before do + stub_feature_flags(super_sidebar_logged_out: false) visit explore_root_path end it 'navigate to tabs' do find('body').send_keys([:shift, 'G']) - find('.nothing-here-block') expect(page).to have_content('No public groups') find('body').send_keys([:shift, 'S']) diff --git a/spec/features/dashboard/snippets_spec.rb b/spec/features/dashboard/snippets_spec.rb index da985c6dc07..f9284f9479e 100644 --- a/spec/features/dashboard/snippets_spec.rb +++ b/spec/features/dashboard/snippets_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Dashboard snippets', feature_category: :source_code_management do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_snippets_path, :snippets diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb index 9d59126df8d..5642d083673 100644 --- a/spec/features/dashboard/todos/todos_spec.rb +++ b/spec/features/dashboard/todos/todos_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe 'Dashboard Todos', feature_category: :team_planning do include DesignManagementTestHelpers - let_it_be(:user) { create(:user, username: 'john') } - let_it_be(:user2) { create(:user, username: 'diane') } - let_it_be(:author) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar, username: 'john') } + let_it_be(:user2) { create(:user, :no_super_sidebar, username: 'diane') } + let_it_be(:author) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, :public) } let_it_be(:issue) { create(:issue, project: project, due_date: Date.today, title: "Fix bug") } diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb index 39cd3c80307..91ee6d48a48 100644 --- a/spec/features/explore/groups_list_spec.rb +++ b/spec/features/explore/groups_list_spec.rb @@ -4,88 +4,104 @@ require 'spec_helper' RSpec.describe 'Explore Groups page', :js, feature_category: :groups_and_projects do let!(:user) { create :user } - let!(:group) { create(:group) } - let!(:public_group) { create(:group, :public) } - let!(:private_group) { create(:group, :private) } - let!(:empty_project) { create(:project, group: public_group) } - before do - group.add_owner(user) + context 'when there are groups to show' do + let!(:group) { create(:group) } + let!(:public_group) { create(:group, :public) } + let!(:private_group) { create(:group, :private) } + let!(:empty_project) { create(:project, group: public_group) } - sign_in(user) + before do + group.add_owner(user) - visit explore_groups_path - wait_for_requests - end + sign_in(user) - it 'shows groups user is member of' do - expect(page).to have_content(group.full_name) - expect(page).to have_content(public_group.full_name) - expect(page).not_to have_content(private_group.full_name) - end + visit explore_groups_path + wait_for_requests + end - it 'filters groups' do - fill_in 'filter', with: group.name - wait_for_requests + it 'shows groups user is member of' do + expect(page).to have_content(group.full_name) + expect(page).to have_content(public_group.full_name) + expect(page).not_to have_content(private_group.full_name) + end - expect(page).to have_content(group.full_name) - expect(page).not_to have_content(public_group.full_name) - expect(page).not_to have_content(private_group.full_name) - end + it 'filters groups' do + fill_in 'filter', with: group.name + wait_for_requests - it 'resets search when user cleans the input' do - fill_in 'filter', with: group.name - wait_for_requests + expect(page).to have_content(group.full_name) + expect(page).not_to have_content(public_group.full_name) + expect(page).not_to have_content(private_group.full_name) + end - expect(page).to have_content(group.full_name) - expect(page).not_to have_content(public_group.full_name) + it 'resets search when user cleans the input' do + fill_in 'filter', with: group.name + wait_for_requests - fill_in 'filter', with: "" - page.find('[name="filter"]').send_keys(:enter) - wait_for_requests + expect(page).to have_content(group.full_name) + expect(page).not_to have_content(public_group.full_name) - expect(page).to have_content(group.full_name) - expect(page).to have_content(public_group.full_name) - expect(page).not_to have_content(private_group.full_name) - expect(page.all('.js-groups-list-holder .groups-list li').length).to eq 2 - end + fill_in 'filter', with: "" + page.find('[name="filter"]').send_keys(:enter) + wait_for_requests - it 'shows non-archived projects count' do - # Initially project is not archived - expect(find('.js-groups-list-holder .groups-list li:first-child .stats .number-projects')).to have_text("1") + expect(page).to have_content(group.full_name) + expect(page).to have_content(public_group.full_name) + expect(page).not_to have_content(private_group.full_name) + expect(page.all('.js-groups-list-holder .groups-list li').length).to eq 2 + end - # Archive project - ::Projects::UpdateService.new(empty_project, user, archived: true).execute - visit explore_groups_path + it 'shows non-archived projects count' do + # Initially project is not archived + expect(find('.js-groups-list-holder .groups-list li:first-child .stats .number-projects')).to have_text("1") - # Check project count - expect(find('.js-groups-list-holder .groups-list li:first-child .stats .number-projects')).to have_text("0") + # Archive project + ::Projects::UpdateService.new(empty_project, user, archived: true).execute + visit explore_groups_path - # Unarchive project - ::Projects::UpdateService.new(empty_project, user, archived: false).execute - visit explore_groups_path + # Check project count + expect(find('.js-groups-list-holder .groups-list li:first-child .stats .number-projects')).to have_text("0") - # Check project count - expect(find('.js-groups-list-holder .groups-list li:first-child .stats .number-projects')).to have_text("1") - end + # Unarchive project + ::Projects::UpdateService.new(empty_project, user, archived: false).execute + visit explore_groups_path - describe 'landing component' do - it 'shows a landing component' do - expect(page).to have_content('Below you will find all the groups that are public.') + # Check project count + expect(find('.js-groups-list-holder .groups-list li:first-child .stats .number-projects')).to have_text("1") end - it 'is dismissable' do - find('.dismiss-button').click + describe 'landing component' do + it 'shows a landing component' do + expect(page).to have_content('Below you will find all the groups that are public.') + end + + it 'is dismissable' do + find('.dismiss-button').click + + expect(page).not_to have_content('Below you will find all the groups that are public.') + end - expect(page).not_to have_content('Below you will find all the groups that are public.') + it 'does not show persistently once dismissed' do + find('.dismiss-button').click + + visit explore_groups_path + + expect(page).not_to have_content('Below you will find all the groups that are public.') + end end + end - it 'does not show persistently once dismissed' do - find('.dismiss-button').click + context 'when there are no groups to show' do + before do + sign_in(user) visit explore_groups_path + wait_for_requests + end - expect(page).not_to have_content('Below you will find all the groups that are public.') + it 'shows empty state' do + expect(page).to have_content(_('No public groups')) end end end diff --git a/spec/features/explore/navbar_spec.rb b/spec/features/explore/navbar_spec.rb index 8f281abe6a7..853d66ed4d1 100644 --- a/spec/features/explore/navbar_spec.rb +++ b/spec/features/explore/navbar_spec.rb @@ -7,6 +7,7 @@ RSpec.describe '"Explore" navbar', feature_category: :navigation do it_behaves_like 'verified navigation bar' do before do + stub_feature_flags(super_sidebar_logged_out: false) visit explore_projects_path end end diff --git a/spec/features/explore/user_explores_projects_spec.rb b/spec/features/explore/user_explores_projects_spec.rb index f259ba6a167..43d464e0c9f 100644 --- a/spec/features/explore/user_explores_projects_spec.rb +++ b/spec/features/explore/user_explores_projects_spec.rb @@ -3,6 +3,10 @@ require 'spec_helper' RSpec.describe 'User explores projects', feature_category: :user_profile do + before do + stub_feature_flags(super_sidebar_logged_out: false) + end + describe '"All" tab' do it_behaves_like 'an "Explore" page with sidebar and breadcrumbs', :explore_projects_path, :projects end diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb index f94f0288f99..dfafacf48e2 100644 --- a/spec/features/global_search_spec.rb +++ b/spec/features/global_search_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Global search', :js, feature_category: :global_search do include AfterNextHelpers - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, namespace: user.namespace) } before do diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb index d68b4ccf8f8..953a8e27547 100644 --- a/spec/features/groups/container_registry_spec.rb +++ b/spec/features/groups/container_registry_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Container Registry', :js, feature_category: :container_registry do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } let(:project) { create(:project, namespace: group) } diff --git a/spec/features/groups/dependency_proxy_for_containers_spec.rb b/spec/features/groups/dependency_proxy_for_containers_spec.rb index c0456140291..1e15b97c5aa 100644 --- a/spec/features/groups/dependency_proxy_for_containers_spec.rb +++ b/spec/features/groups/dependency_proxy_for_containers_spec.rb @@ -6,6 +6,7 @@ RSpec.describe 'Group Dependency Proxy for containers', :js, feature_category: : include DependencyProxyHelpers include_context 'file upload requests helpers' + include_context 'with a server running the dependency proxy' let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } @@ -21,17 +22,6 @@ RSpec.describe 'Group Dependency Proxy for containers', :js, feature_category: : HTTParty.get(url, headers: headers) end - def run_server(handler) - default_server = Capybara.server - - Capybara.server = Capybara.servers[:puma] - server = Capybara::Server.new(handler) - server.boot - server - ensure - Capybara.server = default_server - end - let_it_be(:external_server) do handler = lambda do |env| if env['REQUEST_PATH'] == '/token' diff --git a/spec/features/groups/dependency_proxy_spec.rb b/spec/features/groups/dependency_proxy_spec.rb index 60922f813df..2d4f6d4fbf2 100644 --- a/spec/features/groups/dependency_proxy_spec.rb +++ b/spec/features/groups/dependency_proxy_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' RSpec.describe 'Group Dependency Proxy', feature_category: :dependency_proxy do - let(:owner) { create(:user) } - let(:reporter) { create(:user) } + let(:owner) { create(:user, :no_super_sidebar) } + let(:reporter) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } let(:path) { group_dependency_proxy_path(group) } let(:settings_path) { group_settings_packages_and_registries_path(group) } diff --git a/spec/features/groups/group_page_with_external_authorization_service_spec.rb b/spec/features/groups/group_page_with_external_authorization_service_spec.rb index 5b373aecce8..4cc0fe4171d 100644 --- a/spec/features/groups/group_page_with_external_authorization_service_spec.rb +++ b/spec/features/groups/group_page_with_external_authorization_service_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'The group page', feature_category: :groups_and_projects do include ExternalAuthorizationServiceHelpers - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } before do diff --git a/spec/features/groups/group_runners_spec.rb b/spec/features/groups/group_runners_spec.rb index e9d2d185e8a..4e5d7c6f8e8 100644 --- a/spec/features/groups/group_runners_spec.rb +++ b/spec/features/groups/group_runners_spec.rb @@ -7,182 +7,235 @@ RSpec.describe "Group Runners", feature_category: :runner_fleet do include Spec::Support::Helpers::ModalHelpers let_it_be(:group_owner) { create(:user) } + let_it_be(:group_maintainer) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } before do group.add_owner(group_owner) - sign_in(group_owner) + group.add_maintainer(group_maintainer) end describe "Group runners page", :js do - context "with no runners" do + context 'when logged in as group maintainer' do before do - visit group_runners_path(group) + sign_in(group_maintainer) end - it_behaves_like 'shows no runners registered' - - it 'shows tabs with total counts equal to 0' do - expect(page).to have_link('All 0') - expect(page).to have_link('Group 0') - expect(page).to have_link('Project 0') - end - end + context "with no runners" do + before do + visit group_runners_path(group) + end - context "with an online group runner" do - let!(:group_runner) do - create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now) - end + it_behaves_like 'shows no runners registered' - before do - visit group_runners_path(group) + it 'shows tabs with total counts equal to 0' do + expect(page).to have_link('All 0') + expect(page).to have_link('Group 0') + expect(page).to have_link('Project 0') + end end - it_behaves_like 'shows runner in list' do - let(:runner) { group_runner } - end + context "with an online group runner" do + let_it_be(:group_runner) do + create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now) + end - it_behaves_like 'pauses, resumes and deletes a runner' do - let(:runner) { group_runner } - end + before do + visit group_runners_path(group) + end - it 'shows an editable group badge' do - within_runner_row(group_runner.id) do - expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner)) + it_behaves_like 'shows runner in list' do + let(:runner) { group_runner } + end - expect(page).to have_selector '.badge', text: s_('Runners|Group') + it_behaves_like 'shows runner details from list' do + let(:runner) { group_runner } + let(:runner_page_path) { group_runner_path(group, group_runner) } end - end - context 'when description does not match' do - before do - input_filtered_search_keys('runner-baz') + it 'shows a group runner badge' do + within_runner_row(group_runner.id) do + expect(page).to have_selector '.badge', text: s_('Runners|Group') + end end - it_behaves_like 'shows no runners found' + context 'when description does not match' do + before do + input_filtered_search_keys('runner-baz') + end + + it_behaves_like 'shows no runners found' - it 'shows no runner' do - expect(page).not_to have_content 'runner-foo' + it 'shows no runner' do + expect(page).not_to have_content 'runner-foo' + end end end - end - context "with an online project runner" do - let!(:project_runner) do - create(:ci_runner, :project, projects: [project], description: 'runner-bar', contacted_at: Time.zone.now) - end + context "with an online project runner" do + let_it_be(:project_runner) do + create(:ci_runner, :project, projects: [project], description: 'runner-bar', contacted_at: Time.zone.now) + end - before do - visit group_runners_path(group) - end + before do + visit group_runners_path(group) + end - it_behaves_like 'shows runner in list' do - let(:runner) { project_runner } - end + it_behaves_like 'shows runner in list' do + let(:runner) { project_runner } + end - it_behaves_like 'pauses, resumes and deletes a runner' do - let(:runner) { project_runner } + it_behaves_like 'shows runner details from list' do + let(:runner) { project_runner } + let(:runner_page_path) { group_runner_path(group, project_runner) } + end + + it 'shows a project runner badge' do + within_runner_row(project_runner.id) do + expect(page).to have_selector '.badge', text: s_('Runners|Project') + end + end end - it 'shows an editable project runner' do - within_runner_row(project_runner.id) do - expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, project_runner)) + context "with an online instance runner" do + let_it_be(:instance_runner) do + create(:ci_runner, :instance, description: 'runner-baz', contacted_at: Time.zone.now) + end - expect(page).to have_selector '.badge', text: s_('Runners|Project') + before do + visit group_runners_path(group) end - end - end - context "with an online instance runner" do - let!(:instance_runner) do - create(:ci_runner, :instance, description: 'runner-baz', contacted_at: Time.zone.now) - end + context "when selecting 'Show only inherited'" do + before do + find("[data-testid='runner-membership-toggle'] button").click - before do - visit group_runners_path(group) - end + wait_for_requests + end - context "when selecting 'Show only inherited'" do - before do - find("[data-testid='runner-membership-toggle'] button").click + it_behaves_like 'shows runner in list' do + let(:runner) { instance_runner } + end - wait_for_requests + it_behaves_like 'shows runner details from list' do + let(:runner) { instance_runner } + let(:runner_page_path) { group_runner_path(group, instance_runner) } + end end + end - it_behaves_like 'shows runner in list' do - let(:runner) { instance_runner } + describe 'filtered search' do + before do + visit group_runners_path(group) end - it 'shows runner details page' do - click_link("##{instance_runner.id} (#{instance_runner.short_sha})") + it 'allows user to search by paused and status', :js do + focus_filtered_search - expect(current_url).to include(group_runner_path(group, instance_runner)) - expect(page).to have_content "#{s_('Runners|Description')} runner-baz" + page.within(search_bar_selector) do + expect(page).to have_link(s_('Runners|Paused')) + expect(page).to have_content('Status') + end end end - end - context 'with a multi-project runner' do - let(:project) { create(:project, group: group) } - let(:project_2) { create(:project, group: group) } - let!(:runner) { create(:ci_runner, :project, projects: [project, project_2], description: 'group-runner') } + describe 'filter by tag' do + let!(:rnr_1) { create(:ci_runner, :group, groups: [group], description: 'runner-blue', tag_list: ['blue']) } + let!(:rnr_2) { create(:ci_runner, :group, groups: [group], description: 'runner-red', tag_list: ['red']) } - it 'user cannot remove the project runner' do - visit group_runners_path(group) + before do + visit group_runners_path(group) + end - within_runner_row(runner.id) do - expect(page).not_to have_button 'Delete runner' + it_behaves_like 'filters by tag' do + let(:tag) { 'blue' } + let(:found_runner) { rnr_1.description } + let(:missing_runner) { rnr_2.description } end end end - context "with multiple runners" do + context 'when logged in as group owner' do before do - create(:ci_runner, :group, groups: [group], description: 'runner-foo') - create(:ci_runner, :group, groups: [group], description: 'runner-bar') - - visit group_runners_path(group) + sign_in(group_owner) end - it_behaves_like 'deletes runners in bulk' do - let(:runner_count) { '2' } - end - end + context "with an online group runner" do + let_it_be(:group_runner) do + create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now) + end - describe 'filtered search' do - before do - visit group_runners_path(group) + before do + visit group_runners_path(group) + end + + it_behaves_like 'pauses, resumes and deletes a runner' do + let(:runner) { group_runner } + end + + it 'shows an edit link' do + within_runner_row(group_runner.id) do + expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner)) + end + end end - it 'allows user to search by paused and status', :js do - focus_filtered_search + context "with an online project runner" do + let_it_be(:project_runner) do + create(:ci_runner, :project, projects: [project], description: 'runner-bar', contacted_at: Time.zone.now) + end + + before do + visit group_runners_path(group) + end - page.within(search_bar_selector) do - expect(page).to have_link(s_('Runners|Paused')) - expect(page).to have_content('Status') + it_behaves_like 'pauses, resumes and deletes a runner' do + let(:runner) { project_runner } + end + + it 'shows an editable project runner' do + within_runner_row(project_runner.id) do + expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, project_runner)) + end end end - end - describe 'filter by tag' do - let!(:runner_1) { create(:ci_runner, :group, groups: [group], description: 'runner-blue', tag_list: ['blue']) } - let!(:runner_2) { create(:ci_runner, :group, groups: [group], description: 'runner-red', tag_list: ['red']) } + context 'with a multi-project runner' do + let_it_be(:project) { create(:project, group: group) } + let_it_be(:project_2) { create(:project, group: group) } + let_it_be(:runner) do + create(:ci_runner, :project, projects: [project, project_2], description: 'group-runner') + end - before do - visit group_runners_path(group) + it 'owner cannot remove the project runner' do + visit group_runners_path(group) + + within_runner_row(runner.id) do + expect(page).not_to have_button 'Delete runner' + end + end end - it_behaves_like 'filters by tag' do - let(:tag) { 'blue' } - let(:found_runner) { runner_1.description } - let(:missing_runner) { runner_2.description } + context "with multiple runners" do + before do + create(:ci_runner, :group, groups: [group], description: 'runner-foo') + create(:ci_runner, :group, groups: [group], description: 'runner-bar') + + visit group_runners_path(group) + end + + it_behaves_like 'deletes runners in bulk' do + let(:runner_count) { '2' } + end end end end describe "Group runner create page", :js do before do + sign_in(group_owner) + visit new_group_runner_path(group) end @@ -196,23 +249,39 @@ RSpec.describe "Group Runners", feature_category: :runner_fleet do create(:ci_runner, :group, groups: [group], description: 'runner-foo') end - let_it_be(:group_runner_job) { create(:ci_build, runner: group_runner) } + let_it_be(:group_runner_job) { create(:ci_build, runner: group_runner, project: project) } - before do - visit group_runner_path(group, group_runner) - end + context 'when logged in as group maintainer' do + before do + sign_in(group_maintainer) - it 'user views runner details' do - expect(page).to have_content "#{s_('Runners|Description')} runner-foo" + visit group_runner_path(group, group_runner) + end + + it 'user views runner details' do + expect(page).to have_content "#{s_('Runners|Description')} runner-foo" + end end - it_behaves_like 'shows runner jobs tab' do - let(:job_count) { '1' } - let(:job) { group_runner_job } + context 'when logged in as group owner' do + before do + sign_in(group_owner) + + visit group_runner_path(group, group_runner) + end + + it_behaves_like 'shows runner jobs tab' do + let(:job_count) { '1' } + let(:job) { group_runner_job } + end end end describe "Group runner edit page", :js do + before do + sign_in(group_owner) + end + context 'when updating a group runner' do let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group]) } @@ -239,6 +308,8 @@ RSpec.describe "Group Runners", feature_category: :runner_fleet do let(:runner) { project_runner } let(:runner_page_path) { group_runner_path(group, project_runner) } end + + it_behaves_like 'shows locked field' end end end diff --git a/spec/features/groups/labels/create_spec.rb b/spec/features/groups/labels/create_spec.rb index 5b57e670c1d..8242f422e6e 100644 --- a/spec/features/groups/labels/create_spec.rb +++ b/spec/features/groups/labels/create_spec.rb @@ -9,15 +9,17 @@ RSpec.describe 'Create a group label', feature_category: :team_planning do before do group.add_owner(user) sign_in(user) - visit group_labels_path(group) + + visit new_group_label_path(group) end it 'creates a new label' do - click_link 'New label' fill_in 'Title', with: 'test-label' click_button 'Create label' expect(page).to have_content 'test-label' expect(page).to have_current_path(group_labels_path(group), ignore_query: true) end + + it_behaves_like 'lock_on_merge when creating labels' end diff --git a/spec/features/groups/labels/edit_spec.rb b/spec/features/groups/labels/edit_spec.rb index 6e056d35435..70568d4baa2 100644 --- a/spec/features/groups/labels/edit_spec.rb +++ b/spec/features/groups/labels/edit_spec.rb @@ -12,6 +12,7 @@ RSpec.describe 'Edit group label', feature_category: :team_planning do before do group.add_owner(user) sign_in(user) + visit edit_group_label_path(group, label) end @@ -34,4 +35,17 @@ RSpec.describe 'Edit group label', feature_category: :team_planning do expect(page).to have_content("#{label.title} was removed").and have_no_content("#{label.title}") end + + describe 'lock_on_merge' do + let(:label_unlocked) { create(:group_label, group: group, lock_on_merge: false) } + let(:label_locked) { create(:group_label, group: group, lock_on_merge: true) } + let(:edit_label_path_unlocked) { edit_group_label_path(group, label_unlocked) } + let(:edit_label_path_locked) { edit_group_label_path(group, label_locked) } + + before do + visit edit_label_path_unlocked + end + + it_behaves_like 'lock_on_merge when editing labels' + end end diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb index 138031ffaac..dd64ddcede5 100644 --- a/spec/features/groups/members/manage_members_spec.rb +++ b/spec/features/groups/members/manage_members_spec.rb @@ -85,7 +85,7 @@ RSpec.describe 'Groups > Members > Manage members', feature_category: :groups_an end end - it_behaves_like 'inviting members', 'group-members-page' do + it_behaves_like 'inviting members', 'group_members_page' do let_it_be(:entity) { group } let_it_be(:members_page_path) { group_group_members_path(entity) } let_it_be(:subentity) { create(:group, parent: group) } diff --git a/spec/features/groups/members/request_access_spec.rb b/spec/features/groups/members/request_access_spec.rb index cd0c9bfe3eb..c04b84be90e 100644 --- a/spec/features/groups/members/request_access_spec.rb +++ b/spec/features/groups/members/request_access_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' RSpec.describe 'Groups > Members > Request access', feature_category: :groups_and_projects do - let(:user) { create(:user) } - let(:owner) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } + let(:owner) { create(:user, :no_super_sidebar) } let(:group) { create(:group, :public) } let!(:project) { create(:project, :private, namespace: group) } diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index a52e2d95fed..6a38f0c59a8 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'Group navbar', :with_license, feature_category: :navigation do include_context 'group navbar structure' - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } @@ -18,7 +18,6 @@ RSpec.describe 'Group navbar', :with_license, feature_category: :navigation do stub_config(dependency_proxy: { enabled: false }) stub_config(registry: { enabled: false }) - stub_feature_flags(harbor_registry_integration: false) stub_feature_flags(observability_group_tab: false) stub_group_wikis(false) group.add_maintainer(user) @@ -87,8 +86,6 @@ RSpec.describe 'Group navbar', :with_license, feature_category: :navigation do before do group.update!(harbor_integration: harbor_integration) - stub_feature_flags(harbor_registry_integration: true) - insert_harbor_registry_nav(_('Package Registry')) visit group_path(group) diff --git a/spec/features/groups/new_group_page_spec.rb b/spec/features/groups/new_group_page_spec.rb index c3731565ddf..e1034f2bb9d 100644 --- a/spec/features/groups/new_group_page_spec.rb +++ b/spec/features/groups/new_group_page_spec.rb @@ -39,14 +39,14 @@ RSpec.describe 'New group page', :js, feature_category: :groups_and_projects do context 'for a new top-level group' do it 'shows the "Your work" navigation' do visit new_group_path - expect(page).to have_selector(".super-sidebar .context-switcher-toggle", text: "Your work") + expect(page).to have_selector(".super-sidebar", text: "Your work") end end context 'for a new subgroup' do it 'shows the group navigation of the parent group' do visit new_group_path(parent_id: parent_group.id, anchor: 'create-group-pane') - expect(page).to have_selector(".super-sidebar .context-switcher-toggle", text: parent_group.name) + expect(page).to have_selector(".super-sidebar", text: parent_group.name) end end end diff --git a/spec/features/groups/packages_spec.rb b/spec/features/groups/packages_spec.rb index ec8215928e4..1d9269501be 100644 --- a/spec/features/groups/packages_spec.rb +++ b/spec/features/groups/packages_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Group Packages', feature_category: :package_registry do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } diff --git a/spec/features/groups/settings/packages_and_registries_spec.rb b/spec/features/groups/settings/packages_and_registries_spec.rb index 8ea8dc9219a..fa310722860 100644 --- a/spec/features/groups/settings/packages_and_registries_spec.rb +++ b/spec/features/groups/settings/packages_and_registries_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Group Package and registry settings', feature_category: :package_registry do include WaitForRequests - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } let(:sub_group) { create(:group, parent: group) } diff --git a/spec/features/groups/user_sees_package_sidebar_spec.rb b/spec/features/groups/user_sees_package_sidebar_spec.rb index 6a91dfb92bf..4efb9ff7608 100644 --- a/spec/features/groups/user_sees_package_sidebar_spec.rb +++ b/spec/features/groups/user_sees_package_sidebar_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Groups > sidebar', feature_category: :groups_and_projects do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:group) { create(:group) } before do diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 67133b1856f..7af58bf460c 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Group', feature_category: :groups_and_projects do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/help_dropdown_spec.rb b/spec/features/help_dropdown_spec.rb index 5f1d3a5e2b7..08d7dba4d79 100644 --- a/spec/features/help_dropdown_spec.rb +++ b/spec/features/help_dropdown_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' RSpec.describe "Help Dropdown", :js, feature_category: :shared do - let_it_be(:user) { create(:user) } - let_it_be(:admin) { create(:admin) } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:admin) { create(:admin, :no_super_sidebar) } before do stub_application_setting(version_check_enabled: true) diff --git a/spec/features/ide/user_opens_merge_request_spec.rb b/spec/features/ide/user_opens_merge_request_spec.rb index dc280133a20..2aa89cadb7d 100644 --- a/spec/features/ide/user_opens_merge_request_spec.rb +++ b/spec/features/ide/user_opens_merge_request_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe 'IDE merge request', :js, feature_category: :web_ide do include CookieHelper - let(:merge_request) { create(:merge_request, :simple, source_project: project) } - let(:project) { create(:project, :public, :repository) } - let(:user) { project.first_owner } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :public, :repository, namespace: user.namespace) } + let_it_be(:merge_request) { create(:merge_request, :simple, source_project: project) } before do stub_feature_flags(vscode_web_ide: false) diff --git a/spec/features/incidents/incident_details_spec.rb b/spec/features/incidents/incident_details_spec.rb index d6feb008d47..7e447ae32c0 100644 --- a/spec/features/incidents/incident_details_spec.rb +++ b/spec/features/incidents/incident_details_spec.rb @@ -150,6 +150,9 @@ RSpec.describe 'Incident details', :js, feature_category: :incident_management d wait_for_requests sticky_header = find_by_scrolling('[data-testid=issue-sticky-header]') - expect(sticky_header.find('[data-testid=confidential]')).to be_present + + page.within(sticky_header) do + expect(page).to have_text 'Confidential' + end end end diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb index 03ec72980e5..a56df7bdecc 100644 --- a/spec/features/invites_spec.rb +++ b/spec/features/invites_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_category: :experimentation_expansion do let_it_be(:owner) { create(:user, name: 'John Doe') } - let_it_be(:group) { create(:group, name: 'Owned') } + # private will ensure we really have access to the group when we land on the activity page + let_it_be(:group) { create(:group, :private, name: 'Owned') } let_it_be(:project) { create(:project, :repository, namespace: group) } let(:group_invite) { group.group_members.invite.last } @@ -22,18 +23,6 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate visit user_confirmation_path(confirmation_token: new_user_token) end - def fill_in_sign_up_form(new_user, submit_button_text = 'Register') - fill_in 'new_user_first_name', with: new_user.first_name - fill_in 'new_user_last_name', with: new_user.last_name - fill_in 'new_user_username', with: new_user.username - fill_in 'new_user_email', with: new_user.email - fill_in 'new_user_password', with: new_user.password - - wait_for_all_requests - - click_button submit_button_text - end - def fill_in_welcome_form select 'Software Developer', from: 'user_role' click_button 'Get started!' @@ -58,10 +47,10 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate expect(page).to have_content('To accept this invitation, create an account or sign in') end - it 'pre-fills the "Username or email" field on the sign in box with the invite_email from the invite' do + it 'pre-fills the "Username or primary email" field on the sign in box with the invite_email from the invite' do click_link 'Sign in' - expect(find_field('Username or email').value).to eq(group_invite.invite_email) + expect(find_field('Username or primary email').value).to eq(group_invite.invite_email) end it 'pre-fills the Email field on the sign up box with the invite_email from the invite' do @@ -70,11 +59,11 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate end context 'when invite is sent before account is created;ldap or service sign in for manual acceptance edge case' do - let(:user) { create(:user, email: 'user@example.com') } + let(:user) { create(:user, :no_super_sidebar, email: 'user@example.com') } context 'when invite clicked and not signed in' do before do - visit invite_path(group_invite.raw_invite_token) + visit invite_path(group_invite.raw_invite_token, invite_type: Emails::Members::INITIAL_INVITE) end it 'sign in, grants access and redirects to group activity page' do @@ -82,7 +71,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate gitlab_sign_in(user, remember: true, visit: false) - expect(page).to have_current_path(activity_group_path(group), ignore_query: true) + expect_to_be_on_group_activity_page(group) end end @@ -143,6 +132,10 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate end end end + + def expect_to_be_on_group_activity_page(group) + expect(page).to have_current_path(activity_group_path(group)) + end end end end @@ -195,12 +188,11 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate context 'when the user sign-up using a different email address' do let(:invite_email) { build_stubbed(:user).email } - it 'signs up and redirects to the activity page', - quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/414971' do + it 'signs up and redirects to the projects dashboard' do fill_in_sign_up_form(new_user) fill_in_welcome_form - expect(page).to have_current_path(activity_group_path(group), ignore_query: true) + expect_to_be_on_projects_dashboard_with_zero_authorized_projects end end end @@ -232,8 +224,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate end context 'when the user signs up for an account with the invitation email address' do - it 'redirects to the most recent membership activity page with all invitations automatically accepted', - quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/417092' do + it 'redirects to the most recent membership activity page with all invitations automatically accepted' do fill_in_sign_up_form(new_user) fill_in_welcome_form @@ -250,13 +241,13 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate stub_feature_flags(identity_verification: false) end - it 'signs up and redirects to the group activity page' do + it 'signs up and redirects to the projects dashboard' do fill_in_sign_up_form(new_user) confirm_email(new_user) gitlab_sign_in(new_user, remember: true, visit: false) fill_in_welcome_form - expect(page).to have_current_path(activity_group_path(group), ignore_query: true) + expect_to_be_on_projects_dashboard_with_zero_authorized_projects end end @@ -266,15 +257,22 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures, feature_cate allow(User).to receive(:allow_unconfirmed_access_for).and_return 2.days end - it 'signs up and redirects to the group activity page' do + it 'signs up and redirects to the projects dashboard' do fill_in_sign_up_form(new_user) fill_in_welcome_form - expect(page).to have_current_path(activity_group_path(group), ignore_query: true) + expect_to_be_on_projects_dashboard_with_zero_authorized_projects end end end end + + def expect_to_be_on_projects_dashboard_with_zero_authorized_projects + expect(page).to have_current_path(dashboard_projects_path) + + expect(page).to have_content _('Welcome to GitLab') + expect(page).to have_content _('Faster releases. Better code. Less pain.') + end end context 'when accepting an invite without an account' do diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 0a06a052bc2..0c5b33c2530 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -113,33 +113,5 @@ RSpec.describe 'Dropdown assignee', :js, feature_category: :team_planning do expect(page).to have_text invited_to_group_group_user.name expect(page).not_to have_text subsubgroup_user.name end - - context 'when new_graphql_users_autocomplete is disabled' do - before do - stub_feature_flags(new_graphql_users_autocomplete: false) - end - - it 'shows inherited, direct, and invited group members but not descendent members', :aggregate_failures do - visit issues_group_path(subgroup) - - select_tokens 'Assignee', '=' - - expect(page).to have_text group_user.name - expect(page).to have_text subgroup_user.name - expect(page).to have_text invited_to_group_group_user.name - expect(page).not_to have_text subsubgroup_user.name - expect(page).not_to have_text invited_to_project_group_user.name - - visit project_issues_path(subgroup_project) - - select_tokens 'Assignee', '=' - - expect(page).to have_text group_user.name - expect(page).to have_text subgroup_user.name - expect(page).to have_text invited_to_project_group_user.name - expect(page).to have_text invited_to_group_group_user.name - expect(page).not_to have_text subsubgroup_user.name - end - end end end diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index 3031b20eb7c..e51c82081ff 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -6,8 +6,8 @@ RSpec.describe 'Visual tokens', :js, feature_category: :team_planning do include FilteredSearchHelpers let_it_be(:project) { create(:project) } - let_it_be(:user) { create(:user, name: 'administrator', username: 'root') } - let_it_be(:user_rock) { create(:user, name: 'The Rock', username: 'rock') } + let_it_be(:user) { create(:user, :no_super_sidebar, name: 'administrator', username: 'root') } + let_it_be(:user_rock) { create(:user, :no_super_sidebar, name: 'The Rock', username: 'rock') } let_it_be(:milestone_nine) { create(:milestone, title: '9.0', project: project) } let_it_be(:milestone_ten) { create(:milestone, title: '10.0', project: project) } let_it_be(:label) { create(:label, project: project, title: 'abc') } diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 5f7a4f26a98..ed2c712feb1 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -8,14 +8,13 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do include ContentEditorHelpers let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { create(:user) } - let_it_be(:user2) { create(:user) } - let_it_be(:guest) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:user2) { create(:user, :no_super_sidebar) } + let_it_be(:guest) { create(:user, :no_super_sidebar) } let_it_be(:milestone) { create(:milestone, project: project) } let_it_be(:label) { create(:label, project: project) } let_it_be(:label2) { create(:label, project: project) } let_it_be(:issue) { create(:issue, project: project, assignees: [user], milestone: milestone) } - let_it_be(:issue2) { create(:issue, project: project, assignees: [user], milestone: milestone) } let_it_be(:confidential_issue) { create(:issue, project: project, assignees: [user], milestone: milestone, confidential: true) } let(:current_user) { user } @@ -666,69 +665,59 @@ RSpec.describe 'New/edit issue', :js, feature_category: :team_planning do end end - describe 'inline edit' do - context 'within issue 1' do - before do - visit project_issue_path(project, issue) - wait_for_requests - end + describe 'editing an issue by hotkey' do + let_it_be(:issue2) { create(:issue, project: project) } - it 'opens inline edit form with shortcut' do - find('body').send_keys('e') + before do + visit project_issue_path(project, issue2) + end - expect(page).to have_selector('.detail-page-description form') - end + it 'opens inline edit form with shortcut' do + find('body').send_keys('e') - describe 'when user has made no changes' do - it 'let user leave the page without warnings' do - expected_content = 'Issue created' - expect(page).to have_content(expected_content) + expect(page).to have_selector('.detail-page-description form') + end - find('body').send_keys('e') + context 'when user has made no changes' do + it 'let user leave the page without warnings' do + expected_content = 'Issue created' + expect(page).to have_content(expected_content) - click_link 'Boards' + find('body').send_keys('e') - expect(page).not_to have_content(expected_content) - end - end + click_link 'Boards' - describe 'when user has made changes' do - it 'shows a warning and can stay on page', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397683' do - content = 'new issue content' + expect(page).not_to have_content(expected_content) + end + end - find('body').send_keys('e') - fill_in 'issue-description', with: content + context 'when user has made changes' do + it 'shows a warning and can stay on page' do + content = 'new issue content' - click_link 'Boards' + find('body').send_keys('e') + fill_in 'issue-description', with: content + click_link 'Boards' do page.driver.browser.switch_to.alert.dismiss - - click_button 'Save changes' - wait_for_requests - - expect(page).to have_content(content) end - end - end - context 'within issue 2' do - before do - visit project_issue_path(project, issue2) + click_button 'Save changes' wait_for_requests - end - describe 'when user has made changes' do - it 'shows a warning and can leave page', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410497' do - content = 'new issue content' - find('body').send_keys('e') - fill_in 'issue-description', with: content + expect(page).to have_content(content) + end - click_link 'Boards' + it 'shows a warning and can leave page' do + content = 'new issue content' + find('body').send_keys('e') + fill_in 'issue-description', with: content + click_link 'Boards' do page.driver.browser.switch_to.alert.accept - - expect(page).not_to have_content(content) end + + expect(page).not_to have_content(content) end end end diff --git a/spec/features/issues/issue_state_spec.rb b/spec/features/issues/issue_state_spec.rb index 758dafccb86..2a8b33183bb 100644 --- a/spec/features/issues/issue_state_spec.rb +++ b/spec/features/issues/issue_state_spec.rb @@ -3,53 +3,71 @@ require 'spec_helper' RSpec.describe 'issue state', :js, feature_category: :team_planning do - let_it_be(:project) { create(:project) } + include CookieHelper + + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } let_it_be(:user) { create(:user) } before do project.add_developer(user) sign_in(user) + set_cookie('new-actions-popover-viewed', 'true') end shared_examples 'issue closed' do |selector| it 'can close an issue' do - wait_for_requests + expect(page).to have_selector('[data-testid="issue-state-badge"]') - expect(find('.status-box')).to have_content 'Open' + expect(find('[data-testid="issue-state-badge"]')).to have_content 'Open' within selector do click_button 'Close issue' wait_for_requests end - expect(find('.status-box')).to have_content 'Closed' + expect(find('[data-testid="issue-state-badge"]')).to have_content 'Closed' end end shared_examples 'issue reopened' do |selector| it 'can reopen an issue' do - wait_for_requests + expect(page).to have_selector('[data-testid="issue-state-badge"]') - expect(find('.status-box')).to have_content 'Closed' + expect(find('[data-testid="issue-state-badge"]')).to have_content 'Closed' within selector do click_button 'Reopen issue' wait_for_requests end - expect(find('.status-box')).to have_content 'Open' + expect(find('[data-testid="issue-state-badge"]')).to have_content 'Open' end end - describe 'when open', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297348' do + describe 'when open' do context 'when clicking the top `Close issue` button', :aggregate_failures do - let(:open_issue) { create(:issue, project: project) } + context 'when move_close_into_dropdown FF is disabled' do + let(:open_issue) { create(:issue, project: project) } - before do - visit project_issue_path(project, open_issue) + before do + stub_feature_flags(move_close_into_dropdown: false) + visit project_issue_path(project, open_issue) + end + + it_behaves_like 'issue closed', '.detail-page-header-actions' end - it_behaves_like 'issue closed', '.detail-page-header' + context 'when move_close_into_dropdown FF is enabled' do + let(:open_issue) { create(:issue, project: project) } + + before do + visit project_issue_path(project, open_issue) + find('#new-actions-header-dropdown > button').click + end + + it_behaves_like 'issue closed', '.dropdown-menu-right' + end end context 'when clicking the bottom `Close issue` button', :aggregate_failures do @@ -63,15 +81,29 @@ RSpec.describe 'issue state', :js, feature_category: :team_planning do end end - describe 'when closed', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297201' do + describe 'when closed' do context 'when clicking the top `Reopen issue` button', :aggregate_failures do - let(:closed_issue) { create(:issue, project: project, state: 'closed') } + context 'when move_close_into_dropdown FF is disabled' do + let(:closed_issue) { create(:issue, project: project, state: 'closed', author: user) } - before do - visit project_issue_path(project, closed_issue) + before do + stub_feature_flags(move_close_into_dropdown: false) + visit project_issue_path(project, closed_issue) + end + + it_behaves_like 'issue reopened', '.detail-page-header-actions' end - it_behaves_like 'issue reopened', '.detail-page-header' + context 'when move_close_into_dropdown FF is enabled' do + let(:closed_issue) { create(:issue, project: project, state: 'closed', author: user) } + + before do + visit project_issue_path(project, closed_issue) + find('#new-actions-header-dropdown > button').click + end + + it_behaves_like 'issue reopened', '.dropdown-menu-right' + end end context 'when clicking the bottom `Reopen issue` button', :aggregate_failures do diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 4512e88ae72..a6ed0b52e7d 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -103,7 +103,7 @@ RSpec.describe 'issue move to another project', feature_category: :team_planning let(:namespace) { create(:namespace) } let(:regular_project) { create(:project, title: project_title, service_desk_enabled: false) } let(:service_desk_project) { build(:project, :private, namespace: namespace, service_desk_enabled: true) } - let(:service_desk_issue) { create(:issue, project: service_desk_project, author: ::User.support_bot) } + let(:service_desk_issue) { create(:issue, project: service_desk_project, author: ::Users::Internal.support_bot) } before do allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true) diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb index a390dca6822..293b6c53eb5 100644 --- a/spec/features/issues/note_polling_spec.rb +++ b/spec/features/issues/note_polling_spec.rb @@ -19,22 +19,6 @@ RSpec.describe 'Issue notes polling', :js, feature_category: :team_planning do expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') end - - context 'when action_cable_notes is disabled' do - before do - stub_feature_flags(action_cable_notes: false) - end - - it 'displays the new comment' do - visit project_issue_path(project, issue) - close_rich_text_promo_popover_if_present - - note = create(:note, noteable: issue, project: project, note: 'Looks good!') - wait_for_requests - - expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') - end - end end describe 'updates' do diff --git a/spec/features/issues/service_desk_spec.rb b/spec/features/issues/service_desk_spec.rb index 1b99c8b39d3..120b4ddb6e1 100644 --- a/spec/features/issues/service_desk_spec.rb +++ b/spec/features/issues/service_desk_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe 'Service Desk Issue Tracker', :js, feature_category: :service_desk do let(:project) { create(:project, :private, service_desk_enabled: true) } - let_it_be(:user) { create(:user) } - let_it_be(:support_bot) { User.support_bot } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:support_bot) { Users::Internal.support_bot } before do # The following two conditions equate to Gitlab::ServiceDesk.supported == true @@ -252,7 +252,7 @@ RSpec.describe 'Service Desk Issue Tracker', :js, feature_category: :service_des end it 'shows service_desk_reply_to in issues list' do - expect(page).to have_text('by GitLab Support Bot') + expect(page).to have_text('by service.desk@example.com via GitLab Support Bot') end end end diff --git a/spec/features/issues/todo_spec.rb b/spec/features/issues/todo_spec.rb index 2c537cefa5e..c503c18be8d 100644 --- a/spec/features/issues/todo_spec.rb +++ b/spec/features/issues/todo_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Manually create a todo item from issue', :js, feature_category: :team_planning do let!(:project) { create(:project) } let!(:issue) { create(:issue, project: project) } - let!(:user) { create(:user) } + let!(:user) { create(:user, :no_super_sidebar) } before do project.add_maintainer(user) diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb index 76b07d903bc..857cb1f39a2 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -16,7 +16,7 @@ RSpec.describe "User creates issue", feature_category: :team_planning do sign_out(:user) end - it "redirects to signin then back to new issue after signin", :js, quarantine: 'https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/1486' do + it "redirects to signin then back to new issue after signin", :js do create(:issue, project: project) visit project_issues_path(project) diff --git a/spec/features/issues/user_sees_live_update_spec.rb b/spec/features/issues/user_sees_live_update_spec.rb index 860603ad546..0822542ca02 100644 --- a/spec/features/issues/user_sees_live_update_spec.rb +++ b/spec/features/issues/user_sees_live_update_spec.rb @@ -19,34 +19,32 @@ RSpec.describe 'Issues > User sees live update', :js, feature_category: :team_pl expect(page).to have_text("new title") issue.update!(title: "updated title") - wait_for_requests + expect(page).to have_text("updated title") end end describe 'confidential issue#show' do - it 'shows confidential sibebar information as confidential and can be turned off', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/254644' do + it 'shows confidential sidebar information as confidential and can be turned off' do issue = create(:issue, :confidential, project: project) visit project_issue_path(project, issue) - expect(page).to have_css('.issuable-note-warning') - expect(find('.issuable-sidebar-item.confidentiality')).to have_css('.is-active') - expect(find('.issuable-sidebar-item.confidentiality')).not_to have_css('.not-active') - - find('.confidential-edit').click - expect(page).to have_css('.sidebar-item-warning-message') + expect(page).to have_text('This is a confidential issue. People without permission will never get a notification.') - within('.sidebar-item-warning-message') do - find('[data-testid="confidential-toggle"]').click + within '.block.confidentiality' do + click_button 'Edit' end - wait_for_requests + expect(page).to have_text('You are going to turn off the confidentiality. This means everyone will be able to see and leave a comment on this issue.') + + click_button 'Turn off' visit project_issue_path(project, issue) - expect(page).not_to have_css('.is-active') + expect(page).not_to have_css('.gl-badge', text: 'Confidential') + expect(page).not_to have_text('This is a confidential issue. People without permission will never get a notification.') end end end diff --git a/spec/features/issues/user_uses_quick_actions_spec.rb b/spec/features/issues/user_uses_quick_actions_spec.rb index dc149ccc698..c15716243ae 100644 --- a/spec/features/issues/user_uses_quick_actions_spec.rb +++ b/spec/features/issues/user_uses_quick_actions_spec.rb @@ -13,7 +13,7 @@ RSpec.describe 'Issues > User uses quick actions', :js, feature_category: :team_ context "issuable common quick actions" do let(:new_url_opts) { {} } - let(:maintainer) { create(:user) } + let(:maintainer) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :public) } let!(:label_bug) { create(:label, project: project, title: 'bug') } let!(:label_feature) { create(:label, project: project, title: 'feature') } @@ -26,7 +26,7 @@ RSpec.describe 'Issues > User uses quick actions', :js, feature_category: :team_ end describe 'issue-only commands' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :public, :repository) } let(:issue) { create(:issue, project: project, due_date: Date.new(2016, 8, 28)) } diff --git a/spec/features/jira_connect/branches_spec.rb b/spec/features/jira_connect/branches_spec.rb index 25dc14a1dc9..ae1dd551c47 100644 --- a/spec/features/jira_connect/branches_spec.rb +++ b/spec/features/jira_connect/branches_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe 'Create GitLab branches from Jira', :js, feature_category: :integrations do include ListboxHelpers - let_it_be(:alice) { create(:user, name: 'Alice') } - let_it_be(:bob) { create(:user, name: 'Bob') } + let_it_be(:alice) { create(:user, :no_super_sidebar, name: 'Alice') } + let_it_be(:bob) { create(:user, :no_super_sidebar, name: 'Bob') } let_it_be(:project1) { create(:project, :repository, namespace: alice.namespace, title: 'foo') } let_it_be(:project2) { create(:project, :repository, namespace: alice.namespace, title: 'bar') } diff --git a/spec/features/labels_hierarchy_spec.rb b/spec/features/labels_hierarchy_spec.rb index eb79d6e64f3..0cb712622f2 100644 --- a/spec/features/labels_hierarchy_spec.rb +++ b/spec/features/labels_hierarchy_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'Labels Hierarchy', :js, feature_category: :team_planning do include FilteredSearchHelpers include ContentEditorHelpers - let!(:user) { create(:user) } + let!(:user) { create(:user, :no_super_sidebar) } let!(:grandparent) { create(:group) } let!(:parent) { create(:group, parent: grandparent) } let!(:child) { create(:group, parent: parent) } diff --git a/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb b/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb index 446f6a470de..fea4841c5ea 100644 --- a/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb +++ b/spec/features/merge_request/user_closes_reopens_merge_request_state_spec.rb @@ -2,8 +2,7 @@ require 'spec_helper' -RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297500', - feature_category: :code_review_workflow do +RSpec.describe 'User closes/reopens a merge request', :js, feature_category: :code_review_workflow do let_it_be(:project) { create(:project, :repository) } let_it_be(:user) { create(:user) } @@ -13,89 +12,67 @@ RSpec.describe 'User closes/reopens a merge request', :js, quarantine: 'https:// end describe 'when open' do - context 'when clicking the top `Close merge request` link', :aggregate_failures do - let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) } - before do - visit merge_request_path(open_merge_request) - end + before do + visit merge_request_path(open_merge_request) + end - it 'can close a merge request' do - expect(find('.status-box')).to have_content 'Open' + context 'when clicking the top `Close merge request` button', :aggregate_failures do + it 'closes the merge request' do + expect(page).to have_css('.gl-badge', text: 'Open') within '.detail-page-header' do - click_button 'Toggle dropdown' - click_link 'Close merge request' + click_button 'Merge request actions' + click_button 'Close merge request' end - wait_for_requests - - expect(find('.status-box')).to have_content 'Closed' + expect(page).to have_css('.gl-badge', text: 'Closed') end end context 'when clicking the bottom `Close merge request` button', :aggregate_failures do - let(:open_merge_request) { create(:merge_request, source_project: project, target_project: project) } - - before do - visit merge_request_path(open_merge_request) - end - - it 'can close a merge request' do - expect(find('.status-box')).to have_content 'Open' + it 'closes the merge request' do + expect(page).to have_css('.gl-badge', text: 'Open') within '.timeline-content-form' do click_button 'Close merge request' - - # Clicking the bottom `Close merge request` button does not yet update - # the header status so for now we'll check that the button text changes - expect(page).not_to have_button 'Close merge request' - expect(page).to have_button 'Reopen merge request' end + + expect(page).to have_css('.gl-badge', text: 'Closed') end end end describe 'when closed' do - context 'when clicking the top `Reopen merge request` link', :aggregate_failures do - let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') } + let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') } - before do - visit merge_request_path(closed_merge_request) - end + before do + visit merge_request_path(closed_merge_request) + end - it 'can reopen a merge request' do - expect(find('.status-box')).to have_content 'Closed' + context 'when clicking the top `Reopen merge request` button', :aggregate_failures do + it 'reopens the merge request' do + expect(page).to have_css('.gl-badge', text: 'Closed') within '.detail-page-header' do - click_button 'Toggle dropdown' - click_link 'Reopen merge request' + click_button 'Merge request actions' + click_button 'Reopen merge request' end - wait_for_requests - - expect(find('.status-box')).to have_content 'Open' + expect(page).to have_css('.gl-badge', text: 'Open') end end context 'when clicking the bottom `Reopen merge request` button', :aggregate_failures do - let(:closed_merge_request) { create(:merge_request, source_project: project, target_project: project, state: 'closed') } - - before do - visit merge_request_path(closed_merge_request) - end - - it 'can reopen a merge request' do - expect(find('.status-box')).to have_content 'Closed' + it 'reopens the merge request' do + expect(page).to have_css('.gl-badge', text: 'Closed') within '.timeline-content-form' do click_button 'Reopen merge request' - - # Clicking the bottom `Reopen merge request` button does not yet update - # the header status so for now we'll check that the button text changes - expect(page).not_to have_button 'Reopen merge request' - expect(page).to have_button 'Close merge request' end + + expect(page).to have_css('.gl-badge', text: 'Open') end end end diff --git a/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb b/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb index a96ec1f68aa..df39fe492c1 100644 --- a/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb +++ b/spec/features/merge_request/user_interacts_with_batched_mr_diffs_spec.rb @@ -48,7 +48,8 @@ RSpec.describe 'Batch diffs', :js, feature_category: :code_review_workflow do context 'when user visits a URL with a link directly to to a discussion' do context 'which is in the first batched page of diffs' do - it 'scrolls to the correct discussion' do + it 'scrolls to the correct discussion', + quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410029' } do page.within get_first_diff do click_link('just now') end diff --git a/spec/features/merge_request/user_merges_merge_request_spec.rb b/spec/features/merge_request/user_merges_merge_request_spec.rb index 402405e1fb6..aee42784d05 100644 --- a/spec/features/merge_request/user_merges_merge_request_spec.rb +++ b/spec/features/merge_request/user_merges_merge_request_spec.rb @@ -5,7 +5,7 @@ require "spec_helper" RSpec.describe "User merges a merge request", :js, feature_category: :code_review_workflow do include ContentEditorHelpers - let(:user) { project.first_owner } + let_it_be(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) @@ -24,7 +24,7 @@ RSpec.describe "User merges a merge request", :js, feature_category: :code_revie end context 'sidebar merge requests counter' do - let(:project) { create(:project, :public, :repository) } + let_it_be(:project) { create(:project, :public, :repository, namespace: user.namespace) } let!(:merge_request) { create(:merge_request, source_project: project) } it 'decrements the open MR count', :sidekiq_inline do diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb deleted file mode 100644 index ebec8a6d2ea..00000000000 --- a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb +++ /dev/null @@ -1,150 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Merge request > User merges when pipeline succeeds', :js, feature_category: :code_review_workflow do - let(:project) { create(:project, :public, :repository) } - let(:user) { project.creator } - let(:merge_request) do - create( - :merge_request_with_diffs, - source_project: project, - author: user, - title: 'Bug NS-04', - merge_params: { force_remove_source_branch: '1' } - ) - end - - let(:pipeline) do - create( - :ci_pipeline, - project: project, - sha: merge_request.diff_head_sha, - ref: merge_request.source_branch, - head_pipeline_of: merge_request - ) - end - - before do - project.add_maintainer(user) - end - - context 'when there is active pipeline for merge request' do - before do - create(:ci_build, pipeline: pipeline) - stub_feature_flags(auto_merge_labels_mr_widget: true) - - sign_in(user) - visit project_merge_request_path(project, merge_request) - end - - describe 'enabling Merge when pipeline succeeds' do - shared_examples 'Set to auto-merge activator' do - it 'activates the Merge when pipeline succeeds feature', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410055' do - click_button "Set to auto-merge" - - expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds" - expect(page).to have_content "Source branch will not be deleted" - expect(page).to have_selector ".js-cancel-auto-merge" - visit project_merge_request_path(project, merge_request) # Needed to refresh the page - expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i - end - end - - context "when enabled immediately" do - it_behaves_like 'Set to auto-merge activator' - end - - context 'when enabled after it was previously canceled' do - before do - click_button "Set to auto-merge" - - wait_for_requests - - click_button "Cancel auto-merge" - - wait_for_requests - - expect(page).to have_content 'Set to auto-merge' - end - - it_behaves_like 'Set to auto-merge activator' - end - - context 'when it was enabled and then canceled' do - let(:merge_request) do - create( - :merge_request_with_diffs, - :merge_when_pipeline_succeeds, - source_project: project, - title: 'Bug NS-04', - author: user, - merge_user: user - ) - end - - before do - merge_request.merge_params['force_remove_source_branch'] = '0' - merge_request.save! - click_button "Cancel auto-merge" - end - - it_behaves_like 'Set to auto-merge activator' - end - end - end - - context 'when merge when pipeline succeeds is enabled' do - let(:merge_request) do - create( - :merge_request_with_diffs, - :simple, - :merge_when_pipeline_succeeds, - source_project: project, - author: user, - merge_user: user, - title: 'MepMep' - ) - end - - let!(:build) do - create(:ci_build, pipeline: pipeline) - end - - before do - stub_feature_flags(auto_merge_labels_mr_widget: true) - sign_in user - visit project_merge_request_path(project, merge_request) - end - - it 'allows to cancel the automatic merge', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410494' do - click_button "Cancel auto-merge" - - expect(page).to have_button "Set to auto-merge" - - refresh - - expect(page).to have_content "canceled the automatic merge" - end - end - - context 'when pipeline is not active' do - it 'does not allow to enable merge when pipeline succeeds' do - stub_feature_flags(auto_merge_labels_mr_widget: false) - - visit project_merge_request_path(project, merge_request) - - expect(page).not_to have_link 'Merge when pipeline succeeds' - end - end - - context 'when pipeline is not active and auto_merge_labels_mr_widget on' do - it 'does not allow to enable merge when pipeline succeeds' do - stub_feature_flags(auto_merge_labels_mr_widget: true) - - visit project_merge_request_path(project, merge_request) - - expect(page).not_to have_link 'Set to auto-merge' - end - end -end diff --git a/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb b/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb index 63f03ae64e0..c12816b6521 100644 --- a/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb +++ b/spec/features/merge_request/user_opens_checkout_branch_modal_spec.rb @@ -6,17 +6,16 @@ RSpec.describe 'Merge request > User opens checkout branch modal', :js, feature_ include ProjectForksHelper include CookieHelper - let(:project) { create(:project, :public, :repository) } - let(:user) { project.creator } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :public, :repository, namespace: user.namespace) } before do - project.add_maintainer(user) sign_in(user) set_cookie('new-actions-popover-viewed', 'true') end describe 'for fork' do - let(:author) { create(:user) } + let(:author) { create(:user, :no_super_sidebar) } let(:source_project) { fork_project(project, author, repository: true) } let(:merge_request) do diff --git a/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb b/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb index 21c62b0d0d8..e55ecd2a531 100644 --- a/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb +++ b/spec/features/merge_request/user_sees_check_out_branch_modal_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe 'Merge request > User sees check out branch modal', :js, feature_category: :code_review_workflow do include CookieHelper - let(:project) { create(:project, :public, :repository) } - let(:user) { project.creator } - let(:merge_request) { create(:merge_request, source_project: project) } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :public, :repository, creator: user) } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } let(:modal_window_title) { 'Check out, review, and resolve locally' } before do diff --git a/spec/features/merge_request/user_sees_deployment_widget_spec.rb b/spec/features/merge_request/user_sees_deployment_widget_spec.rb index d237faba663..dd1119c5648 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -119,37 +119,6 @@ RSpec.describe 'Merge request > User sees deployment widget', :js, feature_categ end before do - stub_feature_flags(review_apps_redeploy_mr_widget: false) - build.success! - deployment.update!(on_stop: manual.name) - visit project_merge_request_path(project, merge_request) - wait_for_requests - end - - it 'does start build when stop button clicked' do - accept_gl_confirm(button_text: 'Stop environment') do - find('.js-stop-env').click - end - - expect(page).to have_content('close_app') - end - - context 'for reporter' do - let(:role) { :reporter } - - it 'does not show stop button' do - expect(page).not_to have_selector('.js-stop-env') - end - end - end - - context 'with stop action with the review_apps_redeploy_mr_widget feature flag turned on' do - let(:manual) do - create(:ci_build, :manual, pipeline: pipeline, name: 'close_app', environment: environment.name) - end - - before do - stub_feature_flags(review_apps_redeploy_mr_widget: true) build.success! deployment.update!(on_stop: manual.name) visit project_merge_request_path(project, merge_request) @@ -173,9 +142,8 @@ RSpec.describe 'Merge request > User sees deployment widget', :js, feature_categ end end - context 'with redeploy action and with the review_apps_redeploy_mr_widget feature flag turned on' do + context 'with redeploy action' do before do - stub_feature_flags(review_apps_redeploy_mr_widget: true) build.success! environment.update!(state: 'stopped') visit project_merge_request_path(project, merge_request) diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb index add8e9f30de..e052d06c158 100644 --- a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb +++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb @@ -48,60 +48,29 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request', end # rubocop:enable RSpec/AvoidConditionalStatements - context 'when a user created a merge request in the parent project' do - let!(:merge_request) do - create( - :merge_request, - source_project: project, - target_project: project, - source_branch: 'feature', - target_branch: 'master' - ) - end - - let!(:push_pipeline) do - Ci::CreatePipelineService.new(project, user, ref: 'feature') - .execute(:push) - .payload - end - - let!(:detached_merge_request_pipeline) do - Ci::CreatePipelineService.new(project, user, ref: 'feature') - .execute(:merge_request_event, merge_request: merge_request) - .payload - end - + context 'with feature flag `mr_pipelines_graphql turned off`' do before do - visit project_merge_request_path(project, merge_request) - - page.within('.merge-request-tabs') do - click_link('Pipelines') - end - end - - it 'sees branch pipelines and detached merge request pipelines in correct order' do - page.within('.ci-table') do - expect(page).to have_selector('[data-testid="ci-badge-created"]', count: 2) - expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{detached_merge_request_pipeline.id}") - end + stub_feature_flags(mr_pipelines_graphql: false) end - it 'sees the latest detached merge request pipeline as the head pipeline', :sidekiq_might_not_need_inline do - click_link "Overview" - - page.within('.ci-widget-content') do - expect(page).to have_content("##{detached_merge_request_pipeline.id}") + context 'when a user created a merge request in the parent project' do + let!(:merge_request) do + create( + :merge_request, + source_project: project, + target_project: project, + source_branch: 'feature', + target_branch: 'master' + ) end - end - context 'when a user updated a merge request in the parent project', :sidekiq_might_not_need_inline do - let!(:push_pipeline_2) do + let!(:push_pipeline) do Ci::CreatePipelineService.new(project, user, ref: 'feature') .execute(:push) .payload end - let!(:detached_merge_request_pipeline_2) do + let!(:detached_merge_request_pipeline) do Ci::CreatePipelineService.new(project, user, ref: 'feature') .execute(:merge_request_event, merge_request: merge_request) .payload @@ -117,192 +86,184 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request', it 'sees branch pipelines and detached merge request pipelines in correct order' do page.within('.ci-table') do - expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 4) - - expect(all('[data-testid="pipeline-url-link"]')[0]) - .to have_content("##{detached_merge_request_pipeline_2.id}") - - expect(all('[data-testid="pipeline-url-link"]')[1]) - .to have_content("##{detached_merge_request_pipeline.id}") + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'created', count: 2) + expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{detached_merge_request_pipeline.id}") + end + end - expect(all('[data-testid="pipeline-url-link"]')[2]) - .to have_content("##{push_pipeline_2.id}") + it 'sees the latest detached merge request pipeline as the head pipeline', :sidekiq_might_not_need_inline do + click_link "Overview" - expect(all('[data-testid="pipeline-url-link"]')[3]) - .to have_content("##{push_pipeline.id}") + page.within('.ci-widget-content') do + expect(page).to have_content("##{detached_merge_request_pipeline.id}") end end - it 'sees detached tag for detached merge request pipelines' do - page.within('.ci-table') do - expect(all('.pipeline-tags')[0]) - .to have_content(expected_detached_mr_tag) + context 'when a user updated a merge request in the parent project', :sidekiq_might_not_need_inline do + let!(:push_pipeline_2) do + Ci::CreatePipelineService.new(project, user, ref: 'feature') + .execute(:push) + .payload + end - expect(all('.pipeline-tags')[1]) - .to have_content(expected_detached_mr_tag) + let!(:detached_merge_request_pipeline_2) do + Ci::CreatePipelineService.new(project, user, ref: 'feature') + .execute(:merge_request_event, merge_request: merge_request) + .payload + end - expect(all('.pipeline-tags')[2]) - .not_to have_content(expected_detached_mr_tag) + before do + visit project_merge_request_path(project, merge_request) - expect(all('.pipeline-tags')[3]) - .not_to have_content(expected_detached_mr_tag) + page.within('.merge-request-tabs') do + click_link('Pipelines') + end end - end - it 'sees the latest detached merge request pipeline as the head pipeline' do - click_link 'Overview' + it 'sees branch pipelines and detached merge request pipelines in correct order' do + page.within('.ci-table') do + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'pending', count: 4) - page.within('.ci-widget-content') do - expect(page).to have_content("##{detached_merge_request_pipeline_2.id}") + expect(all('[data-testid="pipeline-url-link"]')[0]) + .to have_content("##{detached_merge_request_pipeline_2.id}") + + expect(all('[data-testid="pipeline-url-link"]')[1]) + .to have_content("##{detached_merge_request_pipeline.id}") + + expect(all('[data-testid="pipeline-url-link"]')[2]) + .to have_content("##{push_pipeline_2.id}") + + expect(all('[data-testid="pipeline-url-link"]')[3]) + .to have_content("##{push_pipeline.id}") + end end - end - end - context 'when a user created a merge request in the parent project' do - before do - visit project_merge_request_path(project, merge_request) + it 'sees detached tag for detached merge request pipelines' do + page.within('.ci-table') do + expect(all('.pipeline-tags')[0]) + .to have_content(expected_detached_mr_tag) - page.within('.merge-request-tabs') do - click_link('Pipelines') + expect(all('.pipeline-tags')[1]) + .to have_content(expected_detached_mr_tag) + + expect(all('.pipeline-tags')[2]) + .not_to have_content(expected_detached_mr_tag) + + expect(all('.pipeline-tags')[3]) + .not_to have_content(expected_detached_mr_tag) + end end - end - context 'when a user merges a merge request in the parent project', :sidekiq_might_not_need_inline do - before do + it 'sees the latest detached merge request pipeline as the head pipeline' do click_link 'Overview' - click_button 'Set to auto-merge' - wait_for_requests + page.within('.ci-widget-content') do + expect(page).to have_content("##{detached_merge_request_pipeline_2.id}") + end end + end - context 'when detached merge request pipeline is pending' do - it 'waits the head pipeline' do - expect(page).to have_content mr_widget_title - expect(page).to have_button('Cancel auto-merge') + context 'when a user created a merge request in the parent project' do + before do + visit project_merge_request_path(project, merge_request) + + page.within('.merge-request-tabs') do + click_link('Pipelines') end end - context 'when branch pipeline succeeds' do + context 'when a user merges a merge request in the parent project', :sidekiq_might_not_need_inline do before do click_link 'Overview' - push_pipeline.reload.succeed! + click_button 'Set to auto-merge' wait_for_requests end - it 'waits the head pipeline' do - expect(page).to have_content mr_widget_title - expect(page).to have_button('Cancel auto-merge') + context 'when detached merge request pipeline is pending' do + it 'waits the head pipeline' do + expect(page).to have_content mr_widget_title + expect(page).to have_button('Cancel auto-merge') + end end - end - end - end - context 'when there are no `merge_requests` keyword in .gitlab-ci.yml' do - let(:config) do - { - build: { - script: 'build' - }, - test: { - script: 'test' - }, - deploy: { - script: 'deploy' - } - } - end - - it 'sees a branch pipeline in pipeline tab' do - page.within('.ci-table') do - expect(page).to have_selector('[data-testid="ci-badge-created"]', count: 1) - expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{push_pipeline.id}") - end - end + context 'when branch pipeline succeeds' do + before do + click_link 'Overview' + push_pipeline.reload.succeed! - it 'sees the latest branch pipeline as the head pipeline', :sidekiq_might_not_need_inline do - click_link 'Overview' + wait_for_requests + end - page.within('.ci-widget-content') do - expect(page).to have_content("##{push_pipeline.id}") + it 'waits the head pipeline' do + expect(page).to have_content mr_widget_title + expect(page).to have_button('Cancel auto-merge') + end + end end end - end - end - - context 'when a user created a merge request from a forked project to the parent project', :sidekiq_might_not_need_inline do - let(:merge_request) do - create( - :merge_request, - source_project: forked_project, - target_project: project, - source_branch: 'feature', - target_branch: 'master' - ) - end - - let!(:push_pipeline) do - Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') - .execute(:push) - .payload - end - - let!(:detached_merge_request_pipeline) do - Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') - .execute(:merge_request_event, merge_request: merge_request) - .payload - end - let(:forked_project) { fork_project(project, user2, repository: true) } - let(:user2) { create(:user) } - - before do - forked_project.add_maintainer(user2) - - stub_feature_flags(auto_merge_labels_mr_widget: false) + context 'when there are no `merge_requests` keyword in .gitlab-ci.yml' do + let(:config) do + { + build: { + script: 'build' + }, + test: { + script: 'test' + }, + deploy: { + script: 'deploy' + } + } + end - visit project_merge_request_path(project, merge_request) + it 'sees a branch pipeline in pipeline tab' do + page.within('.ci-table') do + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'created', count: 1) + expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{push_pipeline.id}") + end + end - page.within('.merge-request-tabs') do - click_link('Pipelines') - end - end + it 'sees the latest branch pipeline as the head pipeline', :sidekiq_might_not_need_inline do + click_link 'Overview' - it 'sees branch pipelines and detached merge request pipelines in correct order' do - page.within('.ci-table') do - expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 2) - expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{detached_merge_request_pipeline.id}") + page.within('.ci-widget-content') do + expect(page).to have_content("##{push_pipeline.id}") + end + end end end - it 'sees the latest detached merge request pipeline as the head pipeline' do - click_link "Overview" - - page.within('.ci-widget-content') do - expect(page).to have_content("##{detached_merge_request_pipeline.id}") + context 'when a user created a merge request from a forked project to the parent project', :sidekiq_might_not_need_inline do + let(:merge_request) do + create( + :merge_request, + source_project: forked_project, + target_project: project, + source_branch: 'feature', + target_branch: 'master' + ) end - end - it 'sees pipeline list in forked project' do - visit project_pipelines_path(forked_project) - - expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 2) - end - - context 'when a user updated a merge request from a forked project to the parent project' do - let!(:push_pipeline_2) do + let!(:push_pipeline) do Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') .execute(:push) .payload end - let!(:detached_merge_request_pipeline_2) do + let!(:detached_merge_request_pipeline) do Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') .execute(:merge_request_event, merge_request: merge_request) .payload end + let(:forked_project) { fork_project(project, user2, repository: true) } + let(:user2) { create(:user) } + before do + forked_project.add_maintainer(user2) + visit project_merge_request_path(project, merge_request) page.within('.merge-request-tabs') do @@ -312,35 +273,8 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request', it 'sees branch pipelines and detached merge request pipelines in correct order' do page.within('.ci-table') do - expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 4) - - expect(all('[data-testid="pipeline-url-link"]')[0]) - .to have_content("##{detached_merge_request_pipeline_2.id}") - - expect(all('[data-testid="pipeline-url-link"]')[1]) - .to have_content("##{detached_merge_request_pipeline.id}") - - expect(all('[data-testid="pipeline-url-link"]')[2]) - .to have_content("##{push_pipeline_2.id}") - - expect(all('[data-testid="pipeline-url-link"]')[3]) - .to have_content("##{push_pipeline.id}") - end - end - - it 'sees detached tag for detached merge request pipelines' do - page.within('.ci-table') do - expect(all('.pipeline-tags')[0]) - .to have_content(expected_detached_mr_tag) - - expect(all('.pipeline-tags')[1]) - .to have_content(expected_detached_mr_tag) - - expect(all('.pipeline-tags')[2]) - .not_to have_content(expected_detached_mr_tag) - - expect(all('.pipeline-tags')[3]) - .not_to have_content(expected_detached_mr_tag) + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'pending', count: 2) + expect(first('[data-testid="pipeline-url-link"]')).to have_content("##{detached_merge_request_pipeline.id}") end end @@ -348,88 +282,158 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request', click_link "Overview" page.within('.ci-widget-content') do - expect(page).to have_content("##{detached_merge_request_pipeline_2.id}") + expect(page).to have_content("##{detached_merge_request_pipeline.id}") end end it 'sees pipeline list in forked project' do visit project_pipelines_path(forked_project) - expect(page).to have_selector('[data-testid="ci-badge-pending"]', count: 4) + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'pending', count: 2) end - end - context 'when the latest pipeline is running in the parent project' do - before do - create(:ci_pipeline, - source: :merge_request_event, - project: project, - ref: 'feature', - sha: merge_request.diff_head_sha, - user: user, - merge_request: merge_request, - status: :running) - merge_request.update_head_pipeline - end + context 'when a user updated a merge request from a forked project to the parent project' do + let!(:push_pipeline_2) do + Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') + .execute(:push) + .payload + end + + let!(:detached_merge_request_pipeline_2) do + Ci::CreatePipelineService.new(forked_project, user2, ref: 'feature') + .execute(:merge_request_event, merge_request: merge_request) + .payload + end - context 'when the previous pipeline failed in the fork project' do before do - detached_merge_request_pipeline.reload.drop! + visit project_merge_request_path(project, merge_request) + + page.within('.merge-request-tabs') do + click_link('Pipelines') + end end - context 'when the parent project enables pipeline must succeed' do - before do - project.update!(only_allow_merge_if_pipeline_succeeds: true) + it 'sees branch pipelines and detached merge request pipelines in correct order' do + page.within('.ci-table') do + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'pending', count: 4) + + expect(all('[data-testid="pipeline-url-link"]')[0]) + .to have_content("##{detached_merge_request_pipeline_2.id}") + + expect(all('[data-testid="pipeline-url-link"]')[1]) + .to have_content("##{detached_merge_request_pipeline.id}") + + expect(all('[data-testid="pipeline-url-link"]')[2]) + .to have_content("##{push_pipeline_2.id}") + + expect(all('[data-testid="pipeline-url-link"]')[3]) + .to have_content("##{push_pipeline.id}") end + end + + it 'sees detached tag for detached merge request pipelines' do + page.within('.ci-table') do + expect(all('.pipeline-tags')[0]) + .to have_content(expected_detached_mr_tag) + + expect(all('.pipeline-tags')[1]) + .to have_content(expected_detached_mr_tag) - it 'shows Set to auto-merge button' do - visit project_merge_request_path(project, merge_request) + expect(all('.pipeline-tags')[2]) + .not_to have_content(expected_detached_mr_tag) - expect(page).to have_button('Set to auto-merge') + expect(all('.pipeline-tags')[3]) + .not_to have_content(expected_detached_mr_tag) end end - end - end - context 'when a user merges a merge request from a forked project to the parent project' do - before do - click_link("Overview") + it 'sees the latest detached merge request pipeline as the head pipeline' do + click_link "Overview" - click_button 'Set to auto-merge' + page.within('.ci-widget-content') do + expect(page).to have_content("##{detached_merge_request_pipeline_2.id}") + end + end - wait_for_requests - end + it 'sees pipeline list in forked project' do + visit project_pipelines_path(forked_project) - context 'when detached merge request pipeline is pending' do - it 'waits the head pipeline' do - expect(page).to have_content mr_widget_title - expect(page).to have_button('Cancel auto-merge') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'pending', count: 4) end end - context 'when detached merge request pipeline succeeds' do + context 'when the latest pipeline is running in the parent project' do before do - detached_merge_request_pipeline.reload.succeed! - - refresh + create(:ci_pipeline, + source: :merge_request_event, + project: project, + ref: 'feature', + sha: merge_request.diff_head_sha, + user: user, + merge_request: merge_request, + status: :running) + merge_request.update_head_pipeline end - it 'merges the merge request' do - expect(page).to have_content('Merged by') - expect(page).to have_button('Revert') + context 'when the previous pipeline failed in the fork project' do + before do + detached_merge_request_pipeline.reload.drop! + end + + context 'when the parent project enables pipeline must succeed' do + before do + project.update!(only_allow_merge_if_pipeline_succeeds: true) + end + + it 'shows Set to auto-merge button' do + visit project_merge_request_path(project, merge_request) + + expect(page).to have_button('Set to auto-merge') + end + end end end - context 'when branch pipeline succeeds' do + context 'when a user merges a merge request from a forked project to the parent project' do before do - push_pipeline.reload.succeed! + click_link("Overview") + + click_button 'Set to auto-merge' wait_for_requests end - it 'waits the head pipeline' do - expect(page).to have_content mr_widget_title - expect(page).to have_button('Cancel auto-merge') + context 'when detached merge request pipeline is pending' do + it 'waits the head pipeline' do + expect(page).to have_content mr_widget_title + expect(page).to have_button('Cancel auto-merge') + end + end + + context 'when detached merge request pipeline succeeds' do + before do + detached_merge_request_pipeline.reload.succeed! + + wait_for_requests + end + + it 'merges the merge request' do + expect(page).to have_content('Merged by') + expect(page).to have_button('Revert') + end + end + + context 'when branch pipeline succeeds' do + before do + push_pipeline.reload.succeed! + + wait_for_requests + end + + it 'waits the head pipeline' do + expect(page).to have_content mr_widget_title + expect(page).to have_button('Cancel auto-merge') + end end end end diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index 75df93d1a6c..1db09790e1c 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -646,7 +646,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category: click_expand_button within('[data-testid="widget-extension-collapsed-section"]') do - click_link 'addTest' + click_button 'View details' end end @@ -693,7 +693,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category: click_expand_button within('[data-testid="widget-extension-collapsed-section"]') do - click_link 'Test#sum when a is 1 and b is 3 returns summary' + click_button 'View details' end end @@ -741,7 +741,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category: click_expand_button within('[data-testid="widget-extension-collapsed-section"]') do - click_link 'addTest' + click_button 'View details' end end @@ -788,7 +788,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category: click_expand_button within('[data-testid="widget-extension-collapsed-section"]') do - click_link 'addTest' + click_button 'View details' end end @@ -834,7 +834,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category: click_expand_button within('[data-testid="widget-extension-collapsed-section"]') do - click_link 'Test#sum when a is 4 and b is 4 returns summary' + click_button 'View details' end end @@ -881,7 +881,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category: click_expand_button within('[data-testid="widget-extension-collapsed-section"]') do - click_link 'addTest' + click_button 'View details' end end @@ -958,4 +958,21 @@ RSpec.describe 'Merge request > User sees merge widget', :js, feature_category: end end end + + context 'views MR when pipeline has code coverage enabled' do + let!(:pipeline) { create(:ci_pipeline, status: 'success', project: project, ref: merge_request.source_branch) } + let!(:build) { create(:ci_build, :success, :coverage, pipeline: pipeline) } + + before do + merge_request.update!(head_pipeline: pipeline) + + visit project_merge_request_path(project, merge_request) + end + + it 'shows the coverage' do + within '.ci-widget' do + expect(find_by_testid('pipeline-coverage')).to have_content('Test coverage 99.90% ') + end + end + end end diff --git a/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb index 5801e8a1a11..77cd116ecc9 100644 --- a/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb +++ b/spec/features/merge_request/user_sees_pipelines_from_forked_project_spec.rb @@ -30,15 +30,33 @@ RSpec.describe 'Merge request > User sees pipelines from forked project', :js, before do create(:ci_build, pipeline: pipeline, name: 'rspec') create(:ci_build, pipeline: pipeline, name: 'spinach') - sign_in(user) - visit project_merge_request_path(target_project, merge_request) end - it 'user visits a pipelines page', :sidekiq_might_not_need_inline do - page.within('.merge-request-tabs') { click_link 'Pipelines' } + context 'with feature flag `mr_pipelines_graphql` turned off' do + before do + stub_feature_flags(mr_pipelines_graphql: false) + visit project_merge_request_path(target_project, merge_request) + end + + it 'user visits a pipelines page', :sidekiq_might_not_need_inline do + page.within('.merge-request-tabs') { click_link 'Pipelines' } + + page.within('.ci-table') do + expect(page).to have_content(pipeline.id) + end + end + end + + context 'with feature flag `mr_pipelines_graphql` turned on' do + before do + stub_feature_flags(mr_pipelines_graphql: true) + visit project_merge_request_path(target_project, merge_request) + end + + it 'user visits a pipelines page', :sidekiq_might_not_need_inline do + page.within('.merge-request-tabs') { click_link 'Pipelines' } - page.within('.ci-table') do expect(page).to have_content(pipeline.id) end end diff --git a/spec/features/merge_request/user_sees_pipelines_spec.rb b/spec/features/merge_request/user_sees_pipelines_spec.rb index 5ce919fe2e6..bb3890f5242 100644 --- a/spec/features/merge_request/user_sees_pipelines_spec.rb +++ b/spec/features/merge_request/user_sees_pipelines_spec.rb @@ -3,285 +3,291 @@ require 'spec_helper' RSpec.describe 'Merge request > User sees pipelines', :js, feature_category: :code_review_workflow do - describe 'pipeline tab' do - let(:merge_request) { create(:merge_request) } - let(:project) { merge_request.target_project } - let(:user) { project.creator } - + context 'with feature flag `mr_pipelines_graphql turned off`' do before do - project.add_maintainer(user) - sign_in(user) + stub_feature_flags(mr_pipelines_graphql: false) end - context 'with pipelines' do - let!(:pipeline) do - create( - :ci_pipeline, - :success, - project: merge_request.source_project, - ref: merge_request.source_branch, - sha: merge_request.diff_head_sha - ) - end - - let!(:manual_job) { create(:ci_build, :manual, name: 'job1', stage: 'deploy', pipeline: pipeline) } - - let!(:job) { create(:ci_build, :success, name: 'job2', stage: 'test', pipeline: pipeline) } + describe 'pipeline tab' do + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.target_project } + let(:user) { project.creator } before do - merge_request.update_attribute(:head_pipeline_id, pipeline.id) + project.add_maintainer(user) + sign_in(user) end - it 'pipelines table displays correctly' do - visit project_merge_request_path(project, merge_request) - - expect(page.find('.ci-widget')).to have_content('passed') - - page.within('.merge-request-tabs') do - click_link('Pipelines') + context 'with pipelines' do + let!(:pipeline) do + create( + :ci_pipeline, + :success, + project: merge_request.source_project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha + ) end - wait_for_requests + let!(:manual_job) { create(:ci_build, :manual, name: 'job1', stage: 'deploy', pipeline: pipeline) } - page.within(find('[data-testid="pipeline-table-row"]', match: :first)) do - expect(page).to have_selector('[data-testid="ci-badge-passed"]') - expect(page).to have_content(pipeline.id) - expect(page).to have_content('API') - expect(page).to have_css('[data-testid="pipeline-mini-graph"]') - expect(page).to have_css('[data-testid="pipelines-manual-actions-dropdown"]') - expect(page).to have_css('[data-testid="pipeline-multi-actions-dropdown"]') - end - end + let!(:job) { create(:ci_build, :success, name: 'job2', stage: 'test', pipeline: pipeline) } - context 'with a detached merge request pipeline' do - let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) } + before do + merge_request.update_attribute(:head_pipeline_id, pipeline.id) + end - it 'displays the "Run pipeline" button' do + it 'pipelines table displays correctly' do visit project_merge_request_path(project, merge_request) + expect(page.find('.ci-widget')).to have_content('passed') + page.within('.merge-request-tabs') do click_link('Pipelines') end wait_for_requests - expect(page.find('[data-testid="run_pipeline_button"]')).to have_text('Run pipeline') + page.within(find('[data-testid="pipeline-table-row"]', match: :first)) do + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'passed') + expect(page).to have_content(pipeline.id) + expect(page).to have_content('API') + expect(page).to have_css('[data-testid="pipeline-mini-graph"]') + expect(page).to have_css('[data-testid="pipelines-manual-actions-dropdown"]') + expect(page).to have_css('[data-testid="pipeline-multi-actions-dropdown"]') + end end - end - context 'with a merged results pipeline' do - let(:merge_request) { create(:merge_request, :with_merge_request_pipeline) } + context 'with a detached merge request pipeline' do + let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) } - it 'displays the "Run pipeline" button' do - visit project_merge_request_path(project, merge_request) + it 'displays the "Run pipeline" button' do + visit project_merge_request_path(project, merge_request) - page.within('.merge-request-tabs') do - click_link('Pipelines') - end + page.within('.merge-request-tabs') do + click_link('Pipelines') + end - wait_for_requests + wait_for_requests - expect(page.find('[data-testid="run_pipeline_button"]')).to have_text('Run pipeline') + expect(page.find('[data-testid="run_pipeline_button"]')).to have_text('Run pipeline') + end end - end - end - context 'without pipelines' do - before do - visit project_merge_request_path(project, merge_request) - end + context 'with a merged results pipeline' do + let(:merge_request) { create(:merge_request, :with_merge_request_pipeline) } + + it 'displays the "Run pipeline" button' do + visit project_merge_request_path(project, merge_request) + + page.within('.merge-request-tabs') do + click_link('Pipelines') + end + + wait_for_requests - it 'user visits merge request page' do - page.within('.merge-request-tabs') do - expect(page).to have_link('Pipelines') + expect(page.find('[data-testid="run_pipeline_button"]')).to have_text('Run pipeline') + end end end - it 'shows empty state with run pipeline button' do - page.within('.merge-request-tabs') do - click_link('Pipelines') + context 'without pipelines' do + before do + visit project_merge_request_path(project, merge_request) end - expect(page).to have_content('There are currently no pipelines.') - expect(page.find('[data-testid="run_pipeline_button"]')).to have_text('Run pipeline') - end - end - end + it 'user visits merge request page' do + page.within('.merge-request-tabs') do + expect(page).to have_link('Pipelines') + end + end - describe 'fork MRs in parent project', :sidekiq_inline do - include ProjectForksHelper - - let_it_be(:parent_project) { create(:project, :public, :repository) } - let_it_be(:forked_project) { fork_project(parent_project, developer_in_fork, repository: true, target_project: create(:project, :public, :repository)) } - let_it_be(:developer_in_parent) { create(:user) } - let_it_be(:developer_in_fork) { create(:user) } - let_it_be(:reporter_in_parent_and_developer_in_fork) { create(:user) } - - let(:merge_request) do - create( - :merge_request, - :with_detached_merge_request_pipeline, - source_project: forked_project, - source_branch: 'feature', - target_project: parent_project, - target_branch: 'master' - ) - end + it 'shows empty state with run pipeline button' do + page.within('.merge-request-tabs') do + click_link('Pipelines') + end - let(:config) do - { test: { script: 'test', rules: [{ if: '$CI_MERGE_REQUEST_ID' }] } } + expect(page).to have_content('There are currently no pipelines.') + expect(page.find('[data-testid="run_pipeline_button"]')).to have_text('Run pipeline') + end + end end - before_all do - parent_project.add_developer(developer_in_parent) - parent_project.add_reporter(reporter_in_parent_and_developer_in_fork) - forked_project.add_developer(developer_in_fork) - forked_project.add_developer(reporter_in_parent_and_developer_in_fork) - end + describe 'fork MRs in parent project', :sidekiq_inline do + include ProjectForksHelper - before do - stub_ci_pipeline_yaml_file(YAML.dump(config)) - sign_in(actor) - end + let_it_be(:parent_project) { create(:project, :public, :repository) } + let_it_be(:forked_project) { fork_project(parent_project, developer_in_fork, repository: true, target_project: create(:project, :public, :repository)) } + let_it_be(:developer_in_parent) { create(:user) } + let_it_be(:developer_in_fork) { create(:user) } + let_it_be(:reporter_in_parent_and_developer_in_fork) { create(:user) } - after do - parent_project.all_pipelines.delete_all - forked_project.all_pipelines.delete_all - end + let(:merge_request) do + create( + :merge_request, + :with_detached_merge_request_pipeline, + source_project: forked_project, + source_branch: 'feature', + target_project: parent_project, + target_branch: 'master' + ) + end - context 'when actor is a developer in parent project' do - let(:actor) { developer_in_parent } + let(:config) do + { test: { script: 'test', rules: [{ if: '$CI_MERGE_REQUEST_ID' }] } } + end - it 'creates a pipeline in the parent project when user proceeds with the warning' do - visit project_merge_request_path(parent_project, merge_request) + before_all do + parent_project.add_developer(developer_in_parent) + parent_project.add_reporter(reporter_in_parent_and_developer_in_fork) + forked_project.add_developer(developer_in_fork) + forked_project.add_developer(reporter_in_parent_and_developer_in_fork) + end - create_merge_request_pipeline - act_on_security_warning(action: 'Run pipeline') + before do + stub_ci_pipeline_yaml_file(YAML.dump(config)) + sign_in(actor) + end - check_pipeline(expected_project: parent_project) - check_head_pipeline(expected_project: parent_project) + after do + parent_project.all_pipelines.delete_all + forked_project.all_pipelines.delete_all end - it 'does not create a pipeline in the parent project when user cancels the action', :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state do - visit project_merge_request_path(parent_project, merge_request) + context 'when actor is a developer in parent project' do + let(:actor) { developer_in_parent } - create_merge_request_pipeline - act_on_security_warning(action: 'Cancel') + it 'creates a pipeline in the parent project when user proceeds with the warning' do + visit project_merge_request_path(parent_project, merge_request) - check_no_new_pipeline_created - end - end + create_merge_request_pipeline + act_on_security_warning(action: 'Run pipeline') - context 'when actor is a developer in fork project' do - let(:actor) { developer_in_fork } + check_pipeline(expected_project: parent_project) + check_head_pipeline(expected_project: parent_project) + end - it 'creates a pipeline in the fork project' do - visit project_merge_request_path(parent_project, merge_request) + it 'does not create a pipeline in the parent project when user cancels the action', :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state do + visit project_merge_request_path(parent_project, merge_request) - create_merge_request_pipeline + create_merge_request_pipeline + act_on_security_warning(action: 'Cancel') - check_pipeline(expected_project: forked_project) - check_head_pipeline(expected_project: forked_project) + check_no_new_pipeline_created + end end - end - context 'when actor is a reporter in parent project and a developer in fork project' do - let(:actor) { reporter_in_parent_and_developer_in_fork } + context 'when actor is a developer in fork project' do + let(:actor) { developer_in_fork } - it 'creates a pipeline in the fork project' do - visit project_merge_request_path(parent_project, merge_request) + it 'creates a pipeline in the fork project' do + visit project_merge_request_path(parent_project, merge_request) - create_merge_request_pipeline + create_merge_request_pipeline - check_pipeline(expected_project: forked_project) - check_head_pipeline(expected_project: forked_project) + check_pipeline(expected_project: forked_project) + check_head_pipeline(expected_project: forked_project) + end end - end - def create_merge_request_pipeline - page.within('.merge-request-tabs') { click_link('Pipelines') } - click_on('Run pipeline') - end + context 'when actor is a reporter in parent project and a developer in fork project' do + let(:actor) { reporter_in_parent_and_developer_in_fork } - def check_pipeline(expected_project:) - page.within('.ci-table') do - expect(page).to have_selector('[data-testid="pipeline-table-row"]', count: 4) + it 'creates a pipeline in the fork project' do + visit project_merge_request_path(parent_project, merge_request) - page.within(first('[data-testid="pipeline-table-row"]')) do - page.within('.pipeline-tags') do - expect(page.find('[data-testid="pipeline-url-link"]')[:href]).to include(expected_project.full_path) - expect(page).to have_content('merge request') - end - page.within('.pipeline-triggerer') do - expect(page).to have_link(href: user_path(actor)) + create_merge_request_pipeline + + check_pipeline(expected_project: forked_project) + check_head_pipeline(expected_project: forked_project) + end + end + + def create_merge_request_pipeline + page.within('.merge-request-tabs') { click_link('Pipelines') } + click_on('Run pipeline') + end + + def check_pipeline(expected_project:) + page.within('.ci-table') do + expect(page).to have_selector('[data-testid="pipeline-table-row"]', count: 4) + + page.within(first('[data-testid="pipeline-table-row"]')) do + page.within('.pipeline-tags') do + expect(page.find('[data-testid="pipeline-url-link"]')[:href]).to include(expected_project.full_path) + expect(page).to have_content('merge request') + end + page.within('.pipeline-triggerer') do + expect(page).to have_link(href: user_path(actor)) + end end end end - end - def check_head_pipeline(expected_project:) - page.within('.merge-request-tabs') { click_link('Overview') } + def check_head_pipeline(expected_project:) + page.within('.merge-request-tabs') { click_link('Overview') } - page.within('.ci-widget-content') do - expect(page.find('.pipeline-id')[:href]).to include(expected_project.full_path) + page.within('.ci-widget-content') do + expect(page.find('.pipeline-id')[:href]).to include(expected_project.full_path) + end end - end - def act_on_security_warning(action:) - page.within('#create-pipeline-for-fork-merge-request-modal') do - expect(page).to have_content('Are you sure you want to run this pipeline?') - click_button(action) + def act_on_security_warning(action:) + page.within('#create-pipeline-for-fork-merge-request-modal') do + expect(page).to have_content('Are you sure you want to run this pipeline?') + click_button(action) + end end - end - def check_no_new_pipeline_created - page.within('.ci-table') do - expect(page).to have_selector('[data-testid="pipeline-table-row"]', count: 2) + def check_no_new_pipeline_created + page.within('.ci-table') do + expect(page).to have_selector('[data-testid="pipeline-table-row"]', count: 2) + end end end - end - - describe 'race condition' do - let(:project) { create(:project, :repository) } - let(:user) { create(:user) } - let(:build_push_data) { { ref: 'feature', checkout_sha: TestEnv::BRANCH_SHA['feature'] } } - let(:merge_request_params) do - { "source_branch" => "feature", "source_project_id" => project.id, - "target_branch" => "master", "target_project_id" => project.id, "title" => "A" } - end + describe 'race condition' do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + let(:build_push_data) { { ref: 'feature', checkout_sha: TestEnv::BRANCH_SHA['feature'] } } - before do - project.add_maintainer(user) - sign_in user - end + let(:merge_request_params) do + { "source_branch" => "feature", "source_project_id" => project.id, + "target_branch" => "master", "target_project_id" => project.id, "title" => "A" } + end - context 'when pipeline and merge request were created simultaneously', :delete do before do - stub_ci_pipeline_to_return_yaml_file + project.add_maintainer(user) + sign_in user + end + + context 'when pipeline and merge request were created simultaneously', :delete do + before do + stub_ci_pipeline_to_return_yaml_file - threads = [] + threads = [] - threads << Thread.new do - Sidekiq::Worker.skipping_transaction_check do - @merge_request = MergeRequests::CreateService.new(project: project, current_user: user, params: merge_request_params).execute + threads << Thread.new do + Sidekiq::Worker.skipping_transaction_check do + @merge_request = MergeRequests::CreateService.new(project: project, current_user: user, params: merge_request_params).execute + end end - end - threads << Thread.new do - Sidekiq::Worker.skipping_transaction_check do - @pipeline = Ci::CreatePipelineService.new(project, user, build_push_data).execute(:push).payload + threads << Thread.new do + Sidekiq::Worker.skipping_transaction_check do + @pipeline = Ci::CreatePipelineService.new(project, user, build_push_data).execute(:push).payload + end end - end - threads.each { |thr| thr.join } - end + threads.each { |thr| thr.join } + end - it 'user sees pipeline in merge request widget', :sidekiq_might_not_need_inline do - visit project_merge_request_path(project, @merge_request) + it 'user sees pipeline in merge request widget', :sidekiq_might_not_need_inline do + visit project_merge_request_path(project, @merge_request) - expect(page.find(".ci-widget")).to have_content(TestEnv::BRANCH_SHA['feature']) - expect(page.find(".ci-widget")).to have_content("##{@pipeline.id}") + expect(page.find(".ci-widget")).to have_content(TestEnv::BRANCH_SHA['feature']) + expect(page.find(".ci-widget")).to have_content("##{@pipeline.id}") + end end end end diff --git a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb index e3be99254dc..16578af238d 100644 --- a/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb +++ b/spec/features/merge_request/user_selects_branches_for_new_mr_spec.rb @@ -6,8 +6,8 @@ RSpec.describe 'Merge request > User selects branches for new MR', :js, feature_ include ListboxHelpers include CookieHelper - let(:project) { create(:project, :public, :repository) } - let(:user) { project.creator } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :public, :repository, namespace: user.namespace) } def select_source_branch(branch_name) find('.js-source-branch', match: :first).click @@ -16,7 +16,6 @@ RSpec.describe 'Merge request > User selects branches for new MR', :js, feature_ end before do - project.add_maintainer(user) sign_in(user) set_cookie('new-actions-popover-viewed', 'true') end diff --git a/spec/features/merge_request/user_sets_to_auto_merge_spec.rb b/spec/features/merge_request/user_sets_to_auto_merge_spec.rb new file mode 100644 index 00000000000..4dc0c03aedc --- /dev/null +++ b/spec/features/merge_request/user_sets_to_auto_merge_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Merge request > User sets to auto-merge', :js, feature_category: :code_review_workflow do + include ContentEditorHelpers + + let(:project) { create(:project, :public, :repository) } + let(:user) { project.creator } + let(:merge_request) do + create( + :merge_request_with_diffs, + source_project: project, + author: user, + title: 'Bug NS-04', + merge_params: { force_remove_source_branch: '1' } + ) + end + + let(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + head_pipeline_of: merge_request + ) + end + + before do + project.add_maintainer(user) + end + + context 'when there is active pipeline for merge request' do + before do + create(:ci_build, pipeline: pipeline) + + sign_in(user) + visit project_merge_request_path(project, merge_request) + end + + describe 'setting to auto-merge when pipeline succeeds' do + shared_examples 'Set to auto-merge activator' do + it 'activates auto-merge feature', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410055' do + close_rich_text_promo_popover_if_present + expect(page).to have_content 'Set to auto-merge' + click_button "Set to auto-merge" + wait_for_requests + + expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds" + expect(page).to have_content "Source branch will not be deleted" + expect(page).to have_selector ".js-cancel-auto-merge" + expect(page).to have_content(/enabled an automatic merge when the pipeline for \h{8} succeeds/i) + end + end + + context "when enabled immediately" do + it_behaves_like 'Set to auto-merge activator' + end + + context 'when enabled after it was previously canceled' do + before do + close_rich_text_promo_popover_if_present + click_button "Set to auto-merge" + + wait_for_requests + + click_button "Cancel auto-merge" + + wait_for_requests + end + + it_behaves_like 'Set to auto-merge activator' + end + + context 'when it is enabled and then canceled' do + let(:merge_request) do + create( + :merge_request_with_diffs, + :merge_when_pipeline_succeeds, + source_project: project, + title: 'Bug NS-04', + author: user, + merge_user: user + ) + end + + before do + merge_request.merge_params['force_remove_source_branch'] = '0' + merge_request.save! + click_button "Cancel auto-merge" + end + + it_behaves_like 'Set to auto-merge activator' + end + end + end + + context 'when there is an active pipeline' do + let(:merge_request) do + create( + :merge_request_with_diffs, + :simple, + :merge_when_pipeline_succeeds, + source_project: project, + author: user, + merge_user: user, + title: 'MepMep' + ) + end + + let!(:build) do + create(:ci_build, pipeline: pipeline) + end + + before do + sign_in user + visit project_merge_request_path(project, merge_request) + end + + it 'allows to cancel the auto-merge', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/410055' do + close_rich_text_promo_popover_if_present + + click_button "Cancel auto-merge" + + expect(page).to have_button "Set to auto-merge" + + refresh + + expect(page).to have_content "canceled the automatic merge" + end + end + + context 'when there is no active pipeline' do + before do + sign_in user + visit project_merge_request_path(project, merge_request.reload) + end + + it 'does not allow to set to auto-merge' do + expect(page).not_to have_link 'Set to auto-merge' + end + end +end diff --git a/spec/features/merge_request/user_uses_quick_actions_spec.rb b/spec/features/merge_request/user_uses_quick_actions_spec.rb index 1c63f5b56b0..b2cc25f1c34 100644 --- a/spec/features/merge_request/user_uses_quick_actions_spec.rb +++ b/spec/features/merge_request/user_uses_quick_actions_spec.rb @@ -11,15 +11,9 @@ RSpec.describe 'Merge request > User uses quick actions', :js, :use_clean_rails_ feature_category: :code_review_workflow do include Features::NotesHelpers - let(:project) { create(:project, :public, :repository) } - let(:user) { project.creator } - let(:guest) { create(:user) } - let(:merge_request) { create(:merge_request, source_project: project) } - let!(:milestone) { create(:milestone, project: project, title: 'ASAP') } - context "issuable common quick actions" do let!(:new_url_opts) { { merge_request: { source_branch: 'feature', target_branch: 'master' } } } - let(:maintainer) { create(:user) } + let(:maintainer) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :public, :repository) } let!(:label_bug) { create(:label, project: project, title: 'bug') } let!(:label_feature) { create(:label, project: project, title: 'feature') } @@ -32,7 +26,8 @@ RSpec.describe 'Merge request > User uses quick actions', :js, :use_clean_rails_ end describe 'merge-request-only commands' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } + let(:guest) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :public, :repository) } let(:merge_request) { create(:merge_request, source_project: project) } let!(:milestone) { create(:milestone, project: project, title: 'ASAP') } diff --git a/spec/features/monitor_sidebar_link_spec.rb b/spec/features/monitor_sidebar_link_spec.rb index 6e464cb8752..1d39f749ca7 100644 --- a/spec/features/monitor_sidebar_link_spec.rb +++ b/spec/features/monitor_sidebar_link_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'Monitor dropdown sidebar', :aggregate_failures, feature_category: :shared do let_it_be_with_reload(:project) { create(:project, :internal, :repository) } - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let(:role) { nil } diff --git a/spec/features/nav/pinned_nav_items_spec.rb b/spec/features/nav/pinned_nav_items_spec.rb index cf53e0a322a..1a3ac973ed4 100644 --- a/spec/features/nav/pinned_nav_items_spec.rb +++ b/spec/features/nav/pinned_nav_items_spec.rb @@ -168,17 +168,19 @@ RSpec.describe 'Navigation menu item pinning', :js, feature_category: :navigatio private - def add_pin(menu_item_title) - menu_item = find("[data-testid=\"nav-item-link\"]", text: menu_item_title) - menu_item.hover - menu_item.find("[data-testid=\"thumbtack-icon\"]").click + def add_pin(nav_item_title) + nav_item = find("[data-testid=\"nav-item\"]", text: nav_item_title) + nav_item.hover + pin_button = nav_item.find("[data-testid=\"nav-item-pin\"]") + pin_button.click wait_for_requests end - def remove_pin(menu_item_title) - menu_item = find("[data-testid=\"nav-item-link\"]", text: menu_item_title) - menu_item.hover - menu_item.find("[data-testid=\"thumbtack-solid-icon\"]").click + def remove_pin(nav_item_title) + nav_item = find("[data-testid=\"nav-item\"]", text: nav_item_title) + nav_item.hover + unpin_button = nav_item.find("[data-testid=\"nav-item-unpin\"]") + unpin_button.click wait_for_requests end diff --git a/spec/features/nav/top_nav_responsive_spec.rb b/spec/features/nav/top_nav_responsive_spec.rb index ff8132dc087..2a07742c91e 100644 --- a/spec/features/nav/top_nav_responsive_spec.rb +++ b/spec/features/nav/top_nav_responsive_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'top nav responsive', :js, feature_category: :navigation do include MobileHelpers include Features::InviteMembersModalHelpers - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/nav/top_nav_spec.rb b/spec/features/nav/top_nav_spec.rb index ccbf4646273..bf91897eb26 100644 --- a/spec/features/nav/top_nav_spec.rb +++ b/spec/features/nav/top_nav_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'top nav responsive', :js, feature_category: :navigation do include Features::InviteMembersModalHelpers - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb index ca20a1cd81b..b65416ee618 100644 --- a/spec/features/oauth_login_spec.rb +++ b/spec/features/oauth_login_spec.rb @@ -136,7 +136,7 @@ RSpec.describe 'OAuth Login', :allow_forgery_protection, feature_category: :syst # record as the host / port depends on whether or not the spec uses # JS. let(:application) do - create(:oauth_application, scopes: 'api', redirect_uri: redirect_uri, confidential: false) + create(:oauth_application, scopes: 'api', redirect_uri: redirect_uri, confidential: true) end let(:params) do diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index a756c524cbb..697ad4c87f7 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'User edit profile', feature_category: :user_profile do include Features::NotesHelpers - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } before do stub_feature_flags(edit_user_profile_vue: false) @@ -478,7 +478,7 @@ RSpec.describe 'User edit profile', feature_category: :user_profile do end context 'Remove status button' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do user.status = UserStatus.new(message: 'Eating bread', emoji: 'stuffed_flatbread') diff --git a/spec/features/profiles/user_visits_notifications_tab_spec.rb b/spec/features/profiles/user_visits_notifications_tab_spec.rb index 7d858e3c92c..3af8dadcde0 100644 --- a/spec/features/profiles/user_visits_notifications_tab_spec.rb +++ b/spec/features/profiles/user_visits_notifications_tab_spec.rb @@ -12,14 +12,6 @@ RSpec.describe 'User visits the notifications tab', :js, feature_category: :user visit(profile_notifications_path) end - it 'turns on the receive product marketing emails setting' do - expect(page).to have_content('Notifications') - - expect do - check 'Receive product marketing emails' - end.to change { user.reload.email_opted_in }.to(true) - end - it 'changes the project notifications setting' do expect(page).to have_content('Notifications') diff --git a/spec/features/profiles/user_visits_profile_account_page_spec.rb b/spec/features/profiles/user_visits_profile_account_page_spec.rb index 8ff9cbc242e..8569cefd1f4 100644 --- a/spec/features/profiles/user_visits_profile_account_page_spec.rb +++ b/spec/features/profiles/user_visits_profile_account_page_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User visits the profile account page', feature_category: :user_profile do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/profiles/user_visits_profile_authentication_log_spec.rb b/spec/features/profiles/user_visits_profile_authentication_log_spec.rb index ac0ed91468c..f92b8e2e751 100644 --- a/spec/features/profiles/user_visits_profile_authentication_log_spec.rb +++ b/spec/features/profiles/user_visits_profile_authentication_log_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User visits the authentication log', feature_category: :user_profile do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } context 'when user signed in' do before do diff --git a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb index d690589b893..033711f699e 100644 --- a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb +++ b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'User visits the profile preferences page', :js, feature_category: :user_profile do include ListboxHelpers - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/profiles/user_visits_profile_spec.rb b/spec/features/profiles/user_visits_profile_spec.rb index 14fc6ed33b3..821c3d5ef2b 100644 --- a/spec/features/profiles/user_visits_profile_spec.rb +++ b/spec/features/profiles/user_visits_profile_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User visits their profile', feature_category: :user_profile do - let_it_be_with_refind(:user) { create(:user) } + let_it_be_with_refind(:user) { create(:user, :no_super_sidebar) } before do stub_feature_flags(profile_tabs_vue: false) diff --git a/spec/features/profiles/user_visits_profile_ssh_keys_page_spec.rb b/spec/features/profiles/user_visits_profile_ssh_keys_page_spec.rb index 547e47ead77..728fe1a3172 100644 --- a/spec/features/profiles/user_visits_profile_ssh_keys_page_spec.rb +++ b/spec/features/profiles/user_visits_profile_ssh_keys_page_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User visits the profile SSH keys page', feature_category: :user_profile do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/projects/active_tabs_spec.rb b/spec/features/projects/active_tabs_spec.rb index 594c2b442aa..973a1e76679 100644 --- a/spec/features/projects/active_tabs_spec.rb +++ b/spec/features/projects/active_tabs_spec.rb @@ -3,9 +3,8 @@ require 'spec_helper' RSpec.describe 'Project active tab', feature_category: :groups_and_projects do - let_it_be(:project) { create(:project, :repository, :with_namespace_settings) } - - let(:user) { project.first_owner } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :repository, :with_namespace_settings, namespace: user.namespace) } before do sign_in(user) diff --git a/spec/features/projects/branches/user_creates_branch_spec.rb b/spec/features/projects/branches/user_creates_branch_spec.rb index 8d636dacb75..eafb75d75ac 100644 --- a/spec/features/projects/branches/user_creates_branch_spec.rb +++ b/spec/features/projects/branches/user_creates_branch_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'User creates branch', :js, feature_category: :groups_and_project include Features::BranchesHelpers let_it_be(:group) { create(:group, :public) } - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } shared_examples 'creates new branch' do specify do diff --git a/spec/features/projects/ci/editor_spec.rb b/spec/features/projects/ci/editor_spec.rb index b09aa91f4ab..adaa5e48967 100644 --- a/spec/features/projects/ci/editor_spec.rb +++ b/spec/features/projects/ci/editor_spec.rb @@ -71,7 +71,7 @@ RSpec.describe 'Pipeline Editor', :js, feature_category: :pipeline_composition d it 'renders the empty page', :aggregate_failures do expect(page).to have_content 'Optimize your workflow with CI/CD Pipelines' - expect(page).to have_selector '[data-testid="create_new_ci_button"]' + expect(page).to have_selector '[data-testid="create-new-ci-button"]' end context 'when clicking on the create new CI button' do diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index eadcc0e62c4..b16f43a16b6 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'Gcp Cluster', :js, feature_category: :deployment_management do include GoogleApi::CloudPlatformHelpers let(:project) { create(:project) } - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do project.add_maintainer(user) diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb index 6da8eea687e..1393cc6db15 100644 --- a/spec/features/projects/clusters/user_spec.rb +++ b/spec/features/projects/clusters/user_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'User Cluster', :js, feature_category: :deployment_management do include GoogleApi::CloudPlatformHelpers let(:project) { create(:project) } - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do project.add_maintainer(user) diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb index d40f929d0b2..e075cc86319 100644 --- a/spec/features/projects/clusters_spec.rb +++ b/spec/features/projects/clusters_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'Clusters', :js, feature_category: :groups_and_projects do include GoogleApi::CloudPlatformHelpers let(:project) { create(:project) } - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do project.add_maintainer(user) diff --git a/spec/features/projects/commit/user_sees_pipelines_tab_spec.rb b/spec/features/projects/commit/user_sees_pipelines_tab_spec.rb index e44364c7f2d..bc5d468c97a 100644 --- a/spec/features/projects/commit/user_sees_pipelines_tab_spec.rb +++ b/spec/features/projects/commit/user_sees_pipelines_tab_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Commit > Pipelines tab', :js, feature_category: :source_code_man wait_for_requests page.within('[data-testid="pipeline-table-row"]') do - expect(page).to have_selector('[data-testid="ci-badge-passed"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'passed') expect(page).to have_content(pipeline.id) expect(page).to have_content('API') expect(page).to have_css('[data-testid="pipeline-mini-graph"]') diff --git a/spec/features/projects/confluence/user_views_confluence_page_spec.rb b/spec/features/projects/confluence/user_views_confluence_page_spec.rb index c1ce6ea4536..216bea74c09 100644 --- a/spec/features/projects/confluence/user_views_confluence_page_spec.rb +++ b/spec/features/projects/confluence/user_views_confluence_page_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User views the Confluence page', feature_category: :integrations do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :public) } diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 11ea72b87a2..3abe3ce1396 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -10,24 +10,19 @@ RSpec.describe 'Environment', feature_category: :groups_and_projects do before do sign_in(user) project.add_role(user, role) - stub_feature_flags(environment_details_vue: false) end def auto_stop_button_selector %q{button[title="Prevent environment from auto-stopping"]} end - describe 'environment details page vue' do + describe 'environment details page', :js do let_it_be(:environment) { create(:environment, project: project) } let!(:permissions) {} let!(:deployment) {} let!(:action) {} let!(:cluster) {} - before do - stub_feature_flags(environment_details_vue: true) - end - context 'with auto-stop' do let_it_be(:environment) { create(:environment, :will_auto_stop, name: 'staging', project: project) } @@ -35,122 +30,16 @@ RSpec.describe 'Environment', feature_category: :groups_and_projects do visit_environment(environment) end - it 'shows auto stop info', :js do - expect(page).to have_content('Auto stops') - end - - it 'shows auto stop button', :js do - expect(page).to have_selector(auto_stop_button_selector) - expect(page.find(auto_stop_button_selector).find(:xpath, '..')['action']).to have_content(cancel_auto_stop_project_environment_path(environment.project, environment)) - end - - it 'allows user to cancel auto stop', :js do - page.find(auto_stop_button_selector).click - wait_for_all_requests - expect(page).to have_content('Auto stop successfully canceled.') - expect(page).not_to have_selector(auto_stop_button_selector) - end - end - - context 'without deployments' do - before do - visit_environment(environment) - end - - it 'does not show deployments', :js do - expect(page).to have_content('You don\'t have any deployments right now.') - end - end - - context 'with deployments' do - before do - visit_environment(environment) - end - - context 'when there is a successful deployment' do - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, :success, pipeline: pipeline) } - - let(:deployment) do - create(:deployment, :success, environment: environment, deployable: build) - end - - it 'does show deployments', :js do - wait_for_requests - expect(page).to have_link("#{build.name} (##{build.id})") - end - end - - context 'when there is a failed deployment' do - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline) } - - let(:deployment) do - create(:deployment, :failed, environment: environment, deployable: build) - end - - it 'does show deployments', :js do - wait_for_requests - expect(page).to have_link("#{build.name} (##{build.id})") - end - end - - context 'with related deployable present' do - let_it_be(:previous_pipeline) { create(:ci_pipeline, project: project) } - - let_it_be(:previous_build) do - create(:ci_build, :success, pipeline: previous_pipeline, environment: environment.name) - end - - let_it_be(:previous_deployment) do - create(:deployment, :success, environment: environment, deployable: previous_build) - end - - let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - let_it_be(:build) { create(:ci_build, pipeline: pipeline, environment: environment.name) } - - let_it_be(:deployment) do - create(:deployment, :success, environment: environment, deployable: build) - end - - before do - visit_environment(environment) - end - - it 'shows deployment information and buttons', :js do - wait_for_requests - expect(page).to have_button('Re-deploy to environment') - expect(page).to have_button('Rollback environment') - expect(page).to have_link("#{build.name} (##{build.id})") - end - end - end - end - - describe 'environment details page' do - let_it_be(:environment) { create(:environment, project: project) } - let!(:permissions) {} - let!(:deployment) {} - let!(:action) {} - let!(:cluster) {} - - context 'with auto-stop' do - let!(:environment) { create(:environment, :will_auto_stop, name: 'staging', project: project) } - - before do - visit_environment(environment) - end - - it 'shows auto stop info', :js do + it 'shows auto stop info' do expect(page).to have_content('Auto stops') end - it 'shows auto stop button', :js do + it 'shows auto stop button' do expect(page).to have_selector(auto_stop_button_selector) expect(page.find(auto_stop_button_selector).find(:xpath, '..')['action']).to have_content(cancel_auto_stop_project_environment_path(environment.project, environment)) end - it 'allows user to cancel auto stop', :js do + it 'allows user to cancel auto stop' do page.find(auto_stop_button_selector).click wait_for_all_requests expect(page).to have_content('Auto stop successfully canceled.') @@ -208,10 +97,6 @@ RSpec.describe 'Environment', feature_category: :groups_and_projects do it 'does show deployments' do expect(page).to have_link("#{build.name} (##{build.id})") end - - it 'shows a tooltip on the job name' do - expect(page).to have_css("[title=\"#{build.name} (##{build.id})\"].has-tooltip") - end end context 'when there is a failed deployment' do @@ -227,26 +112,6 @@ RSpec.describe 'Environment', feature_category: :groups_and_projects do end end - context 'with many deployments' do - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline) } - - let!(:second) { create(:deployment, environment: environment, deployable: build, status: :success, finished_at: Time.current) } - let!(:first) { create(:deployment, environment: environment, deployable: build, status: :running) } - let!(:last) { create(:deployment, environment: environment, deployable: build, status: :success, finished_at: 2.days.ago) } - let!(:third) { create(:deployment, environment: environment, deployable: build, status: :canceled, finished_at: 1.day.ago) } - - before do - visit_environment(environment) - end - - it 'shows all of them in ordered way' do - ids = find_all('[data-testid="deployment-id"]').map { |e| e.text } - expected_ordered_ids = [first, second, third, last].map { |d| "##{d.iid}" } - expect(ids).to eq(expected_ordered_ids) - end - end - context 'with upcoming deployments' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } @@ -265,7 +130,7 @@ RSpec.describe 'Environment', feature_category: :groups_and_projects do # See https://gitlab.com/gitlab-org/gitlab/-/issues/350618 for more information. it 'shows upcoming deployments in unordered way' do displayed_ids = find_all('[data-testid="deployment-id"]').map { |e| e.text } - internal_ids = [runnind_deployment_1, runnind_deployment_2, success_without_finished_at].map { |d| "##{d.iid}" } + internal_ids = [runnind_deployment_1, runnind_deployment_2, success_without_finished_at].map { |d| d.iid.to_s } expect(displayed_ids).to match_array(internal_ids) end end @@ -309,20 +174,19 @@ RSpec.describe 'Environment', feature_category: :groups_and_projects do end it 'does show a play button' do - expect(page).to have_link(action.name) + expect(page).to have_button(action.name, visible: :all) end - it 'does allow to play manual action', :js do + it 'does allow to play manual action' do expect(action).to be_manual - find('button.dropdown').click + click_button('Deploy to...') - expect { click_link(action.name) } + expect { click_button(action.name) } .not_to change { Ci::Pipeline.count } wait_for_all_requests - expect(page).to have_content(action.name) expect(action.reload).to be_pending end end @@ -347,38 +211,6 @@ RSpec.describe 'Environment', feature_category: :groups_and_projects do end end - context 'with terminal' do - context 'when user configured kubernetes from CI/CD > Clusters' do - let!(:cluster) do - create(:cluster, :project, :provided_by_gcp, projects: [project]) - end - - context 'for project maintainer' do - let(:role) { :maintainer } - - context 'web terminal', :js do - before do - # Stub #terminals as it causes js-enabled feature specs to - # render the page incorrectly - # - # In EE we have to stub EE::Environment since it overwrites - # the "terminals" method. - allow_next_instance_of(Gitlab.ee? ? EE::Environment : Environment) do |instance| - allow(instance).to receive(:terminals) { nil } - end - - visit terminal_project_environment_path(project, environment) - end - - it 'displays a web terminal' do - expect(page).to have_selector('#terminal') - expect(page).to have_link(nil, href: environment.external_url) - end - end - end - end - end - context 'when environment is available' do context 'with stop action' do let(:build) { create(:ci_build, :success, pipeline: pipeline, environment: environment.name) } @@ -446,6 +278,8 @@ RSpec.describe 'Environment', feature_category: :groups_and_projects do visit folder_project_environments_path(project, id: 'staging-1.0') end + wait_for_requests + expect(reqs.first.status_code).to eq(200) expect(page).to have_content('Environments / staging-1.0') end diff --git a/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb index 4af5c91479a..127610cf4db 100644 --- a/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb +++ b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb @@ -7,13 +7,14 @@ RSpec.describe 'User creates feature flag', :js do let(:user) { create(:user) } let(:project) { create(:project, namespace: user.namespace) } + let!(:environment) { create(:environment, :production, project: project) } before do project.add_developer(user) sign_in(user) end - it 'user creates a flag enabled for user ids' do + it 'user creates a flag enabled for user ids with existing environment' do visit(new_project_feature_flag_path(project)) set_feature_flag_info('test_feature', 'Test feature') within_strategy_row(1) do @@ -29,6 +30,22 @@ RSpec.describe 'User creates feature flag', :js do expect(page).to have_text('test_feature') end + it 'user creates a flag enabled for user ids with non-existing environment' do + visit(new_project_feature_flag_path(project)) + set_feature_flag_info('test_feature', 'Test feature') + within_strategy_row(1) do + select 'User IDs', from: 'Type' + fill_in 'User IDs', with: 'user1, user2' + environment_plus_button.click + environment_search_input.set('foo-bar') + environment_search_create_button.first.click + end + click_button 'Create feature flag' + + expect_user_to_see_feature_flags_index_page + expect(page).to have_text('test_feature') + end + it 'user creates a flag with default environment scopes' do visit(new_project_feature_flag_path(project)) set_feature_flag_info('test_flag', 'Test flag') @@ -74,14 +91,18 @@ RSpec.describe 'User creates feature flag', :js do end def environment_plus_button - find('.js-new-environments-dropdown') + find('[data-testid=new-environments-dropdown]') end def environment_search_input - find('.js-new-environments-dropdown input') + find('[data-testid=new-environments-dropdown] input') end def environment_search_results - all('.js-new-environments-dropdown button.dropdown-item') + all('[data-testid=new-environments-dropdown] li') + end + + def environment_search_create_button + all('[data-testid=new-environments-dropdown] button') end end diff --git a/spec/features/projects/features_visibility_spec.rb b/spec/features/projects/features_visibility_spec.rb index b798524b9c4..8f66b722ead 100644 --- a/spec/features/projects/features_visibility_spec.rb +++ b/spec/features/projects/features_visibility_spec.rb @@ -3,10 +3,10 @@ require 'spec_helper' RSpec.describe 'Edit Project Settings', feature_category: :groups_and_projects do - let(:member) { create(:user) } + let(:member) { create(:user, :no_super_sidebar) } let!(:project) { create(:project, :public, :repository) } let!(:issue) { create(:issue, project: project) } - let(:non_member) { create(:user) } + let(:non_member) { create(:user, :no_super_sidebar) } describe 'project features visibility selectors', :js do before do diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index 95e96159744..595aad0144b 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' RSpec.describe 'Projects > Files > Project owner creates a license file', :js, feature_category: :groups_and_projects do - let(:project) { create(:project, :repository) } - let(:project_maintainer) { project.first_owner } + let_it_be(:project_maintainer) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :repository, namespace: project_maintainer.namespace) } before do project.repository.delete_file(project_maintainer, 'LICENSE', diff --git a/spec/features/projects/files/user_find_file_spec.rb b/spec/features/projects/files/user_find_file_spec.rb index 5406726eb6e..005a870bea0 100644 --- a/spec/features/projects/files/user_find_file_spec.rb +++ b/spec/features/projects/files/user_find_file_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'User find project file', feature_category: :groups_and_projects do include ListboxHelpers - let(:user) { create :user } + let(:user) { create :user, :no_super_sidebar } let(:project) { create :project, :repository } before do diff --git a/spec/features/projects/files/user_searches_for_files_spec.rb b/spec/features/projects/files/user_searches_for_files_spec.rb index 25456593fc4..627912df408 100644 --- a/spec/features/projects/files/user_searches_for_files_spec.rb +++ b/spec/features/projects/files/user_searches_for_files_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe 'Projects > Files > User searches for files', feature_category: :groups_and_projects do - let(:user) { project.first_owner } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :repository, namespace: user.namespace) } before do sign_in(user) @@ -11,7 +12,7 @@ RSpec.describe 'Projects > Files > User searches for files', feature_category: : describe 'project main screen' do context 'when project is empty' do - let(:project) { create(:project) } + let_it_be(:project) { create(:project, namespace: user.namespace) } before do visit project_path(project) @@ -25,10 +26,7 @@ RSpec.describe 'Projects > Files > User searches for files', feature_category: : end context 'when project is not empty' do - let(:project) { create(:project, :repository) } - before do - project.add_developer(user) visit project_path(project) end @@ -39,10 +37,7 @@ RSpec.describe 'Projects > Files > User searches for files', feature_category: : end describe 'project tree screen' do - let(:project) { create(:project, :repository) } - before do - project.add_developer(user) visit project_tree_path(project, project.default_branch) end diff --git a/spec/features/projects/forks/fork_list_spec.rb b/spec/features/projects/forks/fork_list_spec.rb index 966147637f5..86e4e03259e 100644 --- a/spec/features/projects/forks/fork_list_spec.rb +++ b/spec/features/projects/forks/fork_list_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'listing forks of a project', feature_category: :groups_and_proje let(:source) { create(:project, :public, :repository) } let!(:fork) { fork_project(source, nil, repository: true) } - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do source.add_maintainer(user) diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb index 16a3686215f..9b0803e4b0c 100644 --- a/spec/features/projects/graph_spec.rb +++ b/spec/features/projects/graph_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Project Graph', :js, feature_category: :groups_and_projects do - let(:user) { create :user } + let(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :repository, namespace: user.namespace) } let(:branch_name) { 'master' } diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb index ad2fccc14bf..bda45da0fa5 100644 --- a/spec/features/projects/import_export/export_file_spec.rb +++ b/spec/features/projects/import_export/export_file_spec.rb @@ -38,6 +38,10 @@ RSpec.describe 'Import/Export - project export integration test', :js, feature_c context 'admin user' do before do sign_in(user) + + # Now that we export project in batches we produce more queries than before + # needing to increase the default threshold + allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(200) end it 'exports a project successfully', :sidekiq_inline do diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb index 77f95827d88..afcf0e660f7 100644 --- a/spec/features/projects/jobs/user_browses_jobs_spec.rb +++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb @@ -72,7 +72,7 @@ RSpec.describe 'User browses jobs', feature_category: :groups_and_projects do wait_for_requests - expect(page).to have_selector('[data-testid="ci-badge-canceled"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'canceled') expect(page).not_to have_selector('[data-testid="jobs-table-error-alert"]') end end @@ -93,7 +93,7 @@ RSpec.describe 'User browses jobs', feature_category: :groups_and_projects do wait_for_requests - expect(page).to have_selector('[data-testid="ci-badge-pending"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'pending') end end @@ -133,7 +133,7 @@ RSpec.describe 'User browses jobs', feature_category: :groups_and_projects do wait_for_requests - expect(page).to have_selector('[data-testid="ci-badge-pending"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'pending') end it 'unschedules a job successfully' do @@ -141,7 +141,7 @@ RSpec.describe 'User browses jobs', feature_category: :groups_and_projects do wait_for_requests - expect(page).to have_selector('[data-testid="ci-badge-manual"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'manual') end end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index c203e644280..1bee4cc5081 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -66,7 +66,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou wait_for_requests - expect(page).to have_css('[data-testid="ci-badge-passed"]', text: 'passed') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'passed') end it 'shows commit`s data', :js do @@ -93,7 +93,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou visit project_job_path(project, job) within '.js-pipeline-info' do - expect(page).to have_content("Pipeline ##{pipeline.id} for #{pipeline.ref}") + expect(page).to have_content("Pipeline ##{pipeline.id} #{pipeline.status} for #{pipeline.ref}") end end @@ -239,7 +239,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou href = new_project_issue_path(project, options) - page.within('.build-sidebar') do + page.within('aside.right-sidebar') do expect(find('[data-testid="job-new-issue"]')['href']).to include(href) end end @@ -1051,7 +1051,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state, feature_category: :grou it 'retries the job' do find('[data-testid="retry-button-modal"]').click - within '[data-testid="ci-header-content"]' do + within '[data-testid="job-header-content"]' do expect(page).to have_content('pending') end end diff --git a/spec/features/projects/labels/user_creates_labels_spec.rb b/spec/features/projects/labels/user_creates_labels_spec.rb index 46729048fe7..6e52963bee2 100644 --- a/spec/features/projects/labels/user_creates_labels_spec.rb +++ b/spec/features/projects/labels/user_creates_labels_spec.rb @@ -63,6 +63,8 @@ RSpec.describe "User creates labels", feature_category: :team_planning do end end end + + it_behaves_like "lock_on_merge when creating labels" end context "in another project" do diff --git a/spec/features/projects/labels/user_edits_labels_spec.rb b/spec/features/projects/labels/user_edits_labels_spec.rb index bf1182cfddd..059edea2109 100644 --- a/spec/features/projects/labels/user_edits_labels_spec.rb +++ b/spec/features/projects/labels/user_edits_labels_spec.rb @@ -13,16 +13,16 @@ RSpec.describe "User edits labels", feature_category: :team_planning do project.add_maintainer(user) sign_in(user) - visit(edit_project_label_path(project, label)) + visit edit_project_label_path(project, label) end - it "updates label's title" do - new_title = "fix" + it 'update label with new title' do + new_title = 'fix' - fill_in("Title", with: new_title) - click_button("Save changes") + fill_in('Title', with: new_title) + click_button('Save changes') - page.within(".other-labels .manage-labels-list") do + page.within('.other-labels .manage-labels-list') do expect(page).to have_content(new_title).and have_no_content(label.title) end end @@ -38,4 +38,17 @@ RSpec.describe "User edits labels", feature_category: :team_planning do expect(page).to have_content("#{label.title} was removed").and have_no_content("#{label.title}") end + + describe 'lock_on_merge' do + let_it_be_with_reload(:label_unlocked) { create(:label, project: project, lock_on_merge: false) } + let_it_be(:label_locked) { create(:label, project: project, lock_on_merge: true) } + let_it_be(:edit_label_path_unlocked) { edit_project_label_path(project, label_unlocked) } + let_it_be(:edit_label_path_locked) { edit_project_label_path(project, label_locked) } + + before do + visit edit_label_path_unlocked + end + + it_behaves_like 'lock_on_merge when editing labels' + end end diff --git a/spec/features/projects/members/manage_members_spec.rb b/spec/features/projects/members/manage_members_spec.rb index 0e3ac5ff3ac..76b2a73e170 100644 --- a/spec/features/projects/members/manage_members_spec.rb +++ b/spec/features/projects/members/manage_members_spec.rb @@ -173,7 +173,7 @@ RSpec.describe 'Projects > Members > Manage members', :js, feature_category: :on end end - it_behaves_like 'inviting members', 'project-members-page' do + it_behaves_like 'inviting members', 'project_members_page' do let_it_be(:entity) { project } let_it_be(:members_page_path) { project_project_members_path(entity) } let_it_be(:subentity) { project } diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 9af36b4b2a9..d1e58ba91f0 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe 'Projects > Members > User requests access', :js, feature_category: :groups_and_projects do include Spec::Support::Helpers::ModalHelpers - let_it_be(:user) { create(:user) } - let_it_be(:maintainer) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:maintainer) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, :public, :repository) } let(:owner) { project.first_owner } diff --git a/spec/features/projects/milestones/user_interacts_with_labels_spec.rb b/spec/features/projects/milestones/user_interacts_with_labels_spec.rb index 36dfee7811d..3742c9f19d8 100644 --- a/spec/features/projects/milestones/user_interacts_with_labels_spec.rb +++ b/spec/features/projects/milestones/user_interacts_with_labels_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User interacts with labels', feature_category: :team_planning do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, namespace: user.namespace) } let(:milestone) { create(:milestone, project: project, title: 'v2.2', description: '# Description header') } let(:issue1) { create(:issue, project: project, title: 'Bugfix1', milestone: milestone) } diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index b6645e9b710..e967c1be3bc 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -8,15 +8,13 @@ RSpec.describe 'Project navbar', :with_license, feature_category: :groups_and_pr include_context 'project navbar structure' - let_it_be(:project) { create(:project, :repository) } - - let(:user) { project.first_owner } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :repository, namespace: user.namespace) } before do sign_in(user) stub_config(registry: { enabled: false }) - stub_feature_flags(harbor_registry_integration: false) stub_feature_flags(ml_experiment_tracking: false) insert_package_nav(_('Deployments')) insert_infrastructure_registry_nav @@ -88,8 +86,6 @@ RSpec.describe 'Project navbar', :with_license, feature_category: :groups_and_pr let_it_be(:harbor_integration) { create(:harbor_integration, project: project) } before do - stub_feature_flags(harbor_registry_integration: true) - insert_harbor_registry_nav(_('Terraform modules')) visit project_path(project) diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 6e6d9ff4af9..926fea24e14 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -10,7 +10,7 @@ RSpec.describe 'New project', :js, feature_category: :groups_and_projects do end context 'as a user' do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) @@ -76,7 +76,7 @@ RSpec.describe 'New project', :js, feature_category: :groups_and_projects do end context 'as an admin' do - let(:user) { create(:admin) } + let(:user) { create(:admin, :no_super_sidebar) } shared_examples '"New project" page' do before do @@ -566,14 +566,14 @@ RSpec.describe 'New project', :js, feature_category: :groups_and_projects do let(:provider) { :bitbucket } context 'as a user' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:oauth_config_instructions) { 'To enable importing projects from Bitbucket, ask your GitLab administrator to configure OAuth integration' } it_behaves_like 'has instructions to enable OAuth' end context 'as an admin', :do_not_mock_admin_mode_setting do - let(:user) { create(:admin) } + let(:user) { create(:admin, :no_super_sidebar) } let(:oauth_config_instructions) { 'To enable importing projects from Bitbucket, as administrator you need to configure OAuth integration' } it_behaves_like 'has instructions to enable OAuth' @@ -581,7 +581,7 @@ RSpec.describe 'New project', :js, feature_category: :groups_and_projects do end describe 'sidebar' do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:parent_group) { create(:group) } before do @@ -616,14 +616,14 @@ RSpec.describe 'New project', :js, feature_category: :groups_and_projects do context 'for a new top-level project' do it 'shows the "Your work" navigation' do visit new_project_path - expect(page).to have_selector(".super-sidebar .context-switcher-toggle", text: "Your work") + expect(page).to have_selector(".super-sidebar", text: "Your work") end end context 'for a new group project' do it 'shows the group sidebar of the parent group' do visit new_project_path(namespace_id: parent_group.id) - expect(page).to have_selector(".super-sidebar .context-switcher-toggle", text: parent_group.name) + expect(page).to have_selector(".super-sidebar", text: parent_group.name) end end end diff --git a/spec/features/projects/pages/user_edits_settings_spec.rb b/spec/features/projects/pages/user_edits_settings_spec.rb index eec9f2befb6..8350214bf99 100644 --- a/spec/features/projects/pages/user_edits_settings_spec.rb +++ b/spec/features/projects/pages/user_edits_settings_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Pages edits pages settings', :js, feature_category: :pages do include Spec::Support::Helpers::ModalHelpers let_it_be_with_reload(:project) { create(:project, :pages_published, pages_https_only: false) } - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } before do allow(Gitlab.config.pages).to receive(:enabled).and_return(true) diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index 358c55376d4..322d25ed052 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -12,389 +12,301 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :groups_and_projects let!(:user) { create(:user) } let!(:maintainer) { create(:user) } - context 'with pipeline_schedules_vue feature flag turned off' do + context 'logged in as the pipeline schedule owner' do before do - stub_feature_flags(pipeline_schedules_vue: false) + project.add_developer(user) + pipeline_schedule.update!(owner: user) + gitlab_sign_in(user) end - context 'logged in as the pipeline schedule owner' do + describe 'GET /projects/pipeline_schedules' do before do - project.add_developer(user) - pipeline_schedule.update!(owner: user) - gitlab_sign_in(user) + visit_pipelines_schedules end - describe 'GET /projects/pipeline_schedules' do - before do - visit_pipelines_schedules - end - - it 'edits the pipeline' do - page.within('.pipeline-schedule-table-row') do - click_link 'Edit' - end + it 'edits the pipeline' do + page.find('[data-testid="edit-pipeline-schedule-btn"]').click - expect(page).to have_content('Edit Pipeline Schedule') - end + expect(page).to have_content(s_('PipelineSchedules|Edit pipeline schedule')) end + end - describe 'PATCH /projects/pipelines_schedules/:id/edit' do - before do - edit_pipeline_schedule - end - - it 'displays existing properties' do - description = find_field('schedule_description').value - expect(description).to eq('pipeline schedule') - expect(page).to have_button('master') - expect(page).to have_button('Select timezone') - end + describe 'PATCH /projects/pipelines_schedules/:id/edit' do + before do + edit_pipeline_schedule + end - it 'edits the scheduled pipeline' do - fill_in 'schedule_description', with: 'my brand new description' + it 'displays existing properties' do + description = find_field('schedule-description').value + expect(description).to eq('pipeline schedule') + expect(page).to have_button('master') + expect(page).to have_button(_('Select timezone')) + end - save_pipeline_schedule + it 'edits the scheduled pipeline' do + fill_in 'schedule-description', with: 'my brand new description' - expect(page).to have_content('my brand new description') - end + save_pipeline_schedule - context 'when ref is nil' do - before do - pipeline_schedule.update_attribute(:ref, nil) - edit_pipeline_schedule - end + expect(page).to have_content('my brand new description') + end - it 'shows the pipeline schedule with default ref' do - page.within('[data-testid="schedule-target-ref"]') do - expect(first('.gl-button-text').text).to eq('master') - end - end + context 'when ref is nil' do + before do + pipeline_schedule.update_attribute(:ref, nil) + edit_pipeline_schedule end - context 'when ref is empty' do - before do - pipeline_schedule.update_attribute(:ref, '') - edit_pipeline_schedule - end - - it 'shows the pipeline schedule with default ref' do - page.within('[data-testid="schedule-target-ref"]') do - expect(first('.gl-button-text').text).to eq('master') - end + it 'shows the pipeline schedule with default ref' do + page.within('#schedule-target-branch-tag') do + expect(first('.gl-button-text').text).to eq('master') end end end - end - - context 'logged in as a project maintainer' do - before do - project.add_maintainer(user) - gitlab_sign_in(user) - end - describe 'GET /projects/pipeline_schedules' do + context 'when ref is empty' do before do - visit_pipelines_schedules + pipeline_schedule.update_attribute(:ref, '') + edit_pipeline_schedule end - describe 'The view' do - it 'displays the required information description' do - page.within('.pipeline-schedule-table-row') do - expect(page).to have_content('pipeline schedule') - expect(find("[data-testid='next-run-cell'] time")['title']) - .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y')) - expect(page).to have_link('master') - expect(page).to have_link("##{pipeline.id}") - end - end - - it 'creates a new scheduled pipeline' do - click_link 'New schedule' - - expect(page).to have_content('Schedule a new pipeline') - end - - it 'changes ownership of the pipeline' do - click_button 'Take ownership' - - page.within('#pipeline-take-ownership-modal') do - click_link 'Take ownership' - end - - page.within('.pipeline-schedule-table-row') do - expect(page).not_to have_content('No owner') - expect(page).to have_link('Sidney Jones') - end - end - - it 'deletes the pipeline' do - click_link 'Delete' - - accept_gl_confirm(button_text: 'Delete pipeline schedule') - - expect(page).not_to have_css(".pipeline-schedule-table-row") + it 'shows the pipeline schedule with default ref' do + page.within('#schedule-target-branch-tag') do + expect(first('.gl-button-text').text).to eq('master') end end + end + end + end - context 'when ref is nil' do - before do - pipeline_schedule.update_attribute(:ref, nil) - visit_pipelines_schedules - end - - it 'shows a list of the pipeline schedules with empty ref column' do - expect(first('.branch-name-cell').text).to eq('') - end - end + context 'logged in as a project maintainer' do + before do + project.add_maintainer(user) + pipeline_schedule.update!(owner: maintainer) + gitlab_sign_in(user) + end - context 'when ref is empty' do - before do - pipeline_schedule.update_attribute(:ref, '') - visit_pipelines_schedules - end + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules - it 'shows a list of the pipeline schedules with empty ref column' do - expect(first('.branch-name-cell').text).to eq('') - end - end + wait_for_requests end - describe 'POST /projects/pipeline_schedules/new' do - before do - visit_new_pipeline_schedule - end - - it 'sets defaults for timezone and target branch' do - expect(page).to have_button('master') - expect(page).to have_button('Select timezone') + describe 'The view' do + it 'displays the required information description' do + page.within('[data-testid="pipeline-schedule-table-row"]') do + expect(page).to have_content('pipeline schedule') + expect(find('[data-testid="next-run-cell"] time')['title']) + .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y')) + expect(page).to have_link('master') + expect(find("[data-testid='last-pipeline-status'] a")['href']).to include(pipeline.id.to_s) + end end it 'creates a new scheduled pipeline' do - fill_in_schedule_form - save_pipeline_schedule + click_link 'New schedule' - expect(page).to have_content('my fancy description') + expect(page).to have_content('Schedule a new pipeline') end - it 'prevents an invalid form from being submitted' do - save_pipeline_schedule + it 'changes ownership of the pipeline' do + find("[data-testid='take-ownership-pipeline-schedule-btn']").click - expect(page).to have_content('This field is required') - end - end + page.within('#pipeline-take-ownership-modal') do + click_button s_('PipelineSchedules|Take ownership') - context 'when user creates a new pipeline schedule with variables' do - before do - visit_pipelines_schedules - click_link 'New schedule' - fill_in_schedule_form - all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA') - all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123') - all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB') - all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123') - save_pipeline_schedule - end + wait_for_requests + end - it 'user sees the new variable in edit window', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397040' do - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.ci-variable-list') do - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA') - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('AAA123') - expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-key").value).to eq('BBB') - expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-value", visible: false).value).to eq('BBB123') + page.within('[data-testid="pipeline-schedule-table-row"]') do + expect(page).not_to have_content('No owner') + expect(page).to have_link('Sidney Jones') end end - end - context 'when user edits a variable of a pipeline schedule' do - before do - create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| - create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule) + it 'deletes the pipeline' do + page.within('[data-testid="pipeline-schedule-table-row"]') do + click_button s_('PipelineSchedules|Delete pipeline schedule') end - visit_pipelines_schedules - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - find('.js-ci-variable-list-section .js-secret-value-reveal-button').click - first('.js-ci-variable-input-key').set('foo') - first('.js-ci-variable-input-value').set('bar') - click_button 'Save pipeline schedule' - end + accept_gl_confirm(button_text: s_('PipelineSchedules|Delete pipeline schedule')) - it 'user sees the updated variable in edit window' do - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.ci-variable-list') do - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('foo') - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('bar') - end + expect(page).not_to have_css('[data-testid="pipeline-schedule-table-row"]') end end - context 'when user removes a variable of a pipeline schedule' do + context 'when ref is nil' do before do - create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| - create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule) - end - + pipeline_schedule.update_attribute(:ref, nil) visit_pipelines_schedules - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - find('.ci-variable-list .ci-variable-row-remove-button').click - click_button 'Save pipeline schedule' + wait_for_requests end - it 'user does not see the removed variable in edit window' do - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - page.within('.ci-variable-list') do - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('') - expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('') + it 'shows a list of the pipeline schedules with empty ref column' do + target = find('[data-testid="pipeline-schedule-target"]') + + page.within('[data-testid="pipeline-schedule-table-row"]') do + expect(target.text).to eq(s_('PipelineSchedules|None')) end end end - context 'when active is true and next_run_at is NULL' do + context 'when ref is empty' do before do - create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| - pipeline_schedule.update_attribute(:next_run_at, nil) # Consequently next_run_at will be nil - end + pipeline_schedule.update_attribute(:ref, '') + visit_pipelines_schedules + wait_for_requests end - it 'user edit and recover the problematic pipeline schedule' do - visit_pipelines_schedules - find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click - fill_in 'schedule_cron', with: '* 1 2 3 4' - click_button 'Save pipeline schedule' + it 'shows a list of the pipeline schedules with empty ref column' do + target = find('[data-testid="pipeline-schedule-target"]') - page.within('.pipeline-schedule-table-row:nth-child(1)') do - expect(page).to have_css("[data-testid='next-run-cell'] time") - end + expect(target.text).to eq(s_('PipelineSchedules|None')) end end end - context 'logged in as non-member' do + describe 'POST /projects/pipeline_schedules/new' do before do - gitlab_sign_in(user) + visit_new_pipeline_schedule end - describe 'GET /projects/pipeline_schedules' do - before do - visit_pipelines_schedules - end + it 'sets defaults for timezone and target branch' do + expect(page).to have_button('master') + expect(page).to have_button('Select timezone') + end - describe 'The view' do - it 'does not show create schedule button' do - expect(page).not_to have_link('New schedule') - end - end + it 'creates a new scheduled pipeline' do + fill_in_schedule_form + create_pipeline_schedule + + expect(page).to have_content('my fancy description') end - end - context 'not logged in' do - describe 'GET /projects/pipeline_schedules' do - before do - visit_pipelines_schedules - end + it 'prevents an invalid form from being submitted' do + create_pipeline_schedule - describe 'The view' do - it 'does not show create schedule button' do - expect(page).not_to have_link('New schedule') - end - end + expect(page).to have_content("Cron timezone can't be blank") end end - end - context 'with pipeline_schedules_vue feature flag turned on' do - context 'logged in as a project maintainer' do + context 'when user creates a new pipeline schedule with variables' do before do - project.add_maintainer(maintainer) - pipeline_schedule.update!(owner: user) - gitlab_sign_in(maintainer) + visit_pipelines_schedules + click_link 'New schedule' + fill_in_schedule_form + all('[name="schedule[variables_attributes][][key]"]')[0].set('AAA') + all('[name="schedule[variables_attributes][][secret_value]"]')[0].set('AAA123') + all('[name="schedule[variables_attributes][][key]"]')[1].set('BBB') + all('[name="schedule[variables_attributes][][secret_value]"]')[1].set('BBB123') + create_pipeline_schedule end - describe 'GET /projects/pipeline_schedules' do - before do - visit_pipelines_schedules - - wait_for_requests + it 'user sees the new variable in edit window', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397040' do + find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click + page.within('.ci-variable-list') do + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA') + expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-value", visible: false).value).to eq('AAA123') + expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-key").value).to eq('BBB') + expect(find(".ci-variable-row:nth-child(2) .js-ci-variable-input-value", visible: false).value).to eq('BBB123') end + end + end - describe 'The view' do - it 'displays the required information description' do - page.within('[data-testid="pipeline-schedule-table-row"]') do - expect(page).to have_content('pipeline schedule') - expect(find("[data-testid='next-run-cell'] time")['title']) - .to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y')) - expect(page).to have_link('master') - expect(find("[data-testid='last-pipeline-status'] a")['href']).to include(pipeline.id.to_s) - end - end - - it 'changes ownership of the pipeline' do - click_button 'Take ownership' + context 'when user edits a variable of a pipeline schedule' do + before do + create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| + create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule) + end - page.within('#pipeline-take-ownership-modal') do - click_button 'Take ownership' + visit_pipelines_schedules + first('[data-testid="edit-pipeline-schedule-btn"]').click + click_button _('Reveal values') + first('[data-testid="pipeline-form-ci-variable-key"]').set('foo') + first('[data-testid="pipeline-form-ci-variable-value"]').set('bar') + save_pipeline_schedule + end - wait_for_requests - end + it 'user sees the updated variable' do + first('[data-testid="edit-pipeline-schedule-btn"]').click - page.within('[data-testid="pipeline-schedule-table-row"]') do - expect(page).not_to have_content('No owner') - expect(page).to have_link('Sidney Jones') - end - end + expect(first('[data-testid="pipeline-form-ci-variable-key"]').value).to eq('foo') + expect(first('[data-testid="pipeline-form-ci-variable-value"]').value).to eq('') - it 'runs the pipeline' do - click_button 'Run pipeline schedule' + click_button _('Reveal values') - wait_for_requests + expect(first('[data-testid="pipeline-form-ci-variable-value"]').value).to eq('bar') + end + end - expect(page).to have_content("Successfully scheduled a pipeline to run. Go to the Pipelines page for details.") - end + context 'when user removes a variable of a pipeline schedule' do + before do + create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| + create(:ci_pipeline_schedule_variable, key: 'AAA', value: 'AAA123', pipeline_schedule: pipeline_schedule) + end - it 'deletes the pipeline' do - click_button 'Delete pipeline schedule' + visit_pipelines_schedules + first('[data-testid="edit-pipeline-schedule-btn"]').click + find('[data-testid="remove-ci-variable-row"]').click + save_pipeline_schedule + end - accept_gl_confirm(button_text: 'Delete pipeline schedule') + it 'user does not see the removed variable in edit window' do + first('[data-testid="edit-pipeline-schedule-btn"]').click - expect(page).not_to have_css('[data-testid="pipeline-schedule-table-row"]') - end - end + expect(first('[data-testid="pipeline-form-ci-variable-key"]').value).to eq('') + expect(first('[data-testid="pipeline-form-ci-variable-value"]').value).to eq('') end end - context 'logged in as non-member' do + context 'when active is true and next_run_at is NULL' do before do - gitlab_sign_in(user) + create(:ci_pipeline_schedule, project: project, owner: user).tap do |pipeline_schedule| + pipeline_schedule.update_attribute(:next_run_at, nil) # Consequently next_run_at will be nil + end end - describe 'GET /projects/pipeline_schedules' do - before do - visit_pipelines_schedules + it 'user edit and recover the problematic pipeline schedule' do + visit_pipelines_schedules + first('[data-testid="edit-pipeline-schedule-btn"]').click + fill_in 'schedule_cron', with: '* 1 2 3 4' + save_pipeline_schedule - wait_for_requests - end - - describe 'The view' do - it 'does not show create schedule button' do - expect(page).not_to have_link('New schedule') - end + page.within(first('[data-testid="pipeline-schedule-table-row"]')) do + expect(page).to have_css("[data-testid='next-run-cell'] time") end end end + end - context 'not logged in' do - describe 'GET /projects/pipeline_schedules' do - before do - visit_pipelines_schedules + context 'logged in as non-member' do + before do + gitlab_sign_in(user) + end - wait_for_requests + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules + end + + describe 'The view' do + it 'does not show create schedule button' do + expect(page).not_to have_link('New schedule') end + end + end + end - describe 'The view' do - it 'does not show create schedule button' do - expect(page).not_to have_link('New schedule') - end + context 'not logged in' do + describe 'GET /projects/pipeline_schedules' do + before do + visit_pipelines_schedules + end + + describe 'The view' do + it 'does not show create schedule button' do + expect(page).not_to have_link('New schedule') end end end @@ -413,7 +325,7 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :groups_and_projects end def select_timezone - find('[data-testid="schedule-timezone"] .gl-new-dropdown-toggle').click + find('#schedule-timezone .gl-new-dropdown-toggle').click find("li", text: "Arizona").click end @@ -421,12 +333,16 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :groups_and_projects click_button 'master' end + def create_pipeline_schedule + click_button s_('PipelineSchedules|Create pipeline schedule') + end + def save_pipeline_schedule - click_button 'Save pipeline schedule' + click_button s_('PipelineSchedules|Edit pipeline schedule') end def fill_in_schedule_form - fill_in 'schedule_description', with: 'my fancy description' + fill_in 'schedule-description', with: 'my fancy description' fill_in 'schedule_cron', with: '* 1 2 3 4' select_timezone diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index bb49fb734d7..2fc8345fb47 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -224,7 +224,7 @@ RSpec.describe 'Pipeline', :js, feature_category: :groups_and_projects do expect(page).not_to have_content('Retry job') within('[data-testid="pipeline-details-header"]') do - expect(page).to have_selector('[data-testid="ci-badge-running"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'running') end end end @@ -278,7 +278,7 @@ RSpec.describe 'Pipeline', :js, feature_category: :groups_and_projects do expect(page).not_to have_content('Retry job') within('[data-testid="pipeline-details-header"]') do - expect(page).to have_selector('[data-testid="ci-badge-running"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'running') end end @@ -312,7 +312,7 @@ RSpec.describe 'Pipeline', :js, feature_category: :groups_and_projects do expect(page).not_to have_content('Play job') within('[data-testid="pipeline-details-header"]') do - expect(page).to have_selector('[data-testid="ci-badge-running"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'running') end end end @@ -537,7 +537,7 @@ RSpec.describe 'Pipeline', :js, feature_category: :groups_and_projects do it 'shows running status in pipeline header', :sidekiq_might_not_need_inline do within('[data-testid="pipeline-details-header"]') do - expect(page).to have_selector('[data-testid="ci-badge-running"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'running') end end end @@ -843,12 +843,10 @@ RSpec.describe 'Pipeline', :js, feature_category: :groups_and_projects do end it 'displays the PipelineSchedule in an inactive state' do - stub_feature_flags(pipeline_schedules_vue: false) - visit project_pipeline_schedules_path(project) page.click_link('Inactive') - expect(page).to have_selector('table.ci-table > tbody > tr > td', text: 'blocked user schedule') + expect(page).to have_selector('[data-testid="pipeline-schedule-description"]', text: 'blocked user schedule') end it 'does not create a new Pipeline', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408215' do diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 26fcd8ca3ca..c1aa2c35337 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -11,7 +11,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do let(:expected_detached_mr_tag) { 'merge request' } context 'when user is logged in' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) @@ -115,7 +115,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do it 'indicates that pipeline can be canceled' do expect(page).to have_selector('.js-pipelines-cancel-button') - expect(page).to have_selector('[data-testid="ci-badge-running"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'running') end context 'when canceling' do @@ -127,7 +127,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do it 'indicated that pipelines was canceled', :sidekiq_might_not_need_inline do expect(page).not_to have_selector('.js-pipelines-cancel-button') - expect(page).to have_selector('[data-testid="ci-badge-canceled"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'canceled') end end end @@ -144,7 +144,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do it 'indicates that pipeline can be retried' do expect(page).to have_selector('.js-pipelines-retry-button') - expect(page).to have_selector('[data-testid="ci-badge-failed"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'failed') end context 'when retrying' do @@ -155,7 +155,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do it 'shows running pipeline that is not retryable' do expect(page).not_to have_selector('.js-pipelines-retry-button') - expect(page).to have_selector('[data-testid="ci-badge-running"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'running') end end end @@ -396,7 +396,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do end it 'shows the pipeline as preparing' do - expect(page).to have_selector('[data-testid="ci-badge-preparing"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'preparing') end end @@ -417,7 +417,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do end it 'has pipeline running' do - expect(page).to have_selector('[data-testid="ci-badge-running"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'running') end context 'when canceling' do @@ -428,7 +428,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do it 'indicates that pipeline was canceled', :sidekiq_might_not_need_inline do expect(page).not_to have_selector('.js-pipelines-cancel-button') - expect(page).to have_selector('[data-testid="ci-badge-canceled"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'canceled') end end end @@ -450,7 +450,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do end it 'has failed pipeline', :sidekiq_might_not_need_inline do - expect(page).to have_selector('[data-testid="ci-badge-failed"]') + expect(page).to have_selector('[data-testid="ci-badge-link"]', text: 'failed') end end end diff --git a/spec/features/projects/settings/monitor_settings_spec.rb b/spec/features/projects/settings/monitor_settings_spec.rb index b46451f4255..c2914c020e3 100644 --- a/spec/features/projects/settings/monitor_settings_spec.rb +++ b/spec/features/projects/settings/monitor_settings_spec.rb @@ -5,9 +5,8 @@ require 'spec_helper' RSpec.describe 'Projects > Settings > For a forked project', :js, feature_category: :groups_and_projects do include ListboxHelpers - let_it_be(:project) { create(:project, :repository, create_templates: :issue) } - - let(:user) { project.first_owner } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :repository, create_templates: :issue, namespace: user.namespace) } before do sign_in(user) diff --git a/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb b/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb index 1ab88ec0fff..ee54065fdf8 100644 --- a/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb +++ b/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'Project > Settings > Packages and registries > Container registry tag expiration policy', feature_category: :groups_and_projects do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project, reload: true) { create(:project, namespace: user.namespace) } let(:container_registry_enabled) { true } diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb index 9df82e447aa..7f0367f47f7 100644 --- a/spec/features/projects/settings/registry_settings_spec.rb +++ b/spec/features/projects/settings/registry_settings_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'Project > Settings > Packages and registries > Container registry tag expiration policy', feature_category: :groups_and_projects do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project, reload: true) { create(:project, namespace: user.namespace) } let(:container_registry_enabled) { true } diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb index d068cb219f1..5cc2e2d3c05 100644 --- a/spec/features/projects/settings/service_desk_setting_spec.rb +++ b/spec/features/projects/settings/service_desk_setting_spec.rb @@ -56,7 +56,7 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache, feature_c wait_for_requests project.reload - expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_custom_address) + expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_alias_address) page.within '#js-service-desk' do fill_in('service-desk-project-suffix', with: 'foo') diff --git a/spec/features/projects/show/user_sees_collaboration_links_spec.rb b/spec/features/projects/show/user_sees_collaboration_links_spec.rb index ee017336acc..626d4de7baf 100644 --- a/spec/features/projects/show/user_sees_collaboration_links_spec.rb +++ b/spec/features/projects/show/user_sees_collaboration_links_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'Projects > Show > Collaboration links', :js, feature_category: : using RSpec::Parameterized::TableSyntax let_it_be(:project) { create(:project, :repository, :public) } - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/projects/user_sees_sidebar_spec.rb b/spec/features/projects/user_sees_sidebar_spec.rb index 5a744be5d81..22d00e9a351 100644 --- a/spec/features/projects/user_sees_sidebar_spec.rb +++ b/spec/features/projects/user_sees_sidebar_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Projects > User sees sidebar', feature_category: :groups_and_projects do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :private, public_builds: false, namespace: user.namespace) } # NOTE: See documented behaviour https://design.gitlab.com/regions/navigation#contextual-navigation @@ -182,7 +182,7 @@ RSpec.describe 'Projects > User sees sidebar', feature_category: :groups_and_pro end context 'as guest' do - let(:guest) { create(:user) } + let(:guest) { create(:user, :no_super_sidebar) } let!(:issue) { create(:issue, :opened, project: project, author: guest) } before do diff --git a/spec/features/projects/user_uses_shortcuts_spec.rb b/spec/features/projects/user_uses_shortcuts_spec.rb index 77f753b92eb..b7b2093d78a 100644 --- a/spec/features/projects/user_uses_shortcuts_spec.rb +++ b/spec/features/projects/user_uses_shortcuts_spec.rb @@ -3,9 +3,8 @@ require 'spec_helper' RSpec.describe 'User uses shortcuts', :js, feature_category: :groups_and_projects do - let_it_be(:project) { create(:project, :repository) } - - let(:user) { project.first_owner } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:project) { create(:project, :repository, namespace: user.namespace) } before do sign_in(user) @@ -15,58 +14,6 @@ RSpec.describe 'User uses shortcuts', :js, feature_category: :groups_and_project wait_for_requests end - context 'disabling shortcuts' do - before do - page.evaluate_script("localStorage.removeItem('shortcutsDisabled')") - end - - it 'can disable shortcuts from help menu' do - open_modal_shortcut_keys - click_toggle_button - close_modal - - open_modal_shortcut_keys - - expect(page).not_to have_selector('[data-testid="modal-shortcuts"]') - - page.refresh - open_modal_shortcut_keys - - # after reload, shortcuts modal doesn't exist at all until we add it - expect(page).not_to have_selector('[data-testid="modal-shortcuts"]') - end - - it 're-enables shortcuts' do - open_modal_shortcut_keys - click_toggle_button - close_modal - - open_modal_from_help_menu - click_toggle_button - close_modal - - open_modal_shortcut_keys - expect(find('[data-testid="modal-shortcuts"]')).to be_visible - end - - def open_modal_shortcut_keys - find('body').native.send_key('?') - end - - def open_modal_from_help_menu - find('.header-help-dropdown-toggle').click - find('button', text: 'Keyboard shortcuts').click - end - - def click_toggle_button - find('.js-toggle-shortcuts .gl-toggle').click - end - - def close_modal - find('.modal button[aria-label="Close"]').click - end - end - context 'when navigating to the Project pages' do it 'redirects to the project overview page' do visit project_issues_path(project) diff --git a/spec/features/projects/wikis_spec.rb b/spec/features/projects/wikis_spec.rb index 5d950da6674..63714954c0c 100644 --- a/spec/features/projects/wikis_spec.rb +++ b/spec/features/projects/wikis_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe 'Project wikis', :js, feature_category: :wiki do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let(:wiki) { create(:project_wiki, user: user, project: project) } let(:project) { create(:project, namespace: user.namespace, creator: user) } diff --git a/spec/features/projects/work_items/work_item_spec.rb b/spec/features/projects/work_items/work_item_spec.rb index 618d3e2efd0..a1f5466f5bf 100644 --- a/spec/features/projects/work_items/work_item_spec.rb +++ b/spec/features/projects/work_items/work_item_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' RSpec.describe 'Work item', :js, feature_category: :team_planning do - let_it_be_with_reload(:user) { create(:user) } - let_it_be_with_reload(:user2) { create(:user, name: 'John') } + let_it_be_with_reload(:user) { create(:user, :no_super_sidebar) } + let_it_be_with_reload(:user2) { create(:user, :no_super_sidebar, name: 'John') } let_it_be(:project) { create(:project, :public) } let_it_be(:work_item) { create(:work_item, project: project) } @@ -39,6 +39,44 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do expect(page).to have_selector('[data-testid="work-item-actions-dropdown"]') end + it 'reassigns to another user', + quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/413074' do + find('[data-testid="work-item-assignees-input"]').fill_in(with: user.username) + wait_for_requests + + send_keys(:enter) + find("body").click + wait_for_requests + + find('[data-testid="work-item-assignees-input"]').fill_in(with: user2.username) + wait_for_requests + + send_keys(:enter) + find("body").click + wait_for_requests + + expect(work_item.reload.assignees).to include(user2) + end + + it 'updates the assignee in real-time' do + Capybara::Session.new(:other_session) + + using_session :other_session do + visit work_items_path + expect(work_item.reload.assignees).not_to include(user) + end + + find('[data-testid="work-item-assignees-input"]').hover + find('[data-testid="assign-self"]').click + wait_for_requests + + expect(work_item.reload.assignees).to include(user) + + using_session :other_session do + expect(work_item.reload.assignees).to include(user) + end + end + it_behaves_like 'work items title' it_behaves_like 'work items toggle status button' it_behaves_like 'work items assignees' @@ -90,5 +128,11 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do expect(page).to have_selector('[data-testid="award-button"].disabled') end end + + it 'assignees input field is disabled' do + within('[data-testid="work-item-assignees-input"]') do + expect(page).to have_field(type: 'text', disabled: true) + end + end end end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index d28fafaac45..7ca9395f669 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -7,7 +7,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do include MobileHelpers describe 'template' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in user @@ -78,7 +78,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do end describe 'shows tip about push to create git command' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in user @@ -214,7 +214,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do end describe 'showing information about source of a project fork', :js do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:base_project) { create(:project, :public, :repository) } let(:forked_project) { fork_project(base_project, user, repository: true) } @@ -265,7 +265,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do end describe 'when the project repository is disabled', :js do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :repository_disabled, :repository, namespace: user.namespace) } before do @@ -282,7 +282,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do end describe 'removal', :js do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, namespace: user.namespace) } before do @@ -307,7 +307,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do end describe 'tree view (default view is set to Files)', :js do - let(:user) { create(:user, project_view: 'files') } + let(:user) { create(:user, :no_super_sidebar, project_view: 'files') } let(:project) { create(:forked_project_with_submodules) } before do @@ -379,7 +379,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do end describe 'activity view' do - let(:user) { create(:user, project_view: 'activity') } + let(:user) { create(:user, :no_super_sidebar, project_view: 'activity') } let(:project) { create(:project, :repository) } before do @@ -410,7 +410,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do end describe 'edit' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:project) { create(:project, :public) } let(:path) { edit_project_path(project) } @@ -425,7 +425,7 @@ RSpec.describe 'Project', feature_category: :groups_and_projects do describe 'view for a user without an access to a repo' do let(:project) { create(:project, :repository) } - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } it 'does not contain default branch information in its content' do default_branch = 'merge-commit-analyze-side-branch' diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index 3c63ec82778..091c318459b 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -9,396 +9,387 @@ RSpec.describe 'Runners', feature_category: :runner_fleet do sign_in(user) end - context 'when project_runners_vue_ui is disabled' do - before do - stub_feature_flags(project_runners_vue_ui: false) - end + context 'with user as project maintainer' do + let_it_be(:project) { create(:project).tap { |project| project.add_maintainer(user) } } - context 'with user as project maintainer' do - let_it_be(:project) { create(:project).tap { |project| project.add_maintainer(user) } } + context 'when user views runners page', :js do + before do + visit project_runners_path(project) + end - context 'when user views runners page', :js do - before do - visit project_runners_path(project) - end + it 'user can see a link with instructions on how to install GitLab Runner' do + expect(page).to have_link(s_('Runners|New project runner'), href: new_project_runner_path(project)) + end - it 'user can see a link with instructions on how to install GitLab Runner' do - expect(page).to have_link(s_('Runners|New project runner'), href: new_project_runner_path(project)) - end + it_behaves_like "shows and resets runner registration token" do + let(:dropdown_text) { s_('Runners|Register a project runner') } + let(:registration_token) { project.runners_token } + end + end - it_behaves_like "shows and resets runner registration token" do - let(:dropdown_text) { s_('Runners|Register a project runner') } - let(:registration_token) { project.runners_token } - end + context 'when user views new runner page', :js do + before do + visit new_project_runner_path(project) end - context 'when user views new runner page', :js do - before do - visit new_project_runner_path(project) - end + it_behaves_like 'creates runner and shows register page' do + let(:register_path_pattern) { register_project_runner_path(project, '.*') } + end - it_behaves_like 'creates runner and shows register page' do - let(:register_path_pattern) { register_project_runner_path(project, '.*') } - end + it_behaves_like 'shows locked field' + end + end - it 'shows the locked field' do - expect(page).to have_selector('input[type="checkbox"][name="locked"]') - expect(page).to have_content(_('Lock to current projects')) - end - end + context 'when a project has enabled shared_runners' do + let_it_be(:project) { create(:project) } + + before do + project.add_maintainer(user) end - context 'when a project has enabled shared_runners' do - let_it_be(:project) { create(:project) } + context 'when a project_type runner is activated on the project' do + let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project]) } - before do - project.add_maintainer(user) - end + it 'user sees the project runner' do + visit project_runners_path(project) - context 'when a project_type runner is activated on the project' do - let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project]) } + within '[data-testid="assigned_project_runners"]' do + expect(page).to have_content(project_runner.display_name) + end - it 'user sees the project runner' do - visit project_runners_path(project) + click_on project_runner.short_sha - within '[data-testid="assigned_project_runners"]' do - expect(page).to have_content(project_runner.display_name) - end + expect(page).to have_content(project_runner.platform) + end - click_on project_runner.short_sha + it 'user can pause and resume the project runner' do + visit project_runners_path(project) - expect(page).to have_content(project_runner.platform) + within '[data-testid="assigned_project_runners"]' do + expect(page).to have_link('Pause') end - it 'user can pause and resume the project runner' do - visit project_runners_path(project) + click_on 'Pause' - within '[data-testid="assigned_project_runners"]' do - expect(page).to have_link('Pause') - end + within '[data-testid="assigned_project_runners"]' do + expect(page).to have_link('Resume') + end - click_on 'Pause' + click_on 'Resume' - within '[data-testid="assigned_project_runners"]' do - expect(page).to have_link('Resume') - end + within '[data-testid="assigned_project_runners"]' do + expect(page).to have_link('Pause') + end + end - click_on 'Resume' + it 'user removes an activated project runner if this is last project for that runners' do + visit project_runners_path(project) - within '[data-testid="assigned_project_runners"]' do - expect(page).to have_link('Pause') - end + within '[data-testid="assigned_project_runners"]' do + click_on 'Remove runner' end - it 'user removes an activated project runner if this is last project for that runners' do - visit project_runners_path(project) + expect(page).not_to have_content(project_runner.display_name) + end - within '[data-testid="assigned_project_runners"]' do - click_on 'Remove runner' - end + it 'user edits the runner to be protected' do + visit project_runners_path(project) + + within '[data-testid="assigned_project_runners"]' do + first('[data-testid="edit-runner-link"]').click + end + + expect(page.find_field('runner[access_level]')).not_to be_checked + + check 'runner_access_level' + click_button 'Save changes' + + expect(page).to have_content 'Protected Yes' + end - expect(page).not_to have_content(project_runner.display_name) + context 'when a runner has a tag' do + before do + project_runner.update!(tag_list: ['tag']) end - it 'user edits the runner to be protected' do + it 'user edits runner not to run untagged jobs' do visit project_runners_path(project) within '[data-testid="assigned_project_runners"]' do first('[data-testid="edit-runner-link"]').click end - expect(page.find_field('runner[access_level]')).not_to be_checked + expect(page.find_field('runner[run_untagged]')).to be_checked - check 'runner_access_level' + uncheck 'runner_run_untagged' click_button 'Save changes' - expect(page).to have_content 'Protected Yes' + expect(page).to have_content 'Can run untagged jobs No' end + end - context 'when a runner has a tag' do - before do - project_runner.update!(tag_list: ['tag']) - end - - it 'user edits runner not to run untagged jobs' do - visit project_runners_path(project) - - within '[data-testid="assigned_project_runners"]' do - first('[data-testid="edit-runner-link"]').click - end - - expect(page.find_field('runner[run_untagged]')).to be_checked + context 'when a shared runner is activated on the project' do + let!(:shared_runner) { create(:ci_runner, :instance) } - uncheck 'runner_run_untagged' - click_button 'Save changes' + it 'user sees CI/CD setting page' do + visit project_runners_path(project) - expect(page).to have_content 'Can run untagged jobs No' + within '[data-testid="available-shared-runners"]' do + expect(page).to have_content(shared_runner.display_name) end end - context 'when a shared runner is activated on the project' do - let!(:shared_runner) { create(:ci_runner, :instance) } + context 'when multiple shared runners are configured' do + let_it_be(:shared_runner_2) { create(:ci_runner, :instance) } - it 'user sees CI/CD setting page' do + it 'shows the runner count' do visit project_runners_path(project) within '[data-testid="available-shared-runners"]' do - expect(page).to have_content(shared_runner.display_name) + expect(page).to have_content format(_('Available shared runners: %{count}'), { count: 2 }) end end - context 'when multiple shared runners are configured' do - let_it_be(:shared_runner_2) { create(:ci_runner, :instance) } + it 'adds pagination to the shared runner list' do + stub_const('Projects::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE', 1) - it 'shows the runner count' do - visit project_runners_path(project) + visit project_runners_path(project) - within '[data-testid="available-shared-runners"]' do - expect(page).to have_content format(_('Available shared runners: %{count}'), { count: 2 }) - end + within '[data-testid="available-shared-runners"]' do + expect(find('.pagination')).not_to be_nil end + end + end + end - it 'adds pagination to the shared runner list' do - stub_const('Projects::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE', 1) + context 'when multiple project runners are configured' do + let!(:project_runner_2) { create(:ci_runner, :project, projects: [project]) } - visit project_runners_path(project) + it 'adds pagination to the runner list' do + stub_const('Projects::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE', 1) - within '[data-testid="available-shared-runners"]' do - expect(find('.pagination')).not_to be_nil - end - end - end + visit project_runners_path(project) + + expect(find('.pagination')).not_to be_nil end + end + end - context 'when multiple project runners are configured' do - let!(:project_runner_2) { create(:ci_runner, :project, projects: [project]) } + context 'when a project runner exists in another project' do + let(:another_project) { create(:project) } + let!(:project_runner) { create(:ci_runner, :project, projects: [another_project]) } - it 'adds pagination to the runner list' do - stub_const('Projects::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE', 1) + before do + another_project.add_maintainer(user) + end - visit project_runners_path(project) + it 'user enables and disables a project runner' do + visit project_runners_path(project) - expect(find('.pagination')).not_to be_nil - end + within '[data-testid="available_project_runners"]' do + click_on 'Enable for this project' + end + + expect(page.find('[data-testid="assigned_project_runners"]')).to have_content(project_runner.display_name) + + within '[data-testid="assigned_project_runners"]' do + click_on 'Disable for this project' end + + expect(page.find('[data-testid="available_project_runners"]')).to have_content(project_runner.display_name) end + end - context 'when a project runner exists in another project' do - let(:another_project) { create(:project) } - let!(:project_runner) { create(:ci_runner, :project, projects: [another_project]) } + context 'shared runner text' do + context 'when application settings have shared_runners_text' do + let(:shared_runners_text) { 'custom **shared** runners description' } + let(:shared_runners_html) { 'custom shared runners description' } before do - another_project.add_maintainer(user) + stub_application_setting(shared_runners_text: shared_runners_text) end - it 'user enables and disables a project runner' do + it 'user sees shared runners description' do visit project_runners_path(project) - within '[data-testid="available_project_runners"]' do - click_on 'Enable for this project' + page.within("[data-testid='shared-runners-description']") do + expect(page).not_to have_content('The same shared runner executes code from multiple projects') + expect(page).to have_content(shared_runners_html) end - - expect(page.find('[data-testid="assigned_project_runners"]')).to have_content(project_runner.display_name) - - within '[data-testid="assigned_project_runners"]' do - click_on 'Disable for this project' - end - - expect(page.find('[data-testid="available_project_runners"]')).to have_content(project_runner.display_name) end end - context 'shared runner text' do - context 'when application settings have shared_runners_text' do - let(:shared_runners_text) { 'custom **shared** runners description' } - let(:shared_runners_html) { 'custom shared runners description' } + context 'when application settings have an unsafe link in shared_runners_text' do + let(:shared_runners_text) { 'link' } - before do - stub_application_setting(shared_runners_text: shared_runners_text) - end + before do + stub_application_setting(shared_runners_text: shared_runners_text) + end - it 'user sees shared runners description' do - visit project_runners_path(project) + it 'user sees no link' do + visit project_runners_path(project) - page.within("[data-testid='shared-runners-description']") do - expect(page).not_to have_content('The same shared runner executes code from multiple projects') - expect(page).to have_content(shared_runners_html) - end + page.within("[data-testid='shared-runners-description']") do + expect(page).to have_content('link') + expect(page).not_to have_link('link') end end + end - context 'when application settings have an unsafe link in shared_runners_text' do - let(:shared_runners_text) { 'link' } + context 'when application settings have an unsafe image in shared_runners_text' do + let(:shared_runners_text) { '' } - before do - stub_application_setting(shared_runners_text: shared_runners_text) - end + before do + stub_application_setting(shared_runners_text: shared_runners_text) + end - it 'user sees no link' do - visit project_runners_path(project) + it 'user sees image safely' do + visit project_runners_path(project) - page.within("[data-testid='shared-runners-description']") do - expect(page).to have_content('link') - expect(page).not_to have_link('link') - end + page.within("[data-testid='shared-runners-description']") do + expect(page).to have_css('img') + expect(page).not_to have_css('img[onerror]') end end + end + end + end - context 'when application settings have an unsafe image in shared_runners_text' do - let(:shared_runners_text) { '' } + context 'enable shared runners in project settings', :js do + before do + project.add_maintainer(user) - before do - stub_application_setting(shared_runners_text: shared_runners_text) - end + visit project_runners_path(project) + end - it 'user sees image safely' do - visit project_runners_path(project) + context 'when a project has enabled shared_runners' do + let(:project) { create(:project, shared_runners_enabled: true) } - page.within("[data-testid='shared-runners-description']") do - expect(page).to have_css('img') - expect(page).not_to have_css('img[onerror]') - end - end - end + it 'shared runners toggle is on' do + expect(page).to have_selector('[data-testid="toggle-shared-runners"]') + expect(page).to have_selector('[data-testid="toggle-shared-runners"] .is-checked') end end - context 'enable shared runners in project settings', :js do - before do - project.add_maintainer(user) + context 'when a project has disabled shared_runners' do + let(:project) { create(:project, shared_runners_enabled: false) } - visit project_runners_path(project) + it 'shared runners toggle is off' do + expect(page).not_to have_selector('[data-testid="toggle-shared-runners"] .is-checked') end + end + end - context 'when a project has enabled shared_runners' do - let(:project) { create(:project, shared_runners_enabled: true) } + context 'group runners in project settings' do + before do + project.add_maintainer(user) + end - it 'shared runners toggle is on' do - expect(page).to have_selector('[data-testid="toggle-shared-runners"]') - expect(page).to have_selector('[data-testid="toggle-shared-runners"] .is-checked') - end + let_it_be(:group) { create :group } + let_it_be(:project) { create :project, group: group } + + context 'as project and group maintainer' do + before do + group.add_maintainer(user) end - context 'when a project has disabled shared_runners' do - let(:project) { create(:project, shared_runners_enabled: false) } + context 'project with a group but no group runner' do + it 'group runners are not available' do + visit project_runners_path(project) - it 'shared runners toggle is off' do - expect(page).not_to have_selector('[data-testid="toggle-shared-runners"] .is-checked') + expect(page).not_to have_content 'To register them, go to the group\'s Runners page.' + expect(page).to have_content 'Ask your group owner to set up a group runner' end end end - context 'group runners in project settings' do + context 'as project maintainer and group owner' do before do - project.add_maintainer(user) + group.add_owner(user) end - let_it_be(:group) { create :group } - let_it_be(:project) { create :project, group: group } + context 'project with a group but no group runner' do + it 'group runners are available' do + visit project_runners_path(project) - context 'as project and group maintainer' do - before do - group.add_maintainer(user) + expect(page).to have_content 'This group does not have any group runners yet.' + + expect(page).to have_content 'To register them, go to the group\'s Runners page.' + expect(page).not_to have_content 'Ask your group owner to set up a group runner' end + end + end - context 'project with a group but no group runner' do - it 'group runners are not available' do - visit project_runners_path(project) + context 'as project maintainer' do + context 'project without a group' do + let(:project) { create :project } - expect(page).not_to have_content 'To register them, go to the group\'s Runners page.' - expect(page).to have_content 'Ask your group owner to set up a group runner' - end + it 'group runners are not available' do + visit project_runners_path(project) + + expect(page).to have_content 'This project does not belong to a group and cannot make use of group runners.' end end - context 'as project maintainer and group owner' do - before do - group.add_owner(user) - end + context 'with group project' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } context 'project with a group but no group runner' do - it 'group runners are available' do + it 'group runners are not available' do visit project_runners_path(project) expect(page).to have_content 'This group does not have any group runners yet.' - expect(page).to have_content 'To register them, go to the group\'s Runners page.' - expect(page).not_to have_content 'Ask your group owner to set up a group runner' + expect(page).not_to have_content 'To register them, go to the group\'s Runners page.' + expect(page).to have_content 'Ask your group owner to set up a group runner.' end end - end - context 'as project maintainer' do - context 'project without a group' do - let(:project) { create :project } + context 'project with a group and a group runner' do + let_it_be(:group_runner) do + create(:ci_runner, :group, groups: [group], description: 'group-runner') + end - it 'group runners are not available' do + it 'group runners are available' do visit project_runners_path(project) - expect(page).to have_content 'This project does not belong to a group and cannot make use of group runners.' + expect(page).to have_content 'Available group runners: 1' + expect(page).to have_content 'group-runner' end - end - context 'with group project' do - let_it_be(:group) { create(:group) } - let_it_be(:project) { create(:project, group: group) } + it 'group runners may be disabled for a project' do + visit project_runners_path(project) - context 'project with a group but no group runner' do - it 'group runners are not available' do - visit project_runners_path(project) + click_on 'Disable group runners' - expect(page).to have_content 'This group does not have any group runners yet.' + expect(page).to have_content 'Enable group runners' + expect(project.reload.group_runners_enabled).to be false - expect(page).not_to have_content 'To register them, go to the group\'s Runners page.' - expect(page).to have_content 'Ask your group owner to set up a group runner.' - end - end + click_on 'Enable group runners' - context 'project with a group and a group runner' do - let_it_be(:group_runner) do - create(:ci_runner, :group, groups: [group], description: 'group-runner') - end + expect(page).to have_content 'Disable group runners' + expect(project.reload.group_runners_enabled).to be true + end - it 'group runners are available' do - visit project_runners_path(project) + context 'when multiple group runners are configured' do + let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group]) } - expect(page).to have_content 'Available group runners: 1' - expect(page).to have_content 'group-runner' - end - - it 'group runners may be disabled for a project' do + it 'shows the runner count' do visit project_runners_path(project) - click_on 'Disable group runners' - - expect(page).to have_content 'Enable group runners' - expect(project.reload.group_runners_enabled).to be false - - click_on 'Enable group runners' - - expect(page).to have_content 'Disable group runners' - expect(project.reload.group_runners_enabled).to be true - end - - context 'when multiple group runners are configured' do - let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group]) } - - it 'shows the runner count' do - visit project_runners_path(project) - - within '[data-testid="group-runners"]' do - expect(page).to have_content format(_('Available group runners: %{runners}'), { runners: 2 }) - end + within '[data-testid="group-runners"]' do + expect(page).to have_content format(_('Available group runners: %{runners}'), { runners: 2 }) end + end - it 'adds pagination to the group runner list' do - stub_const('Projects::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE', 1) + it 'adds pagination to the group runner list' do + stub_const('Projects::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE', 1) - visit project_runners_path(project) + visit project_runners_path(project) - within '[data-testid="group-runners"]' do - expect(find('.pagination')).not_to be_nil - end + within '[data-testid="group-runners"]' do + expect(find('.pagination')).not_to be_nil end end end diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb index 976324a5032..d2847203669 100644 --- a/spec/features/search/user_searches_for_code_spec.rb +++ b/spec/features/search/user_searches_for_code_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'User searches for code', :js, :disable_rate_limiter, feature_cat using RSpec::Parameterized::TableSyntax include ListboxHelpers - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be_with_reload(:project) { create(:project, :repository, namespace: user.namespace) } context 'when signed in' do diff --git a/spec/features/search/user_searches_for_comments_spec.rb b/spec/features/search/user_searches_for_comments_spec.rb index f7af1797c71..f47e692c652 100644 --- a/spec/features/search/user_searches_for_comments_spec.rb +++ b/spec/features/search/user_searches_for_comments_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'User searches for comments', :js, :disable_rate_limiter, feature_category: :global_search do let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } before do project.add_reporter(user) diff --git a/spec/features/search/user_searches_for_commits_spec.rb b/spec/features/search/user_searches_for_commits_spec.rb index 724daf9277d..140d8763813 100644 --- a/spec/features/search/user_searches_for_commits_spec.rb +++ b/spec/features/search/user_searches_for_commits_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User searches for commits', :js, :clean_gitlab_redis_rate_limiting, feature_category: :global_search do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, :repository) } let(:sha) { '6d394385cf567f80a8fd85055db1ab4c5295806f' } diff --git a/spec/features/search/user_searches_for_issues_spec.rb b/spec/features/search/user_searches_for_issues_spec.rb index 9451e337db1..d816b393cce 100644 --- a/spec/features/search/user_searches_for_issues_spec.rb +++ b/spec/features/search/user_searches_for_issues_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User searches for issues', :js, :clean_gitlab_redis_rate_limiting, feature_category: :global_search do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, namespace: user.namespace) } let!(:issue1) { create(:issue, title: 'issue Foo', project: project, created_at: 1.hour.ago) } diff --git a/spec/features/search/user_searches_for_merge_requests_spec.rb b/spec/features/search/user_searches_for_merge_requests_spec.rb index d7b52d9e07a..61af5e86eea 100644 --- a/spec/features/search/user_searches_for_merge_requests_spec.rb +++ b/spec/features/search/user_searches_for_merge_requests_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User searches for merge requests', :js, :clean_gitlab_redis_rate_limiting, feature_category: :global_search do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, namespace: user.namespace) } let_it_be(:merge_request1) { create(:merge_request, title: 'Merge Request Foo', source_project: project, target_project: project, created_at: 1.hour.ago) } let_it_be(:merge_request2) { create(:merge_request, :simple, title: 'Merge Request Bar', source_project: project, target_project: project) } diff --git a/spec/features/search/user_searches_for_milestones_spec.rb b/spec/features/search/user_searches_for_milestones_spec.rb index 7ca7958f61b..ad62c8eb3da 100644 --- a/spec/features/search/user_searches_for_milestones_spec.rb +++ b/spec/features/search/user_searches_for_milestones_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'User searches for milestones', :js, :clean_gitlab_redis_rate_limiting, feature_category: :global_search do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, namespace: user.namespace) } let_it_be(:milestone1) { create(:milestone, title: 'Foo', project: project) } let_it_be(:milestone2) { create(:milestone, title: 'Bar', project: project) } diff --git a/spec/features/search/user_searches_for_projects_spec.rb b/spec/features/search/user_searches_for_projects_spec.rb index 48a94161927..51e5ad85e2b 100644 --- a/spec/features/search/user_searches_for_projects_spec.rb +++ b/spec/features/search/user_searches_for_projects_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'User searches for projects', :js, :disable_rate_limiter, feature context 'when signed out' do context 'when block_anonymous_global_searches is disabled' do before do - stub_feature_flags(block_anonymous_global_searches: false) + stub_feature_flags(block_anonymous_global_searches: false, super_sidebar_logged_out: false) end include_examples 'top right search form' diff --git a/spec/features/search/user_searches_for_users_spec.rb b/spec/features/search/user_searches_for_users_spec.rb index e0a07c5103d..b52f6aeba68 100644 --- a/spec/features/search/user_searches_for_users_spec.rb +++ b/spec/features/search/user_searches_for_users_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' RSpec.describe 'User searches for users', :js, :clean_gitlab_redis_rate_limiting, feature_category: :global_search do - let_it_be(:user1) { create(:user, username: 'gob_bluth', name: 'Gob Bluth') } - let_it_be(:user2) { create(:user, username: 'michael_bluth', name: 'Michael Bluth') } - let_it_be(:user3) { create(:user, username: 'gob_2018', name: 'George Oscar Bluth') } + let_it_be(:user1) { create(:user, :no_super_sidebar, username: 'gob_bluth', name: 'Gob Bluth') } + let_it_be(:user2) { create(:user, :no_super_sidebar, username: 'michael_bluth', name: 'Michael Bluth') } + let_it_be(:user3) { create(:user, :no_super_sidebar, username: 'gob_2018', name: 'George Oscar Bluth') } before do sign_in(user1) diff --git a/spec/features/search/user_searches_for_wiki_pages_spec.rb b/spec/features/search/user_searches_for_wiki_pages_spec.rb index 65f262075f9..a5b63243d0b 100644 --- a/spec/features/search/user_searches_for_wiki_pages_spec.rb +++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'User searches for wiki pages', :js, :clean_gitlab_redis_rate_limiting, feature_category: :global_search do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, :repository, :wiki_repo, namespace: user.namespace) } let_it_be(:wiki_page) do create(:wiki_page, wiki: project.wiki, title: 'directory/title', content: 'Some Wiki content') diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb index 71d0f8d6d7f..3f2a71b63dc 100644 --- a/spec/features/search/user_uses_header_search_field_spec.rb +++ b/spec/features/search/user_uses_header_search_field_spec.rb @@ -6,8 +6,8 @@ RSpec.describe 'User uses header search field', :js, :disable_rate_limiter, feat include FilteredSearchHelpers let_it_be(:project) { create(:project, :repository) } - let_it_be(:reporter) { create(:user) } - let_it_be(:developer) { create(:user) } + let_it_be(:reporter) { create(:user, :no_super_sidebar) } + let_it_be(:developer) { create(:user, :no_super_sidebar) } let(:user) { reporter } diff --git a/spec/features/sentry_js_spec.rb b/spec/features/sentry_js_spec.rb index d3880011914..0cf32864b1e 100644 --- a/spec/features/sentry_js_spec.rb +++ b/spec/features/sentry_js_spec.rb @@ -41,6 +41,7 @@ RSpec.describe 'Sentry', feature_category: :error_tracking do it 'loads sentry if sentry settings are enabled', :js do allow(Gitlab::CurrentSettings).to receive(:sentry_enabled).and_return(true) + allow(Gitlab::CurrentSettings).to receive(:sentry_clientside_dsn).and_return('https://mockdsn@example.com/1') visit new_user_session_path diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb index 0268c8ad0d4..08d2d0575eb 100644 --- a/spec/features/signed_commits_spec.rb +++ b/spec/features/signed_commits_spec.rb @@ -2,8 +2,8 @@ require 'spec_helper' -RSpec.describe 'GPG signed commits', feature_category: :source_code_management do - let(:project) { create(:project, :public, :repository) } +RSpec.describe 'GPG signed commits', :js, feature_category: :source_code_management do + let_it_be(:project) { create(:project, :public, :repository) } it 'changes from unverified to verified when the user changes their email to match the gpg key', :sidekiq_might_not_need_inline do ref = GpgHelpers::SIGNED_AND_AUTHORED_SHA @@ -47,7 +47,7 @@ RSpec.describe 'GPG signed commits', feature_category: :source_code_management d expect(page).to have_selector('.gl-badge', text: 'Verified') end - context 'shows popover badges', :js do + context 'shows popover badges' do let(:user_1) do create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard' end @@ -163,7 +163,7 @@ RSpec.describe 'GPG signed commits', feature_category: :source_code_management d end end - context 'view signed commit on the tree view', :js do + context 'view signed commit on the tree view' do shared_examples 'a commit with a signature' do before do visit project_tree_path(project, 'signed-commits') diff --git a/spec/features/snippets/search_snippets_spec.rb b/spec/features/snippets/search_snippets_spec.rb index afb53c563de..7a07299a14f 100644 --- a/spec/features/snippets/search_snippets_spec.rb +++ b/spec/features/snippets/search_snippets_spec.rb @@ -4,10 +4,11 @@ require 'spec_helper' RSpec.describe 'Search Snippets', :js, feature_category: :global_search do it 'user searches for snippets by title' do + user = create(:user, :no_super_sidebar) public_snippet = create(:personal_snippet, :public, title: 'Beginning and Middle') - private_snippet = create(:personal_snippet, :private, title: 'Middle and End') + private_snippet = create(:personal_snippet, :private, title: 'Middle and End', author: user) - sign_in private_snippet.author + sign_in user visit dashboard_snippets_path submit_search('Middle') diff --git a/spec/features/snippets/show_spec.rb b/spec/features/snippets/show_spec.rb index 2673ad5e1d7..bbb120edb80 100644 --- a/spec/features/snippets/show_spec.rb +++ b/spec/features/snippets/show_spec.rb @@ -3,9 +3,13 @@ require 'spec_helper' RSpec.describe 'Snippet', :js, feature_category: :source_code_management do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let_it_be(:snippet) { create(:personal_snippet, :public, :repository, author: user) } + before do + stub_feature_flags(super_sidebar_logged_out: false) + end + it_behaves_like 'show and render proper snippet blob' do let(:anchor) { nil } @@ -36,7 +40,7 @@ RSpec.describe 'Snippet', :js, feature_category: :source_code_management do end context 'when authenticated as a different user' do - let_it_be(:different_user) { create(:user) } + let_it_be(:different_user) { create(:user, :no_super_sidebar) } before do sign_in(different_user) diff --git a/spec/features/snippets/user_creates_snippet_spec.rb b/spec/features/snippets/user_creates_snippet_spec.rb index 090d854081a..341cc150a64 100644 --- a/spec/features/snippets/user_creates_snippet_spec.rb +++ b/spec/features/snippets/user_creates_snippet_spec.rb @@ -6,7 +6,7 @@ RSpec.describe 'User creates snippet', :js, feature_category: :source_code_manag include DropzoneHelper include Features::SnippetSpecHelpers - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } let(:title) { 'My Snippet Title' } let(:file_content) { 'Hello World!' } @@ -130,7 +130,7 @@ RSpec.describe 'User creates snippet', :js, feature_category: :source_code_manag expect(page).not_to have_content(files_validation_message) end - it 'previews a snippet with file', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408203' do + it 'previews a snippet with file' do # Click placeholder first to expand full description field snippet_fill_in_description('My Snippet') dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') @@ -145,7 +145,11 @@ RSpec.describe 'User creates snippet', :js, feature_category: :source_code_manag # Adds a cache buster for checking if the image exists as Selenium is now handling the cached requests # not anymore as requests when they come straight from memory cache. # accept_confirm is needed because of https://gitlab.com/gitlab-org/gitlab/-/issues/262102 - reqs = inspect_requests { accept_confirm { visit("#{link}?ran=#{SecureRandom.base64(20)}") } } + reqs = inspect_requests do + visit("#{link}?ran=#{SecureRandom.base64(20)}") do + page.driver.browser.switch_to.alert.accept + end + end expect(reqs.first.status_code).to eq(200) end end diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index beadeab1736..24d63cadf00 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -6,8 +6,8 @@ RSpec.describe 'Task Lists', :js, feature_category: :team_planning do include Warden::Test::Helpers let_it_be(:project) { create(:project, :public, :repository) } - let_it_be(:user) { create(:user) } - let_it_be(:user2) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } + let_it_be(:user2) { create(:user, :no_super_sidebar) } let(:markdown) do <<-MARKDOWN.strip_heredoc diff --git a/spec/features/unsubscribe_links_spec.rb b/spec/features/unsubscribe_links_spec.rb index 77ef3df97f6..b78efa65888 100644 --- a/spec/features/unsubscribe_links_spec.rb +++ b/spec/features/unsubscribe_links_spec.rb @@ -6,8 +6,8 @@ RSpec.describe 'Unsubscribe links', :sidekiq_inline, feature_category: :shared d include Warden::Test::Helpers let_it_be(:project) { create(:project, :public) } - let_it_be(:author) { create(:user).tap { |u| project.add_reporter(u) } } - let_it_be(:recipient) { create(:user) } + let_it_be(:author) { create(:user, :no_super_sidebar).tap { |u| project.add_reporter(u) } } + let_it_be(:recipient) { create(:user, :no_super_sidebar) } let(:params) { { title: 'A bug!', description: 'Fix it!', assignee_ids: [recipient.id] } } let(:issue) { Issues::CreateService.new(container: project, current_user: author, params: params).execute[:issue] } @@ -22,6 +22,10 @@ RSpec.describe 'Unsubscribe links', :sidekiq_inline, feature_category: :shared d end context 'when logged out' do + before do + stub_feature_flags(super_sidebar_logged_out: false) + end + context 'when visiting the link from the body' do it 'shows the unsubscribe confirmation page and redirects to root path when confirming' do visit body_link diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb index cd181f73473..5de544e866e 100644 --- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb +++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'User uploads avatar to profile', feature_category: :user_profile do - let!(:user) { create(:user) } + let!(:user) { create(:user, :no_super_sidebar) } let(:avatar_file_path) { Rails.root.join('spec', 'fixtures', 'dk.png') } shared_examples 'upload avatar' do diff --git a/spec/features/usage_stats_consent_spec.rb b/spec/features/usage_stats_consent_spec.rb index c446fe1531b..92f7a944007 100644 --- a/spec/features/usage_stats_consent_spec.rb +++ b/spec/features/usage_stats_consent_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'Usage stats consent', feature_category: :service_ping do context 'when signed in' do - let(:user) { create(:admin, created_at: 8.days.ago) } + let(:user) { create(:admin, :no_super_sidebar, created_at: 8.days.ago) } let(:message) { 'To help improve GitLab, we would like to periodically collect usage information.' } before do diff --git a/spec/features/users/active_sessions_spec.rb b/spec/features/users/active_sessions_spec.rb index 53a4c8a91e9..663d2283dbd 100644 --- a/spec/features/users/active_sessions_spec.rb +++ b/spec/features/users/active_sessions_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'Active user sessions', :clean_gitlab_redis_sessions, feature_category: :system_access do it 'successful login adds a new active user login' do - user = create(:user) + user = create(:user, :no_super_sidebar) now = Time.zone.parse('2018-03-12 09:06') travel_to(now) do @@ -31,7 +31,7 @@ RSpec.describe 'Active user sessions', :clean_gitlab_redis_sessions, feature_cat end it 'successful login cleans up obsolete entries' do - user = create(:user) + user = create(:user, :no_super_sidebar) Gitlab::Redis::Sessions.with do |redis| redis.sadd?("session:lookup:user:gitlab:#{user.id}", '59822c7d9fcdfa03725eff41782ad97d') @@ -45,7 +45,7 @@ RSpec.describe 'Active user sessions', :clean_gitlab_redis_sessions, feature_cat end it 'sessionless login does not clean up obsolete entries' do - user = create(:user) + user = create(:user, :no_super_sidebar) personal_access_token = create(:personal_access_token, user: user) Gitlab::Redis::Sessions.with do |redis| @@ -61,7 +61,7 @@ RSpec.describe 'Active user sessions', :clean_gitlab_redis_sessions, feature_cat end it 'logout deletes the active user login' do - user = create(:user) + user = create(:user, :no_super_sidebar) gitlab_sign_in(user) expect(page).to have_current_path root_path, ignore_query: true diff --git a/spec/features/users/anonymous_sessions_spec.rb b/spec/features/users/anonymous_sessions_spec.rb index 83473964d6b..368f272ba23 100644 --- a/spec/features/users/anonymous_sessions_spec.rb +++ b/spec/features/users/anonymous_sessions_spec.rb @@ -18,7 +18,7 @@ RSpec.describe 'Session TTLs', :clean_gitlab_redis_shared_state, feature_categor end it 'increases the TTL when the login succeeds' do - user = create(:user) + user = create(:user, :no_super_sidebar) gitlab_sign_in(user) expect(page).to have_content(user.name) diff --git a/spec/features/users/email_verification_on_login_spec.rb b/spec/features/users/email_verification_on_login_spec.rb index 7675de28f86..d83040efd72 100644 --- a/spec/features/users/email_verification_on_login_spec.rb +++ b/spec/features/users/email_verification_on_login_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe 'Email Verification On Login', :clean_gitlab_redis_rate_limiting, :js, feature_category: :system_access do include EmailHelpers - let_it_be_with_reload(:user) { create(:user) } - let_it_be(:another_user) { create(:user) } + let_it_be_with_reload(:user) { create(:user, :no_super_sidebar) } + let_it_be(:another_user) { create(:user, :no_super_sidebar) } let_it_be(:new_email) { build_stubbed(:user).email } let(:require_email_verification_enabled) { user } @@ -220,7 +220,7 @@ RSpec.describe 'Email Verification On Login', :clean_gitlab_redis_rate_limiting, shared_examples 'no email verification required when 2fa enabled or ff disabled' do context 'when 2FA is enabled' do - let_it_be(:user) { create(:user, :two_factor) } + let_it_be(:user) { create(:user, :no_super_sidebar, :two_factor) } it_behaves_like 'no email verification required', two_factor_auth: true end @@ -234,8 +234,7 @@ RSpec.describe 'Email Verification On Login', :clean_gitlab_redis_rate_limiting, describe 'when failing to login the maximum allowed number of times' do before do - # See comment in RequireEmailVerification::MAXIMUM_ATTEMPTS on why this is divided by 2 - (RequireEmailVerification::MAXIMUM_ATTEMPTS / 2).times do + RequireEmailVerification::MAXIMUM_ATTEMPTS.times do gitlab_sign_in(user, password: 'wrong_password') end end @@ -345,7 +344,7 @@ RSpec.describe 'Email Verification On Login', :clean_gitlab_redis_rate_limiting, before do perform_enqueued_jobs do - (User.maximum_attempts / 2).times do + User.maximum_attempts.times do gitlab_sign_in(user, password: 'wrong_password') end end diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 047590fb3aa..c07e419be1f 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ expect(authentication_metrics) .to increment(:user_authenticated_counter) - user = create(:user) + user = create(:user, :no_super_sidebar) expect(user.reset_password_token).to be_nil @@ -43,7 +43,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ # This behavior is dependent on there only being one user User.delete_all - user = create(:admin, password_automatically_set: true) + user = create(:admin, :no_super_sidebar, password_automatically_set: true) visit root_path expect(page).to have_current_path edit_user_password_path, ignore_query: true @@ -77,7 +77,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ .and increment(:user_unauthenticated_counter) .and increment(:user_session_destroyed_counter).twice - user = create(:user, :blocked) + user = create(:user, :no_super_sidebar, :blocked) gitlab_sign_in(user) @@ -90,14 +90,14 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ .and increment(:user_unauthenticated_counter) .and increment(:user_session_destroyed_counter).twice - user = create(:user, :blocked) + user = create(:user, :no_super_sidebar, :blocked) expect { gitlab_sign_in(user) }.not_to change { user.reload.sign_in_count } end end describe 'with an unconfirmed email address' do - let!(:user) { create(:user, confirmed_at: nil) } + let!(:user) { create(:user, :no_super_sidebar, confirmed_at: nil) } let(:grace_period) { 2.days } let(:alert_title) { 'Please confirm your email address' } let(:alert_message) { "To continue, you need to select the link in the confirmation email we sent to verify your email address. If you didn't get our email, select Resend confirmation email" } @@ -141,7 +141,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end context 'when resending the confirmation email' do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } it 'redirects to the "almost there" page' do visit new_user_confirmation_path @@ -154,7 +154,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end describe 'with a disallowed password' do - let(:user) { create(:user, :disallowed_password) } + let(:user) { create(:user, :no_super_sidebar, :disallowed_password) } before do expect(authentication_metrics) @@ -180,7 +180,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ .to increment(:user_unauthenticated_counter) .and increment(:user_password_invalid_counter) - gitlab_sign_in(User.ghost) + gitlab_sign_in(Users::Internal.ghost) expect(page).to have_content('Invalid login or password.') end @@ -190,8 +190,8 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ .to increment(:user_unauthenticated_counter) .and increment(:user_password_invalid_counter) - expect { gitlab_sign_in(User.ghost) } - .not_to change { User.ghost.reload.sign_in_count } + expect { gitlab_sign_in(Users::Internal.ghost) } + .not_to change { Users::Internal.ghost.reload.sign_in_count } end end @@ -286,6 +286,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ enter_code(code, only_two_factor_webauthn_enabled: only_two_factor_webauthn_enabled) expect(page).to have_content('Invalid two-factor code.') + expect(user.reload.failed_attempts).to eq(1) end end end @@ -294,7 +295,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ # Freeze time to prevent failures when time between code being entered and # validated greater than otp_allowed_drift context 'with valid username/password', :freeze_time do - let(:user) { create(:user, :two_factor) } + let(:user) { create(:user, :no_super_sidebar, :two_factor) } before do gitlab_sign_in(user, remember: true) @@ -371,13 +372,13 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end context 'when user with TOTP enabled' do - let(:user) { create(:user, :two_factor) } + let(:user) { create(:user, :no_super_sidebar, :two_factor) } include_examples 'can login with recovery codes' end context 'when user with only Webauthn enabled' do - let(:user) { create(:user, :two_factor_via_webauthn, registrations_count: 1) } + let(:user) { create(:user, :no_super_sidebar, :two_factor_via_webauthn, registrations_count: 1) } include_examples 'can login with recovery codes', only_two_factor_webauthn_enabled: true end @@ -468,6 +469,12 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end describe 'without two-factor authentication' do + it 'renders sign in text for providers' do + visit new_user_session_path + + expect(page).to have_content(_('or sign in with')) + end + it 'displays the remember me checkbox' do visit new_user_session_path @@ -487,7 +494,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end context 'with correct username and password' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } it 'allows basic login' do expect(authentication_metrics) @@ -576,8 +583,8 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end end - context 'with invalid username and password' do - let(:user) { create(:user) } + context 'with correct username and invalid password' do + let(:user) { create(:user, :no_super_sidebar) } it 'blocks invalid login' do expect(authentication_metrics) @@ -588,12 +595,13 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ expect_single_session_with_short_ttl expect(page).to have_content('Invalid login or password.') + expect(user.reload.failed_attempts).to eq(1) end end end describe 'with required two-factor authentication enabled' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } # TODO: otp_grace_period_started_at @@ -631,7 +639,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end context 'after the grace period' do - let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) } + let(:user) { create(:user, :no_super_sidebar, otp_grace_period_started_at: 9999.hours.ago) } it 'redirects to two-factor configuration page' do expect(authentication_metrics) @@ -720,7 +728,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end context 'after the grace period' do - let(:user) { create(:user, otp_grace_period_started_at: 9999.hours.ago) } + let(:user) { create(:user, :no_super_sidebar, otp_grace_period_started_at: 9999.hours.ago) } it 'redirects to two-factor configuration page' do expect(authentication_metrics) @@ -911,7 +919,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end context 'when terms are enforced', :js do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do enforce_terms @@ -1082,7 +1090,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions, feature_category: :system_ end context 'when sending confirmation email and not yet confirmed' do - let!(:user) { create(:user, confirmed_at: nil) } + let!(:user) { create(:user, :no_super_sidebar, confirmed_at: nil) } let(:grace_period) { 2.days } let(:alert_title) { 'Please confirm your email address' } let(:alert_message) { "To continue, you need to select the link in the confirmation email we sent to verify your email address. If you didn't get our email, select Resend confirmation email" } diff --git a/spec/features/users/logout_spec.rb b/spec/features/users/logout_spec.rb index c9839247e7d..d0e5be8dca3 100644 --- a/spec/features/users/logout_spec.rb +++ b/spec/features/users/logout_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Logout/Sign out', :js, feature_category: :system_access do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/users/overview_spec.rb b/spec/features/users/overview_spec.rb index fdd0c38a718..d1ff60b6069 100644 --- a/spec/features/users/overview_spec.rb +++ b/spec/features/users/overview_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Overview tab on a user profile', :js, feature_category: :user_profile do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } let(:contributed_project) { create(:project, :public, :repository) } def push_code_contribution diff --git a/spec/features/users/rss_spec.rb b/spec/features/users/rss_spec.rb index 2db58ce04a1..99451ac472d 100644 --- a/spec/features/users/rss_spec.rb +++ b/spec/features/users/rss_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' RSpec.describe 'User RSS', feature_category: :user_profile do - let(:user) { create(:user) } - let(:path) { user_path(create(:user)) } + let(:user) { create(:user, :no_super_sidebar) } + let(:path) { user_path(create(:user, :no_super_sidebar)) } describe 'with "user_profile_overflow_menu_vue" feature flag off' do before do @@ -22,6 +22,7 @@ RSpec.describe 'User RSS', feature_category: :user_profile do context 'when signed out' do before do + stub_feature_flags(super_sidebar_logged_out: false) visit path end @@ -45,6 +46,7 @@ RSpec.describe 'User RSS', feature_category: :user_profile do context 'when signed out' do before do + stub_feature_flags(super_sidebar_logged_out: false) visit path end diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb index f8653b22377..522eb12f507 100644 --- a/spec/features/users/show_spec.rb +++ b/spec/features/users/show_spec.rb @@ -7,6 +7,10 @@ RSpec.describe 'User page', feature_category: :user_profile do let_it_be(:user) { create(:user, bio: 'Lorem ipsum dolor sit amet') } + before do + stub_feature_flags(super_sidebar_logged_out: false) + end + subject(:visit_profile) { visit(user_path(user)) } context 'with "user_profile_overflow_menu_vue" feature flag enabled', :js do diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb index 450b9fa46b1..111c0cce1b1 100644 --- a/spec/features/users/signup_spec.rb +++ b/spec/features/users/signup_spec.rb @@ -234,7 +234,7 @@ RSpec.describe 'Signup', :js, feature_category: :user_profile do confirm_email - expect(find_field('Username or email').value).to eq(new_user.email) + expect(find_field('Username or primary email').value).to eq(new_user.email) end end @@ -332,7 +332,6 @@ RSpec.describe 'Signup', :js, feature_category: :user_profile do click_button 'Register' expect(page).to have_current_path(users_sign_up_welcome_path), ignore_query: true - visit new_project_path select 'Software Developer', from: 'user_role' click_button 'Get started!' @@ -341,7 +340,7 @@ RSpec.describe 'Signup', :js, feature_category: :user_profile do expect(created_user.software_developer_role?).to be_truthy expect(created_user.setup_for_company).to be_nil - expect(page).to have_current_path(new_project_path) + expect(page).to have_current_path(dashboard_projects_path) end it_behaves_like 'Signup name validation', 'new_user_first_name', 127, 'First name' @@ -388,7 +387,7 @@ RSpec.describe 'Signup', :js, feature_category: :user_profile do end end - it 'redirects to step 2 of the signup process, sets the role and redirects back' do + it 'allows visiting of a page after initial registration' do visit new_user_registration_path fill_in_signup_form @@ -397,15 +396,6 @@ RSpec.describe 'Signup', :js, feature_category: :user_profile do visit new_project_path - expect(page).to have_current_path(users_sign_up_welcome_path) - - select 'Software Developer', from: 'user_role' - click_button 'Get started!' - - created_user = User.find_by_username(new_user.username) - - expect(created_user.software_developer_role?).to be_truthy - expect(created_user.setup_for_company).to be_nil expect(page).to have_current_path(new_project_path) end diff --git a/spec/features/users/snippets_spec.rb b/spec/features/users/snippets_spec.rb index 2876351be37..98ac9fa5f92 100644 --- a/spec/features/users/snippets_spec.rb +++ b/spec/features/users/snippets_spec.rb @@ -4,10 +4,10 @@ require 'spec_helper' RSpec.describe 'Snippets tab on a user profile', :js, feature_category: :source_code_management do context 'when the user has snippets' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do - stub_feature_flags(profile_tabs_vue: false) + stub_feature_flags(profile_tabs_vue: false, super_sidebar_logged_out: false) end context 'pagination' do @@ -30,7 +30,7 @@ RSpec.describe 'Snippets tab on a user profile', :js, feature_category: :source_ let!(:other_snippet) { create(:snippet, :public) } it 'contains only internal and public snippets of a user when a user is logged in' do - sign_in(create(:user)) + sign_in(create(:user, :no_super_sidebar)) visit user_path(user) page.within('.user-profile-nav') { click_link 'Snippets' } wait_for_requests diff --git a/spec/features/users/terms_spec.rb b/spec/features/users/terms_spec.rb index cf62ccaf999..3495af3ae85 100644 --- a/spec/features/users/terms_spec.rb +++ b/spec/features/users/terms_spec.rb @@ -27,7 +27,7 @@ RSpec.describe 'Users > Terms', :js, feature_category: :user_profile do end context 'when user is a project bot' do - let(:project_bot) { create(:user, :project_bot) } + let(:project_bot) { create(:user, :no_super_sidebar, :project_bot) } before do enforce_terms @@ -42,7 +42,7 @@ RSpec.describe 'Users > Terms', :js, feature_category: :user_profile do end context 'when user is a service account' do - let(:service_account) { create(:user, :service_account) } + let(:service_account) { create(:user, :no_super_sidebar, :service_account) } before do enforce_terms @@ -57,7 +57,7 @@ RSpec.describe 'Users > Terms', :js, feature_category: :user_profile do end context 'when signed in' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do sign_in(user) diff --git a/spec/features/users/user_browses_projects_on_user_page_spec.rb b/spec/features/users/user_browses_projects_on_user_page_spec.rb index 8bdc09f3f87..5e047192e7b 100644 --- a/spec/features/users/user_browses_projects_on_user_page_spec.rb +++ b/spec/features/users/user_browses_projects_on_user_page_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Users > User browses projects on user page', :js, feature_category: :groups_and_projects do - let!(:user) { create :user } + let!(:user) { create(:user, :no_super_sidebar) } let!(:private_project) do create :project, :private, name: 'private', namespace: user.namespace do |project| project.add_maintainer(user) @@ -29,7 +29,7 @@ RSpec.describe 'Users > User browses projects on user page', :js, feature_catego end before do - stub_feature_flags(profile_tabs_vue: false) + stub_feature_flags(profile_tabs_vue: false, super_sidebar_logged_out: false) end it 'hides loading spinner after load', :js do @@ -87,7 +87,7 @@ RSpec.describe 'Users > User browses projects on user page', :js, feature_catego end context 'when signed in as another user' do - let(:another_user) { create :user } + let(:another_user) { create(:user, :no_super_sidebar) } before do sign_in(another_user) diff --git a/spec/features/webauthn_spec.rb b/spec/features/webauthn_spec.rb index 5c42facfa8b..52e2b375187 100644 --- a/spec/features/webauthn_spec.rb +++ b/spec/features/webauthn_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_categor # TODO: it_behaves_like 'hardware device for 2fa', 'WebAuthn' describe 'registration' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do gitlab_sign_in(user) @@ -58,7 +58,8 @@ RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_categor gitlab_sign_out # Second user - user = gitlab_sign_in(:user) + user = create(:user, :no_super_sidebar) + gitlab_sign_in(user) visit profile_account_path enable_two_factor_authentication webauthn_device_registration(webauthn_device: webauthn_device, name: 'My other device', password: user.password) @@ -125,7 +126,7 @@ RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_categor it_behaves_like 'hardware device for 2fa', 'WebAuthn' describe 'registration' do - let(:user) { create(:user) } + let(:user) { create(:user, :no_super_sidebar) } before do gitlab_sign_in(user) @@ -160,7 +161,8 @@ RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_categor gitlab_sign_out # Second user - user = gitlab_sign_in(:user) + user = create(:user, :no_super_sidebar) + gitlab_sign_in(user) user.update_attribute(:otp_required_for_login, true) visit profile_account_path manage_two_factor_authentication @@ -225,7 +227,7 @@ RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_categor describe 'authentication' do let(:otp_required_for_login) { true } - let(:user) { create(:user, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) } + let(:user) { create(:user, :no_super_sidebar, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) } let!(:webauthn_device) do add_webauthn_device(app_id, user) end @@ -254,7 +256,7 @@ RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_categor describe 'when a given WebAuthn device has already been registered by another user' do describe 'but not the current user' do - let(:other_user) { create(:user, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) } + let(:other_user) { create(:user, :no_super_sidebar, webauthn_xid: WebAuthn.generate_user_id, otp_required_for_login: otp_required_for_login) } it 'does not allow logging in with that particular device' do # Register other user with a different WebAuthn device @@ -275,7 +277,8 @@ RSpec.describe 'Using WebAuthn Devices for Authentication', :js, feature_categor it "allows logging in with that particular device" do pending("support for passing credential options in FakeClient") # Register current user with the same WebAuthn device - current_user = gitlab_sign_in(:user) + current_user = create(:user, :no_super_sidebar) + gitlab_sign_in(current_user) visit profile_account_path manage_two_factor_authentication register_webauthn_device(webauthn_device) diff --git a/spec/features/whats_new_spec.rb b/spec/features/whats_new_spec.rb index 3668d90f2e9..c8bcf5f6ef0 100644 --- a/spec/features/whats_new_spec.rb +++ b/spec/features/whats_new_spec.rb @@ -3,9 +3,13 @@ require "spec_helper" RSpec.describe "renders a `whats new` dropdown item", feature_category: :onboarding do - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, :no_super_sidebar) } context 'when not logged in' do + before do + stub_feature_flags(super_sidebar_logged_out: false) + end + it 'and on SaaS it renders', :saas do visit user_path(user) diff --git a/spec/finders/abuse_reports_finder_spec.rb b/spec/finders/abuse_reports_finder_spec.rb index 0b641d0cb08..c3cf84d082f 100644 --- a/spec/finders/abuse_reports_finder_spec.rb +++ b/spec/finders/abuse_reports_finder_spec.rb @@ -17,17 +17,21 @@ RSpec.describe AbuseReportsFinder, feature_category: :insider_threat do create(:abuse_report, :closed, category: 'phishing', user: user_2, reporter: reporter_2, id: 2) end - let(:params) { {} } - subject(:finder) { described_class.new(params).execute } describe '#execute' do - context 'when params is empty' do + shared_examples 'returns all abuse reports' do it 'returns all abuse reports' do expect(finder).to match_array([abuse_report_1, abuse_report_2]) end end + context 'when params is empty' do + let(:params) { {} } + + it_behaves_like 'returns all abuse reports' + end + shared_examples 'returns filtered reports' do |filter_field| it "returns abuse reports filtered by #{filter_field}_id" do expect(finder).to match_array(filtered_reports) @@ -41,9 +45,7 @@ RSpec.describe AbuseReportsFinder, feature_category: :insider_threat do .and_return(nil) end - it 'returns all abuse reports' do - expect(finder).to match_array([abuse_report_1, abuse_report_2]) - end + it_behaves_like 'returns all abuse reports' end end @@ -169,39 +171,5 @@ RSpec.describe AbuseReportsFinder, feature_category: :insider_threat do end end end - - context 'when legacy view is enabled' do - before do - stub_feature_flags(abuse_reports_list: false) - end - - context 'when params is empty' do - it 'returns all abuse reports' do - expect(subject).to match_array([abuse_report_1, abuse_report_2]) - end - end - - context 'when params[:user_id] is present' do - let(:params) { { user_id: user_1 } } - - it 'returns abuse reports for the specified user' do - expect(subject).to match_array([abuse_report_1]) - end - end - - context 'when sorting' do - it 'returns reports sorted by id in descending order' do - expect(subject).to match_array([abuse_report_2, abuse_report_1]) - end - end - - context 'when any of the new filters are present such as params[:status]' do - let(:params) { { status: 'open' } } - - it 'returns all abuse reports' do - expect(subject).to match_array([abuse_report_1, abuse_report_2]) - end - end - end end end diff --git a/spec/finders/ci/jobs_finder_spec.rb b/spec/finders/ci/jobs_finder_spec.rb index 0b3777a2fe8..57046baafab 100644 --- a/spec/finders/ci/jobs_finder_spec.rb +++ b/spec/finders/ci/jobs_finder_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::JobsFinder, '#execute' do +RSpec.describe Ci::JobsFinder, '#execute', feature_category: :continuous_integration do let_it_be(:user) { create(:user) } let_it_be(:admin) { create(:user, :admin) } let_it_be(:project) { create(:project, :private, public_builds: false) } @@ -13,8 +13,8 @@ RSpec.describe Ci::JobsFinder, '#execute' do let(:params) { {} } - context 'no project' do - subject { described_class.new(current_user: current_user, params: params).execute } + context 'when project, pipeline, and runner are blank' do + subject(:finder_execute) { described_class.new(current_user: current_user, params: params).execute } context 'with admin' do let(:current_user) { admin } @@ -34,43 +34,139 @@ RSpec.describe Ci::JobsFinder, '#execute' do end end - context 'with normal user' do - let(:current_user) { user } + context 'with admin and admin mode enabled', :enable_admin_mode do + let(:current_user) { admin } - it { is_expected.to be_empty } - end + context 'with param `scope`' do + using RSpec::Parameterized::TableSyntax - context 'without user' do - let(:current_user) { nil } + where(:scope, :expected_jobs) do + 'pending' | lazy { [pending_job] } + 'running' | lazy { [running_job] } + 'finished' | lazy { [successful_job] } + %w[running success] | lazy { [running_job, successful_job] } + end - it { is_expected.to be_empty } - end + with_them do + let(:params) { { scope: scope } } - context 'with scope', :enable_admin_mode do - let(:current_user) { admin } - let(:jobs) { [pending_job, running_job, successful_job] } + it { is_expected.to match_array(expected_jobs) } + end + end - using RSpec::Parameterized::TableSyntax + context 'with param `runner_type`' do + let_it_be(:job_with_group_runner) { create(:ci_build, :success, runner: create(:ci_runner, :group)) } + let_it_be(:job_with_instance_runner) { create(:ci_build, :success, runner: create(:ci_runner, :instance)) } + let_it_be(:job_with_project_runner) { create(:ci_build, :success, runner: create(:ci_runner, :project)) } + + context 'with feature flag :admin_jobs_filter_runner_type enabled' do + using RSpec::Parameterized::TableSyntax + + where(:runner_type, :expected_jobs) do + 'group_type' | lazy { [job_with_group_runner] } + 'instance_type' | lazy { [job_with_instance_runner] } + 'project_type' | lazy { [job_with_project_runner] } + %w[instance_type project_type] | lazy { [job_with_instance_runner, job_with_project_runner] } + end + + with_them do + let(:params) { { runner_type: runner_type } } + it { is_expected.to match_array(expected_jobs) } + end + end - where(:scope, :expected_jobs) do - 'pending' | lazy { [pending_job] } - 'running' | lazy { [running_job] } - 'finished' | lazy { [successful_job] } - %w[running success] | lazy { [running_job, successful_job] } + context 'with feature flag :admin_jobs_filter_runner_type disabled' do + let(:params) { { runner_type: 'instance_type' } } + let(:expected_jobs) do + [ + job_with_group_runner, + job_with_instance_runner, + job_with_project_runner, + pending_job, + running_job, + successful_job + ] + end + + before do + stub_feature_flags(admin_jobs_filter_runner_type: false) + end + + it { is_expected.to match_array(expected_jobs) } + end end - with_them do - let(:params) { { scope: scope } } + context "with params" do + let_it_be(:job_with_running_status_and_group_runner) do + create(:ci_build, :running, runner: create(:ci_runner, :group)) + end + + let_it_be(:job_with_instance_runner) { create(:ci_build, :success, runner: create(:ci_runner, :instance)) } + let_it_be(:job_with_project_runner) { create(:ci_build, :success, runner: create(:ci_runner, :project)) } + + context 'with feature flag :admin_jobs_filter_runner_type enabled' do + using RSpec::Parameterized::TableSyntax + + where(:param_runner_type, :param_scope, :expected_jobs) do + 'group_type' | 'running' | lazy { [job_with_running_status_and_group_runner] } + %w[instance_type project_type] | 'finished' | lazy { [job_with_instance_runner, job_with_project_runner] } + %w[instance_type project_type] | 'pending' | lazy { [] } + end + + with_them do + let(:params) { { runner_type: param_runner_type, scope: param_scope } } + + it { is_expected.to match_array(expected_jobs) } + end + end - it { is_expected.to match_array(expected_jobs) } + context 'with feature flag :admin_jobs_filter_runner_type disabled' do + before do + stub_feature_flags(admin_jobs_filter_runner_type: false) + end + + using RSpec::Parameterized::TableSyntax + + where(:param_runner_type, :param_scope, :expected_jobs) do + 'group_type' | 'running' | lazy do + [job_with_running_status_and_group_runner, running_job] + end + %w[instance_type project_type] | 'finished' | lazy do + [ + job_with_instance_runner, + job_with_project_runner, + successful_job + ] + end + %w[instance_type project_type] | 'pending' | lazy { [pending_job] } + end + + with_them do + let(:params) { { runner_type: param_runner_type, scope: param_scope } } + + it { is_expected.to match_array(expected_jobs) } + end + end end end + + context 'with user not being project member' do + let(:current_user) { user } + + it { is_expected.to be_empty } + end + + context 'without user' do + let(:current_user) { nil } + + it { is_expected.to be_empty } + end end - context 'a project is present' do + context 'when project is present' do subject { described_class.new(current_user: user, project: project, params: params).execute } - context 'user has access to the project' do + context 'with user being project maintainer' do before do project.add_maintainer(user) end @@ -78,9 +174,47 @@ RSpec.describe Ci::JobsFinder, '#execute' do it 'returns jobs for the specified project' do expect(subject).to match_array([successful_job]) end + + context 'when artifacts are present for some jobs' do + let_it_be(:job_with_artifacts) { create(:ci_build, :success, pipeline: pipeline, name: 'test') } + let_it_be(:artifact) { create(:ci_job_artifact, job: job_with_artifacts) } + + context 'when with_artifacts is true' do + let(:params) { { with_artifacts: true } } + + it 'returns only jobs with artifacts' do + expect(subject).to match_array([job_with_artifacts]) + end + end + + context 'when with_artifacts is false' do + let(:params) { { with_artifacts: false } } + + it 'returns all jobs' do + expect(subject).to match_array([successful_job, job_with_artifacts]) + end + end + + context "with param `scope" do + using RSpec::Parameterized::TableSyntax + + where(:param_scope, :expected_jobs) do + 'success' | lazy { [successful_job, job_with_artifacts] } + '[success pending]' | lazy { [successful_job, job_with_artifacts] } + 'pending' | lazy { [] } + nil | lazy { [successful_job, job_with_artifacts] } + end + + with_them do + let(:params) { { with_artifacts: false, scope: param_scope } } + + it { is_expected.to match_array(expected_jobs) } + end + end + end end - context 'user has no access to project builds' do + context 'with user being project guest' do before do project.add_guest(user) end @@ -99,79 +233,80 @@ RSpec.describe Ci::JobsFinder, '#execute' do end end - context 'when artifacts are present for some jobs' do - let_it_be(:job_with_artifacts) { create(:ci_build, :success, pipeline: pipeline, name: 'test') } - let_it_be(:artifact) { create(:ci_job_artifact, job: job_with_artifacts) } - - subject { described_class.new(current_user: user, project: project, params: params).execute } - - before do - project.add_maintainer(user) - end - - context 'when with_artifacts is true' do - let(:params) { { with_artifacts: true } } + context 'when pipeline is present' do + subject { described_class.new(current_user: user, pipeline: pipeline, params: params).execute } - it 'returns only jobs with artifacts' do - expect(subject).to match_array([job_with_artifacts]) + context 'with user being project maintainer' do + before_all do + project.add_maintainer(user) + successful_job.update!(retried: true) end - end - context 'when with_artifacts is false' do - let(:params) { { with_artifacts: false } } + let_it_be(:job_4) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } - it 'returns all jobs' do - expect(subject).to match_array([successful_job, job_with_artifacts]) + it 'does not return retried jobs by default' do + expect(subject).to match_array([job_4]) end - end - end - - context 'when pipeline is present' do - before_all do - project.add_maintainer(user) - successful_job.update!(retried: true) - end - - let_it_be(:job_4) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } - subject { described_class.new(current_user: user, pipeline: pipeline, params: params).execute } + context 'when include_retried is false' do + let(:params) { { include_retried: false } } - it 'does not return retried jobs by default' do - expect(subject).to match_array([job_4]) - end + it 'does not return retried jobs' do + expect(subject).to match_array([job_4]) + end + end - context 'when include_retried is false' do - let(:params) { { include_retried: false } } + context 'when include_retried is true' do + let(:params) { { include_retried: true } } - it 'does not return retried jobs' do - expect(subject).to match_array([job_4]) + it 'returns retried jobs' do + expect(subject).to match_array([successful_job, job_4]) + end end end - context 'when include_retried is true' do - let(:params) { { include_retried: true } } + context 'without user' do + let(:user) { nil } - it 'returns retried jobs' do - expect(subject).to match_array([successful_job, job_4]) + it 'returns no jobs' do + expect(subject).to be_empty end end end - context 'a runner is present' do + context 'when runner is present' do let_it_be(:runner) { create(:ci_runner, :project, projects: [project]) } let_it_be(:job_4) { create(:ci_build, :success, runner: runner) } - subject { described_class.new(current_user: user, runner: runner, params: params).execute } + subject(:execute) { described_class.new(current_user: user, runner: runner, params: params).execute } - context 'user has access to the runner', :enable_admin_mode do + context 'when current user is an admin' do let(:user) { admin } - it 'returns jobs for the specified project' do - expect(subject).to match_array([job_4]) + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns jobs for the specified project' do + expect(subject).to contain_exactly job_4 + end + + context 'with params' do + using RSpec::Parameterized::TableSyntax + + where(:param_runner_type, :param_scope, :expected_jobs) do + 'project_type' | 'success' | lazy { [job_4] } + 'instance_type' | nil | lazy { [] } + nil | 'pending' | lazy { [] } + end + + with_them do + let(:params) { { runner_type: param_runner_type, scope: param_scope } } + + it { is_expected.to match_array(expected_jobs) } + end + end end end - context 'user has no access to project builds' do + context 'with user being project guest' do let_it_be(:guest) { create(:user) } let(:user) { guest } diff --git a/spec/finders/ci/runners_finder_spec.rb b/spec/finders/ci/runners_finder_spec.rb index e57ad5bc76d..5d249ddb391 100644 --- a/spec/finders/ci/runners_finder_spec.rb +++ b/spec/finders/ci/runners_finder_spec.rb @@ -222,12 +222,14 @@ RSpec.describe Ci::RunnersFinder, feature_category: :runner_fleet do end shared_examples 'executes as normal user' do - it 'returns no runners' do + it 'raises Gitlab::Access::AccessDeniedError' do user = create :user create :ci_runner, active: true create :ci_runner, active: false - expect(described_class.new(current_user: user, params: {}).execute).to be_empty + expect do + described_class.new(current_user: user, params: {}).execute + end.to raise_error(Gitlab::Access::AccessDeniedError) end end @@ -250,12 +252,14 @@ RSpec.describe Ci::RunnersFinder, feature_category: :runner_fleet do end context 'when user is nil' do - it 'returns no runners' do + it 'raises Gitlab::Access::AccessDeniedError' do user = nil create :ci_runner, active: true create :ci_runner, active: false - expect(described_class.new(current_user: user, params: {}).execute).to be_empty + expect do + described_class.new(current_user: user, params: {}).execute + end.to raise_error(Gitlab::Access::AccessDeniedError) end end end @@ -306,154 +310,162 @@ RSpec.describe Ci::RunnersFinder, feature_category: :runner_fleet do shared_examples 'membership equal to :descendants' do it 'returns all descendant runners' do - expect(subject).to eq([runner_project_7, runner_project_6, runner_project_5, - runner_project_4, runner_project_3, runner_project_2, - runner_project_1, runner_sub_group_4, runner_sub_group_3, - runner_sub_group_2, runner_sub_group_1, runner_group]) + is_expected.to contain_exactly( + runner_project_7, runner_project_6, runner_project_5, + runner_project_4, runner_project_3, runner_project_2, + runner_project_1, runner_sub_group_4, runner_sub_group_3, + runner_sub_group_2, runner_sub_group_1, runner_group) end end - context 'with user as group owner' do - before do - group.add_owner(user) + context 'with user is group maintainer or owner' do + where(:user_role) do + [GroupMember::OWNER, GroupMember::MAINTAINER] end - context 'with :group as target group' do - let(:target_group) { group } - - context 'passing no membership params' do - it_behaves_like 'membership equal to :descendants' + with_them do + before do + group.add_member(user, user_role) end - context 'with :descendants membership' do - let(:membership) { :descendants } + context 'with :group as target group' do + let(:target_group) { group } - it_behaves_like 'membership equal to :descendants' - end + context 'passing no membership params' do + it_behaves_like 'membership equal to :descendants' + end - context 'with :direct membership' do - let(:membership) { :direct } + context 'with :descendants membership' do + let(:membership) { :descendants } - it 'returns runners belonging to group' do - expect(subject).to eq([runner_group]) + it_behaves_like 'membership equal to :descendants' end - end - context 'with :all_available membership' do - let(:membership) { :all_available } + context 'with :direct membership' do + let(:membership) { :direct } - it 'returns runners available to group' do - expect(subject).to match_array([runner_project_7, runner_project_6, runner_project_5, - runner_project_4, runner_project_3, runner_project_2, - runner_project_1, runner_sub_group_4, runner_sub_group_3, - runner_sub_group_2, runner_sub_group_1, runner_group, runner_instance]) + it 'returns runners belonging to group' do + is_expected.to contain_exactly(runner_group) + end end - end - context 'with unknown membership' do - let(:membership) { :unsupported } + context 'with :all_available membership' do + let(:membership) { :all_available } - it 'raises an error' do - expect { subject }.to raise_error(ArgumentError, 'Invalid membership filter') + it 'returns runners available to group' do + is_expected.to contain_exactly( + runner_project_7, runner_project_6, runner_project_5, + runner_project_4, runner_project_3, runner_project_2, + runner_project_1, runner_sub_group_4, runner_sub_group_3, + runner_sub_group_2, runner_sub_group_1, runner_group, runner_instance) + end end - end - context 'with nil group' do - let(:target_group) { nil } + context 'with unknown membership' do + let(:membership) { :unsupported } - it 'returns no runners' do - # Query should run against all runners, however since user is not admin, query returns no results - expect(subject).to eq([]) + it 'raises an error' do + expect { subject }.to raise_error(ArgumentError, 'Invalid membership filter') + end end - end - context 'with sort param' do - let(:extra_params) { { sort: 'contacted_asc' } } + context 'with nil group' do + let(:target_group) { nil } - it 'sorts by specified attribute' do - expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2, - runner_sub_group_3, runner_sub_group_4, runner_project_1, - runner_project_2, runner_project_3, runner_project_4, - runner_project_5, runner_project_6, runner_project_7]) + it 'raises Gitlab::Access::AccessDeniedError' do + # Query should run against all runners, however since user is not admin, we raise an error + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) + end end - end - context 'filtering' do - context 'by search term' do - let(:extra_params) { { search: 'runner_project_search' } } + context 'with sort param' do + let(:extra_params) { { sort: 'contacted_asc' } } - it 'returns correct runner' do - expect(subject).to match_array([runner_project_3]) + it 'sorts by specified attribute' do + expect(subject).to eq([runner_group, runner_sub_group_1, runner_sub_group_2, + runner_sub_group_3, runner_sub_group_4, runner_project_1, + runner_project_2, runner_project_3, runner_project_4, + runner_project_5, runner_project_6, runner_project_7]) end end - context 'by active status' do - let(:extra_params) { { active: false } } + context 'filtering' do + context 'by search term' do + let(:extra_params) { { search: 'runner_project_search' } } - it 'returns correct runner' do - expect(subject).to match_array([runner_sub_group_1]) + it 'returns correct runner' do + expect(subject).to match_array([runner_project_3]) + end end - end - context 'by status' do - let(:extra_params) { { status_status: 'paused' } } + context 'by active status' do + let(:extra_params) { { active: false } } - it 'returns correct runner' do - expect(subject).to match_array([runner_sub_group_1]) + it 'returns correct runner' do + expect(subject).to match_array([runner_sub_group_1]) + end end - end - context 'by tag_name' do - let(:extra_params) { { tag_name: %w[runner_tag] } } + context 'by status' do + let(:extra_params) { { status_status: 'paused' } } - it 'returns correct runner' do - expect(subject).to match_array([runner_project_5]) + it 'returns correct runner' do + expect(subject).to match_array([runner_sub_group_1]) + end end - end - context 'by runner type' do - let(:extra_params) { { type_type: 'project_type' } } + context 'by tag_name' do + let(:extra_params) { { tag_name: %w[runner_tag] } } - it 'returns correct runners' do - expect(subject).to eq([runner_project_7, runner_project_6, - runner_project_5, runner_project_4, - runner_project_3, runner_project_2, runner_project_1]) + it 'returns correct runner' do + expect(subject).to match_array([runner_project_5]) + end + end + + context 'by runner type' do + let(:extra_params) { { type_type: 'project_type' } } + + it 'returns correct runners' do + expect(subject).to eq([runner_project_7, runner_project_6, + runner_project_5, runner_project_4, + runner_project_3, runner_project_2, runner_project_1]) + end end end end end end - context 'when user is not group owner' do - where(:user_permission) do - [:maintainer, :developer, :reporter, :guest] + context 'when user is group developer or below' do + where(:user_role) do + [GroupMember::DEVELOPER, GroupMember::REPORTER, GroupMember::GUEST] end with_them do before do - create(:group_member, user_permission, group: sub_group_1, user: user) + group.add_member(user, user_role) end context 'with :sub_group_1 as target group' do let(:target_group) { sub_group_1 } - it 'returns no runners' do - is_expected.to be_empty + it 'raises Gitlab::Access::AccessDeniedError' do + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end context 'with :group as target group' do let(:target_group) { group } - it 'returns no runners' do - is_expected.to be_empty + it 'raises Gitlab::Access::AccessDeniedError' do + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) end context 'with :all_available membership' do let(:membership) { :all_available } - it 'returns no runners' do - expect(subject).to be_empty + it 'raises Gitlab::Access::AccessDeniedError' do + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end end @@ -461,35 +473,31 @@ RSpec.describe Ci::RunnersFinder, feature_category: :runner_fleet do end context 'when user has no access' do - it 'returns no runners' do - expect(subject).to be_empty + it 'raises Gitlab::Access::AccessDeniedError' do + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end context 'when user is nil' do - let_it_be(:user) { nil } + let(:user) { nil } - it 'returns no runners' do - expect(subject).to be_empty + it 'raises Gitlab::Access::AccessDeniedError' do + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end end describe '#sort_key' do - subject { described_class.new(current_user: user, params: params.merge(group: group)).sort_key } + subject(:sort_key) { described_class.new(current_user: user, params: params.merge(group: group)).sort_key } context 'without params' do - it 'returns created_at_desc' do - expect(subject).to eq('created_at_desc') - end + it { is_expected.to eq('created_at_desc') } end context 'with params' do let(:extra_params) { { sort: 'contacted_asc' } } - it 'returns contacted_asc' do - expect(subject).to eq('contacted_asc') - end + it { is_expected.to eq('contacted_asc') } end end end @@ -504,7 +512,7 @@ RSpec.describe Ci::RunnersFinder, feature_category: :runner_fleet do let(:params) { { project: project }.merge(extra_params).reject { |_, v| v.nil? } } describe '#execute' do - subject { described_class.new(current_user: user, params: params).execute } + subject(:execute) { described_class.new(current_user: user, params: params).execute } context 'with user as project admin' do before do @@ -515,7 +523,7 @@ RSpec.describe Ci::RunnersFinder, feature_category: :runner_fleet do let_it_be(:runner_project) { create(:ci_runner, :project, contacted_at: 7.minutes.ago, projects: [project]) } it 'returns runners available to project' do - expect(subject).to match_array([runner_project]) + is_expected.to match_array([runner_project]) end end @@ -524,7 +532,7 @@ RSpec.describe Ci::RunnersFinder, feature_category: :runner_fleet do let_it_be(:runner_group) { create(:ci_runner, :group, contacted_at: 12.minutes.ago, groups: [group]) } it 'returns runners available to project' do - expect(subject).to match_array([runner_instance, runner_group]) + is_expected.to match_array([runner_instance, runner_group]) end end @@ -610,24 +618,24 @@ RSpec.describe Ci::RunnersFinder, feature_category: :runner_fleet do project.add_developer(user) end - it 'returns no runners' do - expect(subject).to be_empty + it 'raises Gitlab::Access::AccessDeniedError' do + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end context 'when user is nil' do let_it_be(:user) { nil } - it 'returns no runners' do - expect(subject).to be_empty + it 'raises Gitlab::Access::AccessDeniedError' do + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end context 'with nil project_full_path' do let(:project_full_path) { nil } - it 'returns no runners' do - expect(subject).to be_empty + it 'raises Gitlab::Access::AccessDeniedError' do + expect { execute }.to raise_error(Gitlab::Access::AccessDeniedError) end end end diff --git a/spec/finders/ci/triggers_finder_spec.rb b/spec/finders/ci/triggers_finder_spec.rb new file mode 100644 index 00000000000..2df79e8f023 --- /dev/null +++ b/spec/finders/ci/triggers_finder_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::TriggersFinder, feature_category: :continuous_integration do + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:trigger) { create(:ci_trigger, project: project) } + + subject { described_class.new(current_user, project).execute } + + describe "#execute" do + context 'when the current user is authorized' do + before_all do + project.add_owner(current_user) + end + + it 'returns list of trigger tokens' do + expect(subject).to contain_exactly(trigger) + end + end + + context 'when the current user is not authorized' do + it 'does not return trigger tokens' do + expect(subject).to be_blank + end + end + end +end diff --git a/spec/finders/deployments_finder_spec.rb b/spec/finders/deployments_finder_spec.rb index 5a803ee2a0d..807a7ca8e26 100644 --- a/spec/finders/deployments_finder_spec.rb +++ b/spec/finders/deployments_finder_spec.rb @@ -280,6 +280,22 @@ RSpec.describe DeploymentsFinder, feature_category: :deployment_management do it { is_expected.to match_array([deployment_2]) } end end + + context 'with mixed deployable types' do + let!(:deployment_1) do + create(:deployment, :success, project: project, deployable: create(:ci_build)) + end + + let!(:deployment_2) do + create(:deployment, :success, project: project, deployable: create(:ci_bridge)) + end + + let(:params) { { **base_params, status: 'success' } } + + it 'successfuly fetches deployments' do + is_expected.to contain_exactly(deployment_1, deployment_2) + end + end end context 'at group scope' do diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb index 18473a5e70b..b8a5be44241 100644 --- a/spec/finders/group_members_finder_spec.rb +++ b/spec/finders/group_members_finder_spec.rb @@ -288,4 +288,39 @@ RSpec.describe GroupMembersFinder, '#execute', feature_category: :groups_and_pro end end end + + context 'filter by non-invite' do + let_it_be(:member) { group.add_maintainer(user1) } + let_it_be(:invited_member) do + create(:group_member, :invited, { user: user2, group: group }) + end + + context 'params is not passed in' do + subject { described_class.new(group, user1).execute } + + it 'does not filter members by invite' do + expect(subject).to match_array([member, invited_member]) + end + end + + context 'params is passed in' do + subject { described_class.new(group, user1, params: { non_invite: non_invite_param }).execute } + + context 'filtering is set to false' do + let(:non_invite_param) { false } + + it 'does not filter members by invite' do + expect(subject).to match_array([member, invited_member]) + end + end + + context 'filtering is set to true' do + let(:non_invite_param) { true } + + it 'filters members by invite' do + expect(subject).to match_array([member]) + end + end + end + end end diff --git a/spec/finders/groups/accepting_group_transfers_finder_spec.rb b/spec/finders/groups/accepting_group_transfers_finder_spec.rb index 18407dd0196..5c78ec3124b 100644 --- a/spec/finders/groups/accepting_group_transfers_finder_spec.rb +++ b/spec/finders/groups/accepting_group_transfers_finder_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Groups::AcceptingGroupTransfersFinder do +RSpec.describe Groups::AcceptingGroupTransfersFinder, feature_category: :groups_and_projects do let_it_be(:current_user) { create(:user) } let_it_be(:great_grandparent_group) do @@ -119,6 +119,25 @@ RSpec.describe Groups::AcceptingGroupTransfersFinder do expect(result).to contain_exactly(great_grandparent_group) end end + + context 'on searching with multiple matches' do + let(:params) { { search: 'great-grandparent-group' } } + let(:other_groups) { [] } + + before do + 2.times do + # app/finders/group/base.rb adds an ORDER BY path, so create a group with 1 in the front. + group = create(:group, parent: great_grandparent_group, path: "1-#{SecureRandom.hex}") + group.add_owner(current_user) + other_groups << group + end + end + + it 'prioritizes exact matches first' do + expect(result.first).to eq(great_grandparent_group) + expect(result[1..]).to match_array(other_groups) + end + end end end end diff --git a/spec/finders/organizations/groups_finder_spec.rb b/spec/finders/organizations/groups_finder_spec.rb new file mode 100644 index 00000000000..08c5604149b --- /dev/null +++ b/spec/finders/organizations/groups_finder_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Organizations::GroupsFinder, feature_category: :cell do + let_it_be(:organization_user) { create(:organization_user) } + let_it_be(:organization) { organization_user.organization } + let_it_be(:user) { organization_user.user } + let_it_be(:public_group) { create(:group, name: 'public-group', organization: organization) } + let_it_be(:other_group) { create(:group, name: 'other-group', organization: organization) } + let_it_be(:outside_organization_group) { create(:group) } + let_it_be(:private_group) do + create(:group, :private, name: 'private-group', organization: organization) + end + + let_it_be(:no_access_group_in_org) do + create(:group, :private, name: 'no-access', organization: organization) + end + + let(:current_user) { user } + let(:params) { {} } + let(:finder) { described_class.new(organization: organization, current_user: current_user, params: params) } + + before_all do + private_group.add_developer(user) + public_group.add_developer(user) + other_group.add_developer(user) + outside_organization_group.add_developer(user) + end + + subject(:result) { finder.execute.to_a } + + describe '#execute' do + context 'when user is not authorized to read the organization' do + let(:current_user) { create(:user) } + + it { is_expected.to be_empty } + end + + context 'when organization is nil' do + let(:organization) { nil } + + it { is_expected.to be_empty } + end + + context 'when user is authorized to read the organization' do + it 'return all accessible groups' do + expect(result).to contain_exactly(public_group, private_group, other_group) + end + + context 'when search param is passed' do + let(:params) { { search: 'the' } } + + it 'filters the groups by search' do + expect(result).to contain_exactly(other_group) + end + end + + context 'when sort param is not passed' do + it 'return groups sorted by name in ascending order by default' do + expect(result).to eq([other_group, private_group, public_group]) + end + end + + context 'when sort param is passed' do + using RSpec::Parameterized::TableSyntax + + where(:field, :direction, :sorted_groups) do + 'name' | 'asc' | lazy { [other_group, private_group, public_group] } + 'name' | 'desc' | lazy { [public_group, private_group, other_group] } + 'path' | 'asc' | lazy { [other_group, private_group, public_group] } + 'path' | 'desc' | lazy { [public_group, private_group, other_group] } + end + + with_them do + let(:params) { { sort: { field: field, direction: direction } } } + it 'sorts the groups' do + expect(result).to eq(sorted_groups) + end + end + end + end + end +end diff --git a/spec/finders/organizations/organization_users_finder_spec.rb b/spec/finders/organizations/organization_users_finder_spec.rb new file mode 100644 index 00000000000..d7fba372e40 --- /dev/null +++ b/spec/finders/organizations/organization_users_finder_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Organizations::OrganizationUsersFinder, feature_category: :cell do + let_it_be(:organization) { create(:organization) } + let_it_be(:organization_user_1) { create(:organization_user, organization: organization) } + let_it_be(:organization_user_2) { create(:organization_user, organization: organization) } + let_it_be(:other_organization_user) { create(:organization_user) } + + let(:current_user) { organization_user_1.user } + let(:finder) { described_class.new(organization: organization, current_user: current_user) } + + subject(:result) { finder.execute.to_a } + + describe '#execute' do + context 'when user is not authorized to read the organization' do + let(:current_user) { create(:user) } + + it { is_expected.to be_empty } + end + + context 'when organization is nil' do + let(:organization) { nil } + + it { is_expected.to be_empty } + end + + context 'when user is authorized to read the organization' do + it 'returns all organization users' do + expect(result).to contain_exactly(organization_user_1, organization_user_2) + end + end + end +end diff --git a/spec/finders/packages/npm/packages_for_user_finder_spec.rb b/spec/finders/packages/npm/packages_for_user_finder_spec.rb new file mode 100644 index 00000000000..e2dc21e1008 --- /dev/null +++ b/spec/finders/packages/npm/packages_for_user_finder_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Npm::PackagesForUserFinder, feature_category: :package_registry do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:project2) { create(:project, group: group) } + let_it_be(:package) { create(:npm_package, project: project) } + let_it_be(:package_name) { package.name } + let_it_be(:package_with_diff_name) { create(:npm_package, project: project) } + let_it_be(:package_with_diff_project) { create(:npm_package, name: package_name, project: project2) } + let_it_be(:maven_package) { create(:maven_package, name: package_name, project: project) } + + let(:finder) { described_class.new(user, project_or_group, package_name: package_name) } + + describe '#execute' do + subject { finder.execute } + + shared_examples 'searches for packages' do + it { is_expected.to contain_exactly(package) } + end + + context 'with a project' do + let(:project_or_group) { project } + + it_behaves_like 'searches for packages' + end + + context 'with a group' do + let(:project_or_group) { group } + + before_all do + project.add_reporter(user) + end + + it_behaves_like 'searches for packages' + end + end +end diff --git a/spec/finders/packages/nuget/package_finder_spec.rb b/spec/finders/packages/nuget/package_finder_spec.rb index 792e543e424..8230d132d75 100644 --- a/spec/finders/packages/nuget/package_finder_spec.rb +++ b/spec/finders/packages/nuget/package_finder_spec.rb @@ -114,16 +114,6 @@ RSpec.describe Packages::Nuget::PackageFinder, feature_category: :package_regist it_behaves_like 'calling with_nuget_version_or_normalized_version scope', with_normalized: true end - - context 'when nuget_normalized_version feature flag is disabled' do - let(:package_version) { '2.0.0+abc' } - - before do - stub_feature_flags(nuget_normalized_version: false) - end - - it_behaves_like 'calling with_nuget_version_or_normalized_version scope', with_normalized: false - end end context 'with a project' do diff --git a/spec/fixtures/api/schemas/entities/codequality_degradation.json b/spec/fixtures/api/schemas/entities/codequality_degradation.json index ac772873daf..ac1a556e33c 100644 --- a/spec/fixtures/api/schemas/entities/codequality_degradation.json +++ b/spec/fixtures/api/schemas/entities/codequality_degradation.json @@ -10,6 +10,9 @@ "description": { "type": "string" }, + "fingerprint": { + "type": "string" + }, "severity": { "type": "string" }, diff --git a/spec/fixtures/api/schemas/job/job.json b/spec/fixtures/api/schemas/job/job.json index f3d5e9b038a..34668f309a6 100644 --- a/spec/fixtures/api/schemas/job/job.json +++ b/spec/fixtures/api/schemas/job/job.json @@ -5,7 +5,6 @@ "id", "name", "started", - "build_path", "playable", "created_at", "updated_at", diff --git a/spec/fixtures/api/schemas/ml/search_runs.json b/spec/fixtures/api/schemas/ml/search_runs.json new file mode 100644 index 00000000000..c1db2c9f15c --- /dev/null +++ b/spec/fixtures/api/schemas/ml/search_runs.json @@ -0,0 +1,82 @@ +{ + "type": "object", + "required": [ + "runs", + "next_page_token" + ], + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "required": [ + "info", + "data" + ], + "properties": { + "info": { + "type": "object", + "required": [ + "run_id", + "run_uuid", + "user_id", + "experiment_id", + "status", + "start_time", + "artifact_uri", + "lifecycle_stage" + ], + "optional": [ + "end_time" + ], + "properties": { + "run_id": { + "type": "string" + }, + "run_uuid": { + "type": "string" + }, + "experiment_id": { + "type": "string" + }, + "artifact_uri": { + "type": "string" + }, + "start_time": { + "type": "integer" + }, + "end_time": { + "type": "integer" + }, + "user_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "RUNNING", + "SCHEDULED", + "FINISHED", + "FAILED", + "KILLED" + ] + }, + "lifecycle_stage": { + "type": "string", + "enum": [ + "active" + ] + } + } + }, + "data": { + "type": "object" + } + } + } + }, + "next_page_token": { + "type": "string" + } + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/integration.json b/spec/fixtures/api/schemas/public_api/v4/integration.json index 8902196a2c4..2b16e44eb85 100644 --- a/spec/fixtures/api/schemas/public_api/v4/integration.json +++ b/spec/fixtures/api/schemas/public_api/v4/integration.json @@ -65,6 +65,9 @@ }, "comment_on_event_enabled": { "type": "boolean" + }, + "vulnerability_events": { + "type": "boolean" } }, "additionalProperties": false diff --git a/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json b/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json index f572b1a4f9b..a72260af145 100644 --- a/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json +++ b/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json @@ -8,7 +8,8 @@ "id": { "type": "integer" }, "name": { "type": "string" }, "parameters": { "type": "object" }, - "scopes": { "type": "array", "items": { "$ref": "scope.json" } } + "scopes": { "type": "array", "items": { "$ref": "scope.json" } }, + "user_list": { "type": ["object", "null"], "$ref": "user_list.json" } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/public_api/v4/operations/user_list.json b/spec/fixtures/api/schemas/public_api/v4/operations/user_list.json new file mode 100644 index 00000000000..6a9f977e37d --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/operations/user_list.json @@ -0,0 +1,16 @@ +{ + "type": ["object", "null"], + "required": [ + "id", + "iid", + "name", + "user_xids" + ], + "properties": { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "name": { "type": "string" }, + "user_xids": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/status/ci_detailed_status.json b/spec/fixtures/api/schemas/status/ci_detailed_status.json index 8d0f1e4a6af..0d9e4975858 100644 --- a/spec/fixtures/api/schemas/status/ci_detailed_status.json +++ b/spec/fixtures/api/schemas/status/ci_detailed_status.json @@ -17,7 +17,7 @@ "group": { "type": "string" }, "tooltip": { "type": "string" }, "has_details": { "type": "boolean" }, - "details_path": { "type": "string" }, + "details_path": { "oneOf": [{ "type": "null" }, {"type": "string" }] }, "favicon": { "type": "string" }, "illustration": { "$ref": "illustration.json" }, "action": { "$ref": "action.json" } diff --git a/spec/fixtures/ci_secure_files/sample.p12 b/spec/fixtures/ci_secure_files/sample.p12 index c74df26a8d4..84c7bf6a2f5 100644 Binary files a/spec/fixtures/ci_secure_files/sample.p12 and b/spec/fixtures/ci_secure_files/sample.p12 differ diff --git a/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml b/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml index 1c1ad65796c..6c3a0ef5ed6 100644 --- a/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml +++ b/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event.yml @@ -1,6 +1,6 @@ --- description: -category: Groups::EmailCampaignsController +category: Projects::Pipelines::EmailCampaignsController action: click label_description: property_description: @@ -13,12 +13,12 @@ identifiers: product_section: product_stage: product_group: -milestone: "13.11" +milestone: '13.11' introduced_by_url: distributions: -- ce -- ee + - ce + - ee tiers: -- free -- premium -- ultimate + - free + - premium + - ultimate diff --git a/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml b/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml index 174468028b8..3381c73f23e 100644 --- a/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml +++ b/spec/fixtures/lib/generators/gitlab/snowplow_event_definition_generator/sample_event_ee.yml @@ -1,6 +1,6 @@ --- description: -category: Groups::EmailCampaignsController +category: Projects::Pipelines::EmailCampaignsController action: click label_description: property_description: @@ -13,10 +13,10 @@ identifiers: product_section: product_stage: product_group: -milestone: "13.11" +milestone: '13.11' introduced_by_url: distributions: -- ee + - ee tiers: -#- premium -- ultimate + #- premium + - ultimate diff --git a/spec/fixtures/packages/nuget/symbol/package.pdb b/spec/fixtures/packages/nuget/symbol/package.pdb new file mode 100644 index 00000000000..dc82bf418a2 Binary files /dev/null and b/spec/fixtures/packages/nuget/symbol/package.pdb differ diff --git a/spec/fixtures/security_reports/master/gl-common-scanning-report.json b/spec/fixtures/security_reports/master/gl-common-scanning-report.json index 4c494963a79..31a86d3a8ae 100644 --- a/spec/fixtures/security_reports/master/gl-common-scanning-report.json +++ b/spec/fixtures/security_reports/master/gl-common-scanning-report.json @@ -1,11 +1,11 @@ { "vulnerabilities": [ { + "id": "vulnerability-1", "category": "dependency_scanning", "name": "Vulnerability for remediation testing 1", "message": "This vulnerability should have ONE remediation", "description": "", - "cve": "CVE-2137", "severity": "High", "solution": "Upgrade to latest version.", "scanner": { @@ -43,11 +43,11 @@ } }, { + "id": "vulnerability-2", "category": "dependency_scanning", "name": "Vulnerability for remediation testing 2", "message": "This vulnerability should have ONE remediation", "description": "", - "cve": "CVE-2138", "severity": "High", "solution": "Upgrade to latest version.", "scanner": { @@ -85,11 +85,11 @@ } }, { + "id": "vulnerability-3", "category": "dependency_scanning", "name": "Vulnerability for remediation testing 3", "message": "Remediation for this vulnerability should remediate CVE-2140 as well", "description": "", - "cve": "CVE-2139", "severity": "High", "solution": "Upgrade to latest version.", "scanner": { @@ -127,11 +127,11 @@ } }, { + "id": "vulnerability-4", "category": "dependency_scanning", "name": "Vulnerability for remediation testing 4", "message": "Remediation for this vulnerability should remediate CVE-2139 as well", "description": "", - "cve": "CVE-2140", "severity": "High", "solution": "Upgrade to latest version.", "scanner": { @@ -169,11 +169,11 @@ } }, { + "id": "vulnerability-5", "category": "dependency_scanning", "name": "Vulnerabilities in libxml2", "message": "Vulnerabilities in libxml2 in nokogiri", "description": "", - "cve": "CVE-1020", "severity": "High", "solution": "Upgrade to latest version.", "scanner": { @@ -281,12 +281,11 @@ } }, { - "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3", + "id": "vulnerability-6", "category": "dependency_scanning", "name": "Regular Expression Denial of Service", "message": "Regular Expression Denial of Service in debug", "description": "", - "cve": "CVE-1030", "severity": "Unknown", "solution": "Upgrade to latest versions.", "scanner": { @@ -387,6 +386,7 @@ ] }, { + "id": "vulnerability-7", "category": "dependency_scanning", "name": "Authentication bypass via incorrect DOM traversal and canonicalization", "message": "Authentication bypass via incorrect DOM traversal and canonicalization in saml2-js", @@ -421,47 +421,46 @@ { "fixes": [ { - "cve": "CVE-2137" + "id": "vulnerability-1" } ], - "summary": "this remediates CVE-2137", + "summary": "this remediates the first vulnerability", "diff": "dG90YWxseSBsZWdpdCBkaWZm" }, { "fixes": [ { - "cve": "CVE-2138" + "id": "vulnerability-2" } ], - "summary": "this remediates CVE-2138", + "summary": "this remediates the second vulnerability", "diff": "dG90YWxseSBsZWdpdCBkaWZm" }, { "fixes": [ { - "cve": "CVE-2139" + "id": "vulnerability-3" }, { - "cve": "CVE-2140" + "id": "vulnerability-4" } ], - "summary": "this remediates CVE-2139 and CVE-2140", + "summary": "this remediates the third and fourth vulnerability", "diff": "dG90YWxseSBsZWdpdGltYXRlIGRpZmYsIDEwLzEwIHdvdWxkIGFwcGx5" }, { "fixes": [ { - "cve": "CVE-1020" + "id": "vulnerability-5" } ], - "summary": "this fixes CVE-1020", + "summary": "this fixes the fifth vulnerability", "diff": "dG90YWxseSBsZWdpdGltYXRlIGRpZmYsIDEwLzEwIHdvdWxkIGFwcGx5" }, { "fixes": [ { - "cve": "CVE", - "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3" + "id": "vulnerability-6" } ], "summary": "this fixes CVE", @@ -470,22 +469,11 @@ { "fixes": [ { - "cve": "CVE", - "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3" + "id": "vulnerability-6" } ], "summary": "this fixed CVE", "diff": "dG90YWxseSBsZWdpdGltYXRlIGRpZmYsIDEwLzEwIHdvdWxkIGFwcGx5" - }, - { - "fixes": [ - { - "id": "2134", - "cve": "CVE-1" - } - ], - "summary": "this fixes CVE-1", - "diff": "dG90YWxseSBsZWdpdGltYXRlIGRpZmYsIDEwLzEwIHdvdWxkIGFwcGx5" } ], "dependency_files": [], diff --git a/spec/frontend/__helpers__/clean_html_element_serializer.js b/spec/frontend/__helpers__/clean_html_element_serializer.js new file mode 100644 index 00000000000..d787f5126ec --- /dev/null +++ b/spec/frontend/__helpers__/clean_html_element_serializer.js @@ -0,0 +1,142 @@ +// slot-scope attribute is a result of Vue.js 3 stubs being serialized in slot context, drop it +// modelModifiers are result of Vue.js 3 model modifiers handling and should not be in snapshot +const ATTRIBUTES_TO_REMOVE = ['slot-scope', 'modelmodifiers']; +// Taken from https://github.com/vuejs/vue/blob/72aed6a149b94b5b929fb47370a7a6d4cb7491c5/src/platforms/web/util/attrs.ts#L37-L44 +const BOOLEAN_ATTRIBUTES = new Set( + ( + 'allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,' + + 'default,defaultchecked,defaultmuted,defaultselected,defer,disabled,' + + 'enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,' + + 'muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,' + + 'required,reversed,scoped,seamless,selected,sortable,' + + 'truespeed,typemustmatch,visible' + ).split(','), +); + +function sortClassesAlphabetically(node) { + // Make classes render in alphabetical order for both Vue2 and Vue3 + if (node.hasAttribute('class')) { + const classes = node.getAttribute('class'); + if (classes === '') { + node.removeAttribute('class'); + } else { + node.setAttribute('class', Array.from(node.classList).sort().join(' ')); + } + } +} + +const TRANSITION_VALUES_TO_REMOVE = [ + { attributeName: 'css', defaultValue: 'true' }, + { attributeName: 'persisted', defaultValue: 'true' }, +]; +function removeInternalPropsLeakingToTransitionStub(node) { + TRANSITION_VALUES_TO_REMOVE.forEach((hash) => { + if (node.getAttribute(hash.attributeName) === hash.defaultValue) { + node.removeAttribute(hash.attributeName); + } + }); +} + +function normalizeText(node) { + const newText = node.textContent.trim(); + const textWithoutNewLines = newText.replace(/\n/g, ''); + const textWithoutDeepSpace = textWithoutNewLines.replace(/(?<=\S)\s+/g, ' '); + // eslint-disable-next-line no-param-reassign + node.textContent = textWithoutDeepSpace; +} + +const visited = new WeakSet(); + +// Lovingly borrowed from https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace#whitespace_helper_functions +function isAllWhitespace(node) { + return !/[^\t\n\r ]/.test(node.textContent); +} + +function isIgnorable(node) { + return ( + node.nodeType === Node.COMMENT_NODE || // A comment node + (node.nodeType === Node.TEXT_NODE && isAllWhitespace(node)) + ); // a text node, all ws +} + +const REFERENCE_ATTRIBUTES = ['aria-controls', 'aria-labelledby', 'for']; +function updateIdTags(root) { + const elementsWithIds = [...(root.id ? [root] : []), ...root.querySelectorAll('[id]')]; + + const referenceSelector = REFERENCE_ATTRIBUTES.map((attr) => `[${attr}]`).join(','); + const elementsWithReference = [ + ...(root.matches(referenceSelector) ? [root] : []), + ...root.querySelectorAll(REFERENCE_ATTRIBUTES.map((attr) => `[${attr}]`).join(',')), + ]; + + elementsWithReference.forEach((el) => { + REFERENCE_ATTRIBUTES.filter((attr) => el.getAttribute(attr)).forEach((target) => { + const index = elementsWithIds.findIndex((t) => t.id === el.getAttribute(target)); + if (index !== -1) { + el.setAttribute(target, `reference-${index}`); + } + }); + }); + + elementsWithIds.forEach((el, index) => { + el.setAttribute('id', `reference-${index}`); + }); +} + +export function test(received) { + return received instanceof Element && !visited.has(received); +} + +export function serialize(received, config, indentation, depth, refs, printer) { + // Explicitly set empty string values of img.src to `null` as Vue3 does + // We need to do this before `clone`, otherwise src prop diff will be lost + received.querySelectorAll('img').forEach((img) => img.setAttribute('src', img.src || null)); + + const clone = received.cloneNode(true); + + updateIdTags(clone); + visited.add(clone); + + const iterator = document.createNodeIterator( + clone, + // eslint-disable-next-line no-bitwise + NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, + ); + const ignorableNodes = []; + + for (let currentNode = iterator.nextNode(); currentNode; currentNode = iterator.nextNode()) { + if (isIgnorable(currentNode)) { + ignorableNodes.push(currentNode); + } else { + if (currentNode instanceof Element) { + ATTRIBUTES_TO_REMOVE.forEach((attr) => currentNode.removeAttribute(attr)); + + if (!currentNode.tagName.includes('-')) { + // We want to normalize boolean attributes rendering only on native tags + BOOLEAN_ATTRIBUTES.forEach((attr) => { + if (currentNode.hasAttribute(attr) && currentNode.getAttribute(attr) === attr) { + currentNode.setAttribute(attr, ''); + } + }); + } + + sortClassesAlphabetically(currentNode); + + if (currentNode.tagName === 'TRANSITION-STUB') { + removeInternalPropsLeakingToTransitionStub(currentNode); + } + } + + if (currentNode.nodeType === Node.TEXT_NODE) { + normalizeText(currentNode); + } + + currentNode.normalize(); + visited.add(currentNode); + } + } + + ignorableNodes.forEach((x) => x.remove()); + + return printer(clone, config, indentation, depth, refs); +} diff --git a/spec/frontend/__helpers__/dom_shims/get_client_rects.js b/spec/frontend/__helpers__/dom_shims/get_client_rects.js index 7ba60dd7936..0ec3525f0ef 100644 --- a/spec/frontend/__helpers__/dom_shims/get_client_rects.js +++ b/spec/frontend/__helpers__/dom_shims/get_client_rects.js @@ -1,7 +1,8 @@ function hasHiddenStyle(node) { if (!node.style) { return false; - } else if (node.style.display === 'none' || node.style.visibility === 'hidden') { + } + if (node.style.display === 'none' || node.style.visibility === 'hidden') { return true; } diff --git a/spec/frontend/__helpers__/html_string_serializer.js b/spec/frontend/__helpers__/html_string_serializer.js new file mode 100644 index 00000000000..99f4acd0e97 --- /dev/null +++ b/spec/frontend/__helpers__/html_string_serializer.js @@ -0,0 +1,11 @@ +export function test(received) { + return received && typeof received === 'string' && received.startsWith('<'); +} + +export function serialize(received, config, indentation, depth, refs, printer) { + const parser = new DOMParser(); + const doc = parser.parseFromString(received, 'text/html'); + const el = doc.body.firstElementChild; + + return printer(el, config, indentation, depth, refs); +} diff --git a/spec/frontend/__helpers__/vue_test_utils_helper.js b/spec/frontend/__helpers__/vue_test_utils_helper.js index c144a256dce..20a79fc4d2f 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper.js @@ -1,6 +1,13 @@ import * as testingLibrary from '@testing-library/dom'; -import { createWrapper, WrapperArray, ErrorWrapper, mount, shallowMount } from '@vue/test-utils'; -import { isArray, upperFirst } from 'lodash'; +import { + createWrapper, + Wrapper, // eslint-disable-line no-unused-vars + ErrorWrapper, + mount, + shallowMount, + WrapperArray, +} from '@vue/test-utils'; +import { compose } from 'lodash/fp'; const vNodeContainsText = (vnode, text) => (vnode.text && vnode.text.includes(text)) || @@ -14,7 +21,7 @@ const vNodeContainsText = (vnode, text) => * * @param {HTMLElement} element * @param {Object} options - * @returns VTU wrapper + * @returns {Wrapper} VTU wrapper */ const createWrapperFromElement = (element, options) => // eslint-disable-next-line no-underscore-dangle @@ -52,19 +59,84 @@ export const waitForMutation = (store, expectedMutationType) => }); }); +/** + * Query function type + * @callback FindFunction + * @param text + * @returns {Wrapper} + */ + +/** + * Query all function type + * @callback FindAllFunction + * @param text + * @returns {WrapperArray} + */ + +/** + * Query find with options functions type + * @callback FindWithOptionsFunction + * @param text + * @param options + * @returns {Wrapper} + */ + +/** + * Query find all with options functions type + * @callback FindAllWithOptionsFunction + * @param text + * @param options + * @returns {WrapperArray} + */ + +/** + * Extended Wrapper queries + * @typedef { { + * findByTestId: FindFunction, + * findAllByTestId: FindAllFunction, + * findComponentByTestId: FindFunction, + * findAllComponentsByTestId: FindAllFunction, + * findByRole: FindWithOptionsFunction, + * findAllByRole: FindAllWithOptionsFunction, + * findByLabelText: FindWithOptionsFunction, + * findAllByLabelText: FindAllWithOptionsFunction, + * findByPlaceholderText: FindWithOptionsFunction, + * findAllByPlaceholderText: FindAllWithOptionsFunction, + * findByText: FindWithOptionsFunction, + * findAllByText: FindAllWithOptionsFunction, + * findByDisplayValue: FindWithOptionsFunction, + * findAllByDisplayValue: FindAllWithOptionsFunction, + * findByAltText: FindWithOptionsFunction, + * findAllByAltText: FindAllWithOptionsFunction, + * findByTitle: FindWithOptionsFunction, + * findAllByTitle: FindAllWithOptionsFunction + * } } ExtendedQueries + */ + +/** + * Extended Wrapper + * @typedef {(Wrapper & ExtendedQueries)} ExtendedWrapper + */ + +/** + * Creates a Wrapper {@link https://v1.test-utils.vuejs.org/api/wrapper/} with + * Additional Queries {@link https://testing-library.com/docs/queries/about}. + * @param { Wrapper } wrapper + * @returns { ExtendedWrapper } + */ export const extendedWrapper = (wrapper) => { // https://testing-library.com/docs/queries/about const AVAILABLE_QUERIES = [ - 'byRole', - 'byLabelText', - 'byPlaceholderText', - 'byText', - 'byDisplayValue', - 'byAltText', - 'byTitle', + 'ByRole', + 'ByLabelText', + 'ByPlaceholderText', + 'ByText', + 'ByDisplayValue', + 'ByAltText', + 'ByTitle', ]; - if (isArray(wrapper) || !wrapper?.find) { + if (Array.isArray(wrapper) || !wrapper?.find) { // eslint-disable-next-line no-console console.warn( '[vue-test-utils-helper]: you are trying to extend an object that is not a VueWrapper.', @@ -74,11 +146,13 @@ export const extendedWrapper = (wrapper) => { return Object.defineProperties(wrapper, { findByTestId: { + /** @this { Wrapper } */ value(id) { return this.find(`[data-testid="${id}"]`); }, }, findAllByTestId: { + /** @this { Wrapper } */ value(id) { return this.findAll(`[data-testid="${id}"]`); }, @@ -88,6 +162,7 @@ export const extendedWrapper = (wrapper) => { * with CSS selectors: https://v1.test-utils.vuejs.org/api/wrapper/#findcomponent */ findComponentByTestId: { + /** @this { Wrapper } */ value(id) { return this.findComponent(`[data-testid="${id}"]`); }, @@ -97,6 +172,7 @@ export const extendedWrapper = (wrapper) => { * with CSS selectors: https://v1.test-utils.vuejs.org/api/wrapper/#findallcomponents */ findAllComponentsByTestId: { + /** @this { Wrapper } */ value(id) { return this.findAllComponents(`[data-testid="${id}"]`); }, @@ -105,13 +181,10 @@ export const extendedWrapper = (wrapper) => { ...AVAILABLE_QUERIES.reduce((accumulator, query) => { return { ...accumulator, - [`find${upperFirst(query)}`]: { + [`find${query}`]: { + /** @this { Wrapper } */ value(text, options = {}) { - const elements = testingLibrary[`queryAll${upperFirst(query)}`]( - wrapper.element, - text, - options, - ); + const elements = testingLibrary[`queryAll${query}`](this.element, text, options); // Element not found, return an `ErrorWrapper` if (!elements.length) { @@ -126,13 +199,10 @@ export const extendedWrapper = (wrapper) => { ...AVAILABLE_QUERIES.reduce((accumulator, query) => { return { ...accumulator, - [`findAll${upperFirst(query)}`]: { + [`findAll${query}`]: { + /** @this { Wrapper } */ value(text, options = {}) { - const elements = testingLibrary[`queryAll${upperFirst(query)}`]( - wrapper.element, - text, - options, - ); + const elements = testingLibrary[`queryAll${query}`](this.element, text, options); const wrappers = elements.map((element) => { const elementWrapper = createWrapperFromElement(element, this.options); @@ -152,6 +222,5 @@ export const extendedWrapper = (wrapper) => { }); }; -export const shallowMountExtended = (...args) => extendedWrapper(shallowMount(...args)); - -export const mountExtended = (...args) => extendedWrapper(mount(...args)); +export const shallowMountExtended = compose(extendedWrapper, shallowMount); +export const mountExtended = compose(extendedWrapper, mount); diff --git a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js index 2f69a2348d9..c137561154d 100644 --- a/spec/frontend/__helpers__/vue_test_utils_helper_spec.js +++ b/spec/frontend/__helpers__/vue_test_utils_helper_spec.js @@ -374,34 +374,34 @@ describe('Vue test utils helpers', () => { }); }); - describe.each` - mountExtendedFunction | expectedMountFunction - ${shallowMountExtended} | ${'shallowMount'} - ${mountExtended} | ${'mount'} - `('$mountExtendedFunction', ({ mountExtendedFunction, expectedMountFunction }) => { - const FakeComponent = jest.fn(); - const options = { - propsData: { - foo: 'bar', - }, - }; - - beforeEach(() => { - const mockWrapper = { find: jest.fn() }; - jest.spyOn(vtu, expectedMountFunction).mockImplementation(() => mockWrapper); + describe('mount extended functions', () => { + // eslint-disable-next-line vue/one-component-per-file + const FakeChildComponent = Vue.component('FakeChildComponent', { + template: '
Bar
', }); - it(`calls \`${expectedMountFunction}\` with passed arguments`, () => { - mountExtendedFunction(FakeComponent, options); - - expect(vtu[expectedMountFunction]).toHaveBeenCalledWith(FakeComponent, options); + // eslint-disable-next-line vue/one-component-per-file + const FakeComponent = Vue.component('FakeComponent', { + components: { + FakeChildComponent, + }, + template: '
Foo
', }); - it('returns extended wrapper', () => { - const result = mountExtendedFunction(FakeComponent, options); + describe('mountExtended', () => { + it('mounts component and provides extended queries', () => { + const wrapper = mountExtended(FakeComponent); + expect(wrapper.text()).toBe('Foo Bar'); + expect(wrapper.findAllByTestId('fake-id').length).toBe(2); + }); + }); - expect(result).toHaveProperty('find'); - expect(result).toHaveProperty('findByTestId'); + describe('shallowMountExtended', () => { + it('shallow mounts component and provides extended queries', () => { + const wrapper = shallowMountExtended(FakeComponent); + expect(wrapper.text()).toBe('Foo'); + expect(wrapper.findAllByTestId('fake-id').length).toBe(1); + }); }); }); }); 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 5236f38dc35..ae767f8b3f5 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 @@ -157,9 +157,9 @@ describe('~/access_tokens/components/access_token_table_app', () => { href: '/-/profile/personal_access_tokens/1/revoke', 'data-confirm': sprintf( __( - 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.', + 'Are you sure you want to revoke the %{accessTokenType} "%{tokenName}"? This action cannot be undone.', ), - { accessTokenType }, + { accessTokenType, tokenName: 'a' }, ), }); expect(button.props('category')).toBe('tertiary'); 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 ddeab3e3b62..fca17f948f8 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 @@ -24,7 +24,6 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` -
@@ -38,7 +37,6 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` searchtextoptionlabel="Search for this text" value="" /> -
- - { let wrapper; + const { similarOpenReports } = mockAbuseReport.user; + const findAlert = () => wrapper.findComponent(GlAlert); const findReportHeader = () => wrapper.findComponent(ReportHeader); const findUserDetails = () => wrapper.findComponent(UserDetails); - const findReportedContent = () => wrapper.findComponent(ReportedContent); - const findHistoryItems = () => wrapper.findComponent(HistoryItems); - const createComponent = (props = {}) => { - wrapper = shallowMount(AbuseReportApp, { + const findReportedContent = () => wrapper.findByTestId('reported-content'); + const findReportedContentForSimilarReports = () => + wrapper.findAllByTestId('reported-content-similar-open-reports'); + const firstReportedContentForSimilarReports = () => + findReportedContentForSimilarReports().at(0).findComponent(ReportedContent); + + const findActivityList = () => wrapper.findComponent(ActivityEventsList); + const findActivityItem = () => wrapper.findByTestId('activity'); + const findActivityForSimilarReports = () => + wrapper.findAllByTestId('activity-similar-open-reports'); + const firstActivityForSimilarReports = () => + findActivityForSimilarReports().at(0).findComponent(ActivityHistoryItem); + + const findReportDetails = () => wrapper.findComponent(ReportDetails); + + const createComponent = (props = {}, provide = {}) => { + wrapper = shallowMountExtended(AbuseReportApp, { propsData: { abuseReport: mockAbuseReport, ...props, }, + provide, }); }; @@ -64,7 +82,7 @@ describe('AbuseReportApp', () => { }); }); - describe('ReportHeader', () => { + describe('Report header', () => { it('renders ReportHeader', () => { expect(findReportHeader().props('user')).toBe(mockAbuseReport.user); expect(findReportHeader().props('report')).toBe(mockAbuseReport.report); @@ -83,7 +101,7 @@ describe('AbuseReportApp', () => { }); }); - describe('UserDetails', () => { + describe('User Details', () => { it('renders UserDetails', () => { expect(findUserDetails().props('user')).toBe(mockAbuseReport.user); }); @@ -101,13 +119,47 @@ describe('AbuseReportApp', () => { }); }); - it('renders ReportedContent', () => { - expect(findReportedContent().props('report')).toBe(mockAbuseReport.report); - expect(findReportedContent().props('reporter')).toBe(mockAbuseReport.reporter); + describe('Reported Content', () => { + it('renders ReportedContent', () => { + expect(findReportedContent().props('report')).toBe(mockAbuseReport.report); + }); + + it('renders similar abuse reports', () => { + expect(findReportedContentForSimilarReports()).toHaveLength(similarOpenReports.length); + expect(firstReportedContentForSimilarReports().props('report')).toBe(similarOpenReports[0]); + }); }); - it('renders HistoryItems', () => { - expect(findHistoryItems().props('report')).toBe(mockAbuseReport.report); - expect(findHistoryItems().props('reporter')).toBe(mockAbuseReport.reporter); + describe('ReportDetails', () => { + describe('when abuseReportLabels feature flag is enabled', () => { + it('renders ReportDetails', () => { + createComponent({}, { glFeatures: { abuseReportLabels: true } }); + + expect(findReportDetails().props('reportId')).toBe(mockAbuseReport.report.globalId); + }); + }); + + describe('when abuseReportLabels feature flag is disabled', () => { + it('does not render ReportDetails', () => { + createComponent({}, { glFeatures: { abuseReportLabels: false } }); + + expect(findReportDetails().exists()).toBe(false); + }); + }); + }); + + describe('Activity', () => { + it('renders the activity events list', () => { + expect(findActivityList().exists()).toBe(true); + }); + + it('renders activity item for abuse report', () => { + expect(findActivityItem().props('report')).toBe(mockAbuseReport.report); + }); + + it('renders activity items for similar abuse reports', () => { + expect(findActivityForSimilarReports()).toHaveLength(similarOpenReports.length); + expect(firstActivityForSimilarReports().props('report')).toBe(similarOpenReports[0]); + }); }); }); diff --git a/spec/frontend/admin/abuse_report/components/activity_events_list_spec.js b/spec/frontend/admin/abuse_report/components/activity_events_list_spec.js new file mode 100644 index 00000000000..cd1120d2db4 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/activity_events_list_spec.js @@ -0,0 +1,30 @@ +import { shallowMount } from '@vue/test-utils'; +import ActivityEventsList from '~/admin/abuse_report/components/activity_events_list.vue'; + +describe('ActivityEventsList', () => { + let wrapper; + + const mockSlotContent = 'Test slot content'; + + const findActivityEventsList = () => wrapper.findComponent(ActivityEventsList); + + const createComponent = () => { + wrapper = shallowMount(ActivityEventsList, { + slots: { + 'history-items': mockSlotContent, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders activity title', () => { + expect(findActivityEventsList().text()).toContain('Activity'); + }); + + it('renders history-items slot', () => { + expect(findActivityEventsList().text()).toContain(mockSlotContent); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/activity_history_item_spec.js b/spec/frontend/admin/abuse_report/components/activity_history_item_spec.js new file mode 100644 index 00000000000..3f430b0143e --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/activity_history_item_spec.js @@ -0,0 +1,64 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { sprintf } from '~/locale'; +import AcitivityHistoryItem from '~/admin/abuse_report/components/activity_history_item.vue'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { mockAbuseReport } from '../mock_data'; + +describe('AcitivityHistoryItem', () => { + let wrapper; + + const { report } = mockAbuseReport; + + const findHistoryItem = () => wrapper.findComponent(HistoryItem); + const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); + + const createComponent = (props = {}) => { + wrapper = shallowMount(AcitivityHistoryItem, { + propsData: { + report, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders the icon', () => { + expect(findHistoryItem().props('icon')).toBe('warning'); + }); + + describe('rendering the title', () => { + it('renders the reporters name and the category', () => { + const title = sprintf('Reported by %{name} for %{category}.', { + name: report.reporter.name, + category: report.category, + }); + expect(findHistoryItem().text()).toContain(title); + }); + + describe('when the reporter is not defined', () => { + beforeEach(() => { + createComponent({ report: { ...report, reporter: undefined } }); + }); + + it('renders the `No user found` as the reporters name and the category', () => { + const title = sprintf('Reported by %{name} for %{category}.', { + name: 'No user found', + category: report.category, + }); + expect(findHistoryItem().text()).toContain(title); + }); + }); + }); + + it('renders the time-ago tooltip', () => { + expect(findTimeAgo().props('time')).toBe(report.reportedAt); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/history_items_spec.js b/spec/frontend/admin/abuse_report/components/history_items_spec.js deleted file mode 100644 index 86e994fdc57..00000000000 --- a/spec/frontend/admin/abuse_report/components/history_items_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { sprintf } from '~/locale'; -import HistoryItems from '~/admin/abuse_report/components/history_items.vue'; -import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { HISTORY_ITEMS_I18N } from '~/admin/abuse_report/constants'; -import { mockAbuseReport } from '../mock_data'; - -describe('HistoryItems', () => { - let wrapper; - - const { report, reporter } = mockAbuseReport; - - const findHistoryItem = () => wrapper.findComponent(HistoryItem); - const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); - - const createComponent = (props = {}) => { - wrapper = shallowMount(HistoryItems, { - propsData: { - report, - reporter, - ...props, - }, - stubs: { - GlSprintf, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('renders the icon', () => { - expect(findHistoryItem().props('icon')).toBe('warning'); - }); - - describe('rendering the title', () => { - it('renders the reporters name and the category', () => { - const title = sprintf(HISTORY_ITEMS_I18N.reportedByForCategory, { - name: reporter.name, - category: report.category, - }); - expect(findHistoryItem().text()).toContain(title); - }); - - describe('when the reporter is not defined', () => { - beforeEach(() => { - createComponent({ reporter: undefined }); - }); - - it('renders the `No user found` as the reporters name and the category', () => { - const title = sprintf(HISTORY_ITEMS_I18N.reportedByForCategory, { - name: HISTORY_ITEMS_I18N.deletedReporter, - category: report.category, - }); - expect(findHistoryItem().text()).toContain(title); - }); - }); - }); - - it('renders the time-ago tooltip', () => { - expect(findTimeAgo().props('time')).toBe(report.reportedAt); - }); -}); diff --git a/spec/frontend/admin/abuse_report/components/labels_select_spec.js b/spec/frontend/admin/abuse_report/components/labels_select_spec.js new file mode 100644 index 00000000000..a22dcc18e10 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/labels_select_spec.js @@ -0,0 +1,297 @@ +import MockAdapter from 'axios-mock-adapter'; +import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; +import LabelsSelect from '~/admin/abuse_report/components/labels_select.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent, RENDER_ALL_SLOTS_TEMPLATE } from 'helpers/stub_component'; +import labelsQuery from '~/admin/abuse_report/components/graphql/abuse_report_labels.query.graphql'; +import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue'; +import DropdownHeader from '~/sidebar/components/labels/labels_select_widget/dropdown_header.vue'; +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue'; +import DropdownFooter from '~/sidebar/components/labels/labels_select_widget/dropdown_footer.vue'; +import { createAlert } from '~/alert'; +import { mockLabelsQueryResponse, mockLabel1, mockLabel2 } from '../mock_data'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); + +describe('Labels select component', () => { + let mock; + let wrapper; + let fakeApollo; + + const selectedText = () => wrapper.findByTestId('selected-labels').text(); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findEditButton = () => wrapper.findComponent(GlButton); + const findDropdown = () => wrapper.findComponent(DropdownWidget); + const findDropdownHeader = () => wrapper.findComponent(DropdownHeader); + const findDropdownValue = () => wrapper.findComponent(DropdownValue); + const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView); + const findDropdownFooter = () => wrapper.findComponent(DropdownFooter); + + const labelsQueryHandlerSuccess = jest.fn().mockResolvedValue(mockLabelsQueryResponse); + const labelsQueryHandlerFailure = jest.fn().mockRejectedValue(new Error()); + + const updatePath = '/admin/abuse_reports/1'; + const listPath = '/admin/abuse_reports'; + + async function openLabelsDropdown() { + findEditButton().vm.$emit('click'); + await waitForPromises(); + } + + const selectLabel = (label) => { + findDropdown().vm.$emit('set-option', label); + nextTick(); + }; + + const createComponent = ({ props = {}, labelsQueryHandler = labelsQueryHandlerSuccess } = {}) => { + fakeApollo = createMockApollo([[labelsQuery, labelsQueryHandler]]); + wrapper = shallowMountExtended(LabelsSelect, { + apolloProvider: fakeApollo, + propsData: { + report: { labels: [] }, + canEdit: true, + ...props, + }, + provide: { + updatePath, + listPath, + }, + stubs: { + GlDropdown, + GlDropdownItem, + DropdownWidget: stubComponent(DropdownWidget, { + template: RENDER_ALL_SLOTS_TEMPLATE, + methods: { showDropdown: jest.fn() }, + }), + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + fakeApollo = null; + mock.restore(); + }); + + describe('initial load', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays loading icon', () => { + expect(findLoadingIcon().exists()).toEqual(true); + }); + + it('disables edit button', () => { + expect(findEditButton().props('disabled')).toEqual(true); + }); + + describe('after initial load', () => { + beforeEach(() => { + wrapper.setProps({ report: { labels: [mockLabel1] } }); + }); + + it('does not display loading icon', () => { + expect(findLoadingIcon().exists()).toEqual(false); + }); + + it('enables edit button', () => { + expect(findEditButton().props('disabled')).toEqual(false); + }); + + it('renders fetched DropdownValue with the correct props', () => { + const component = findDropdownValue(); + expect(component.isVisible()).toBe(true); + expect(component.props('selectedLabels')).toEqual([mockLabel1]); + expect(component.props('labelsFilterBasePath')).toBe(listPath); + }); + }); + }); + + describe('when there are no selected labels', () => { + it('displays "None"', () => { + createComponent(); + + expect(selectedText()).toContain('None'); + }); + }); + + describe('when there are selected labels', () => { + beforeEach(() => { + createComponent({ props: { report: { labels: [mockLabel1, mockLabel2] } } }); + + mock.onPut(updatePath).reply(HTTP_STATUS_OK, {}); + jest.spyOn(axios, 'put'); + }); + + it('renders selected labels in DropdownValue', () => { + expect(findDropdownValue().isVisible()).toBe(true); + expect(findDropdownValue().props('selectedLabels')).toEqual([mockLabel1, mockLabel2]); + }); + + it('selected labels can be removed', async () => { + findDropdownValue().vm.$emit('onLabelRemove', mockLabel1.id); + await nextTick(); + + expect(findDropdownValue().props('selectedLabels')).toEqual([mockLabel2]); + expect(axios.put).toHaveBeenCalledWith(updatePath, { + label_ids: [mockLabel2.id], + }); + }); + }); + + describe('when not editing', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not trigger abuse report labels query', () => { + expect(labelsQueryHandlerSuccess).not.toHaveBeenCalled(); + }); + + it('does not render the dropdown', () => { + expect(findDropdown().isVisible()).toBe(false); + }); + }); + + describe('when editing', () => { + beforeEach(async () => { + createComponent(); + await openLabelsDropdown(); + }); + + it('triggers abuse report labels query', () => { + expect(labelsQueryHandlerSuccess).toHaveBeenCalledTimes(1); + }); + + it('renders dropdown with fetched labels', () => { + expect(findDropdown().isVisible()).toBe(true); + expect(findDropdown().props('options')).toEqual([mockLabel1, mockLabel2]); + }); + + it('selects/deselects a label', async () => { + await selectLabel(mockLabel1); + + expect(findDropdownValue().props('selectedLabels')).toEqual([mockLabel1]); + + await selectLabel(mockLabel1); + + expect(selectedText()).toContain('None'); + }); + + it('triggers abuse report labels query when search term is set', async () => { + findDropdown().vm.$emit('set-search', 'Dos'); + await waitForPromises(); + + expect(labelsQueryHandlerSuccess).toHaveBeenCalledTimes(2); + expect(labelsQueryHandlerSuccess).toHaveBeenCalledWith({ searchTerm: 'Dos' }); + }); + + it('does not render DropdownContentsCreateView', () => { + expect(findCreateView().exists()).toBe(false); + }); + + it('renders DropdownFooter', () => { + expect(findDropdownFooter().props('footerCreateLabelTitle')).toEqual('Create label'); + expect(findDropdownFooter().props('footerManageLabelTitle')).toEqual(''); + }); + + describe('when DropdownHeader emits `toggleDropdownContentsCreateView` event', () => { + beforeEach(() => { + findDropdownHeader().vm.$emit('toggleDropdownContentsCreateView'); + }); + + it('renders DropdownContentsCreateView and removes DropdownFooter', () => { + expect(findCreateView().props('workspaceType')).toEqual('abuseReport'); + expect(findDropdownFooter().exists()).toBe(false); + }); + + describe('when DropdownContentsCreateView emits `hideCreateView` event', () => { + it('removes itself', async () => { + findCreateView().vm.$emit('hideCreateView'); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + }); + }); + + describe('when DropdownContentsCreateView emits `labelCreated` event', () => { + it('selects created label', async () => { + findCreateView().vm.$emit('labelCreated', mockLabel1); + await nextTick(); + + expect(findDropdownValue().props('selectedLabels')).toEqual([mockLabel1]); + }); + }); + }); + + describe('when DropdownFooter emits `toggleDropdownContentsCreateView` event', () => { + it('renders DropdownContentsCreateView', async () => { + findDropdownFooter().vm.$emit('toggleDropdownContentsCreateView'); + await nextTick(); + + expect(findCreateView().props('workspaceType')).toEqual('abuseReport'); + }); + }); + }); + + describe('after edit', () => { + const setup = async (response) => { + mock.onPut(updatePath).reply(response, {}); + jest.spyOn(axios, 'put'); + + createComponent(); + await openLabelsDropdown(); + await selectLabel(mockLabel1); + + findDropdown().vm.$emit('hide'); + }; + + describe('successful save', () => { + it('saves', async () => { + await setup(HTTP_STATUS_OK); + + expect(axios.put).toHaveBeenCalledWith(updatePath, { + label_ids: [mockLabel1.id], + }); + }); + }); + + describe('unsuccessful save', () => { + it('creates an alert', async () => { + await setup(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while updating labels.', + captureError: true, + error: expect.any(Error), + }); + }); + }); + }); + + describe('failed abuse report labels query', () => { + it('creates an alert', async () => { + createComponent({ labelsQueryHandler: labelsQueryHandlerFailure }); + await openLabelsDropdown(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while searching for labels, please try again.', + }); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/report_actions_spec.js b/spec/frontend/admin/abuse_report/components/report_actions_spec.js index 6dd6d0e55c5..0e20630db14 100644 --- a/spec/frontend/admin/abuse_report/components/report_actions_spec.js +++ b/spec/frontend/admin/abuse_report/components/report_actions_spec.js @@ -191,31 +191,4 @@ describe('ReportActions', () => { ); }); }); - - describe('when moderateUserPath is not present', () => { - it('sends the request to updatePath', async () => { - jest.spyOn(axios, 'put'); - axiosMock.onPut(report.updatePath).replyOnce(HTTP_STATUS_OK, {}); - - const reportWithoutModerateUserPath = { ...report }; - delete reportWithoutModerateUserPath.moderateUserPath; - - createComponent({ report: reportWithoutModerateUserPath }); - - clickActionsButton(); - - await nextTick(); - - selectAction(params.user_action); - selectReason(params.reason); - - await nextTick(); - - submitForm(); - - await waitForPromises(); - - expect(axios.put).toHaveBeenCalledWith(report.updatePath, expect.any(Object)); - }); - }); }); diff --git a/spec/frontend/admin/abuse_report/components/report_details_spec.js b/spec/frontend/admin/abuse_report/components/report_details_spec.js new file mode 100644 index 00000000000..a5c43dcb82b --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/report_details_spec.js @@ -0,0 +1,74 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import LabelsSelect from '~/admin/abuse_report/components/labels_select.vue'; +import ReportDetails from '~/admin/abuse_report/components/report_details.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import abuseReportQuery from '~/admin/abuse_report/components/graphql/abuse_report.query.graphql'; +import { createAlert } from '~/alert'; +import { mockAbuseReport, mockLabel1, mockReportQueryResponse } from '../mock_data'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); + +describe('Report Details', () => { + let wrapper; + let fakeApollo; + + const findLabelsSelect = () => wrapper.findComponent(LabelsSelect); + + const abuseReportQueryHandlerSuccess = jest.fn().mockResolvedValue(mockReportQueryResponse); + const abuseReportQueryHandlerFailure = jest.fn().mockRejectedValue(new Error()); + + const createComponent = ({ abuseReportQueryHandler = abuseReportQueryHandlerSuccess } = {}) => { + fakeApollo = createMockApollo([[abuseReportQuery, abuseReportQueryHandler]]); + wrapper = shallowMount(ReportDetails, { + apolloProvider: fakeApollo, + propsData: { + reportId: mockAbuseReport.report.globalId, + }, + }); + }; + + afterEach(() => { + fakeApollo = null; + }); + + describe('successful abuse report query', () => { + beforeEach(() => { + createComponent(); + }); + + it('triggers abuse report query', async () => { + await waitForPromises(); + + expect(abuseReportQueryHandlerSuccess).toHaveBeenCalledWith({ + id: mockAbuseReport.report.globalId, + }); + }); + + it('renders LabelsSelect with the fetched report', async () => { + expect(findLabelsSelect().props('report').labels).toEqual([]); + + await waitForPromises(); + + expect(findLabelsSelect().props('report').labels).toEqual([mockLabel1]); + }); + }); + + describe('failed abuse report query', () => { + beforeEach(async () => { + createComponent({ abuseReportQueryHandler: abuseReportQueryHandlerFailure }); + + await waitForPromises(); + }); + + it('creates an alert', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while fetching labels, please try again.', + }); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/report_header_spec.js b/spec/frontend/admin/abuse_report/components/report_header_spec.js index f22f3af091f..6ec380f0387 100644 --- a/spec/frontend/admin/abuse_report/components/report_header_spec.js +++ b/spec/frontend/admin/abuse_report/components/report_header_spec.js @@ -54,37 +54,30 @@ describe('ReportHeader', () => { }); describe.each` - status | text | variant | className | badgeIcon - ${STATUS_OPEN} | ${REPORT_HEADER_I18N[STATUS_OPEN]} | ${'success'} | ${'issuable-status-badge-open'} | ${'issues'} - ${STATUS_CLOSED} | ${REPORT_HEADER_I18N[STATUS_CLOSED]} | ${'info'} | ${'issuable-status-badge-closed'} | ${'issue-closed'} - `( - 'rendering the report $status status badge', - ({ status, text, variant, className, badgeIcon }) => { - beforeEach(() => { - createComponent({ report: { ...report, status } }); - }); - - it(`indicates the ${status} status`, () => { - expect(findBadge().text()).toBe(text); - }); - - it(`with the ${variant} variant`, () => { - expect(findBadge().props('variant')).toBe(variant); - }); - - it(`with the text '${text}' as 'aria-label'`, () => { - expect(findBadge().attributes('aria-label')).toBe(text); - }); - - it(`contains the ${className} class`, () => { - expect(findBadge().element.classList).toContain(className); - }); - - it(`has an icon with the ${badgeIcon} name`, () => { - expect(findIcon().props('name')).toBe(badgeIcon); - }); - }, - ); + status | text | variant | badgeIcon + ${STATUS_OPEN} | ${REPORT_HEADER_I18N[STATUS_OPEN]} | ${'success'} | ${'issues'} + ${STATUS_CLOSED} | ${REPORT_HEADER_I18N[STATUS_CLOSED]} | ${'info'} | ${'issue-closed'} + `('rendering the report $status status badge', ({ status, text, variant, badgeIcon }) => { + beforeEach(() => { + createComponent({ report: { ...report, status } }); + }); + + it(`indicates the ${status} status`, () => { + expect(findBadge().text()).toBe(text); + }); + + it(`with the ${variant} variant`, () => { + expect(findBadge().props('variant')).toBe(variant); + }); + + it(`with the text '${text}' as 'aria-label'`, () => { + expect(findBadge().attributes('aria-label')).toBe(text); + }); + + it(`has an icon with the ${badgeIcon} name`, () => { + expect(findIcon().props('name')).toBe(badgeIcon); + }); + }); it('renders the actions', () => { const actionsComponent = findActions(); diff --git a/spec/frontend/admin/abuse_report/components/reported_content_spec.js b/spec/frontend/admin/abuse_report/components/reported_content_spec.js index 9fc49f08f8c..2f16f5a7af2 100644 --- a/spec/frontend/admin/abuse_report/components/reported_content_spec.js +++ b/spec/frontend/admin/abuse_report/components/reported_content_spec.js @@ -14,7 +14,7 @@ const modalId = 'abuse-report-screenshot-modal'; describe('ReportedContent', () => { let wrapper; - const { report, reporter } = { ...mockAbuseReport }; + const { report } = { ...mockAbuseReport }; const findScreenshotButton = () => wrapper.findByTestId('screenshot-button'); const findReportUrlButton = () => wrapper.findByTestId('report-url-button'); @@ -32,7 +32,6 @@ describe('ReportedContent', () => { wrapper = shallowMountExtended(ReportedContent, { propsData: { report, - reporter, ...props, }, stubs: { @@ -167,18 +166,18 @@ describe('ReportedContent', () => { describe('rendering the card footer', () => { it('renders the reporters avatar', () => { - expect(findAvatar().props('src')).toBe(reporter.avatarUrl); + expect(findAvatar().props('src')).toBe(report.reporter.avatarUrl); }); it('renders the users name', () => { - expect(findCardFooter().text()).toContain(reporter.name); + expect(findCardFooter().text()).toContain(report.reporter.name); }); it('renders a link to the users profile page', () => { const link = findProfileLink(); - expect(link.attributes('href')).toBe(reporter.path); - expect(link.text()).toBe(`@${reporter.username}`); + expect(link.attributes('href')).toBe(report.reporter.path); + expect(link.text()).toBe(`@${report.reporter.username}`); }); it('renders the time-ago tooltip', () => { diff --git a/spec/frontend/admin/abuse_report/components/user_details_spec.js b/spec/frontend/admin/abuse_report/components/user_details_spec.js index ca499fbaa6e..f3d8d5bb610 100644 --- a/spec/frontend/admin/abuse_report/components/user_details_spec.js +++ b/spec/frontend/admin/abuse_report/components/user_details_spec.js @@ -18,7 +18,7 @@ describe('UserDetails', () => { const findLinkFor = (attribute) => findLinkIn(findUserDetail(attribute)); const findTimeIn = (component) => component.findComponent(TimeAgoTooltip).props('time'); const findTimeFor = (attribute) => findTimeIn(findUserDetail(attribute)); - const findOtherReport = (index) => wrapper.findByTestId(`other-report-${index}`); + const findPastReport = (index) => wrapper.findByTestId(`past-report-${index}`); const createComponent = (props = {}) => { wrapper = shallowMountExtended(UserDetails, { @@ -38,8 +38,8 @@ describe('UserDetails', () => { describe('createdAt', () => { it('renders the users createdAt with the correct label', () => { - expect(findUserDetailLabel('createdAt')).toBe(USER_DETAILS_I18N.createdAt); - expect(findTimeFor('createdAt')).toBe(user.createdAt); + expect(findUserDetailLabel('created-at')).toBe(USER_DETAILS_I18N.createdAt); + expect(findTimeFor('created-at')).toBe(user.createdAt); }); }); @@ -67,32 +67,34 @@ describe('UserDetails', () => { describe('creditCard', () => { it('renders the correct label', () => { - expect(findUserDetailLabel('creditCard')).toBe(USER_DETAILS_I18N.creditCard); + expect(findUserDetailLabel('credit-card-verification')).toBe(USER_DETAILS_I18N.creditCard); }); it('renders the users name', () => { - expect(findUserDetail('creditCard').text()).toContain( + expect(findUserDetail('credit-card-verification').text()).toContain( sprintf(USER_DETAILS_I18N.registeredWith, { ...user.creditCard }), ); - expect(findUserDetail('creditCard').text()).toContain(user.creditCard.name); + expect(findUserDetail('credit-card-verification').text()).toContain(user.creditCard.name); }); describe('similar credit cards', () => { it('renders the number of similar records', () => { - expect(findUserDetail('creditCard').text()).toContain( + expect(findUserDetail('credit-card-verification').text()).toContain( sprintf('Card matches %{similarRecordsCount} accounts', { ...user.creditCard }), ); }); it('renders a link to the matching cards', () => { - expect(findLinkFor('creditCard').attributes('href')).toBe(user.creditCard.cardMatchesLink); + expect(findLinkFor('credit-card-verification').attributes('href')).toBe( + user.creditCard.cardMatchesLink, + ); - expect(findLinkFor('creditCard').text()).toBe( + expect(findLinkFor('credit-card-verification').text()).toBe( sprintf('%{similarRecordsCount} accounts', { ...user.creditCard }), ); - expect(findLinkFor('creditCard').text()).toContain( + expect(findLinkFor('credit-card-verification').text()).toContain( user.creditCard.similarRecordsCount.toString(), ); }); @@ -105,13 +107,13 @@ describe('UserDetails', () => { }); it('does not render the number of similar records', () => { - expect(findUserDetail('creditCard').text()).not.toContain( + expect(findUserDetail('credit-card-verification').text()).not.toContain( sprintf('Card matches %{similarRecordsCount} accounts', { ...user.creditCard }), ); }); it('does not render a link to the matching cards', () => { - expect(findLinkFor('creditCard').exists()).toBe(false); + expect(findLinkFor('credit-card-verification').exists()).toBe(false); }); }); }); @@ -124,55 +126,55 @@ describe('UserDetails', () => { }); it('does not render the users creditCard', () => { - expect(findUserDetail('creditCard').exists()).toBe(false); + expect(findUserDetail('credit-card-verification').exists()).toBe(false); }); }); }); describe('otherReports', () => { it('renders the correct label', () => { - expect(findUserDetailLabel('otherReports')).toBe(USER_DETAILS_I18N.otherReports); + expect(findUserDetailLabel('past-closed-reports')).toBe(USER_DETAILS_I18N.pastReports); }); - describe.each(user.otherReports)('renders a line for report %#', (otherReport) => { - const index = user.otherReports.indexOf(otherReport); + describe.each(user.pastClosedReports)('renders a line for report %#', (pastReport) => { + const index = user.pastClosedReports.indexOf(pastReport); it('renders the category', () => { - expect(findOtherReport(index).text()).toContain( - sprintf('Reported for %{category}', { ...otherReport }), + expect(findPastReport(index).text()).toContain( + sprintf('Reported for %{category}', { ...pastReport }), ); }); it('renders a link to the report', () => { - expect(findLinkIn(findOtherReport(index)).attributes('href')).toBe(otherReport.reportPath); + expect(findLinkIn(findPastReport(index)).attributes('href')).toBe(pastReport.reportPath); }); it('renders the time it was created', () => { - expect(findTimeIn(findOtherReport(index))).toBe(otherReport.createdAt); + expect(findTimeIn(findPastReport(index))).toBe(pastReport.createdAt); }); }); describe('when the users otherReports is empty', () => { beforeEach(() => { createComponent({ - user: { ...user, otherReports: [] }, + user: { ...user, pastClosedReports: [] }, }); }); it('does not render the users otherReports', () => { - expect(findUserDetail('otherReports').exists()).toBe(false); + expect(findUserDetail('past-closed-reports').exists()).toBe(false); }); }); }); describe('normalLocation', () => { it('renders the correct label', () => { - expect(findUserDetailLabel('normalLocation')).toBe(USER_DETAILS_I18N.normalLocation); + expect(findUserDetailLabel('normal-location')).toBe(USER_DETAILS_I18N.normalLocation); }); describe('when the users mostUsedIp is blank', () => { it('renders the users lastSignInIp', () => { - expect(findUserDetailValue('normalLocation')).toBe(user.lastSignInIp); + expect(findUserDetailValue('normal-location')).toBe(user.lastSignInIp); }); }); @@ -186,23 +188,25 @@ describe('UserDetails', () => { }); it('renders the users mostUsedIp', () => { - expect(findUserDetailValue('normalLocation')).toBe(mostUsedIp); + expect(findUserDetailValue('normal-location')).toBe(mostUsedIp); }); }); }); describe('lastSignInIp', () => { it('renders the users lastSignInIp with the correct label', () => { - expect(findUserDetailLabel('lastSignInIp')).toBe(USER_DETAILS_I18N.lastSignInIp); - expect(findUserDetailValue('lastSignInIp')).toBe(user.lastSignInIp); + expect(findUserDetailLabel('last-sign-in-ip')).toBe(USER_DETAILS_I18N.lastSignInIp); + expect(findUserDetailValue('last-sign-in-ip')).toBe(user.lastSignInIp); }); }); it.each(['snippets', 'groups', 'notes'])( 'renders the users %s with the correct label', (attribute) => { - expect(findUserDetailLabel(attribute)).toBe(USER_DETAILS_I18N[attribute]); - expect(findUserDetailValue(attribute)).toBe( + const testId = `user-${attribute}-count`; + + expect(findUserDetailLabel(testId)).toBe(USER_DETAILS_I18N[attribute]); + expect(findUserDetailValue(testId)).toBe( USER_DETAILS_I18N[`${attribute}Count`](user[`${attribute}Count`]), ); }, diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js index 8ff0c7d507a..ee61eabfa66 100644 --- a/spec/frontend/admin/abuse_report/mock_data.js +++ b/spec/frontend/admin/abuse_report/mock_data.js @@ -15,7 +15,7 @@ export const mockAbuseReport = { similarRecordsCount: 2, cardMatchesLink: '/admin/users/spamuser417/card_match', }, - otherReports: [ + pastClosedReports: [ { category: 'offensive', createdAt: '2023-02-28T10:09:54.982Z', @@ -32,14 +32,27 @@ export const mockAbuseReport = { snippetsCount: 0, groupsCount: 0, notesCount: 6, - }, - reporter: { - username: 'reporter', - name: 'R Porter', - avatarUrl: 'https://www.gravatar.com/avatar/a2579caffc69ea5d7606f9dd9d8504ba?s=80&d=identicon', - path: '/reporter', + similarOpenReports: [ + { + status: 'open', + message: 'This is obvious spam', + reportedAt: '2023-03-29T09:39:50.502Z', + category: 'spam', + type: 'issue', + content: '', + screenshot: null, + reporter: { + username: 'reporter 2', + name: 'Another Reporter', + avatarUrl: 'https://www.gravatar.com/avatar/anotherreporter', + path: '/reporter-2', + }, + updatePath: '/admin/abuse_reports/28', + }, + ], }, report: { + globalId: 'gid://gitlab/AbuseReport/1', status: 'open', message: 'This is obvious spam', reportedAt: '2023-03-29T09:39:50.502Z', @@ -52,5 +65,66 @@ export const mockAbuseReport = { '/uploads/-/system/abuse_report/screenshot/27/Screenshot_2023-03-30_at_16.56.37.png', updatePath: '/admin/abuse_reports/27', moderateUserPath: '/admin/abuse_reports/27/moderate_user', + reporter: { + username: 'reporter', + name: 'R Porter', + avatarUrl: + 'https://www.gravatar.com/avatar/a2579caffc69ea5d7606f9dd9d8504ba?s=80&d=identicon', + path: '/reporter', + }, + }, +}; + +export const mockLabel1 = { + id: 'gid://gitlab/Admin::AbuseReportLabel/1', + title: 'Uno', + color: '#F0AD4E', + textColor: '#FFFFFF', + description: null, +}; + +export const mockLabel2 = { + id: 'gid://gitlab/Admin::AbuseReportLabel/2', + title: 'Dos', + color: '#F0AD4E', + textColor: '#FFFFFF', + description: null, +}; + +export const mockLabelsQueryResponse = { + data: { + labels: { + nodes: [mockLabel1, mockLabel2], + __typename: 'LabelConnection', + }, + }, +}; + +export const mockReportQueryResponse = { + data: { + abuseReport: { + labels: { + nodes: [mockLabel1], + __typename: 'LabelConnection', + }, + __typename: 'AbuseReport', + }, + }, +}; + +export const mockCreateLabelResponse = { + data: { + labelCreate: { + label: { + id: 'gid://gitlab/Admin::AbuseReportLabel/1', + color: '#ed9121', + description: null, + title: 'abuse report label', + textColor: '#FFFFFF', + __typename: 'Label', + }, + errors: [], + __typename: 'AbuseReportLabelCreatePayload', + }, }, }; diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js index 8482faccca0..7f915dbacb1 100644 --- a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js +++ b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js @@ -1,3 +1,4 @@ +import { GlLabel } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import AbuseReportRow from '~/admin/abuse_reports/components/abuse_report_row.vue'; @@ -13,6 +14,7 @@ describe('AbuseReportRow', () => { const findListItem = () => wrapper.findComponent(ListItem); const findAbuseCategory = () => wrapper.findComponent(AbuseCategory); + const findLabels = () => wrapper.findAllComponents(GlLabel); const findAbuseReportTitle = () => wrapper.findByTestId('abuse-report-title'); const findDisplayedDate = () => wrapper.findByTestId('abuse-report-date'); @@ -95,6 +97,18 @@ describe('AbuseReportRow', () => { expect(findAbuseCategory().exists()).toBe(true); }); + it('renders labels', () => { + const labels = findLabels(); + expect(labels).toHaveLength(2); + + const { color, title } = mockAbuseReports[0].labels[0]; + expect(labels.at(0).props()).toMatchObject({ + backgroundColor: color, + title, + target: `${window.location.href}?${encodeURIComponent('label_name[]')}=${title}`, + }); + }); + describe('aggregated report', () => { const mockAggregatedAbuseReport = mockAbuseReports[1]; const { reportedUser, category, count } = mockAggregatedAbuseReport; diff --git a/spec/frontend/admin/abuse_reports/mock_data.js b/spec/frontend/admin/abuse_reports/mock_data.js index 33a28a21cca..3101321d02d 100644 --- a/spec/frontend/admin/abuse_reports/mock_data.js +++ b/spec/frontend/admin/abuse_reports/mock_data.js @@ -1,3 +1,5 @@ +import { mockLabel1, mockLabel2 } from '../abuse_report/mock_data'; + export const mockAbuseReports = [ { category: 'spam', @@ -7,6 +9,7 @@ export const mockAbuseReports = [ reportedUser: { name: 'Mr. Abuser' }, reportPath: '/admin/abuse_reports/1', count: 1, + labels: [mockLabel1, mockLabel2], }, { category: 'phishing', diff --git a/spec/frontend/admin/applications/components/__snapshots__/delete_application_spec.js.snap b/spec/frontend/admin/applications/components/__snapshots__/delete_application_spec.js.snap index 459a113b6d1..7f068cf9ee9 100644 --- a/spec/frontend/admin/applications/components/__snapshots__/delete_application_spec.js.snap +++ b/spec/frontend/admin/applications/components/__snapshots__/delete_application_spec.js.snap @@ -10,7 +10,6 @@ exports[`DeleteApplication the modal component form matches the snapshot 1`] = ` type="hidden" value="delete" /> - - 5 groups"`; +exports[`AssociationsListItem renders interpolated message in a \`li\` element 1`] = ` +
  • + + 5 + + groups +
  • +`; diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 265569ac0e3..7f853f13363 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -10,13 +10,11 @@ exports[`Delete user modal renders modal with form included 1`] = ` type="hidden" value="delete" /> - - { const findUserActions = (id) => wrapper.findByTestId(`user-actions-${id}`); const findEditButton = (id = user.id) => findUserActions(id).find('[data-testid="edit"]'); const findActionsDropdown = (id = user.id) => - findUserActions(id).find('[data-testid="dropdown-toggle"]'); + findUserActions(id).find('[data-testid="user-actions-dropdown-toggle"]'); const findDisclosureGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup); const initComponent = ({ actions = [], showButtonLabels } = {}) => { 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 80d3676ffee..84156d6daf3 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 @@ -7,7 +7,6 @@ exports[`Alert integration settings form default state should match the default message="Action to take when receiving an alert. %{docsLink}" />

    -
    Create an incident. Incidents are created for each alert triggered. - - - Send a single email notification to Owners and Maintainers for new alerts. - Automatically close associated incident when a recovery alert notification resolves an alert - - Save changes -
    diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index a16a03a2fc5..e01dde8f62c 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -57,7 +57,7 @@ describe('AlertsSettingsWrapper', () => { const findIntegrationsList = () => wrapper.findComponent(IntegrationsList); const findLoader = () => findIntegrationsList().findComponent(GlLoadingIcon); const findIntegrations = () => findIntegrationsList().findAll('table tbody tr'); - const findAddIntegrationBtn = () => wrapper.findByTestId('add-integration-btn'); + const findAddIntegrationBtn = () => wrapper.findByTestId('add-integration-button'); const findAlertsSettingsForm = () => wrapper.findComponent(AlertsSettingsForm); const findAlert = () => wrapper.findComponent(GlAlert); diff --git a/spec/frontend/analytics/cycle_analytics/components/__snapshots__/total_time_spec.js.snap b/spec/frontend/analytics/cycle_analytics/components/__snapshots__/total_time_spec.js.snap index 92927ef16ec..5f712ba41f4 100644 --- a/spec/frontend/analytics/cycle_analytics/components/__snapshots__/total_time_spec.js.snap +++ b/spec/frontend/analytics/cycle_analytics/components/__snapshots__/total_time_spec.js.snap @@ -1,28 +1,52 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TotalTime with a blank object should render -- 1`] = `" -- "`; +exports[`TotalTime with a blank object should render -- 1`] = ` + + -- + +`; exports[`TotalTime with a valid time object with {"days": 3, "mins": 47, "seconds": 3} 1`] = ` -" - 3 days" + + 3 + + days + + `; exports[`TotalTime with a valid time object with {"hours": 7, "mins": 20, "seconds": 10} 1`] = ` -" - 7 hrs" + + 7 + + hrs + + `; exports[`TotalTime with a valid time object with {"hours": 23, "mins": 10} 1`] = ` -" - 23 hrs" + + 23 + + hrs + + `; exports[`TotalTime with a valid time object with {"mins": 47, "seconds": 3} 1`] = ` -" - 47 mins" + + 47 + + mins + + `; exports[`TotalTime with a valid time object with {"seconds": 35} 1`] = ` -" - 35 s" + + 35 + + s + + `; diff --git a/spec/frontend/api/application_settings_api_spec.js b/spec/frontend/api/application_settings_api_spec.js new file mode 100644 index 00000000000..92a6a159913 --- /dev/null +++ b/spec/frontend/api/application_settings_api_spec.js @@ -0,0 +1,45 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as applicationSettingsApi from '~/api/application_settings_api'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; + +describe('~/api/application_settings_api.js', () => { + const MOCK_SETTINGS_RES = { test_setting: 'foo' }; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + window.gon = { api_version: 'v7' }; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('getApplicationSettings', () => { + it('fetches application settings', () => { + const expectedUrl = '/api/v7/application/settings'; + jest.spyOn(axios, 'get'); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, MOCK_SETTINGS_RES); + + return applicationSettingsApi.getApplicationSettings().then(({ data }) => { + expect(data).toEqual(MOCK_SETTINGS_RES); + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); + + describe('updateApplicationSettings', () => { + it('updates application settings', () => { + const expectedUrl = '/api/v7/application/settings'; + const MOCK_REQ = { another_setting: 'bar' }; + jest.spyOn(axios, 'put'); + mock.onPut(expectedUrl).reply(HTTP_STATUS_OK, MOCK_SETTINGS_RES); + + return applicationSettingsApi.updateApplicationSettings(MOCK_REQ).then(({ data }) => { + expect(data).toEqual(MOCK_SETTINGS_RES); + expect(axios.put).toHaveBeenCalledWith(expectedUrl, MOCK_REQ); + }); + }); + }); +}); 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 58aee76e381..1456830b0eb 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 @@ -2,63 +2,45 @@ exports[`Keep latest artifact toggle when application keep latest artifact setting is enabled sets correct setting value in toggle with query result 1`] = `
    - -
    + Keep artifacts from most recent successful jobs + + - - - - - - The latest artifacts created by jobs in the most recent successful pipeline will be stored. - - - Learn more. - - + + Learn more. +
    diff --git a/spec/frontend/avatar_helper_spec.js b/spec/frontend/avatar_helper_spec.js deleted file mode 100644 index 91bf8e28774..00000000000 --- a/spec/frontend/avatar_helper_spec.js +++ /dev/null @@ -1,110 +0,0 @@ -import { TEST_HOST } from 'spec/test_constants'; -import { - DEFAULT_SIZE_CLASS, - IDENTICON_BG_COUNT, - renderAvatar, - renderIdenticon, - getIdenticonBackgroundClass, - getIdenticonTitle, -} from '~/helpers/avatar_helper'; -import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; - -function matchAll(str) { - return new RegExp(`^${str}$`); -} - -describe('avatar_helper', () => { - describe('getIdenticonBackgroundClass', () => { - it('returns identicon bg class from id that is a number', () => { - expect(getIdenticonBackgroundClass(1)).toEqual('bg2'); - }); - - it('returns identicon bg class from id that is a string', () => { - expect(getIdenticonBackgroundClass('1')).toEqual('bg2'); - }); - - it('returns identicon bg class from id that is a GraphQL string id', () => { - expect(getIdenticonBackgroundClass('gid://gitlab/Project/1')).toEqual('bg2'); - }); - - it('returns identicon bg class from unparsable string', () => { - expect(getIdenticonBackgroundClass('gid://gitlab/')).toEqual('bg1'); - }); - - it(`wraps around if id is bigger than ${IDENTICON_BG_COUNT}`, () => { - expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT + 4)).toEqual('bg5'); - expect(getIdenticonBackgroundClass(IDENTICON_BG_COUNT * 5 + 6)).toEqual('bg7'); - }); - }); - - describe('getIdenticonTitle', () => { - it('returns identicon title from name', () => { - expect(getIdenticonTitle('Lorem')).toEqual('L'); - expect(getIdenticonTitle('dolar-sit-amit')).toEqual('D'); - expect(getIdenticonTitle('%-with-special-chars')).toEqual('%'); - }); - - it('returns space if name is falsey', () => { - expect(getIdenticonTitle('')).toEqual(' '); - expect(getIdenticonTitle(null)).toEqual(' '); - }); - }); - - describe('renderIdenticon', () => { - it('renders with the first letter as title and bg based on id', () => { - const entity = { - id: IDENTICON_BG_COUNT + 3, - name: 'Xavior', - }; - const options = { - sizeClass: 's32', - }; - - const result = renderIdenticon(entity, options); - - expect(result).toHaveClass(`identicon ${options.sizeClass} bg4`); - expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name))); - }); - - it('renders with defaults, if no options are given', () => { - const entity = { - id: 1, - name: 'tanuki', - }; - - const result = renderIdenticon(entity); - - expect(result).toHaveClass(`identicon ${DEFAULT_SIZE_CLASS} bg2`); - expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name))); - }); - }); - - describe('renderAvatar', () => { - it('renders an image with the avatarUrl', () => { - const avatarUrl = `${TEST_HOST}/not-real-assets/test.png`; - - const result = renderAvatar({ - avatar_url: avatarUrl, - }); - - expect(result).toBeMatchedBy('img'); - expect(result).toHaveAttr('src', avatarUrl); - expect(result).toHaveClass(DEFAULT_SIZE_CLASS); - }); - - it('renders an identicon if no avatarUrl', () => { - const entity = { - id: 1, - name: 'walrus', - }; - const options = { - sizeClass: 's16', - }; - - const result = renderAvatar(entity, options); - - expect(result).toHaveClass(`identicon ${options.sizeClass} bg2`); - expect(result).toHaveText(matchAll(getFirstCharacterCapitalized(entity.name))); - }); - }); -}); diff --git a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js index 7044618fd9e..154347b08b5 100644 --- a/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js +++ b/spec/frontend/behaviors/markdown/paste_markdown_table_spec.js @@ -77,7 +77,8 @@ describe('PasteMarkdownTable', () => { data.getData = jest.fn().mockImplementation((type) => { if (type === 'text/html') { return '
    FirstLast
    JohnDoe
    JaneDoe
    '; - } else if (type === 'text/plain') { + } + if (type === 'text/plain') { return 'First\tLast\nJohn\tDoe\nJane\tDoe'; } @@ -102,7 +103,8 @@ describe('PasteMarkdownTable', () => { data.getData = jest.fn().mockImplementation((type) => { if (type === 'text/html') { return '
    FirstLast
    JohnDoe
    Jane/td>
    '; - } else if (type === 'text/plain') { + } + if (type === 'text/plain') { return 'First\tLast\nJohn\tDoe\nJane'; } diff --git a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap index 1733c4d4bb4..bd7485e9d80 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap @@ -2,10 +2,10 @@ exports[`Blob Header Editing rendering matches the snapshot 1`] = `
    - -
    `; diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap index 4ae55f34e4c..292a0da2bfe 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap @@ -2,9 +2,8 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = `
    -
    `; diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap deleted file mode 100644 index b430dc15557..00000000000 --- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Blob Header Default Actions rendering matches the snapshot 1`] = ` -
    -
    - - - -
    - -
    - - - -
    -
    -`; diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js index 47e09bb38bc..922d6a0211b 100644 --- a/spec/frontend/blob/components/blob_header_spec.js +++ b/spec/frontend/blob/components/blob_header_spec.js @@ -1,4 +1,6 @@ +import Vue from 'vue'; import { shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import BlobHeader from '~/blob/components/blob_header.vue'; import DefaultActions from '~/blob/components/blob_header_default_actions.vue'; @@ -10,8 +12,14 @@ import { SIMPLE_BLOB_VIEWER_TITLE, } from '~/blob/components/constants'; import TableContents from '~/blob/components/table_contents.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WebIdeLink from 'ee_else_ce/vue_shared/components/web_ide_link.vue'; +import userInfoQuery from '~/blob/queries/user_info.query.graphql'; +import applicationInfoQuery from '~/blob/queries/application_info.query.graphql'; +import { Blob, userInfoMock, applicationInfoMock } from './mock_data'; -import { Blob } from './mock_data'; +Vue.use(VueApollo); describe('Blob Header Default Actions', () => { let wrapper; @@ -26,14 +34,29 @@ describe('Blob Header Default Actions', () => { const findBlobFilePath = () => wrapper.findComponent(BlobFilepath); const findRichTextEditorBtn = () => wrapper.findByLabelText(RICH_BLOB_VIEWER_TITLE); const findSimpleTextEditorBtn = () => wrapper.findByLabelText(SIMPLE_BLOB_VIEWER_TITLE); + const findWebIdeLink = () => wrapper.findComponent(WebIdeLink); - function createComponent({ + async function createComponent({ blobProps = {}, options = {}, propsData = {}, mountFn = shallowMount, } = {}) { + const userInfoMockResolver = jest.fn().mockResolvedValue({ + data: { ...userInfoMock }, + }); + + const applicationInfoMockResolver = jest.fn().mockResolvedValue({ + data: { ...applicationInfoMock }, + }); + + const fakeApollo = createMockApollo([ + [userInfoQuery, userInfoMockResolver], + [applicationInfoQuery, applicationInfoMockResolver], + ]); + wrapper = mountFn(BlobHeader, { + apolloProvider: fakeApollo, provide: { ...defaultProvide, }, @@ -43,12 +66,40 @@ describe('Blob Header Default Actions', () => { }, ...options, }); + + await waitForPromises(); } describe('rendering', () => { - it('matches the snapshot', () => { - createComponent(); - expect(wrapper.element).toMatchSnapshot(); + describe('WebIdeLink component', () => { + it('renders the WebIdeLink component with the correct props', async () => { + const { ideEditPath, editBlobPath, gitpodBlobUrl, pipelineEditorPath } = Blob; + const showForkSuggestion = false; + await createComponent({ propsData: { showForkSuggestion } }); + + expect(findWebIdeLink().props()).toMatchObject({ + showEditButton: true, + editUrl: editBlobPath, + webIdeUrl: ideEditPath, + needsToFork: showForkSuggestion, + showPipelineEditorButton: Boolean(pipelineEditorPath), + pipelineEditorUrl: pipelineEditorPath, + gitpodUrl: gitpodBlobUrl, + showGitpodButton: applicationInfoMock.gitpodEnabled, + gitpodEnabled: userInfoMock.currentUser.gitpodEnabled, + userPreferencesGitpodPath: userInfoMock.currentUser.preferencesGitpodPath, + userProfileEnableGitpodPath: userInfoMock.currentUser.profileEnableGitpodPath, + }); + }); + + it.each([[{ archived: true }], [{ editBlobPath: null }]])( + 'does not render the WebIdeLink component when blob is archived or does not have an edit path', + (blobProps) => { + createComponent({ blobProps }); + + expect(findWebIdeLink().exists()).toBe(false); + }, + ); }); describe('default render', () => { diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js index 6ecf5091591..7ed526fba97 100644 --- a/spec/frontend/blob/components/mock_data.js +++ b/spec/frontend/blob/components/mock_data.js @@ -30,6 +30,10 @@ export const Blob = { richViewer: { ...RichViewerMock, }, + ideEditPath: 'ide/edit', + editBlobPath: 'edit/blob', + gitpodBlobUrl: 'gitpod/blob/url', + pipelineEditorPath: 'pipeline/editor/path', }; export const BinaryBlob = { @@ -60,3 +64,14 @@ export const SimpleBlobContentMock = { export const mockEnvironmentName = 'my.testing.environment'; export const mockEnvironmentPath = 'https://my.testing.environment'; + +export const userInfoMock = { + currentUser: { + id: '123', + gitpodEnabled: true, + preferencesGitpodPath: '/-/profile/preferences#user_gitpod_enabled', + profileEnableGitpodPath: '/-/profile?user%5Bgitpod_enabled%5D=true', + }, +}; + +export const applicationInfoMock = { gitpodEnabled: true }; diff --git a/spec/frontend/blob/line_highlighter_spec.js b/spec/frontend/blob/line_highlighter_spec.js index de39a8f688a..c7a86d6230a 100644 --- a/spec/frontend/blob/line_highlighter_spec.js +++ b/spec/frontend/blob/line_highlighter_spec.js @@ -72,6 +72,15 @@ describe('LineHighlighter', () => { expect(utils.scrollToElement).toHaveBeenCalledWith('#L5', expect.anything()); }); + it('does not scroll to the first highlighted line when disableScroll is `true`', () => { + jest.spyOn(utils, 'scrollToElement'); + const highlighter = new LineHighlighter(); + const scrollEnabled = false; + highlighter.highlightHash('#L5-25', scrollEnabled); + + expect(utils.scrollToElement).not.toHaveBeenCalled(); + }); + it('discards click events', () => { const clickSpy = jest.fn(); diff --git a/spec/frontend/blob/openapi/index_spec.js b/spec/frontend/blob/openapi/index_spec.js index 95e86398ab8..c96a021550d 100644 --- a/spec/frontend/blob/openapi/index_spec.js +++ b/spec/frontend/blob/openapi/index_spec.js @@ -4,16 +4,16 @@ import { TEST_HOST } from 'helpers/test_constants'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import renderOpenApi from '~/blob/openapi'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import setWindowLocation from 'helpers/set_window_location_helper'; describe('OpenAPI blob viewer', () => { const id = 'js-openapi-viewer'; const mockEndpoint = 'some/endpoint'; let mock; - beforeEach(async () => { + beforeEach(() => { setHTMLFixture(`
    `); mock = new MockAdapter(axios).onGet().reply(HTTP_STATUS_OK); - await renderOpenApi(); }); afterEach(() => { @@ -21,9 +21,28 @@ describe('OpenAPI blob viewer', () => { mock.restore(); }); - it('initializes SwaggerUI with the correct configuration', () => { - expect(document.body.innerHTML).toContain( - ``, - ); + describe('without config options', () => { + beforeEach(async () => { + await renderOpenApi(); + }); + + it('initializes SwaggerUI without config options', () => { + expect(document.body.innerHTML).toContain( + ``, + ); + }); + }); + + describe('with config options', () => { + beforeEach(async () => { + setWindowLocation('?displayOperationId=true'); + await renderOpenApi(); + }); + + it('initializes SwaggerUI with the correct config options', () => { + expect(document.body.innerHTML).toContain( + ``, + ); + }); }); }); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 1740676161f..95b5712bab0 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -91,6 +91,7 @@ describe('Board card component', () => { rootPath: '/', scopedLabelsAvailable: false, isEpicBoard, + allowSubEpics: isEpicBoard, issuableType: TYPE_ISSUE, isGroupBoard, isApolloBoard: false, diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index 167efb94fcc..f0d40af94fe 100644 --- a/spec/frontend/boards/components/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -72,6 +72,7 @@ describe('Board card', () => { issuableType: 'issue', isGroupBoard: true, disabled: false, + allowSubEpics: false, isApolloBoard: false, ...provide, }, diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index b17a5589c07..fa18b47cf54 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -87,6 +87,7 @@ describe('BoardsSelector', () => { isGroupBoard = false, isProjectBoard = false, provide = {}, + props = {}, } = {}) => { fakeApollo = createMockApollo([ [projectBoardsQuery, projectBoardsQueryHandler], @@ -100,6 +101,7 @@ describe('BoardsSelector', () => { apolloProvider: fakeApollo, propsData: { throttleDuration, + ...props, }, attachTo: document.body, provide: { @@ -307,4 +309,14 @@ describe('BoardsSelector', () => { }); }); }); + + describe('Apollo boards', () => { + it('displays loading state of dropdown while current board is being fetched', () => { + createComponent({ + props: { isCurrentBoardLoading: true }, + provide: { isApolloBoard: true }, + }); + expect(findDropdown().props('loading')).toBe(true); + }); + }); }); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index 5b5b68d5dbe..16ad54f0854 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -61,12 +61,7 @@ describe('IssueBoardFilter', () => { ({ isSignedIn }) => { createComponent({ isSignedIn }); - const tokens = mockTokens( - fetchLabelsSpy, - fetchUsersSpy, - wrapper.vm.fetchMilestones, - isSignedIn, - ); + const tokens = mockTokens(fetchLabelsSpy, fetchUsersSpy, isSignedIn); expect(findBoardsFilteredSearch().props('tokens')).toEqual(orderBy(tokens, ['title'])); }, diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js index 1b526e6fbec..f354067e226 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_title_spec.js @@ -8,6 +8,7 @@ import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.v import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { createStore } from '~/boards/stores'; import issueSetTitleMutation from '~/boards/graphql/issue_set_title.mutation.graphql'; +import * as cacheUpdates from '~/boards/graphql/cache_updates'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; import { updateIssueTitleResponse, updateEpicTitleResponse } from '../../mock_data'; @@ -40,6 +41,10 @@ describe('BoardSidebarTitle', () => { .fn() .mockResolvedValue(updateEpicTitleResponse); + beforeEach(() => { + cacheUpdates.setError = jest.fn(); + }); + afterEach(() => { localStorage.clear(); store = null; @@ -207,8 +212,7 @@ describe('BoardSidebarTitle', () => { it('collapses sidebar and renders former item title', () => { expect(findCollapsed().isVisible()).toBe(true); expect(findTitle().text()).toContain(TEST_ISSUE_B.title); - expect(storeDispatch).toHaveBeenCalledWith( - 'setError', + expect(cacheUpdates.setError).toHaveBeenCalledWith( expect.objectContaining({ message: 'An error occurred when updating the title' }), ); }); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 8f57a6eb7da..dfcdb4c05d0 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -827,7 +827,7 @@ export const mockConfidentialToken = { ], }; -export const mockTokens = (fetchLabels, fetchUsers, fetchMilestones, isSignedIn) => [ +export const mockTokens = (fetchLabels, fetchUsers, isSignedIn) => [ { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, @@ -870,7 +870,8 @@ export const mockTokens = (fetchLabels, fetchUsers, fetchMilestones, isSignedIn) shouldSkipSort: true, token: MilestoneToken, unique: true, - fetchMilestones, + fullPath: 'gitlab-org', + isProject: false, }, { icon: 'issues', diff --git a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap index 4da56a865d5..ee8031f2475 100644 --- a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap +++ b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap @@ -16,102 +16,82 @@ exports[`Delete merged branches component Delete merged branches confirmation mo toggletext="" variant="default" > - - - - - - - - - Delete merged branches - + Delete merged branches -

    - You are about to + You are about to delete all branches - that were merged into + that were merged into master .

    -

    - This may include merged branches that are not visible on the current screen. -

    -

    - A branch won't be deleted if it is protected or associated with an open merge request. -

    -

    - This bulk action is + This bulk action is permanent and cannot be undone or recovered .

    -

    - Plese type the following to confirm: + Plese type the following to confirm: delete - . + .

    - -
    - - - - - Cancel - - - - - - diff --git a/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap b/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap index 2afca66b0c1..81a57653f61 100644 --- a/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap +++ b/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap @@ -2,7 +2,7 @@ exports[`Branch divergence graph component renders ahead and behind count 1`] = `
    -
    - { + let wrapper; + + const successHandler = jest.fn().mockResolvedValue(mockAllJobsResponsePaginated); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const cancelHandler = jest.fn().mockResolvedValue(mockCancelableJobsCountResponse); + const emptyHandler = jest.fn().mockResolvedValue(mockAllJobsResponseEmpty); + const countSuccessHandler = jest.fn().mockResolvedValue(mockAllJobsCountResponse); + + const findSkeletonLoader = () => wrapper.findComponent(JobsSkeletonLoader); + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findTable = () => wrapper.findComponent(JobsTable); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findAlert = () => wrapper.findComponent(GlAlert); + const findTabs = () => wrapper.findComponent(JobsTableTabs); + const findCancelJobsButton = () => wrapper.findComponent(CancelJobs); + const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch); + + const mockSearchTokenRunnerType = { + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + value: { data: 'INSTANCE_TYPE', operator: '=' }, + }; + + const triggerInfiniteScroll = () => + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); + + const createMockApolloProvider = (handler, cancelableHandler, countHandler) => { + const requestHandlers = [ + [getAllJobsQuery, handler], + [getCancelableJobsQuery, cancelableHandler], + [getAllJobsCount, countHandler], + ]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = ({ + handler = successHandler, + cancelableHandler = cancelHandler, + countHandler = countSuccessHandler, + mountFn = shallowMount, + data = {}, + provideOptions = {}, + } = {}) => { + wrapper = mountFn(AdminJobsTableApp, { + data() { + return { + ...data, + }; + }, + provide: { + jobStatuses: statuses, + glFeatures: { adminJobsFilterRunnerType: true }, + ...provideOptions, + }, + apolloProvider: createMockApolloProvider(handler, cancelableHandler, countHandler), + }); + }; + + describe('loading state', () => { + it('should display skeleton loader when loading', () => { + createComponent(); + + 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); + expect(findLoadingSpinner().exists()).toBe(false); + }); + }); + + describe('loaded state', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('should display the jobs table with data', () => { + expect(findTable().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + expect(findLoadingSpinner().exists()).toBe(false); + }); + + it('should refetch jobs query on fetchJobsByStatus event', async () => { + expect(successHandler).toHaveBeenCalledTimes(1); + + await findTabs().vm.$emit('fetchJobsByStatus'); + + expect(successHandler).toHaveBeenCalledTimes(2); + }); + + it('avoids refetch jobs query when scope has not changed', async () => { + expect(successHandler).toHaveBeenCalledTimes(1); + + await findTabs().vm.$emit('fetchJobsByStatus', null); + + expect(successHandler).toHaveBeenCalledTimes(1); + }); + + it('should refetch jobs count query when the amount jobs and count do not match', async () => { + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + + // after applying filter a new count is fetched + findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(successHandler).toHaveBeenCalledTimes(2); + + // tab is switched to `finished`, no count + await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']); + + // tab is switched back to `all`, the old filter count has to be overwritten with new count + await findTabs().vm.$emit('fetchJobsByStatus', null); + + expect(successHandler).toHaveBeenCalledTimes(4); + }); + + describe('when infinite scrolling is triggered', () => { + it('does not display a skeleton loader', () => { + triggerInfiniteScroll(); + + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('handles infinite scrolling by calling fetch more', async () => { + triggerInfiniteScroll(); + + await nextTick(); + + const pageSize = 50; + + expect(findLoadingSpinner().exists()).toBe(true); + expect(findLoadingSpinner().attributes('aria-label')).toBe(LOADING_ARIA_LABEL); + + await waitForPromises(); + + expect(findLoadingSpinner().exists()).toBe(false); + + expect(successHandler).toHaveBeenLastCalledWith({ + first: pageSize, + after: mockAllJobsResponsePaginated.data.jobs.pageInfo.endCursor, + }); + }); + }); + }); + + describe('empty state', () => { + it('should display empty state if there are no jobs and tab scope is null', async () => { + createComponent({ handler: emptyHandler, mountFn: mount }); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + }); + + it('should not display empty state if there are jobs and tab scope is not null', async () => { + createComponent({ handler: successHandler, mountFn: mount }); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTable().exists()).toBe(true); + }); + }); + + describe('error state', () => { + it('should show an alert if there is an error fetching the jobs data', async () => { + createComponent({ handler: failedHandler }); + + await waitForPromises(); + + expect(findAlert().text()).toBe(JOBS_FETCH_ERROR_MSG); + expect(findTable().exists()).toBe(false); + }); + + it('should show an alert if there is an error fetching the jobs count data', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findAlert().text()).toBe(JOBS_COUNT_ERROR_MESSAGE); + }); + + it('should show an alert if there is an error fetching the cancelable jobs data', async () => { + createComponent({ handler: successHandler, cancelableHandler: failedHandler }); + + await waitForPromises(); + + expect(findAlert().text()).toBe(CANCELABLE_JOBS_ERROR_MSG); + }); + + it('jobs table should still load if count query fails', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + }); + + it('jobs table should still load if cancel query fails', async () => { + createComponent({ handler: successHandler, cancelableHandler: failedHandler }); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + }); + + it('jobs count should be zero if count query fails', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findTabs().props('allJobsCount')).toBe(0); + }); + + it('cancel button should be hidden if query fails', async () => { + createComponent({ handler: successHandler, cancelableHandler: failedHandler }); + + await waitForPromises(); + + expect(findCancelJobsButton().exists()).toBe(false); + }); + }); + + describe('cancel jobs button', () => { + it('should display cancel all jobs button', async () => { + createComponent({ cancelableHandler: cancelHandler, mountFn: mount }); + + await waitForPromises(); + + expect(findCancelJobsButton().exists()).toBe(true); + }); + + it('should not display cancel all jobs button', async () => { + createComponent(); + + await waitForPromises(); + + expect(findCancelJobsButton().exists()).toBe(false); + }); + }); + + describe('filtered search', () => { + it('should display filtered search', () => { + createComponent(); + + expect(findFilteredSearch().exists()).toBe(true); + }); + + // this test should be updated once BE supports tab and filtered search filtering + // https://gitlab.com/gitlab-org/gitlab/-/issues/356210 + it.each` + scope | shouldDisplay + ${null} | ${true} + ${['FAILED', 'SUCCESS', 'CANCELED']} | ${false} + `( + 'with tab scope $scope the filtered search displays $shouldDisplay', + async ({ scope, shouldDisplay }) => { + createComponent(); + + await waitForPromises(); + + await findTabs().vm.$emit('fetchJobsByStatus', scope); + + expect(findFilteredSearch().exists()).toBe(shouldDisplay); + }, + ); + + describe.each` + searchTokens | expectedQueryParams + ${[]} | ${{ runnerTypes: null, statuses: null }} + ${[mockFailedSearchToken]} | ${{ runnerTypes: null, statuses: 'FAILED' }} + ${[mockFailedSearchToken, mockSearchTokenRunnerType]} | ${{ runnerTypes: 'INSTANCE_TYPE', statuses: 'FAILED' }} + `('when filtering jobs by searchTokens', ({ searchTokens, expectedQueryParams }) => { + it(`refetches jobs query including filters ${JSON.stringify( + expectedQueryParams, + )}`, async () => { + createComponent(); + + expect(successHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', searchTokens); + + expect(successHandler).toHaveBeenCalledTimes(2); + expect(successHandler).toHaveBeenNthCalledWith(2, { first: 50, ...expectedQueryParams }); + }); + + it(`refetches jobs count query including filters ${JSON.stringify( + expectedQueryParams, + )}`, async () => { + createComponent(); + + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', searchTokens); + + expect(countSuccessHandler).toHaveBeenCalledTimes(2); + expect(countSuccessHandler).toHaveBeenNthCalledWith(2, expectedQueryParams); + }); + }); + + it('shows raw text warning when user inputs raw text', async () => { + const expectedWarning = { + message: RAW_TEXT_WARNING_ADMIN, + type: 'warning', + }; + + createComponent(); + + expect(successHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']); + + expect(createAlert).toHaveBeenCalledWith(expectedWarning); + expect(successHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + }); + + it('updates URL query string when filtering jobs by status', async () => { + createComponent(); + + jest.spyOn(urlUtils, 'updateHistory'); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?statuses=FAILED`, + }); + }); + + it('resets query param after clearing tokens', () => { + createComponent(); + + jest.spyOn(urlUtils, 'updateHistory'); + + findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(successHandler).toHaveBeenCalledWith({ + first: 50, + statuses: 'FAILED', + runnerTypes: null, + }); + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?statuses=FAILED`, + }); + + findFilteredSearch().vm.$emit('filterJobsBySearch', []); + + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/`, + }); + + expect(successHandler).toHaveBeenCalledWith({ + first: 50, + statuses: null, + runnerTypes: null, + }); + }); + + describe('when feature flag `adminJobsFilterRunnerType` is disabled', () => { + const provideOptions = { glFeatures: { adminJobsFilterRunnerType: false } }; + + describe.each` + searchTokens | expectedQueryParams + ${[]} | ${{ statuses: null }} + ${[mockFailedSearchToken]} | ${{ statuses: 'FAILED' }} + ${[mockFailedSearchToken, mockSearchTokenRunnerType]} | ${{ statuses: 'FAILED' }} + `('when filtering jobs by searchTokens', ({ searchTokens, expectedQueryParams }) => { + it(`refetches jobs query including filters ${JSON.stringify( + expectedQueryParams, + )}`, async () => { + createComponent({ provideOptions }); + + expect(successHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', searchTokens); + + expect(successHandler).toHaveBeenCalledTimes(2); + expect(successHandler).toHaveBeenNthCalledWith(2, { first: 50, ...expectedQueryParams }); + }); + + it(`refetches jobs count query including filters ${JSON.stringify( + expectedQueryParams, + )}`, async () => { + createComponent({ provideOptions }); + + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', searchTokens); + + expect(countSuccessHandler).toHaveBeenCalledTimes(2); + expect(countSuccessHandler).toHaveBeenNthCalledWith(2, expectedQueryParams); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/admin/jobs_table/components/cancel_jobs_modal_spec.js b/spec/frontend/ci/admin/jobs_table/components/cancel_jobs_modal_spec.js new file mode 100644 index 00000000000..c3d1d0266f4 --- /dev/null +++ b/spec/frontend/ci/admin/jobs_table/components/cancel_jobs_modal_spec.js @@ -0,0 +1,66 @@ +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import CancelJobsModal from '~/ci/admin/jobs_table/components/cancel_jobs_modal.vue'; +import { setVueErrorHandler } from '../../../../__helpers__/set_vue_error_handler'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + redirectTo: jest.fn(), +})); + +describe('Cancel jobs modal', () => { + const props = { + url: `${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`, + modalId: 'cancel-jobs-modal', + }; + let wrapper; + + beforeEach(() => { + wrapper = mount(CancelJobsModal, { propsData: props }); + }); + + describe('on submit', () => { + it('cancels jobs and redirects to overview page', async () => { + const responseURL = `${TEST_HOST}/cancel_jobs_modal.vue/jobs`; + // TODO: We can't use axios-mock-adapter because our current version + // does not support responseURL + // + // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details + jest.spyOn(axios, 'post').mockImplementation((url) => { + expect(url).toBe(props.url); + return Promise.resolve({ + request: { + responseURL, + }, + }); + }); + + wrapper.findComponent(GlModal).vm.$emit('primary'); + await nextTick(); + + expect(redirectTo).toHaveBeenCalledWith(responseURL); // eslint-disable-line import/no-deprecated + }); + + it('displays error if canceling jobs failed', async () => { + const dummyError = new Error('canceling jobs failed'); + // TODO: We can't use axios-mock-adapter because our current version + // does not support responseURL + // + // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details + jest.spyOn(axios, 'post').mockImplementation((url) => { + expect(url).toBe(props.url); + return Promise.reject(dummyError); + }); + + setVueErrorHandler({ instance: wrapper.vm, handler: () => {} }); // silencing thrown error + wrapper.findComponent(GlModal).vm.$emit('primary'); + await nextTick(); + + expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated + }); + }); +}); diff --git a/spec/frontend/ci/admin/jobs_table/components/cancel_jobs_spec.js b/spec/frontend/ci/admin/jobs_table/components/cancel_jobs_spec.js new file mode 100644 index 00000000000..2884e4ed521 --- /dev/null +++ b/spec/frontend/ci/admin/jobs_table/components/cancel_jobs_spec.js @@ -0,0 +1,54 @@ +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { TEST_HOST } from 'helpers/test_constants'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CancelJobs from '~/ci/admin/jobs_table/components/cancel_jobs.vue'; +import CancelJobsModal from '~/ci/admin/jobs_table/components/cancel_jobs_modal.vue'; +import { CANCEL_JOBS_MODAL_ID, CANCEL_BUTTON_TOOLTIP } from '~/ci/admin/jobs_table/constants'; + +describe('CancelJobs component', () => { + let wrapper; + + const findCancelJobs = () => wrapper.findComponent(CancelJobs); + const findButton = () => wrapper.findComponent(GlButton); + const findModal = () => wrapper.findComponent(CancelJobsModal); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(CancelJobs, { + directives: { + GlModal: createMockDirective('gl-modal'), + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + url: `${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('has correct inputs', () => { + expect(findCancelJobs().props().url).toBe(`${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`); + }); + + it('has correct button variant', () => { + expect(findButton().props().variant).toBe('danger'); + }); + + it('checks that button and modal are connected', () => { + const buttonModalDirective = getBinding(findButton().element, 'gl-modal'); + const modalId = findModal().props('modalId'); + + expect(buttonModalDirective.value).toBe(CANCEL_JOBS_MODAL_ID); + expect(modalId).toBe(CANCEL_JOBS_MODAL_ID); + }); + + it('checks that tooltip is displayed', () => { + const buttonTooltipDirective = getBinding(findButton().element, 'gl-tooltip'); + + expect(buttonTooltipDirective.value).toBe(CANCEL_BUTTON_TOOLTIP); + }); +}); diff --git a/spec/frontend/ci/admin/jobs_table/components/cells/project_cell_spec.js b/spec/frontend/ci/admin/jobs_table/components/cells/project_cell_spec.js new file mode 100644 index 00000000000..3e391e74394 --- /dev/null +++ b/spec/frontend/ci/admin/jobs_table/components/cells/project_cell_spec.js @@ -0,0 +1,32 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; +import { mockAllJobsNodes } from 'jest/ci/jobs_mock_data'; + +const mockJob = mockAllJobsNodes[0]; + +describe('Project cell', () => { + let wrapper; + + const findProjectLink = () => wrapper.findComponent(GlLink); + + const createComponent = (props = {}) => { + wrapper = shallowMount(ProjectCell, { + propsData: { + ...props, + }, + }); + }; + + describe('Project Link', () => { + beforeEach(() => { + createComponent({ job: mockJob }); + }); + + it('shows and links to the project', () => { + expect(findProjectLink().exists()).toBe(true); + expect(findProjectLink().text()).toBe(mockJob.pipeline.project.fullPath); + expect(findProjectLink().attributes('href')).toBe(mockJob.pipeline.project.webUrl); + }); + }); +}); diff --git a/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js b/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js new file mode 100644 index 00000000000..2f1dae71572 --- /dev/null +++ b/spec/frontend/ci/admin/jobs_table/components/cells/runner_cell_spec.js @@ -0,0 +1,64 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; +import { RUNNER_EMPTY_TEXT } from '~/ci/admin/jobs_table/constants'; +import { allRunnersData } from 'jest/ci/runner/mock_data'; + +const mockRunner = allRunnersData.data.runners.nodes[0]; + +const mockJobWithRunner = { + id: 'gid://gitlab/Ci::Build/2264', + runner: mockRunner, +}; + +const mockJobWithoutRunner = { + id: 'gid://gitlab/Ci::Build/2265', +}; + +describe('Runner Cell', () => { + let wrapper; + + const findRunnerLink = () => wrapper.findComponent(GlLink); + const findEmptyRunner = () => wrapper.find('[data-testid="empty-runner-text"]'); + + const createComponent = (props = {}) => { + wrapper = shallowMount(RunnerCell, { + propsData: { + ...props, + }, + }); + }; + + describe('Runner Link', () => { + describe('Job with runner', () => { + beforeEach(() => { + createComponent({ job: mockJobWithRunner }); + }); + + it('shows and links to the runner', () => { + expect(findRunnerLink().exists()).toBe(true); + expect(findRunnerLink().text()).toBe(mockRunner.description); + expect(findRunnerLink().attributes('href')).toBe(mockRunner.adminUrl); + }); + + it('hides the empty runner text', () => { + expect(findEmptyRunner().exists()).toBe(false); + }); + }); + + describe('Job without runner', () => { + beforeEach(() => { + createComponent({ job: mockJobWithoutRunner }); + }); + + it('shows default `empty` text', () => { + expect(findEmptyRunner().exists()).toBe(true); + expect(findEmptyRunner().text()).toBe(RUNNER_EMPTY_TEXT); + }); + + it('hides the runner link', () => { + expect(findRunnerLink().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/admin/jobs_table/components/jobs_skeleton_loader_spec.js b/spec/frontend/ci/admin/jobs_table/components/jobs_skeleton_loader_spec.js new file mode 100644 index 00000000000..0d2f5f58121 --- /dev/null +++ b/spec/frontend/ci/admin/jobs_table/components/jobs_skeleton_loader_spec.js @@ -0,0 +1,28 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import JobsSkeletonLoader from '~/ci/admin/jobs_table/components/jobs_skeleton_loader.vue'; + +describe('jobs_skeleton_loader.vue', () => { + let wrapper; + + const findGlSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const WIDTH = '1248'; + const HEIGHT = '73'; + + beforeEach(() => { + wrapper = shallowMount(JobsSkeletonLoader); + }); + + it('renders a GlSkeletonLoader', () => { + expect(findGlSkeletonLoader().exists()).toBe(true); + }); + + it('has correct width', () => { + expect(findGlSkeletonLoader().attributes('width')).toBe(WIDTH); + }); + + it('has correct height', () => { + expect(findGlSkeletonLoader().attributes('height')).toBe(HEIGHT); + }); +}); diff --git a/spec/frontend/ci/admin/jobs_table/graphql/cache_config_spec.js b/spec/frontend/ci/admin/jobs_table/graphql/cache_config_spec.js new file mode 100644 index 00000000000..36fbbafac44 --- /dev/null +++ b/spec/frontend/ci/admin/jobs_table/graphql/cache_config_spec.js @@ -0,0 +1,106 @@ +import cacheConfig from '~/ci/admin/jobs_table/graphql/cache_config'; +import { + CIJobConnectionExistingCache, + CIJobConnectionIncomingCache, + CIJobConnectionIncomingCacheRunningStatus, +} from 'jest/ci/jobs_mock_data'; + +const firstLoadArgs = { first: 3, statuses: 'PENDING' }; +const runningArgs = { first: 3, statuses: 'RUNNING' }; + +describe('jobs/components/table/graphql/cache_config', () => { + describe('when fetching data with the same statuses', () => { + it('should contain cache nodes and a status when merging caches on first load', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { + args: firstLoadArgs, + }); + + expect(res.nodes).toHaveLength(CIJobConnectionIncomingCache.nodes.length); + expect(res.statuses).toBe('PENDING'); + }); + + it('should add to existing caches when merging caches after first load', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + CIJobConnectionIncomingCache, + { + args: firstLoadArgs, + }, + ); + + expect(res.nodes).toHaveLength( + CIJobConnectionIncomingCache.nodes.length + CIJobConnectionExistingCache.nodes.length, + ); + }); + + it('should not add to existing cache if the incoming elements are the same', () => { + // simulate that this is the last page + const finalExistingCache = { + ...CIJobConnectionExistingCache, + pageInfo: { + hasNextPage: false, + }, + }; + + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + finalExistingCache, + { + args: firstLoadArgs, + }, + ); + + expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length); + }); + + it('should contain the pageInfo key as part of the result', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { + args: firstLoadArgs, + }); + + expect(res.pageInfo).toEqual( + expect.objectContaining({ + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjIwNTEifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIxNzMifQ', + }), + ); + }); + }); + + describe('when fetching data with different statuses', () => { + it('should reset cache when a cache already exists', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + CIJobConnectionIncomingCacheRunningStatus, + { + args: runningArgs, + }, + ); + + expect(res.nodes).not.toEqual(CIJobConnectionExistingCache.nodes); + expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length); + }); + }); + + describe('when incoming data has no nodes', () => { + it('should return existing cache', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + { __typename: 'CiJobConnection', count: 500 }, + { + args: { statuses: 'SUCCESS' }, + }, + ); + + const expectedResponse = { + ...CIJobConnectionExistingCache, + statuses: 'SUCCESS', + }; + + expect(res).toEqual(expectedResponse); + }); + }); +}); diff --git a/spec/frontend/ci/artifacts/components/feedback_banner_spec.js b/spec/frontend/ci/artifacts/components/feedback_banner_spec.js deleted file mode 100644 index 53e0fdac6f6..00000000000 --- a/spec/frontend/ci/artifacts/components/feedback_banner_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { GlBanner } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import FeedbackBanner from '~/ci/artifacts/components/feedback_banner.vue'; -import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; -import { - I18N_FEEDBACK_BANNER_TITLE, - I18N_FEEDBACK_BANNER_BUTTON, - FEEDBACK_URL, -} from '~/ci/artifacts/constants'; - -const mockBannerImagePath = 'banner/image/path'; - -describe('Artifacts management feedback banner', () => { - let wrapper; - let userCalloutDismissSpy; - - const findBanner = () => wrapper.findComponent(GlBanner); - - const createComponent = ({ shouldShowCallout = true } = {}) => { - userCalloutDismissSpy = jest.fn(); - - wrapper = shallowMount(FeedbackBanner, { - provide: { - artifactsManagementFeedbackImagePath: mockBannerImagePath, - }, - stubs: { - UserCalloutDismisser: makeMockUserCalloutDismisser({ - dismiss: userCalloutDismissSpy, - shouldShowCallout, - }), - }, - }); - }; - - it('is displayed with the correct props', () => { - createComponent(); - - expect(findBanner().props()).toMatchObject({ - title: I18N_FEEDBACK_BANNER_TITLE, - buttonText: I18N_FEEDBACK_BANNER_BUTTON, - buttonLink: FEEDBACK_URL, - svgPath: mockBannerImagePath, - }); - }); - - it('dismisses the callout when closed', () => { - createComponent(); - - findBanner().vm.$emit('close'); - - expect(userCalloutDismissSpy).toHaveBeenCalled(); - }); - - it('is not displayed once it has been dismissed', () => { - createComponent({ shouldShowCallout: false }); - - expect(findBanner().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js index e062140246b..1cbb1a714c9 100644 --- a/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js +++ b/spec/frontend/ci/artifacts/components/job_artifacts_table_spec.js @@ -13,7 +13,6 @@ import getJobArtifactsResponse from 'test_fixtures/graphql/ci/artifacts/graphql/ import CiIcon from '~/vue_shared/components/ci_icon.vue'; import waitForPromises from 'helpers/wait_for_promises'; import JobArtifactsTable from '~/ci/artifacts/components/job_artifacts_table.vue'; -import FeedbackBanner from '~/ci/artifacts/components/feedback_banner.vue'; import ArtifactsTableRowDetails from '~/ci/artifacts/components/artifacts_table_row_details.vue'; import ArtifactDeleteModal from '~/ci/artifacts/components/artifact_delete_modal.vue'; import ArtifactsBulkDelete from '~/ci/artifacts/components/artifacts_bulk_delete.vue'; @@ -46,8 +45,6 @@ describe('JobArtifactsTable component', () => { const mockToastShow = jest.fn(); - const findBanner = () => wrapper.findComponent(FeedbackBanner); - const findLoadingState = () => wrapper.findComponent(GlLoadingIcon); const findTable = () => wrapper.findComponent(GlTable); const findDetailsRows = () => wrapper.findAllComponents(ArtifactsTableRowDetails); @@ -162,7 +159,6 @@ describe('JobArtifactsTable component', () => { projectPath: 'project/path', projectId, canDestroyArtifacts, - artifactsManagementFeedbackImagePath: 'banner/image/path', }, mocks: { $toast: { @@ -175,12 +171,6 @@ describe('JobArtifactsTable component', () => { }); }; - it('renders feedback banner', () => { - createComponent(); - - expect(findBanner().exists()).toBe(true); - }); - it('when loading, shows a loading state', () => { createComponent(); @@ -373,6 +363,7 @@ describe('JobArtifactsTable component', () => { it('is disabled when job has no metadata.gz', async () => { const jobWithoutMetadata = { ...job, + hasArtifacts: true, artifacts: { nodes: [archiveArtifact] }, }; @@ -389,6 +380,7 @@ describe('JobArtifactsTable component', () => { it('is disabled when job has no artifacts', async () => { const jobWithoutArtifacts = { ...job, + hasArtifacts: false, artifacts: { nodes: [] }, }; diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js deleted file mode 100644 index 8990a70d4ef..00000000000 --- a/spec/frontend/ci/ci_variable_list/ci_variable_list/ci_variable_list_spec.js +++ /dev/null @@ -1,161 +0,0 @@ -import $ from 'jquery'; -import htmlPipelineSchedulesEdit from 'test_fixtures/pipeline_schedules/edit.html'; -import htmlPipelineSchedulesEditWithVariables from 'test_fixtures/pipeline_schedules/edit_with_variables.html'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import VariableList from '~/ci/ci_variable_list/ci_variable_list'; - -const HIDE_CLASS = 'hide'; - -describe('VariableList', () => { - let $wrapper; - let variableList; - - describe('with only key/value inputs', () => { - describe('with no variables', () => { - beforeEach(() => { - setHTMLFixture(htmlPipelineSchedulesEdit); - $wrapper = $('.js-ci-variable-list-section'); - - variableList = new VariableList({ - container: $wrapper, - formField: 'schedule', - }); - variableList.init(); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('should remove the row when clicking the remove button', () => { - $wrapper.find('.js-row-remove-button').trigger('click'); - - expect($wrapper.find('.js-row').length).toBe(0); - }); - - it('should add another row when editing the last rows key input', () => { - const $row = $wrapper.find('.js-row'); - $row.find('.js-ci-variable-input-key').val('foo').trigger('input'); - - expect($wrapper.find('.js-row').length).toBe(2); - - // Check for the correct default in the new row - const $keyInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); - - expect($keyInput.val()).toBe(''); - }); - - it('should add another row when editing the last rows value textarea', () => { - const $row = $wrapper.find('.js-row'); - $row.find('.js-ci-variable-input-value').val('foo').trigger('input'); - - expect($wrapper.find('.js-row').length).toBe(2); - - // Check for the correct default in the new row - const $valueInput = $wrapper.find('.js-row:last-child').find('.js-ci-variable-input-key'); - - expect($valueInput.val()).toBe(''); - }); - - it('should remove empty row after blurring', () => { - const $row = $wrapper.find('.js-row'); - $row.find('.js-ci-variable-input-key').val('foo').trigger('input'); - - expect($wrapper.find('.js-row').length).toBe(2); - - $row.find('.js-ci-variable-input-key').val('').trigger('input').trigger('blur'); - - expect($wrapper.find('.js-row').length).toBe(1); - }); - }); - - describe('with persisted variables', () => { - beforeEach(() => { - setHTMLFixture(htmlPipelineSchedulesEditWithVariables); - $wrapper = $('.js-ci-variable-list-section'); - - variableList = new VariableList({ - container: $wrapper, - formField: 'schedule', - }); - variableList.init(); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('should have "Reveal values" button initially when there are already variables', () => { - expect($wrapper.find('.js-secret-value-reveal-button').text()).toBe('Reveal values'); - }); - - it('should reveal hidden values', () => { - const $row = $wrapper.find('.js-row:first-child'); - const $inputValue = $row.find('.js-ci-variable-input-value'); - const $placeholder = $row.find('.js-secret-value-placeholder'); - - expect($placeholder.hasClass(HIDE_CLASS)).toBe(false); - expect($inputValue.hasClass(HIDE_CLASS)).toBe(true); - - // Reveal values - $wrapper.find('.js-secret-value-reveal-button').click(); - - expect($placeholder.hasClass(HIDE_CLASS)).toBe(true); - expect($inputValue.hasClass(HIDE_CLASS)).toBe(false); - }); - }); - }); - - describe('toggleEnableRow method', () => { - beforeEach(() => { - setHTMLFixture(htmlPipelineSchedulesEditWithVariables); - $wrapper = $('.js-ci-variable-list-section'); - - variableList = new VariableList({ - container: $wrapper, - formField: 'variables', - }); - variableList.init(); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('should disable all key inputs', () => { - expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); - - variableList.toggleEnableRow(false); - - expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); - }); - - it('should disable all remove buttons', () => { - expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); - - variableList.toggleEnableRow(false); - - expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); - }); - - it('should enable all remove buttons', () => { - variableList.toggleEnableRow(false); - - expect($wrapper.find('.js-row-remove-button[disabled]').length).toBe(3); - - variableList.toggleEnableRow(true); - - expect($wrapper.find('.js-row-remove-button:not([disabled])').length).toBe(3); - }); - - it('should enable all key inputs', () => { - variableList.toggleEnableRow(false); - - expect($wrapper.find('.js-ci-variable-input-key[disabled]').length).toBe(3); - - variableList.toggleEnableRow(true); - - expect($wrapper.find('.js-ci-variable-input-key:not([disabled])').length).toBe(3); - }); - }); -}); diff --git a/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js b/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js deleted file mode 100644 index 3ef5427f288..00000000000 --- a/spec/frontend/ci/ci_variable_list/ci_variable_list/native_form_variable_list_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import $ from 'jquery'; -import htmlPipelineSchedulesEdit from 'test_fixtures/pipeline_schedules/edit.html'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import setupNativeFormVariableList from '~/ci/ci_variable_list/native_form_variable_list'; - -describe('NativeFormVariableList', () => { - let $wrapper; - - beforeEach(() => { - setHTMLFixture(htmlPipelineSchedulesEdit); - $wrapper = $('.js-ci-variable-list-section'); - - setupNativeFormVariableList({ - container: $wrapper, - formField: 'schedule', - }); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - describe('onFormSubmit', () => { - it('should clear out the `name` attribute on the inputs for the last empty row on form submission (avoid BE validation)', () => { - const $row = $wrapper.find('.js-row'); - - expect($row.find('.js-ci-variable-input-key').attr('name')).toBe( - 'schedule[variables_attributes][][key]', - ); - - expect($row.find('.js-ci-variable-input-value').attr('name')).toBe( - 'schedule[variables_attributes][][secret_value]', - ); - - $wrapper.closest('form').trigger('trigger-submit'); - - expect($row.find('.js-ci-variable-input-key').attr('name')).toBe(''); - expect($row.find('.js-ci-variable-input-value').attr('name')).toBe(''); - }); - }); -}); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js index 762c9611dac..ab5d914a6a1 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js @@ -1,42 +1,90 @@ -import { GlDrawer, GlFormSelect } from '@gitlab/ui'; +import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect } from '@gitlab/ui'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue'; import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue'; +import { awsTokenList } from '~/ci/ci_variable_list/components/ci_variable_autocomplete_tokens'; import { ADD_VARIABLE_ACTION, + DRAWER_EVENT_LABEL, + EDIT_VARIABLE_ACTION, + EVENT_ACTION, variableOptions, + projectString, variableTypes, } from '~/ci/ci_variable_list/constants'; +import { mockTracking } from 'helpers/tracking_helper'; +import { mockVariablesWithScopes } from '../mocks'; describe('CI Variable Drawer', () => { let wrapper; + let trackingSpy; + + const mockProjectVariable = mockVariablesWithScopes(projectString)[0]; + const mockProjectVariableFileType = mockVariablesWithScopes(projectString)[1]; + const mockEnvScope = 'staging'; + const mockEnvironments = ['*', 'dev', 'staging', 'production']; + + // matches strings that contain at least 8 consecutive characters consisting of only + // letters (both uppercase and lowercase), digits, or the specified special characters + const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$'; + + // matches strings that consist of at least 8 or more non-whitespace characters + const maskableRawRegex = '^\\S{8,}$'; const defaultProps = { areEnvironmentsLoading: false, - hasEnvScopeQuery: true, + areScopedVariablesAvailable: true, + environments: mockEnvironments, + hideEnvironmentScope: false, + selectedVariable: {}, mode: ADD_VARIABLE_ACTION, }; - const createComponent = ({ mountFn = shallowMountExtended, props = {} } = {}) => { + const defaultProvide = { + isProtectedByDefault: true, + environmentScopeLink: '/help/environments', + maskableRawRegex, + maskableRegex, + }; + + const createComponent = ({ + mountFn = shallowMountExtended, + props = {}, + provide = {}, + stubs = {}, + } = {}) => { wrapper = mountFn(CiVariableDrawer, { propsData: { ...defaultProps, ...props, }, provide: { - environmentScopeLink: '/help/environments', + ...defaultProvide, + ...provide, }, + stubs, }); }; + const findConfirmBtn = () => wrapper.findByTestId('ci-variable-confirm-btn'); + const findDisabledEnvironmentScopeDropdown = () => wrapper.findComponent(GlFormInput); const findDrawer = () => wrapper.findComponent(GlDrawer); + const findEnvironmentScopeDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown); + const findExpandedCheckbox = () => wrapper.findByTestId('ci-variable-expanded-checkbox'); + const findKeyField = () => wrapper.findComponent(GlFormCombobox); + const findMaskedCheckbox = () => wrapper.findByTestId('ci-variable-masked-checkbox'); + const findProtectedCheckbox = () => wrapper.findByTestId('ci-variable-protected-checkbox'); + const findValueField = () => wrapper.findByTestId('ci-variable-value'); + const findValueLabel = () => wrapper.findByTestId('ci-variable-value-label'); + const findTitle = () => findDrawer().find('h2'); const findTypeDropdown = () => wrapper.findComponent(GlFormSelect); describe('validations', () => { - beforeEach(() => { - createComponent({ mountFn: mountExtended }); - }); - describe('type dropdown', () => { + beforeEach(() => { + createComponent({ mountFn: mountExtended }); + }); + it('adds each type option as a dropdown item', () => { expect(findTypeDropdown().findAll('option')).toHaveLength(variableOptions.length); @@ -50,20 +98,288 @@ describe('CI Variable Drawer', () => { variableTypes.envType, ); }); + + it('renders the selected variable type', () => { + createComponent({ + mountFn: mountExtended, + props: { + areEnvironmentsLoading: true, + selectedVariable: mockProjectVariableFileType, + }, + }); + + expect(findTypeDropdown().element.value).toBe(variableTypes.fileType); + }); + }); + + describe('environment scope dropdown', () => { + it('passes correct props to the dropdown', () => { + createComponent({ + props: { + areEnvironmentsLoading: true, + selectedVariable: { ...mockProjectVariable, environmentScope: mockEnvScope }, + }, + stubs: { CiEnvironmentsDropdown }, + }); + + expect(findEnvironmentScopeDropdown().props()).toMatchObject({ + areEnvironmentsLoading: true, + environments: mockEnvironments, + selectedEnvironmentScope: mockEnvScope, + }); + }); + + it('hides environment scope dropdown when hideEnvironmentScope is true', () => { + createComponent({ + props: { hideEnvironmentScope: true }, + stubs: { CiEnvironmentsDropdown }, + }); + + expect(findEnvironmentScopeDropdown().exists()).toBe(false); + }); + + it('disables the environment scope dropdown when areScopedVariablesAvailable is false', () => { + createComponent({ + mountFn: mountExtended, + props: { areScopedVariablesAvailable: false }, + }); + + expect(findEnvironmentScopeDropdown().exists()).toBe(false); + expect(findDisabledEnvironmentScopeDropdown().attributes('readonly')).toBe('readonly'); + }); + }); + + describe('protected flag', () => { + beforeEach(() => { + createComponent(); + }); + + it('is true by default when isProtectedByDefault is true', () => { + expect(findProtectedCheckbox().attributes('checked')).toBeDefined(); + }); + + it('is not checked when isProtectedByDefault is false', () => { + createComponent({ provide: { isProtectedByDefault: false } }); + + expect(findProtectedCheckbox().attributes('checked')).toBeUndefined(); + }); + + it('inherits value of selected variable when editing', () => { + createComponent({ + props: { + selectedVariable: mockProjectVariableFileType, + mode: EDIT_VARIABLE_ACTION, + }, + }); + + expect(findProtectedCheckbox().attributes('checked')).toBeUndefined(); + }); + }); + + describe('masked flag', () => { + beforeEach(() => { + createComponent(); + }); + + it('is false by default', () => { + expect(findMaskedCheckbox().attributes('checked')).toBeUndefined(); + }); + + it('inherits value of selected variable when editing', () => { + createComponent({ + props: { + selectedVariable: mockProjectVariableFileType, + mode: EDIT_VARIABLE_ACTION, + }, + }); + + expect(findMaskedCheckbox().attributes('checked')).toBeDefined(); + }); + }); + + describe('expanded flag', () => { + beforeEach(() => { + createComponent(); + }); + + it('is true by default when adding a variable', () => { + expect(findExpandedCheckbox().attributes('checked')).toBeDefined(); + }); + + it('inherits value of selected variable when editing', () => { + createComponent({ + props: { + selectedVariable: mockProjectVariableFileType, + mode: EDIT_VARIABLE_ACTION, + }, + }); + + expect(findExpandedCheckbox().attributes('checked')).toBeUndefined(); + }); + + it("sets the variable's raw value", async () => { + await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); + await findExpandedCheckbox().vm.$emit('change'); + await findConfirmBtn().vm.$emit('click'); + + const sentRawValue = wrapper.emitted('add-variable')[0][0].raw; + expect(sentRawValue).toBe(!defaultProps.raw); + }); + + it('shows help text when variable is not expanded (will be evaluated as raw)', async () => { + expect(findExpandedCheckbox().attributes('checked')).toBeDefined(); + expect(findDrawer().text()).not.toContain( + 'Variable value will be evaluated as raw string.', + ); + + await findExpandedCheckbox().vm.$emit('change'); + + expect(findExpandedCheckbox().attributes('checked')).toBeUndefined(); + expect(findDrawer().text()).toContain('Variable value will be evaluated as raw string.'); + }); + + it('shows help text when variable is expanded and contains the $ character', async () => { + expect(findDrawer().text()).not.toContain( + 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.', + ); + + await findValueField().vm.$emit('input', '$NEW_VALUE'); + + expect(findDrawer().text()).toContain( + 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.', + ); + }); + }); + + describe('key', () => { + beforeEach(() => { + createComponent(); + }); + + it('prompts AWS tokens as options', () => { + expect(findKeyField().props('tokenList')).toBe(awsTokenList); + }); + + it('cannot submit with empty key', async () => { + expect(findConfirmBtn().attributes('disabled')).toBeDefined(); + + await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); + + expect(findConfirmBtn().attributes('disabled')).toBeUndefined(); + }); + }); + + describe('value', () => { + beforeEach(() => { + createComponent(); + }); + + it('can submit empty value', async () => { + await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); + + // value is empty by default + expect(findConfirmBtn().attributes('disabled')).toBeUndefined(); + }); + + describe.each` + value | canSubmit | trackingErrorProperty + ${'secretValue'} | ${true} | ${null} + ${'~v@lid:symbols.'} | ${true} | ${null} + ${'short'} | ${false} | ${null} + ${'multiline\nvalue'} | ${false} | ${'\n'} + ${'dollar$ign'} | ${false} | ${'$'} + ${'unsupported|char'} | ${false} | ${'|'} + `('masking requirements', ({ value, canSubmit, trackingErrorProperty }) => { + beforeEach(async () => { + createComponent(); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); + await findValueField().vm.$emit('input', value); + await findMaskedCheckbox().vm.$emit('input', true); + }); + + it(`${ + canSubmit ? 'can submit' : 'shows validation errors and disables submit button' + } when value is '${value}'`, () => { + if (canSubmit) { + expect(findValueLabel().attributes('invalid-feedback')).toBe(''); + expect(findConfirmBtn().attributes('disabled')).toBeUndefined(); + } else { + expect(findValueLabel().attributes('invalid-feedback')).toBe( + 'This variable value does not meet the masking requirements.', + ); + expect(findConfirmBtn().attributes('disabled')).toBeDefined(); + } + }); + + it(`${ + trackingErrorProperty ? 'sends the correct' : 'does not send the' + } variable validation tracking event when value is '${value}'`, () => { + const trackingEventSent = trackingErrorProperty ? 1 : 0; + expect(trackingSpy).toHaveBeenCalledTimes(trackingEventSent); + + if (trackingErrorProperty) { + expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, { + label: DRAWER_EVENT_LABEL, + property: trackingErrorProperty, + }); + } + }); + }); + + it('only sends the tracking event once', async () => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + await findKeyField().vm.$emit('input', 'NEW_VARIABLE'); + await findMaskedCheckbox().vm.$emit('input', true); + + expect(trackingSpy).toHaveBeenCalledTimes(0); + + await findValueField().vm.$emit('input', 'unsupported|char'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + + await findValueField().vm.$emit('input', 'dollar$ign'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + }); }); }); describe('drawer events', () => { - beforeEach(() => { + it('emits `close-form` when closing the drawer', async () => { createComponent(); - }); - it('emits `close-form` when closing the drawer', async () => { expect(wrapper.emitted('close-form')).toBeUndefined(); await findDrawer().vm.$emit('close'); expect(wrapper.emitted('close-form')).toHaveLength(1); }); + + describe('when adding a variable', () => { + beforeEach(() => { + createComponent({ stubs: { GlDrawer } }); + }); + + it('title and confirm button renders the correct text', () => { + expect(findTitle().text()).toBe('Add Variable'); + expect(findConfirmBtn().text()).toBe('Add Variable'); + }); + }); + + describe('when editing a variable', () => { + beforeEach(() => { + createComponent({ + props: { mode: EDIT_VARIABLE_ACTION }, + stubs: { GlDrawer }, + }); + }); + + it('title and confirm button renders the correct text', () => { + expect(findTitle().text()).toBe('Edit Variable'); + expect(findConfirmBtn().text()).toBe('Edit Variable'); + }); + }); }); }); diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js index f5737c61eea..79dd638e2bd 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_settings_spec.js @@ -77,6 +77,21 @@ describe('Ci variable table', () => { selectedVariable: {}, }); }); + + it('passes props down correctly to the ci drawer', async () => { + createComponent({ featureFlags: { ciVariableDrawer: true } }); + + await findCiVariableTable().vm.$emit('set-selected-variable'); + + expect(findCiVariableDrawer().props()).toEqual({ + areEnvironmentsLoading: defaultProps.areEnvironmentsLoading, + areScopedVariablesAvailable: defaultProps.areScopedVariablesAvailable, + environments: defaultProps.environments, + hideEnvironmentScope: defaultProps.hideEnvironmentScope, + mode: ADD_VARIABLE_ACTION, + selectedVariable: {}, + }); + }); }); describe.each` diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js index 39c03a41660..de24c389511 100644 --- a/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js +++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_table_spec.js @@ -105,9 +105,8 @@ describe('Ci variable table', () => { index | text ${0} | ${'Key (Click to sort descending)'} ${1} | ${'Value'} - ${2} | ${'Attributes'} - ${3} | ${'Environments'} - ${4} | ${'Actions'} + ${2} | ${'Environments'} + ${3} | ${'Actions'} `('renders the $text column', ({ index, text }) => { expect(findTableColumnText(index)).toEqual(text); }); diff --git a/spec/frontend/ci/common/pipelines_table_spec.js b/spec/frontend/ci/common/pipelines_table_spec.js new file mode 100644 index 00000000000..26dd1a2fcc5 --- /dev/null +++ b/spec/frontend/ci/common/pipelines_table_spec.js @@ -0,0 +1,280 @@ +import '~/commons'; +import { GlTableLite } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue'; +import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue'; +import PipelineTriggerer from '~/ci/pipelines_page/components/pipeline_triggerer.vue'; +import PipelineUrl from '~/ci/pipelines_page/components/pipeline_url.vue'; +import PipelinesTable from '~/ci/common/pipelines_table.vue'; +import PipelinesTimeago from '~/ci/pipelines_page/components/time_ago.vue'; +import { + PipelineKeyOptions, + BUTTON_TOOLTIP_RETRY, + BUTTON_TOOLTIP_CANCEL, + TRACKING_CATEGORIES, +} from '~/ci/constants'; + +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; + +jest.mock('~/ci/event_hub'); + +describe('Pipelines Table', () => { + let pipeline; + let wrapper; + let trackingSpy; + + const defaultProvide = { + glFeatures: {}, + withFailedJobsDetails: false, + }; + + const provideWithDetails = { + glFeatures: { + ciJobFailuresInMr: true, + }, + withFailedJobsDetails: true, + }; + + const defaultProps = { + pipelines: [], + viewType: 'root', + pipelineKeyOption: PipelineKeyOptions[0], + }; + + const createMockPipeline = () => { + // Clone fixture as it could be modified by tests + const { pipelines } = JSON.parse(JSON.stringify(fixture)); + return pipelines.find((p) => p.user !== null && p.commit !== null); + }; + + const createComponent = (props = {}, provide = {}) => { + wrapper = extendedWrapper( + mount(PipelinesTable, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: ['PipelineFailedJobsWidget'], + }), + ); + }; + + const findGlTableLite = () => wrapper.findComponent(GlTableLite); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); + const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); + const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); + const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); + const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago); + const findActions = () => wrapper.findComponent(PipelineOperations); + + const findPipelineFailureWidget = () => wrapper.findComponent(PipelineFailedJobsWidget); + const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); + const findStatusTh = () => wrapper.findByTestId('status-th'); + const findPipelineTh = () => wrapper.findByTestId('pipeline-th'); + const findStagesTh = () => wrapper.findByTestId('stages-th'); + const findActionsTh = () => wrapper.findByTestId('actions-th'); + const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); + const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + + beforeEach(() => { + pipeline = createMockPipeline(); + }); + + describe('Pipelines Table', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline], viewType: 'root' }); + }); + + it('displays table', () => { + expect(findGlTableLite().exists()).toBe(true); + }); + + it('should render table head with correct columns', () => { + expect(findStatusTh().text()).toBe('Status'); + expect(findPipelineTh().text()).toBe('Pipeline'); + expect(findStagesTh().text()).toBe('Stages'); + expect(findActionsTh().text()).toBe('Actions'); + }); + + it('should display a table row', () => { + expect(findTableRows()).toHaveLength(1); + }); + + describe('status cell', () => { + it('should render a status badge', () => { + expect(findCiBadgeLink().exists()).toBe(true); + }); + }); + + describe('pipeline cell', () => { + it('should render pipeline information', () => { + expect(findPipelineInfo().exists()).toBe(true); + }); + + it('should display the pipeline id', () => { + expect(findPipelineInfo().text()).toContain(`#${pipeline.id}`); + }); + }); + + describe('stages cell', () => { + it('should render pipeline mini graph', () => { + expect(findLegacyPipelineMiniGraph().exists()).toBe(true); + }); + + it('should render the right number of stages', () => { + const stagesLength = pipeline.details.stages.length; + expect(findLegacyPipelineMiniGraph().props('stages').length).toBe(stagesLength); + }); + + it('should render the latest downstream pipelines only', () => { + // component receives two downstream pipelines. one of them is already outdated + // because we retried the trigger job, so the mini pipeline graph will only + // render the newly created downstream pipeline instead + expect(pipeline.triggered).toHaveLength(2); + expect(findLegacyPipelineMiniGraph().props('downstreamPipelines')).toHaveLength(1); + }); + + describe('when pipeline does not have stages', () => { + beforeEach(() => { + pipeline = createMockPipeline(); + pipeline.details.stages = []; + + createComponent({ pipelines: [pipeline] }); + }); + + it('stages are not rendered', () => { + expect(findLegacyPipelineMiniGraph().props('stages')).toHaveLength(0); + }); + }); + }); + + describe('duration cell', () => { + it('should render duration information', () => { + expect(findTimeAgo().exists()).toBe(true); + }); + }); + + describe('operations cell', () => { + it('should render pipeline operations', () => { + expect(findActions().exists()).toBe(true); + }); + + it('should render retry action tooltip', () => { + expect(findRetryBtn().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); + }); + + it('should render cancel action tooltip', () => { + expect(findCancelBtn().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); + }); + }); + + describe('triggerer cell', () => { + it('should render the pipeline triggerer', () => { + expect(findTriggerer().exists()).toBe(true); + }); + }); + + describe('failed jobs details', () => { + describe('row', () => { + describe('when the FF is disabled', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline] }); + }); + + it('does not render', () => { + expect(findTableRows()).toHaveLength(1); + expect(findPipelineFailureWidget().exists()).toBe(false); + }); + }); + + describe('when the FF is enabled', () => { + describe('and `withFailedJobsDetails` value is provided', () => { + beforeEach(() => { + createComponent({ pipelines: [pipeline] }, provideWithDetails); + }); + + it('renders', () => { + expect(findTableRows()).toHaveLength(2); + expect(findPipelineFailureWidget().exists()).toBe(true); + }); + + it('passes the expected props', () => { + expect(findPipelineFailureWidget().props()).toStrictEqual({ + failedJobsCount: pipeline.failed_builds.length, + isPipelineActive: pipeline.active, + pipelineIid: pipeline.iid, + pipelinePath: pipeline.path, + // Make sure the forward slash was removed + projectPath: 'frontend-fixtures/pipelines-project', + }); + }); + }); + + describe('and `withFailedJobsDetails` value is not provided', () => { + beforeEach(() => { + createComponent( + { pipelines: [pipeline] }, + { glFeatures: { ciJobFailuresInMr: true } }, + ); + }); + + it('does not render', () => { + expect(findTableRows()).toHaveLength(1); + expect(findPipelineFailureWidget().exists()).toBe(false); + }); + }); + }); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks status badge click', () => { + findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks retry pipeline button click', () => { + findRetryBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry_button', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks cancel pipeline button click', () => { + findCancelBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_cancel_button', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks pipeline mini graph stage click', () => { + findLegacyPipelineMiniGraph().vm.$emit('miniGraphStageClick'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_minigraph', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/common/private/job_links_layer_spec.js b/spec/frontend/ci/common/private/job_links_layer_spec.js new file mode 100644 index 00000000000..c2defc8d770 --- /dev/null +++ b/spec/frontend/ci/common/private/job_links_layer_spec.js @@ -0,0 +1,85 @@ +import { shallowMount } from '@vue/test-utils'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; + +import { generateResponse } from 'jest/ci/pipeline_details/graph/mock_data'; + +describe('links layer component', () => { + let wrapper; + + const findLinksInner = () => wrapper.findComponent(LinksInner); + + const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); + const containerId = `pipeline-links-container-${pipeline.id}`; + const slotContent = "
    Ceci n'est pas un graphique
    "; + + const defaultProps = { + containerId, + containerMeasurements: { width: 400, height: 400 }, + pipelineId: pipeline.id, + pipelineData: pipeline.stages, + showLinks: false, + }; + + const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(LinksLayer, { + propsData: { + ...defaultProps, + ...props, + }, + slots: { + default: slotContent, + }, + stubs: { + 'links-inner': true, + }, + }); + }; + + describe('with show links off', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('does not render inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); + + describe('with show links on', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + }, + }); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('renders the inner links component', () => { + expect(findLinksInner().exists()).toBe(true); + }); + }); + + describe('with width or height measurement at 0', () => { + beforeEach(() => { + createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } }); + }); + + it('renders the default slot', () => { + expect(wrapper.html()).toContain(slotContent); + }); + + it('does not render the inner links component', () => { + expect(findLinksInner().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/common/private/jobs_filtered_search/jobs_filtered_search_spec.js b/spec/frontend/ci/common/private/jobs_filtered_search/jobs_filtered_search_spec.js new file mode 100644 index 00000000000..079738557a4 --- /dev/null +++ b/spec/frontend/ci/common/private/jobs_filtered_search/jobs_filtered_search_spec.js @@ -0,0 +1,123 @@ +import { GlFilteredSearch } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { + OPERATORS_IS, + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, + TOKEN_TYPE_JOBS_RUNNER_TYPE, + TOKEN_TITLE_JOBS_RUNNER_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue'; +import { mockFailedSearchToken } from 'jest/ci/jobs_mock_data'; + +describe('Jobs filtered search', () => { + let wrapper; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const getSearchToken = (type) => + findFilteredSearch() + .props('availableTokens') + .find((token) => token.type === type); + + const findStatusToken = () => getSearchToken('status'); + const findRunnerTypeToken = () => getSearchToken('jobs-runner-type'); + + const createComponent = (props, provideOptions = {}) => { + wrapper = shallowMount(JobsFilteredSearch, { + propsData: { + ...props, + }, + provide: { + glFeatures: { adminJobsFilterRunnerType: true }, + ...provideOptions, + }, + }); + }; + + it('displays filtered search', () => { + createComponent(); + + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('displays status token', () => { + createComponent(); + + expect(findStatusToken()).toMatchObject({ + type: TOKEN_TYPE_STATUS, + icon: 'status', + title: TOKEN_TITLE_STATUS, + unique: true, + operators: OPERATORS_IS, + }); + }); + + it('displays token for runner type', () => { + createComponent(); + + expect(findRunnerTypeToken()).toMatchObject({ + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + title: TOKEN_TITLE_JOBS_RUNNER_TYPE, + operators: OPERATORS_IS, + }); + }); + + it('emits filter token to parent component', () => { + createComponent(); + + findFilteredSearch().vm.$emit('submit', mockFailedSearchToken); + + expect(wrapper.emitted('filterJobsBySearch')).toEqual([[mockFailedSearchToken]]); + }); + + it('filtered search value is empty array when no query string is passed', () => { + createComponent(); + + expect(findFilteredSearch().props('value')).toEqual([]); + }); + + describe('with query string passed', () => { + it('filtered search returns correct data shape', () => { + const tokenStatusesValue = 'SUCCESS'; + const tokenRunnerTypesValue = 'INSTANCE_VALUE'; + + createComponent({ + queryString: { statuses: tokenStatusesValue, runnerTypes: tokenRunnerTypesValue }, + }); + + expect(findFilteredSearch().props('value')).toEqual([ + { type: TOKEN_TYPE_STATUS, value: { data: tokenStatusesValue, operator: '=' } }, + { + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + value: { data: tokenRunnerTypesValue, operator: '=' }, + }, + ]); + }); + }); + + describe('when feature flag `adminJobsFilterRunnerType` is disabled', () => { + const provideOptions = { glFeatures: { adminJobsFilterRunnerType: false } }; + + it('does not display token for runner type', () => { + createComponent(null, provideOptions); + + expect(findRunnerTypeToken()).toBeUndefined(); + }); + + describe('with query string passed', () => { + it('filtered search returns only data shape for search token `status` and not for search token `jobs runner type`', () => { + const tokenStatusesValue = 'SUCCESS'; + const tokenRunnerTypesValue = 'INSTANCE_VALUE'; + + createComponent( + { queryString: { statuses: tokenStatusesValue, runnerTypes: tokenRunnerTypesValue } }, + provideOptions, + ); + + expect(findFilteredSearch().props('value')).toEqual([ + { type: TOKEN_TYPE_STATUS, value: { data: tokenStatusesValue, operator: '=' } }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/ci/common/private/jobs_filtered_search/tokens/job_status_token_spec.js b/spec/frontend/ci/common/private/jobs_filtered_search/tokens/job_status_token_spec.js new file mode 100644 index 00000000000..78a1963d939 --- /dev/null +++ b/spec/frontend/ci/common/private/jobs_filtered_search/tokens/job_status_token_spec.js @@ -0,0 +1,58 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import JobStatusToken from '~/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue'; +import { + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +describe('Job Status Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findAllGlIcons = () => wrapper.findAllComponents(GlIcon); + + const defaultProps = { + config: { + type: TOKEN_TYPE_STATUS, + icon: 'status', + title: TOKEN_TITLE_STATUS, + unique: true, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = () => { + wrapper = shallowMount(JobStatusToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `
    `, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('renders all job statuses available', () => { + const expectedLength = 11; + + expect(findAllFilteredSearchSuggestions()).toHaveLength(expectedLength); + expect(findAllGlIcons()).toHaveLength(expectedLength); + }); +}); diff --git a/spec/frontend/ci/common/private/jobs_filtered_search/utils_spec.js b/spec/frontend/ci/common/private/jobs_filtered_search/utils_spec.js new file mode 100644 index 00000000000..8f6d2368bf4 --- /dev/null +++ b/spec/frontend/ci/common/private/jobs_filtered_search/utils_spec.js @@ -0,0 +1,22 @@ +import { validateQueryString } from '~/ci/common/private/jobs_filtered_search/utils'; + +describe('Filtered search utils', () => { + describe('validateQueryString', () => { + it.each` + queryStringObject | expected + ${{ statuses: 'SUCCESS' }} | ${{ statuses: 'SUCCESS' }} + ${{ statuses: 'failed' }} | ${{ statuses: 'FAILED' }} + ${{ runnerTypes: 'instance_type' }} | ${{ runnerTypes: 'INSTANCE_TYPE' }} + ${{ runnerTypes: 'wrong_runner_type' }} | ${null} + ${{ statuses: 'SUCCESS', runnerTypes: 'instance_type' }} | ${{ statuses: 'SUCCESS', runnerTypes: 'INSTANCE_TYPE' }} + ${{ wrong: 'SUCCESS' }} | ${null} + ${{ statuses: 'wrong' }} | ${null} + ${{ wrong: 'wrong' }} | ${null} + `( + 'when provided $queryStringObject, the expected result is $expected', + ({ queryStringObject, expected }) => { + expect(validateQueryString(queryStringObject)).toEqual(expected); + }, + ); + }); +}); diff --git a/spec/frontend/ci/job_details/components/empty_state_spec.js b/spec/frontend/ci/job_details/components/empty_state_spec.js new file mode 100644 index 00000000000..992ed88e81b --- /dev/null +++ b/spec/frontend/ci/job_details/components/empty_state_spec.js @@ -0,0 +1,140 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import EmptyState from '~/ci/job_details/components/empty_state.vue'; +import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue'; +import { mockFullPath, mockId } from '../mock_data'; + +describe('Empty State', () => { + let wrapper; + + const defaultProps = { + illustrationPath: 'illustrations/pending_job_empty.svg', + illustrationSizeClass: 'svg-430', + jobId: mockId, + title: 'This job has not started yet', + playable: false, + isRetryable: true, + }; + + const createWrapper = (props) => { + wrapper = shallowMountExtended(EmptyState, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + projectPath: mockFullPath, + }, + }); + }; + + const content = 'This job is in pending state and is waiting to be picked by a runner'; + + const findEmptyStateImage = () => wrapper.find('img'); + const findTitle = () => wrapper.findByTestId('job-empty-state-title'); + const findContent = () => wrapper.findByTestId('job-empty-state-content'); + const findAction = () => wrapper.findByTestId('job-empty-state-action'); + const findManualVarsForm = () => wrapper.findComponent(ManualVariablesForm); + + describe('renders image and title', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders empty state image', () => { + expect(findEmptyStateImage().exists()).toBe(true); + }); + + it('renders provided title', () => { + expect(findTitle().text().trim()).toBe(defaultProps.title); + }); + }); + + describe('with content', () => { + beforeEach(() => { + createWrapper({ content }); + }); + + it('renders content', () => { + expect(findContent().text().trim()).toBe(content); + }); + }); + + describe('without content', () => { + beforeEach(() => { + createWrapper(); + }); + + it('does not render content', () => { + expect(findContent().exists()).toBe(false); + }); + }); + + describe('with action', () => { + beforeEach(() => { + createWrapper({ + action: { + path: 'runner', + button_title: 'Check runner', + method: 'post', + }, + }); + }); + + it('renders action', () => { + expect(findAction().attributes('href')).toBe('runner'); + }); + }); + + describe('without action', () => { + beforeEach(() => { + createWrapper({ + action: null, + }); + }); + + it('does not render action', () => { + expect(findAction().exists()).toBe(false); + }); + + it('does not render manual variables form', () => { + expect(findManualVarsForm().exists()).toBe(false); + }); + }); + + describe('with playable action and not scheduled job', () => { + beforeEach(() => { + createWrapper({ + content, + playable: true, + scheduled: false, + action: { + path: 'runner', + button_title: 'Check runner', + method: 'post', + }, + }); + }); + + it('renders manual variables form', () => { + expect(findManualVarsForm().exists()).toBe(true); + }); + + it('does not render the empty state action', () => { + expect(findAction().exists()).toBe(false); + }); + }); + + describe('with playable action and scheduled job', () => { + beforeEach(() => { + createWrapper({ + playable: true, + scheduled: true, + content, + }); + }); + + it('does not render manual variables form', () => { + expect(findManualVarsForm().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/environments_block_spec.js b/spec/frontend/ci/job_details/components/environments_block_spec.js new file mode 100644 index 00000000000..56ae6b44e9a --- /dev/null +++ b/spec/frontend/ci/job_details/components/environments_block_spec.js @@ -0,0 +1,260 @@ +import { mount } from '@vue/test-utils'; +import EnvironmentsBlock from '~/ci/job_details/components/environments_block.vue'; + +const TEST_CLUSTER_NAME = 'test_cluster'; +const TEST_CLUSTER_PATH = 'path/to/test_cluster'; +const TEST_KUBERNETES_NAMESPACE = 'this-is-a-kubernetes-namespace'; + +describe('Environments block', () => { + let wrapper; + + const status = { + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }; + + const environment = { + environment_path: '/environment', + name: 'environment', + }; + + const lastDeployment = { iid: 'deployment', deployable: { build_path: 'bar' } }; + + const createEnvironmentWithLastDeployment = () => ({ + ...environment, + last_deployment: { ...lastDeployment }, + }); + + const createDeploymentWithCluster = () => ({ name: TEST_CLUSTER_NAME, path: TEST_CLUSTER_PATH }); + + const createDeploymentWithClusterAndKubernetesNamespace = () => ({ + name: TEST_CLUSTER_NAME, + path: TEST_CLUSTER_PATH, + kubernetes_namespace: TEST_KUBERNETES_NAMESPACE, + }); + + const createComponent = (deploymentStatus = {}, deploymentCluster = {}) => { + wrapper = mount(EnvironmentsBlock, { + propsData: { + deploymentStatus, + deploymentCluster, + iconStatus: status, + }, + }); + }; + + const findText = () => wrapper.findComponent(EnvironmentsBlock).text(); + const findJobDeploymentLink = () => wrapper.find('[data-testid="job-deployment-link"]'); + const findEnvironmentLink = () => wrapper.find('[data-testid="job-environment-link"]'); + const findClusterLink = () => wrapper.find('[data-testid="job-cluster-link"]'); + + describe('with last deployment', () => { + it('renders info for most recent deployment', () => { + createComponent({ + status: 'last', + environment, + }); + + expect(findText()).toBe('This job is deployed to environment.'); + }); + + describe('when there is a cluster', () => { + it('renders info with cluster', () => { + createComponent( + { + status: 'last', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithCluster(), + ); + + expect(findText()).toBe( + `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME}.`, + ); + }); + + describe('when there is a kubernetes namespace', () => { + it('renders info with cluster', () => { + createComponent( + { + status: 'last', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithClusterAndKubernetesNamespace(), + ); + + expect(findText()).toBe( + `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME} and namespace ${TEST_KUBERNETES_NAMESPACE}.`, + ); + }); + }); + }); + }); + + describe('with out of date deployment', () => { + describe('with last deployment', () => { + it('renders info for out date and most recent', () => { + createComponent({ + status: 'out_of_date', + environment: createEnvironmentWithLastDeployment(), + }); + + expect(findText()).toBe( + 'This job is an out-of-date deployment to environment. View the most recent deployment.', + ); + + expect(findJobDeploymentLink().attributes('href')).toBe('bar'); + }); + + describe('when there is a cluster', () => { + it('renders info with cluster', () => { + createComponent( + { + status: 'out_of_date', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithCluster(), + ); + + expect(findText()).toBe( + `This job is an out-of-date deployment to environment using cluster ${TEST_CLUSTER_NAME}. View the most recent deployment.`, + ); + }); + + describe('when there is a kubernetes namespace', () => { + it('renders info with cluster', () => { + createComponent( + { + status: 'out_of_date', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithClusterAndKubernetesNamespace(), + ); + + expect(findText()).toBe( + `This job is an out-of-date deployment to environment using cluster ${TEST_CLUSTER_NAME} and namespace ${TEST_KUBERNETES_NAMESPACE}. View the most recent deployment.`, + ); + }); + }); + }); + }); + + describe('without last deployment', () => { + it('renders info about out of date deployment', () => { + createComponent({ + status: 'out_of_date', + environment, + }); + + expect(findText()).toBe('This job is an out-of-date deployment to environment.'); + }); + }); + }); + + describe('with failed deployment', () => { + it('renders info about failed deployment', () => { + createComponent({ + status: 'failed', + environment, + }); + + expect(findText()).toBe('The deployment of this job to environment did not succeed.'); + }); + }); + + describe('creating deployment', () => { + describe('with last deployment', () => { + it('renders info about creating deployment and overriding latest deployment', () => { + createComponent({ + status: 'creating', + environment: createEnvironmentWithLastDeployment(), + }); + + expect(findText()).toBe( + 'This job is creating a deployment to environment. This will overwrite the latest deployment.', + ); + + expect(findEnvironmentLink().attributes('href')).toBe(environment.environment_path); + + expect(findJobDeploymentLink().attributes('href')).toBe('bar'); + + expect(findClusterLink().exists()).toBe(false); + }); + }); + + describe('without last deployment', () => { + it('renders info about deployment being created', () => { + createComponent({ + status: 'creating', + environment, + }); + + expect(findText()).toBe('This job is creating a deployment to environment.'); + }); + + describe('when there is a cluster', () => { + it('inclues information about the cluster', () => { + createComponent( + { + status: 'creating', + environment, + }, + createDeploymentWithCluster(), + ); + + expect(findText()).toBe( + `This job is creating a deployment to environment using cluster ${TEST_CLUSTER_NAME}.`, + ); + }); + }); + }); + + describe('without environment', () => { + it('does not render environment link', () => { + createComponent({ + status: 'creating', + environment: null, + }); + + expect(findEnvironmentLink().exists()).toBe(false); + }); + }); + }); + + describe('with a cluster', () => { + it('renders the cluster link', () => { + createComponent( + { + status: 'last', + environment: createEnvironmentWithLastDeployment(), + }, + createDeploymentWithCluster(), + ); + + expect(findText()).toBe( + `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME}.`, + ); + + expect(findClusterLink().attributes('href')).toBe(TEST_CLUSTER_PATH); + }); + + describe('when the cluster is missing the path', () => { + it('renders the name without a link', () => { + createComponent( + { + status: 'last', + environment: createEnvironmentWithLastDeployment(), + }, + { name: 'the-cluster' }, + ); + + expect(findText()).toContain('using cluster the-cluster.'); + + expect(findClusterLink().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/erased_block_spec.js b/spec/frontend/ci/job_details/components/erased_block_spec.js new file mode 100644 index 00000000000..7eb856f97f1 --- /dev/null +++ b/spec/frontend/ci/job_details/components/erased_block_spec.js @@ -0,0 +1,59 @@ +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import ErasedBlock from '~/ci/job_details/components/erased_block.vue'; +import { getTimeago } from '~/lib/utils/datetime_utility'; + +describe('Erased block', () => { + let wrapper; + + const erasedAt = '2016-11-07T11:11:16.525Z'; + const timeago = getTimeago(); + const formattedDate = timeago.format(erasedAt); + + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = (props) => { + wrapper = mount(ErasedBlock, { + propsData: props, + }); + }; + + describe('with job erased by user', () => { + beforeEach(() => { + createComponent({ + user: { + username: 'root', + web_url: 'gitlab.com/root', + }, + erasedAt, + }); + }); + + it('renders username and link', () => { + expect(findLink().attributes('href')).toEqual('gitlab.com/root'); + + expect(wrapper.text().trim()).toContain('Job has been erased by'); + expect(wrapper.text().trim()).toContain('root'); + }); + + it('renders erasedAt', () => { + expect(wrapper.text().trim()).toContain(formattedDate); + }); + }); + + describe('with erased job', () => { + beforeEach(() => { + createComponent({ + erasedAt, + }); + }); + + it('renders username and link', () => { + expect(wrapper.text().trim()).toContain('Job has been erased'); + }); + + it('renders erasedAt', () => { + expect(wrapper.text().trim()).toContain(formattedDate); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/job_header_spec.js b/spec/frontend/ci/job_details/components/job_header_spec.js new file mode 100644 index 00000000000..6fc55732353 --- /dev/null +++ b/spec/frontend/ci/job_details/components/job_header_spec.js @@ -0,0 +1,154 @@ +import { GlButton, GlAvatarLink, GlTooltip } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import JobHeader from '~/ci/job_details/components/job_header.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +describe('Header CI Component', () => { + let wrapper; + + const defaultProps = { + status: { + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + name: 'Job build_job', + time: '2017-05-08T14:57:39.781Z', + user: { + id: 1234, + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + shouldRenderTriggeredLabel: true, + }; + + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); + const findTimeAgo = () => wrapper.findComponent(TimeagoTooltip); + const findUserLink = () => wrapper.findComponent(GlAvatarLink); + const findSidebarToggleBtn = () => wrapper.findComponent(GlButton); + const findStatusTooltip = () => wrapper.findComponent(GlTooltip); + const findActionButtons = () => wrapper.findByTestId('job-header-action-buttons'); + const findJobName = () => wrapper.findByTestId('job-name'); + + const createComponent = (props, slots) => { + wrapper = extendedWrapper( + shallowMount(JobHeader, { + propsData: { + ...defaultProps, + ...props, + }, + ...slots, + }), + ); + }; + + describe('render', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render status badge', () => { + expect(findCiBadgeLink().exists()).toBe(true); + }); + + it('should render timeago date', () => { + expect(findTimeAgo().exists()).toBe(true); + }); + + it('should render sidebar toggle button', () => { + expect(findSidebarToggleBtn().exists()).toBe(true); + }); + + it('should not render header action buttons when slot is empty', () => { + expect(findActionButtons().exists()).toBe(false); + }); + }); + + describe('user avatar', () => { + beforeEach(() => { + createComponent(); + }); + + it('contains the username', () => { + expect(findUserLink().text()).toContain(defaultProps.user.username); + }); + + it('has the correct HTML attributes', () => { + expect(findUserLink().attributes()).toMatchObject({ + 'data-user-id': defaultProps.user.id.toString(), + 'data-username': defaultProps.user.username, + 'data-name': defaultProps.user.name, + href: defaultProps.user.web_url, + }); + }); + + describe('when the user has a status', () => { + const STATUS_MESSAGE = 'Working on exciting features...'; + + beforeEach(() => { + createComponent({ + user: { ...defaultProps.user, status: { message: STATUS_MESSAGE } }, + }); + }); + + it('renders a tooltip', () => { + expect(findStatusTooltip().text()).toBe(STATUS_MESSAGE); + }); + }); + + describe('with data from GraphQL', () => { + const userId = 1; + + beforeEach(() => { + createComponent({ + user: { ...defaultProps.user, id: `gid://gitlab/User/${1}` }, + }); + }); + + it('has the correct user id', () => { + expect(findUserLink().attributes('data-user-id')).toBe(userId.toString()); + }); + }); + + describe('with data from REST', () => { + it('has the correct user id', () => { + expect(findUserLink().attributes('data-user-id')).toBe(defaultProps.user.id.toString()); + }); + }); + }); + + describe('job name', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render the job name', () => { + expect(findJobName().text()).toBe('Job build_job'); + }); + }); + + describe('slot', () => { + it('should render header action buttons', () => { + createComponent({}, { slots: { default: 'Test Actions' } }); + + expect(findActionButtons().exists()).toBe(true); + expect(findActionButtons().text()).toBe('Test Actions'); + }); + }); + + describe('shouldRenderTriggeredLabel', () => { + it('should render created keyword when the shouldRenderTriggeredLabel is false', () => { + createComponent({ shouldRenderTriggeredLabel: false }); + + expect(wrapper.text()).toContain('created'); + expect(wrapper.text()).not.toContain('started'); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/job_log_controllers_spec.js b/spec/frontend/ci/job_details/components/job_log_controllers_spec.js new file mode 100644 index 00000000000..84c664aca34 --- /dev/null +++ b/spec/frontend/ci/job_details/components/job_log_controllers_spec.js @@ -0,0 +1,321 @@ +import { GlSearchBoxByClick } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import JobLogControllers from '~/ci/job_details/components/job_log_controllers.vue'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import { backoffMockImplementation } from 'helpers/backoff_helper'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { mockJobLog } from 'jest/ci/jobs_mock_data'; + +const mockToastShow = jest.fn(); + +describe('Job log controllers', () => { + let wrapper; + + beforeEach(() => { + jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); + }); + + afterEach(() => { + commonUtils.backOff.mockReset(); + }); + + const defaultProps = { + rawPath: '/raw', + size: 511952, + isScrollTopDisabled: false, + isScrollBottomDisabled: false, + isScrollingDown: true, + isJobLogSizeVisible: true, + isComplete: true, + jobLog: mockJobLog, + }; + + const createWrapper = (props, { jobLogJumpToFailures = false } = {}) => { + wrapper = mount(JobLogControllers, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + glFeatures: { + jobLogJumpToFailures, + }, + }, + data() { + return { + searchTerm: '82', + }; + }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, + }); + }; + + const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]'); + const findRawLink = () => wrapper.find('[data-testid="raw-link"]'); + 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); + const findScrollFailure = () => wrapper.find('[data-testid="job-controller-scroll-to-failure"]'); + + describe('Truncate information', () => { + describe('with isJobLogSizeVisible', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders size information', () => { + expect(findTruncatedInfo().text()).toMatch('499.95 KiB'); + }); + + it('renders link to raw job log', () => { + expect(findRawLink().attributes('href')).toBe(defaultProps.rawPath); + }); + }); + }); + + describe('links section', () => { + describe('with raw job log path', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders raw job log link', () => { + expect(findRawLinkController().attributes('href')).toBe(defaultProps.rawPath); + }); + }); + + describe('without raw job log path', () => { + beforeEach(() => { + createWrapper({ + rawPath: null, + }); + }); + + it('does not render raw job log link', () => { + expect(findRawLinkController().exists()).toBe(false); + }); + }); + }); + + describe('scroll buttons', () => { + describe('scroll top button', () => { + describe('when user can scroll top', () => { + beforeEach(() => { + createWrapper({ + isScrollTopDisabled: false, + }); + }); + + it('emits scrollJobLogTop event on click', async () => { + await findScrollTop().trigger('click'); + + expect(wrapper.emitted().scrollJobLogTop).toHaveLength(1); + }); + }); + + describe('when user can not scroll top', () => { + beforeEach(() => { + createWrapper({ + isScrollTopDisabled: true, + isScrollBottomDisabled: false, + isScrollingDown: false, + }); + }); + + it('renders disabled scroll top button', () => { + expect(findScrollTop().attributes('disabled')).toBeDefined(); + }); + + it('does not emit scrollJobLogTop event on click', async () => { + await findScrollTop().trigger('click'); + + expect(wrapper.emitted().scrollJobLogTop).toBeUndefined(); + }); + }); + }); + + describe('scroll bottom button', () => { + describe('when user can scroll bottom', () => { + beforeEach(() => { + createWrapper(); + }); + + it('emits scrollJobLogBottom event on click', async () => { + await findScrollBottom().trigger('click'); + + expect(wrapper.emitted().scrollJobLogBottom).toHaveLength(1); + }); + }); + + describe('when user can not scroll bottom', () => { + beforeEach(() => { + createWrapper({ + isScrollTopDisabled: false, + isScrollBottomDisabled: true, + isScrollingDown: false, + }); + }); + + it('renders disabled scroll bottom button', () => { + expect(findScrollBottom().attributes('disabled')).toEqual('disabled'); + }); + + it('does not emit scrollJobLogBottom event on click', async () => { + await findScrollBottom().trigger('click'); + + expect(wrapper.emitted().scrollJobLogBottom).toBeUndefined(); + }); + }); + + describe('while isScrollingDown is true', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders animate class for the scroll down button', () => { + expect(findScrollBottom().classes()).toContain('animate'); + }); + }); + + describe('while isScrollingDown is false', () => { + beforeEach(() => { + createWrapper({ + isScrollTopDisabled: true, + isScrollBottomDisabled: false, + isScrollingDown: false, + }); + }); + + it('does not render animate class for the scroll down button', () => { + expect(findScrollBottom().classes()).not.toContain('animate'); + }); + }); + }); + + describe('scroll to failure button', () => { + describe('with feature flag disabled', () => { + it('does not display button', () => { + createWrapper(); + + expect(findScrollFailure().exists()).toBe(false); + }); + }); + + describe('with red text failures on the page', () => { + let firstFailure; + let secondFailure; + + beforeEach(() => { + jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce(['mock-element']); + + createWrapper({}, { jobLogJumpToFailures: true }); + + firstFailure = document.createElement('div'); + firstFailure.className = 'term-fg-l-red'; + document.body.appendChild(firstFailure); + + secondFailure = document.createElement('div'); + secondFailure.className = 'term-fg-l-red'; + document.body.appendChild(secondFailure); + }); + + afterEach(() => { + if (firstFailure) { + firstFailure.remove(); + firstFailure = null; + } + + if (secondFailure) { + secondFailure.remove(); + secondFailure = null; + } + }); + + it('is enabled', () => { + expect(findScrollFailure().props('disabled')).toBe(false); + }); + + it('scrolls to each failure', async () => { + jest.spyOn(firstFailure, 'scrollIntoView'); + + await findScrollFailure().trigger('click'); + + expect(firstFailure.scrollIntoView).toHaveBeenCalled(); + + await findScrollFailure().trigger('click'); + + expect(secondFailure.scrollIntoView).toHaveBeenCalled(); + + await findScrollFailure().trigger('click'); + + expect(firstFailure.scrollIntoView).toHaveBeenCalled(); + }); + }); + + describe('with no red text failures on the page', () => { + beforeEach(() => { + jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce([]); + + createWrapper({}, { jobLogJumpToFailures: true }); + }); + + it('is disabled', () => { + expect(findScrollFailure().props('disabled')).toBe(true); + }); + }); + + describe('when the job log is not complete', () => { + beforeEach(() => { + jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce(['mock-element']); + + createWrapper({ isComplete: false }, { jobLogJumpToFailures: true }); + }); + + it('is enabled', () => { + expect(findScrollFailure().props('disabled')).toBe(false); + }); + }); + + describe('on error', () => { + beforeEach(() => { + jest.spyOn(commonUtils, 'backOff').mockRejectedValueOnce(); + + createWrapper({}, { jobLogJumpToFailures: true }); + }); + + it('stays disabled', () => { + expect(findScrollFailure().props('disabled')).toBe(true); + }); + }); + }); + }); + + describe('Job log search', () => { + beforeEach(() => { + createWrapper(); + }); + + it('displays job log search', () => { + expect(findJobLogSearch().exists()).toBe(true); + expect(findSearchHelp().exists()).toBe(true); + }); + + it('emits search results', () => { + findJobLogSearch().vm.$emit('submit'); + + expect(wrapper.emitted('searchResults')).toHaveLength(1); + }); + + it('clears search results', () => { + findJobLogSearch().vm.$emit('clear'); + + expect(wrapper.emitted('searchResults')).toEqual([[[]]]); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js b/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js new file mode 100644 index 00000000000..e3d5c448338 --- /dev/null +++ b/spec/frontend/ci/job_details/components/log/collapsible_section_spec.js @@ -0,0 +1,95 @@ +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import CollapsibleSection from '~/ci/job_details/components/log/collapsible_section.vue'; +import LogLineHeader from '~/ci/job_details/components/log/line_header.vue'; +import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data'; + +describe('Job Log Collapsible Section', () => { + let wrapper; + + const jobLogEndpoint = 'jobs/335'; + + const findCollapsibleLine = () => wrapper.find('.collapsible-line'); + const findCollapsibleLineSvg = () => wrapper.find('.collapsible-line svg'); + const findLogLineHeader = () => wrapper.findComponent(LogLineHeader); + + const createComponent = (props = {}) => { + wrapper = mount(CollapsibleSection, { + propsData: { + ...props, + }, + }); + }; + + describe('with closed section', () => { + beforeEach(() => { + createComponent({ + section: collapsibleSectionClosed, + jobLogEndpoint, + }); + }); + + it('renders clickable header line', () => { + expect(findCollapsibleLine().attributes('role')).toBe('button'); + }); + + it('renders an icon with the closed state', () => { + expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-right-icon'); + }); + }); + + describe('with opened section', () => { + beforeEach(() => { + createComponent({ + section: collapsibleSectionOpened, + jobLogEndpoint, + }); + }); + + it('renders clickable header line', () => { + expect(findCollapsibleLine().attributes('role')).toBe('button'); + }); + + it('renders an icon with the open state', () => { + expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-down-icon'); + }); + + it('renders collapsible lines content', () => { + expect(wrapper.findAll('.js-line').length).toEqual(collapsibleSectionOpened.lines.length); + }); + }); + + it('emits onClickCollapsibleLine on click', async () => { + createComponent({ + section: collapsibleSectionOpened, + jobLogEndpoint, + }); + + findCollapsibleLine().trigger('click'); + + await nextTick(); + expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1); + }); + + describe('with search results', () => { + it('passes isHighlighted prop correctly', () => { + const mockSearchResults = [ + { + content: [{ text: 'foo' }], + lineNumber: 1, + offset: 5, + section: 'prepare-script', + section_header: true, + }, + ]; + + createComponent({ + section: collapsibleSectionOpened, + jobLogEndpoint, + searchResults: mockSearchResults, + }); + + expect(findLogLineHeader().props('isHighlighted')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/log/duration_badge_spec.js b/spec/frontend/ci/job_details/components/log/duration_badge_spec.js new file mode 100644 index 00000000000..0d5f60cefd1 --- /dev/null +++ b/spec/frontend/ci/job_details/components/log/duration_badge_spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import DurationBadge from '~/ci/job_details/components/log/duration_badge.vue'; + +describe('Job Log Duration Badge', () => { + let wrapper; + + const data = { + duration: '00:30:01', + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(DurationBadge, { + propsData: { + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(data); + }); + + it('renders provided duration', () => { + expect(wrapper.text()).toBe(data.duration); + }); +}); diff --git a/spec/frontend/ci/job_details/components/log/line_header_spec.js b/spec/frontend/ci/job_details/components/log/line_header_spec.js new file mode 100644 index 00000000000..7d1b05346f2 --- /dev/null +++ b/spec/frontend/ci/job_details/components/log/line_header_spec.js @@ -0,0 +1,133 @@ +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import DurationBadge from '~/ci/job_details/components/log/duration_badge.vue'; +import LineHeader from '~/ci/job_details/components/log/line_header.vue'; +import LineNumber from '~/ci/job_details/components/log/line_number.vue'; + +describe('Job Log Header Line', () => { + let wrapper; + + const defaultProps = { + line: { + content: [ + { + text: 'Running with gitlab-runner 12.1.0 (de7731dd)', + style: 'term-fg-l-green', + }, + ], + lineNumber: 76, + }, + isClosed: true, + path: '/jashkenas/underscore/-/jobs/335', + }; + + const createComponent = (props = defaultProps) => { + wrapper = mount(LineHeader, { + propsData: { + ...props, + }, + }); + }; + + describe('line', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the line number component', () => { + expect(wrapper.findComponent(LineNumber).exists()).toBe(true); + }); + + it('renders a span the provided text', () => { + expect(wrapper.find('span').text()).toBe(defaultProps.line.content[0].text); + }); + + it('renders the provided style as a class attribute', () => { + expect(wrapper.find('span').classes()).toContain(defaultProps.line.content[0].style); + }); + }); + + describe('when isCloses is true', () => { + beforeEach(() => { + createComponent({ ...defaultProps, isClosed: true }); + }); + + it('sets icon name to be chevron-lg-right', () => { + expect(wrapper.vm.iconName).toEqual('chevron-lg-right'); + }); + }); + + describe('when isCloses is false', () => { + beforeEach(() => { + createComponent({ ...defaultProps, isClosed: false }); + }); + + it('sets icon name to be chevron-lg-down', () => { + expect(wrapper.vm.iconName).toEqual('chevron-lg-down'); + }); + }); + + describe('on click', () => { + beforeEach(() => { + createComponent(); + }); + + it('emits toggleLine event', async () => { + wrapper.trigger('click'); + + await nextTick(); + expect(wrapper.emitted().toggleLine.length).toBe(1); + }); + }); + + describe('with duration', () => { + beforeEach(() => { + createComponent({ ...defaultProps, duration: '00:10' }); + }); + + it('renders the duration badge', () => { + expect(wrapper.findComponent(DurationBadge).exists()).toBe(true); + }); + }); + + describe('line highlighting', () => { + describe('with hash', () => { + beforeEach(() => { + setWindowLocation(`http://foo.com/root/ci-project/-/jobs/6353#L77`); + + createComponent(); + }); + + it('highlights line', () => { + expect(wrapper.classes()).toContain('gl-bg-gray-700'); + }); + }); + + describe('without hash', () => { + beforeEach(() => { + setWindowLocation(`http://foo.com/root/ci-project/-/jobs/6353`); + + createComponent(); + }); + + it('does not highlight line', () => { + expect(wrapper.classes()).not.toContain('gl-bg-gray-700'); + }); + }); + + describe('search results', () => { + it('highlights the job log lines', () => { + createComponent({ ...defaultProps, isHighlighted: true }); + + expect(wrapper.classes()).toContain('gl-bg-gray-700'); + }); + + it('does not highlight the job log lines', () => { + createComponent(); + + expect(wrapper.classes()).not.toContain('gl-bg-gray-700'); + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/log/line_number_spec.js b/spec/frontend/ci/job_details/components/log/line_number_spec.js new file mode 100644 index 00000000000..d5c1d0fd985 --- /dev/null +++ b/spec/frontend/ci/job_details/components/log/line_number_spec.js @@ -0,0 +1,35 @@ +import { shallowMount } from '@vue/test-utils'; +import LineNumber from '~/ci/job_details/components/log/line_number.vue'; + +describe('Job Log Line Number', () => { + let wrapper; + + const data = { + lineNumber: 0, + path: '/jashkenas/underscore/-/jobs/335', + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(LineNumber, { + propsData: { + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(data); + }); + + it('renders incremented lineNunber by 1', () => { + expect(wrapper.text()).toBe('1'); + }); + + it('renders link with lineNumber as an ID', () => { + expect(wrapper.attributes().id).toBe('L1'); + }); + + it('links to the provided path with line number as anchor', () => { + expect(wrapper.attributes().href).toBe(`${data.path}#L1`); + }); +}); diff --git a/spec/frontend/ci/job_details/components/log/line_spec.js b/spec/frontend/ci/job_details/components/log/line_spec.js new file mode 100644 index 00000000000..b6f3a2b68df --- /dev/null +++ b/spec/frontend/ci/job_details/components/log/line_spec.js @@ -0,0 +1,256 @@ +import { shallowMount } from '@vue/test-utils'; +import Line from '~/ci/job_details/components/log/line.vue'; +import LineNumber from '~/ci/job_details/components/log/line_number.vue'; +import setWindowLocation from 'helpers/set_window_location_helper'; + +const httpUrl = 'http://example.com'; +const httpsUrl = 'https://example.com'; +const queryUrl = 'https://example.com?param=val'; + +const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = {}) => ({ + line: { + content: [ + { + text, + style: 'term-fg-l-green', + }, + ], + lineNumber: 0, + }, + path: '/jashkenas/underscore/-/jobs/335', +}); + +describe('Job Log Line', () => { + let wrapper; + let data; + + const createComponent = (props = {}) => { + wrapper = shallowMount(Line, { + propsData: { + ...props, + }, + }); + }; + + const findLine = () => wrapper.find('span'); + const findLink = () => findLine().find('a'); + const findLinks = () => findLine().findAll('a'); + const findLinkAttributeByIndex = (i) => findLinks().at(i).attributes(); + + beforeEach(() => { + data = mockProps(); + createComponent(data); + }); + + it('renders the line number component', () => { + expect(wrapper.findComponent(LineNumber).exists()).toBe(true); + }); + + it('renders a span the provided text', () => { + expect(findLine().text()).toBe(data.line.content[0].text); + }); + + it('renders the provided style as a class attribute', () => { + expect(findLine().classes()).toContain(data.line.content[0].style); + }); + + describe('job urls as links', () => { + it('renders an http link', () => { + createComponent(mockProps({ text: httpUrl })); + + expect(findLink().text()).toBe(httpUrl); + expect(findLink().attributes().href).toBe(httpUrl); + }); + + it('renders an https link', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().text()).toBe(httpsUrl); + expect(findLink().attributes().href).toBe(httpsUrl); + }); + + it('renders a link with rel nofollow and noopener', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().attributes().rel).toBe('nofollow noopener noreferrer'); + }); + + it('renders a link with corresponding styles', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().classes()).toEqual(['gl-reset-color!', 'gl-text-decoration-underline']); + }); + + it('renders links with queries, surrounded by questions marks', () => { + createComponent(mockProps({ text: `Did you see my url ${queryUrl}??` })); + + expect(findLine().text()).toBe('Did you see my url https://example.com?param=val??'); + expect(findLinkAttributeByIndex(0).href).toBe(queryUrl); + }); + + it('renders links with queries, surrounded by exclamation marks', () => { + createComponent(mockProps({ text: `No! The ${queryUrl}!?` })); + + expect(findLine().text()).toBe('No! The https://example.com?param=val!?'); + expect(findLinkAttributeByIndex(0).href).toBe(queryUrl); + }); + + it('renders links that have brackets `[]` in their parameters', () => { + const url = `${httpUrl}?label_name[]=frontend`; + + createComponent(mockProps({ text: url })); + + expect(findLine().text()).toBe(url); + expect(findLinks().at(0).text()).toBe(url); + expect(findLinks().at(0).attributes('href')).toBe(url); + }); + + it('renders multiple links surrounded by text', () => { + createComponent( + mockProps({ text: `Well, my HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` }), + ); + expect(findLine().text()).toBe( + 'Well, my HTTP url: http://example.com and my HTTPS url: https://example.com', + ); + + expect(findLinks()).toHaveLength(2); + + expect(findLinkAttributeByIndex(0).href).toBe(httpUrl); + expect(findLinkAttributeByIndex(1).href).toBe(httpsUrl); + }); + + it('renders multiple links surrounded by text, with other symbols', () => { + createComponent( + mockProps({ text: `${httpUrl}, ${httpUrl}: ${httpsUrl}; ${httpsUrl}. ${httpsUrl}...` }), + ); + expect(findLine().text()).toBe( + 'http://example.com, http://example.com: https://example.com; https://example.com. https://example.com...', + ); + + expect(findLinks()).toHaveLength(5); + + expect(findLinkAttributeByIndex(0).href).toBe(httpUrl); + expect(findLinkAttributeByIndex(1).href).toBe(httpUrl); + expect(findLinkAttributeByIndex(2).href).toBe(httpsUrl); + expect(findLinkAttributeByIndex(3).href).toBe(httpsUrl); + expect(findLinkAttributeByIndex(4).href).toBe(httpsUrl); + }); + + it('renders multiple links surrounded by brackets', () => { + createComponent(mockProps({ text: `(${httpUrl}) <${httpUrl}> {${httpsUrl}}` })); + expect(findLine().text()).toBe( + '(http://example.com) {https://example.com}', + ); + + const links = findLinks(); + + expect(links).toHaveLength(3); + + expect(links.at(0).text()).toBe(httpUrl); + expect(links.at(0).attributes('href')).toBe(httpUrl); + + expect(links.at(1).text()).toBe(httpUrl); + expect(links.at(1).attributes('href')).toBe(httpUrl); + + expect(links.at(2).text()).toBe(httpsUrl); + expect(links.at(2).attributes('href')).toBe(httpsUrl); + }); + + it('renders text with symbols in it', () => { + const text = 'apt-get update < /dev/null > /dev/null'; + createComponent(mockProps({ text })); + + expect(findLine().text()).toBe(text); + }); + + const jshref = 'javascript:doEvil();'; // eslint-disable-line no-script-url + + it.each` + type | text + ${'html link'} | ${'linked'} + ${'html script'} | ${''} + ${'html strong'} | ${'highlighted'} + ${'js'} | ${jshref} + ${'file'} | ${'file:///a-file'} + ${'ftp'} | ${'ftp://example.com/file'} + ${'email'} | ${'email@example.com'} + ${'no scheme'} | ${'example.com/page'} + `('does not render a $type link', ({ text }) => { + createComponent(mockProps({ text })); + expect(findLink().exists()).toBe(false); + }); + }); + + describe('job log search', () => { + 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', + isHighlighted: true, + }); + + expect(wrapper.classes()).toContain('gl-bg-gray-700'); + }); + + 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', + }); + + expect(wrapper.classes()).not.toContain('gl-bg-gray-700'); + }); + }); + + describe('job log hash highlighting', () => { + describe('with hash', () => { + beforeEach(() => { + setWindowLocation(`http://foo.com/root/ci-project/-/jobs/6353#L77`); + }); + + it('applies highlight class to job log line', () => { + createComponent({ + line: { + offset: 24526, + content: [{ text: 'job log content' }], + section: 'custom-section', + lineNumber: 76, + }, + path: '/root/ci-project/-/jobs/6353', + }); + + expect(wrapper.classes()).toContain('gl-bg-gray-700'); + }); + }); + + describe('without hash', () => { + beforeEach(() => { + setWindowLocation(`http://foo.com/root/ci-project/-/jobs/6353`); + }); + + it('does not apply highlight class to job log line', () => { + createComponent({ + line: { + offset: 24500, + content: [{ text: 'line' }], + section: 'custom-section', + lineNumber: 10, + }, + path: '/root/ci-project/-/jobs/6353', + }); + + expect(wrapper.classes()).not.toContain('gl-bg-gray-700'); + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/log/log_spec.js b/spec/frontend/ci/job_details/components/log/log_spec.js new file mode 100644 index 00000000000..cc1621b87d6 --- /dev/null +++ b/spec/frontend/ci/job_details/components/log/log_spec.js @@ -0,0 +1,162 @@ +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import Log from '~/ci/job_details/components/log/log.vue'; +import LogLineHeader from '~/ci/job_details/components/log/line_header.vue'; +import { logLinesParser } from '~/ci/job_details/store/utils'; +import { jobLog } from './mock_data'; + +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + scrollToElement: jest.fn(), +})); + +describe('Job Log', () => { + let wrapper; + let actions; + let state; + let store; + let toggleCollapsibleLineMock; + + Vue.use(Vuex); + + const createComponent = (props) => { + wrapper = mount(Log, { + propsData: { + ...props, + }, + store, + }); + }; + + beforeEach(() => { + toggleCollapsibleLineMock = jest.fn(); + actions = { + toggleCollapsibleLine: toggleCollapsibleLineMock, + }; + + state = { + jobLog: logLinesParser(jobLog), + jobLogEndpoint: 'jobs/id', + }; + + store = new Vuex.Store({ + actions, + state, + }); + }); + + const findCollapsibleLine = () => wrapper.findComponent(LogLineHeader); + const findAllCollapsibleLines = () => wrapper.findAllComponents(LogLineHeader); + + describe('line numbers', () => { + beforeEach(() => { + createComponent(); + }); + + 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', () => { + beforeEach(() => { + createComponent(); + }); + + 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', () => { + findCollapsibleLine().trigger('click'); + + expect(toggleCollapsibleLineMock).toHaveBeenCalled(); + }); + }); + }); + + describe('anchor scrolling', () => { + afterEach(() => { + window.location.hash = ''; + }); + + describe('when hash is not present', () => { + it('does not scroll to line number', async () => { + createComponent(); + + await waitForPromises(); + + expect(wrapper.find('#L6').exists()).toBe(false); + expect(scrollToElement).not.toHaveBeenCalled(); + }); + }); + + describe('when hash is present', () => { + beforeEach(() => { + window.location.hash = '#L6'; + }); + + it('scrolls to line number', async () => { + createComponent(); + + state.jobLog = logLinesParser(jobLog, [], '#L6'); + await waitForPromises(); + + expect(scrollToElement).toHaveBeenCalledTimes(1); + + state.jobLog = logLinesParser(jobLog, [], '#L7'); + await waitForPromises(); + + expect(scrollToElement).toHaveBeenCalledTimes(1); + }); + + it('line number within collapsed section is visible', () => { + state.jobLog = logLinesParser(jobLog, [], '#L6'); + + createComponent(); + + expect(wrapper.find('#L6').exists()).toBe(true); + }); + }); + + describe('with search results', () => { + it('passes isHighlighted prop correctly', () => { + const mockSearchResults = [ + { + offset: 1002, + content: [ + { + text: 'Using Docker executor with image dev.gitlab.org3', + }, + ], + section: 'prepare-executor', + section_header: true, + lineNumber: 2, + }, + ]; + + createComponent({ searchResults: mockSearchResults }); + + expect(findAllCollapsibleLines().at(0).props('isHighlighted')).toBe(true); + expect(findAllCollapsibleLines().at(1).props('isHighlighted')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/log/mock_data.js b/spec/frontend/ci/job_details/components/log/mock_data.js new file mode 100644 index 00000000000..fa51b92a044 --- /dev/null +++ b/spec/frontend/ci/job_details/components/log/mock_data.js @@ -0,0 +1,218 @@ +export const jobLog = [ + { + offset: 1000, + content: [{ text: 'Running with gitlab-runner 12.1.0 (de7731dd)' }], + }, + { + offset: 1001, + content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], + }, + { + offset: 1002, + content: [ + { + text: 'Using Docker executor with image dev.gitlab.org3', + }, + ], + section: 'prepare-executor', + section_header: true, + }, + { + offset: 1003, + content: [{ text: 'Starting service postgres:9.6.14 ...', style: 'text-green' }], + section: 'prepare-executor', + }, + { + offset: 1004, + content: [ + { + text: 'Restore cache', + style: 'term-fg-l-cyan term-bold', + }, + ], + section: 'restore-cache', + section_header: true, + section_options: { + collapsed: 'true', + }, + }, + { + offset: 1005, + content: [ + { + text: 'Checking cache for ruby-gems-debian-bullseye-ruby-3.0-16...', + style: 'term-fg-l-green term-bold', + }, + ], + section: 'restore-cache', + }, +]; + +export const utilsMockData = [ + { + offset: 1001, + content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], + }, + { + offset: 1002, + content: [ + { + text: + 'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34', + }, + ], + section: 'prepare-executor', + section_header: true, + }, + { + offset: 1003, + content: [{ text: 'Starting service postgres:9.6.14 ...' }], + section: 'prepare-executor', + }, + { + offset: 1004, + content: [{ text: 'Pulling docker image postgres:9.6.14 ...', style: 'term-fg-l-green' }], + section: 'prepare-executor', + }, + { + offset: 1005, + content: [], + section: 'prepare-executor', + section_duration: '10:00', + }, +]; + +export const originalTrace = [ + { + offset: 1, + content: [ + { + text: 'Downloading', + }, + ], + }, +]; + +export const regularIncremental = [ + { + offset: 2, + content: [ + { + text: 'log line', + }, + ], + }, +]; + +export const regularIncrementalRepeated = [ + { + offset: 1, + content: [ + { + text: 'log line', + }, + ], + }, +]; + +export const headerTrace = [ + { + offset: 1, + section_header: true, + content: [ + { + text: 'log line', + }, + ], + section: 'section', + }, +]; + +export const headerTraceIncremental = [ + { + offset: 1, + section_header: true, + content: [ + { + text: 'updated log line', + }, + ], + section: 'section', + }, +]; + +export const collapsibleTrace = [ + { + offset: 1, + section_header: true, + content: [ + { + text: 'log line', + }, + ], + section: 'section', + }, + { + offset: 2, + content: [ + { + text: 'log line', + }, + ], + section: 'section', + }, +]; + +export const collapsibleTraceIncremental = [ + { + offset: 2, + content: [ + { + text: 'updated log line', + }, + ], + section: 'section', + }, +]; + +export const collapsibleSectionClosed = { + offset: 5, + section_header: true, + isHeader: true, + isClosed: true, + line: { + content: [{ text: 'foo' }], + section: 'prepare-script', + lineNumber: 1, + }, + section_duration: '00:03', + lines: [ + { + offset: 80, + content: [{ text: 'this is a collapsible nested section' }], + section: 'prepare-script', + lineNumber: 3, + }, + ], +}; + +export const collapsibleSectionOpened = { + offset: 5, + section_header: true, + isHeader: true, + isClosed: false, + line: { + content: [{ text: 'foo' }], + section: 'prepare-script', + lineNumber: 1, + }, + section_duration: '00:03', + lines: [ + { + offset: 80, + content: [{ text: 'this is a collapsible nested section' }], + section: 'prepare-script', + lineNumber: 3, + }, + ], +}; diff --git a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js b/spec/frontend/ci/job_details/components/manual_variables_form_spec.js new file mode 100644 index 00000000000..3391cafb4fc --- /dev/null +++ b/spec/frontend/ci/job_details/components/manual_variables_form_spec.js @@ -0,0 +1,364 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; +import { createAlert } from '~/alert'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { TYPENAME_CI_BUILD } from '~/graphql_shared/constants'; +import { JOB_GRAPHQL_ERRORS } from '~/ci/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue'; +import getJobQuery from '~/ci/job_details/graphql/queries/get_job.query.graphql'; +import playJobMutation from '~/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql'; +import retryJobMutation from '~/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql'; + +import { + mockFullPath, + mockId, + mockJobResponse, + mockJobWithVariablesResponse, + mockJobPlayMutationData, + mockJobRetryMutationData, +} from '../mock_data'; + +const localVue = createLocalVue(); +jest.mock('~/alert'); +localVue.use(VueApollo); + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + redirectTo: jest.fn(), +})); + +const defaultProvide = { + projectPath: mockFullPath, +}; + +describe('Manual Variables Form', () => { + let wrapper; + let mockApollo; + let requestHandlers; + + const getJobQueryResponseHandlerWithVariables = jest.fn().mockResolvedValue(mockJobResponse); + const playJobMutationHandler = jest.fn().mockResolvedValue({}); + const retryJobMutationHandler = jest.fn().mockResolvedValue({}); + + const defaultHandlers = { + getJobQueryResponseHandlerWithVariables, + playJobMutationHandler, + retryJobMutationHandler, + }; + + const createComponent = ({ props = {}, handlers = defaultHandlers } = {}) => { + requestHandlers = handlers; + + mockApollo = createMockApollo([ + [getJobQuery, handlers.getJobQueryResponseHandlerWithVariables], + [playJobMutation, handlers.playJobMutationHandler], + [retryJobMutation, handlers.retryJobMutationHandler], + ]); + + const options = { + localVue, + apolloProvider: mockApollo, + }; + + wrapper = mountExtended(ManualVariablesForm, { + propsData: { + jobId: mockId, + isRetryable: false, + ...props, + }, + provide: { + ...defaultProvide, + }, + ...options, + }); + + return waitForPromises(); + }; + + const findHelpText = () => wrapper.findComponent(GlSprintf); + const findHelpLink = () => wrapper.findComponent(GlLink); + const findCancelBtn = () => wrapper.findByTestId('cancel-btn'); + const findRunBtn = () => wrapper.findByTestId('run-manual-job-btn'); + const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); + const findAllDeleteVarBtns = () => wrapper.findAllByTestId('delete-variable-btn'); + const findDeleteVarBtnPlaceholder = () => wrapper.findByTestId('delete-variable-btn-placeholder'); + const findCiVariableKey = () => wrapper.findByTestId('ci-variable-key'); + const findAllCiVariableKeys = () => wrapper.findAllByTestId('ci-variable-key'); + const findCiVariableValue = () => wrapper.findByTestId('ci-variable-value'); + const findAllVariables = () => wrapper.findAllByTestId('ci-variable-row'); + + const setCiVariableKey = () => { + findCiVariableKey().setValue('new key'); + findCiVariableKey().vm.$emit('change'); + nextTick(); + }; + + const setCiVariableKeyByPosition = (position, value) => { + findAllCiVariableKeys().at(position).setValue(value); + findAllCiVariableKeys().at(position).vm.$emit('change'); + nextTick(); + }; + + afterEach(() => { + createAlert.mockClear(); + }); + + describe('when page renders', () => { + beforeEach(async () => { + await createComponent(); + }); + + it('renders help text with provided link', () => { + expect(findHelpText().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe( + '/help/ci/variables/index#add-a-cicd-variable-to-a-project', + ); + }); + }); + + describe('when query is unsuccessful', () => { + beforeEach(async () => { + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest.fn().mockRejectedValue({}), + }, + }); + }); + + it('shows an alert with error', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: JOB_GRAPHQL_ERRORS.jobQueryErrorText, + }); + }); + }); + + describe('when job has not been retried', () => { + beforeEach(async () => { + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest + .fn() + .mockResolvedValue(mockJobWithVariablesResponse), + }, + }); + }); + + it('does not render the cancel button', () => { + expect(findCancelBtn().exists()).toBe(false); + expect(findRunBtn().exists()).toBe(true); + }); + }); + + describe('when job has variables', () => { + beforeEach(async () => { + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest + .fn() + .mockResolvedValue(mockJobWithVariablesResponse), + }, + }); + }); + + it('sets manual job variables', () => { + const queryKey = mockJobWithVariablesResponse.data.project.job.manualVariables.nodes[0].key; + const queryValue = + mockJobWithVariablesResponse.data.project.job.manualVariables.nodes[0].value; + + expect(findCiVariableKey().element.value).toBe(queryKey); + expect(findCiVariableValue().element.value).toBe(queryValue); + }); + }); + + describe('when play mutation fires', () => { + beforeEach(async () => { + await createComponent({ + handlers: { + playJobMutationHandler: jest.fn().mockResolvedValue(mockJobPlayMutationData), + }, + }); + }); + + it('passes variables in correct format', async () => { + await setCiVariableKey(); + + await findCiVariableValue().setValue('new value'); + + await findRunBtn().vm.$emit('click'); + + expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1); + expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledWith({ + id: convertToGraphQLId(TYPENAME_CI_BUILD, mockId), + variables: [ + { + key: 'new key', + value: 'new value', + }, + ], + }); + }); + + it('redirects to job properly after job is run', async () => { + findRunBtn().vm.$emit('click'); + await waitForPromises(); + + expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith(mockJobPlayMutationData.data.jobPlay.job.webPath); // eslint-disable-line import/no-deprecated + }); + }); + + describe('when play mutation is unsuccessful', () => { + beforeEach(async () => { + await createComponent({ + handlers: { + playJobMutationHandler: jest.fn().mockRejectedValue({}), + }, + }); + }); + + it('shows an alert with error', async () => { + findRunBtn().vm.$emit('click'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: JOB_GRAPHQL_ERRORS.jobMutationErrorText, + }); + }); + }); + + describe('when job is retryable', () => { + beforeEach(async () => { + await createComponent({ + props: { isRetryable: true }, + handlers: { + retryJobMutationHandler: jest.fn().mockResolvedValue(mockJobRetryMutationData), + }, + }); + }); + + it('renders cancel button', () => { + expect(findCancelBtn().exists()).toBe(true); + }); + + it('redirects to job properly after rerun', async () => { + findRunBtn().vm.$emit('click'); + await waitForPromises(); + + expect(requestHandlers.retryJobMutationHandler).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith(mockJobRetryMutationData.data.jobRetry.job.webPath); // eslint-disable-line import/no-deprecated + }); + }); + + describe('when retry mutation is unsuccessful', () => { + beforeEach(async () => { + await createComponent({ + props: { isRetryable: true }, + handlers: { + retryJobMutationHandler: jest.fn().mockRejectedValue({}), + }, + }); + }); + + it('shows an alert with error', async () => { + findRunBtn().vm.$emit('click'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: JOB_GRAPHQL_ERRORS.jobMutationErrorText, + }); + }); + }); + + describe('updating variables in UI', () => { + beforeEach(async () => { + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest.fn().mockResolvedValue(mockJobResponse), + }, + }); + }); + + it('creates a new variable when user enters a new key value', async () => { + expect(findAllVariables()).toHaveLength(1); + + await setCiVariableKey(); + + expect(findAllVariables()).toHaveLength(2); + }); + + it('does not create extra empty variables', async () => { + expect(findAllVariables()).toHaveLength(1); + + await setCiVariableKey(); + + expect(findAllVariables()).toHaveLength(2); + + await setCiVariableKey(); + + expect(findAllVariables()).toHaveLength(2); + }); + + it('removes the correct variable row', async () => { + const variableKeyNameOne = 'key-one'; + const variableKeyNameThree = 'key-three'; + + await setCiVariableKeyByPosition(0, variableKeyNameOne); + + await setCiVariableKeyByPosition(1, 'key-two'); + + await setCiVariableKeyByPosition(2, variableKeyNameThree); + + expect(findAllVariables()).toHaveLength(4); + + await findAllDeleteVarBtns().at(1).trigger('click'); + + expect(findAllVariables()).toHaveLength(3); + + expect(findAllCiVariableKeys().at(0).element.value).toBe(variableKeyNameOne); + expect(findAllCiVariableKeys().at(1).element.value).toBe(variableKeyNameThree); + expect(findAllCiVariableKeys().at(2).element.value).toBe(''); + }); + + it('delete variable button should only show when there is more than one variable', async () => { + expect(findDeleteVarBtn().exists()).toBe(false); + + await setCiVariableKey(); + + expect(findDeleteVarBtn().exists()).toBe(true); + }); + }); + + describe('variable delete button placeholder', () => { + beforeEach(async () => { + await createComponent({ + handlers: { + getJobQueryResponseHandlerWithVariables: jest.fn().mockResolvedValue(mockJobResponse), + }, + }); + }); + + it('delete variable button placeholder should only exist when a user cannot remove', () => { + expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); + }); + + it('does not show the placeholder button', () => { + expect(findDeleteVarBtnPlaceholder().classes('gl-opacity-0')).toBe(true); + }); + + it('placeholder button will not delete the row on click', async () => { + expect(findAllCiVariableKeys()).toHaveLength(1); + expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); + + await findDeleteVarBtnPlaceholder().trigger('click'); + + expect(findAllCiVariableKeys()).toHaveLength(1); + expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js b/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js new file mode 100644 index 00000000000..1d61bf3243f --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/artifacts_block_spec.js @@ -0,0 +1,193 @@ +import { GlPopover } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { trimText } from 'helpers/text_helper'; +import ArtifactsBlock from '~/ci/job_details/components/sidebar/artifacts_block.vue'; +import { getTimeago } from '~/lib/utils/datetime_utility'; + +describe('Artifacts block', () => { + let wrapper; + + const createWrapper = (propsData) => + mountExtended(ArtifactsBlock, { + propsData: { + helpUrl: 'help-url', + ...propsData, + }, + }); + + const findArtifactRemoveElt = () => wrapper.findByTestId('artifacts-remove-timeline'); + const findJobLockedElt = () => wrapper.findByTestId('artifacts-locked-message-content'); + const findKeepBtn = () => wrapper.findByTestId('keep-artifacts'); + const findDownloadBtn = () => wrapper.findByTestId('download-artifacts'); + const findBrowseBtn = () => wrapper.findByTestId('browse-artifacts-button'); + const findArtifactsHelpLink = () => wrapper.findByTestId('artifacts-help-link'); + const findPopover = () => wrapper.findComponent(GlPopover); + + const expireAt = '2018-08-14T09:38:49.157Z'; + const timeago = getTimeago(); + const formattedDate = timeago.format(expireAt); + const lockedText = + 'These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.'; + + const expiredArtifact = { + expire_at: expireAt, + expired: true, + locked: false, + }; + + const nonExpiredArtifact = { + download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', + browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', + keep_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/keep', + expire_at: expireAt, + expired: false, + locked: false, + }; + + const lockedExpiredArtifact = { + ...expiredArtifact, + download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', + browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', + expired: true, + locked: true, + }; + + const lockedNonExpiredArtifact = { + ...nonExpiredArtifact, + keep_path: undefined, + locked: true, + }; + + describe('with expired artifacts that are not locked', () => { + beforeEach(() => { + wrapper = createWrapper({ + artifact: expiredArtifact, + }); + }); + + it('renders expired artifact date and info', () => { + expect(trimText(findArtifactRemoveElt().text())).toBe( + `The artifacts were removed ${formattedDate}`, + ); + + expect( + findArtifactRemoveElt() + .find('[data-testid="artifact-expired-help-link"]') + .attributes('href'), + ).toBe('help-url'); + }); + + it('does not show the keep button', () => { + expect(findKeepBtn().exists()).toBe(false); + }); + + it('does not show the download button', () => { + expect(findDownloadBtn().exists()).toBe(false); + }); + + it('does not show the browse button', () => { + expect(findBrowseBtn().exists()).toBe(false); + }); + }); + + describe('with artifacts that will expire', () => { + beforeEach(() => { + wrapper = createWrapper({ + artifact: nonExpiredArtifact, + }); + }); + + it('renders will expire artifact date and info', () => { + expect(trimText(findArtifactRemoveElt().text())).toBe( + `The artifacts will be removed ${formattedDate}`, + ); + + expect( + findArtifactRemoveElt() + .find('[data-testid="artifact-expired-help-link"]') + .attributes('href'), + ).toBe('help-url'); + }); + + it('renders the keep button', () => { + expect(findKeepBtn().exists()).toBe(true); + }); + + it('renders the download button', () => { + expect(findDownloadBtn().exists()).toBe(true); + }); + + it('renders the browse button', () => { + expect(findBrowseBtn().exists()).toBe(true); + }); + }); + + describe('with expired locked artifacts', () => { + beforeEach(() => { + wrapper = createWrapper({ + artifact: lockedExpiredArtifact, + }); + }); + + it('renders the information that the artefacts are locked', () => { + expect(findArtifactRemoveElt().exists()).toBe(false); + expect(trimText(findJobLockedElt().text())).toBe(lockedText); + }); + + it('does not render the keep button', () => { + expect(findKeepBtn().exists()).toBe(false); + }); + + it('renders the download button', () => { + expect(findDownloadBtn().exists()).toBe(true); + }); + + it('renders the browse button', () => { + expect(findBrowseBtn().exists()).toBe(true); + }); + }); + + describe('with non expired locked artifacts', () => { + beforeEach(() => { + wrapper = createWrapper({ + artifact: lockedNonExpiredArtifact, + }); + }); + + it('renders the information that the artefacts are locked', () => { + expect(findArtifactRemoveElt().exists()).toBe(false); + expect(trimText(findJobLockedElt().text())).toBe(lockedText); + }); + + it('does not render the keep button', () => { + expect(findKeepBtn().exists()).toBe(false); + }); + + it('renders the download button', () => { + expect(findDownloadBtn().exists()).toBe(true); + }); + + it('renders the browse button', () => { + expect(findBrowseBtn().exists()).toBe(true); + }); + }); + + describe('artifacts help text', () => { + beforeEach(() => { + wrapper = createWrapper({ + artifact: lockedNonExpiredArtifact, + }); + }); + + it('displays help text', () => { + const expectedHelpText = + 'Job artifacts are files that are configured to be uploaded when a job finishes execution. Artifacts could be compiled files, unit tests or scanning reports, or any other files generated by a job.'; + + expect(findPopover().text()).toBe(expectedHelpText); + }); + + it('links to artifacts help page', () => { + expect(findArtifactsHelpLink().attributes('href')).toBe('/help/ci/jobs/job_artifacts'); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/commit_block_spec.js b/spec/frontend/ci/job_details/components/sidebar/commit_block_spec.js new file mode 100644 index 00000000000..e9a848bcd11 --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/commit_block_spec.js @@ -0,0 +1,66 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import CommitBlock from '~/ci/job_details/components/sidebar/commit_block.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +describe('Commit block', () => { + let wrapper; + + const commit = { + short_id: '1f0fb84f', + id: '1f0fb84fb6770d74d97eee58118fd3909cd4f48c', + commit_path: 'commit/1f0fb84fb6770d74d97eee58118fd3909cd4f48c', + title: 'Update README.md', + }; + + const mergeRequest = { + iid: '!21244', + path: 'merge_requests/21244', + }; + + const findCommitSha = () => wrapper.findByTestId('commit-sha'); + const findLinkSha = () => wrapper.findByTestId('link-commit'); + + const mountComponent = (props) => { + wrapper = extendedWrapper( + shallowMount(CommitBlock, { + propsData: { + commit, + ...props, + }, + }), + ); + }; + + describe('without merge request', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders pipeline short sha link', () => { + expect(findCommitSha().attributes('href')).toBe(commit.commit_path); + expect(findCommitSha().text()).toBe(commit.short_id); + }); + + it('renders clipboard button', () => { + expect(wrapper.findComponent(ClipboardButton).attributes('text')).toBe(commit.id); + }); + + it('renders git commit title', () => { + expect(wrapper.text()).toContain(commit.title); + }); + + it('does not render merge request', () => { + expect(findLinkSha().exists()).toBe(false); + }); + }); + + describe('with merge request', () => { + it('renders merge request link and reference', () => { + mountComponent({ mergeRequest }); + + expect(findLinkSha().attributes('href')).toBe(mergeRequest.path); + expect(findLinkSha().text()).toBe(`!${mergeRequest.iid}`); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/external_links_block_spec.js b/spec/frontend/ci/job_details/components/sidebar/external_links_block_spec.js new file mode 100644 index 00000000000..1f2c448f1c6 --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/external_links_block_spec.js @@ -0,0 +1,49 @@ +import { GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ExternalLinksBlock from '~/ci/job_details/components/sidebar/external_links_block.vue'; + +describe('External links block', () => { + let wrapper; + + const createWrapper = (propsData) => { + wrapper = mountExtended(ExternalLinksBlock, { + propsData: { + ...propsData, + }, + }); + }; + + const findAllLinks = () => wrapper.findAllComponents(GlLink); + const findLink = () => findAllLinks().at(0); + + it('renders a list of links', () => { + createWrapper({ + externalLinks: [ + { + label: 'URL 1', + url: 'https://url1.example.com/', + }, + { + label: 'URL 2', + url: 'https://url2.example.com/', + }, + ], + }); + + expect(findAllLinks()).toHaveLength(2); + }); + + it('renders a link', () => { + createWrapper({ + externalLinks: [ + { + label: 'Example URL', + url: 'https://example.com/', + }, + ], + }); + + expect(findLink().text()).toBe('Example URL'); + expect(findLink().attributes('href')).toBe('https://example.com/'); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js b/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js new file mode 100644 index 00000000000..0eabaefd5de --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/job_container_item_spec.js @@ -0,0 +1,87 @@ +import { GlIcon, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; +import JobContainerItem from '~/ci/job_details/components/sidebar/job_container_item.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import job from 'jest/ci/jobs_mock_data'; + +describe('JobContainerItem', () => { + let wrapper; + + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findGlIcon = () => wrapper.findComponent(GlIcon); + + function createComponent(jobData = {}, props = { isActive: false, retried: false }) { + wrapper = shallowMount(JobContainerItem, { + propsData: { + job: { + ...jobData, + retried: props.retried, + }, + isActive: props.isActive, + }, + }); + } + + describe('when a job is not active and not retried', () => { + beforeEach(() => { + createComponent(job); + }); + + it('displays a status icon', () => { + expect(findCiIcon().props('status')).toBe(job.status); + }); + + it('displays the job name', () => { + expect(wrapper.text()).toContain(job.name); + }); + + it('displays a link to the job', () => { + const link = wrapper.findComponent(GlLink); + + expect(link.attributes('href')).toBe(job.status.details_path); + }); + }); + + describe('when a job is active', () => { + beforeEach(() => { + createComponent(job, { isActive: true }); + }); + + it('displays an arrow sprite icon', () => { + expect(findGlIcon().props('name')).toBe('arrow-right'); + }); + }); + + describe('when a job is retried', () => { + beforeEach(() => { + createComponent(job, { isActive: false, retried: true }); + }); + + it('displays a retry icon', () => { + expect(findGlIcon().props('name')).toBe('retry'); + }); + }); + + describe('for a delayed job', () => { + beforeEach(() => { + const remainingMilliseconds = 1337000; + jest + .spyOn(Date, 'now') + .mockImplementation( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingMilliseconds, + ); + + createComponent(delayedJobFixture); + }); + + it('displays remaining time in tooltip', async () => { + await nextTick(); + + const link = wrapper.findComponent(GlLink); + + expect(link.attributes('title')).toMatch('delayed job - delayed manual action (00:22:17)'); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/job_retry_forward_deployment_modal_spec.js b/spec/frontend/ci/job_details/components/sidebar/job_retry_forward_deployment_modal_spec.js new file mode 100644 index 00000000000..075bccd57cc --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/job_retry_forward_deployment_modal_spec.js @@ -0,0 +1,68 @@ +import { GlLink, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import JobRetryForwardDeploymentModal from '~/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue'; +import createStore from '~/ci/job_details/store'; +import job from 'jest/ci/jobs_mock_data'; + +describe('Job Retry Forward Deployment Modal', () => { + let store; + let wrapper; + + const retryOutdatedJobDocsUrl = 'url-to-docs'; + const findLink = () => wrapper.findComponent(GlLink); + const findModal = () => wrapper.findComponent(GlModal); + + const createWrapper = ({ props = {}, provide = {}, stubs = {} } = {}) => { + store = createStore(); + wrapper = shallowMount(JobRetryForwardDeploymentModal, { + propsData: { + modalId: 'modal-id', + href: job.retry_path, + ...props, + }, + provide, + store, + stubs, + }); + }; + + beforeEach(createWrapper); + + describe('Modal configuration', () => { + it('should display the correct messages', () => { + const modal = findModal(); + expect(modal.attributes('title')).toMatch('Are you sure you want to retry this job?'); + expect(modal.text()).toMatch( + "You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. Retrying this job could result in overwriting the environment with the older source code.", + ); + expect(modal.text()).toMatch('Are you sure you want to proceed?'); + }); + }); + + describe('Modal docs help link', () => { + it('should not display an info link when none is provided', () => { + createWrapper(); + + expect(findLink().exists()).toBe(false); + }); + + it('should display an info link when one is provided', () => { + createWrapper({ provide: { retryOutdatedJobDocsUrl } }); + + expect(findLink().attributes('href')).toBe(retryOutdatedJobDocsUrl); + expect(findLink().text()).toMatch('More information'); + }); + }); + + describe('Modal actions', () => { + beforeEach(createWrapper); + + it('should correctly configure the primary action', () => { + expect(findModal().props('actionPrimary').attributes).toMatchObject({ + 'data-method': 'post', + href: job.retry_path, + variant: 'danger', + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js b/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js new file mode 100644 index 00000000000..8fdf6b72ee1 --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js @@ -0,0 +1,64 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import JobsSidebarRetryButton from '~/ci/job_details/components/sidebar/job_sidebar_retry_button.vue'; +import createStore from '~/ci/job_details/store'; +import job from 'jest/ci/jobs_mock_data'; + +describe('Job Sidebar Retry Button', () => { + let store; + let wrapper; + + const forwardDeploymentFailure = 'forward_deployment_failure'; + const findRetryButton = () => wrapper.findByTestId('retry-job-button'); + const findRetryLink = () => wrapper.findByTestId('retry-job-link'); + + const createWrapper = ({ props = {} } = {}) => { + store = createStore(); + wrapper = shallowMountExtended(JobsSidebarRetryButton, { + propsData: { + href: job.retry_path, + isManualJob: false, + modalId: 'modal-id', + ...props, + }, + store, + }); + }; + + beforeEach(createWrapper); + + it.each([ + [null, false, true], + ['unmet_prerequisites', false, true], + [forwardDeploymentFailure, true, false], + ])( + 'when error is: %s, should render button: %s | should render link: %s', + async (failureReason, buttonExists, linkExists) => { + await store.dispatch('receiveJobSuccess', { ...job, failure_reason: failureReason }); + + expect(findRetryButton().exists()).toBe(buttonExists); + expect(findRetryLink().exists()).toBe(linkExists); + }, + ); + + describe('Button', () => { + it('should have the correct configuration', async () => { + await store.dispatch('receiveJobSuccess', { failure_reason: forwardDeploymentFailure }); + + expect(findRetryButton().attributes()).toMatchObject({ + category: 'primary', + variant: 'confirm', + icon: 'retry', + }); + }); + }); + + describe('Link', () => { + it('should have the correct configuration', () => { + expect(findRetryLink().attributes()).toMatchObject({ + 'data-method': 'post', + href: job.retry_path, + icon: 'retry', + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/jobs_container_spec.js b/spec/frontend/ci/job_details/components/sidebar/jobs_container_spec.js new file mode 100644 index 00000000000..b2b675199ed --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/jobs_container_spec.js @@ -0,0 +1,143 @@ +import { GlLink } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import JobsContainer from '~/ci/job_details/components/sidebar/jobs_container.vue'; + +describe('Jobs List block', () => { + let wrapper; + + const retried = { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + id: 233432756, + tooltip: 'build - passed', + retried: true, + }; + + const active = { + name: 'test', + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + id: 2322756, + tooltip: 'build - passed', + active: true, + }; + + const job = { + name: 'build', + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + id: 232153, + tooltip: 'build - passed', + }; + + const findAllJobs = () => wrapper.findAllComponents(GlLink); + const findJob = () => findAllJobs().at(0); + + const findArrowIcon = () => wrapper.findByTestId('arrow-right-icon'); + const findRetryIcon = () => wrapper.findByTestId('retry-icon'); + + const createComponent = (props) => { + wrapper = extendedWrapper( + mount(JobsContainer, { + propsData: { + ...props, + }, + }), + ); + }; + + it('renders a list of jobs', () => { + createComponent({ + jobs: [job, retried, active], + jobId: 12313, + }); + + expect(findAllJobs()).toHaveLength(3); + }); + + it('renders the arrow right icon when job id matches `jobId`', () => { + createComponent({ + jobs: [active], + jobId: active.id, + }); + + expect(findArrowIcon().exists()).toBe(true); + }); + + it('does not render the arrow right icon when the job is not active', () => { + createComponent({ + jobs: [job], + jobId: active.id, + }); + + expect(findArrowIcon().exists()).toBe(false); + }); + + it('renders the job name when present', () => { + createComponent({ + jobs: [job], + jobId: active.id, + }); + + expect(findJob().text()).toBe(job.name); + expect(findJob().text()).not.toContain(job.id.toString()); + }); + + it('renders job id when job name is not available', () => { + createComponent({ + jobs: [retried], + jobId: active.id, + }); + + expect(findJob().text()).toBe(retried.id.toString()); + }); + + it('links to the job page', () => { + createComponent({ + jobs: [job], + jobId: active.id, + }); + + expect(findJob().attributes('href')).toBe(job.status.details_path); + }); + + it('renders retry icon when job was retried', () => { + createComponent({ + jobs: [retried], + jobId: active.id, + }); + + expect(findRetryIcon().exists()).toBe(true); + }); + + it('does not render retry icon when job was not retried', () => { + createComponent({ + jobs: [job], + jobId: active.id, + }); + + expect(findRetryIcon().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_detail_row_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_detail_row_spec.js new file mode 100644 index 00000000000..52c886e3c88 --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_detail_row_spec.js @@ -0,0 +1,68 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SidebarDetailRow from '~/ci/job_details/components/sidebar/sidebar_detail_row.vue'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; + +describe('Sidebar detail row', () => { + let wrapper; + + const title = 'this is the title'; + const value = 'this is the value'; + const helpUrl = `${DOCS_URL}/runner/register/index.html`; + const path = 'path/to/value'; + + const findHelpLink = () => wrapper.findByTestId('job-sidebar-help-link'); + const findValueLink = () => wrapper.findByTestId('job-sidebar-value-link'); + + const createComponent = (props) => { + wrapper = shallowMountExtended(SidebarDetailRow, { + propsData: { + ...props, + }, + }); + }; + + describe('with title/value and without helpUrl/path', () => { + beforeEach(() => { + createComponent({ title, value }); + }); + + it('should render the provided title and value', () => { + expect(wrapper.text()).toBe(`${title}: ${value}`); + }); + + it('should not render the help link', () => { + expect(findHelpLink().exists()).toBe(false); + }); + + it('should not render the value link', () => { + expect(findValueLink().exists()).toBe(false); + }); + }); + + describe('when helpUrl provided', () => { + beforeEach(() => { + createComponent({ + helpUrl, + title, + value, + }); + }); + + it('should render the help link', () => { + expect(findHelpLink().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe(helpUrl); + }); + }); + + describe('when path is provided', () => { + it('should render link to value', () => { + createComponent({ + path, + title, + value, + }); + + expect(findValueLink().attributes('href')).toBe(path); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js new file mode 100644 index 00000000000..1063bec6f3b --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import SidebarHeader from '~/ci/job_details/components/sidebar/sidebar_header.vue'; +import JobRetryButton from '~/ci/job_details/components/sidebar/job_sidebar_retry_button.vue'; +import getJobQuery from '~/ci/job_details/graphql/queries/get_job.query.graphql'; +import { mockFullPath, mockId, mockJobResponse } from '../../mock_data'; + +Vue.use(VueApollo); + +const defaultProvide = { + projectPath: mockFullPath, +}; + +describe('Sidebar Header', () => { + let wrapper; + + const createComponent = ({ options = {}, props = {}, restJob = {} } = {}) => { + wrapper = shallowMountExtended(SidebarHeader, { + propsData: { + ...props, + jobId: mockId, + restJob, + }, + provide: { + ...defaultProvide, + }, + ...options, + }); + }; + + const createComponentWithApollo = ({ props = {}, restJob = {} } = {}) => { + const getJobQueryResponse = jest.fn().mockResolvedValue(mockJobResponse); + + const requestHandlers = [[getJobQuery, getJobQueryResponse]]; + + const apolloProvider = createMockApollo(requestHandlers); + + const options = { + apolloProvider, + }; + + createComponent({ + props, + restJob, + options, + }); + + return waitForPromises(); + }; + + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findEraseButton = () => wrapper.findByTestId('job-log-erase-link'); + const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); + const findTerminalLink = () => wrapper.findByTestId('terminal-link'); + const findJobName = () => wrapper.findByTestId('job-name'); + const findRetryButton = () => wrapper.findComponent(JobRetryButton); + + describe('when rendering contents', () => { + it('renders the correct job name', async () => { + await createComponentWithApollo(); + expect(findJobName().text()).toBe(mockJobResponse.data.project.job.name); + }); + + it('does not render buttons with no paths', async () => { + await createComponentWithApollo(); + expect(findCancelButton().exists()).toBe(false); + expect(findEraseButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); + expect(findNewIssueButton().exists()).toBe(false); + expect(findTerminalLink().exists()).toBe(false); + }); + + it('renders a retry button with a path', async () => { + await createComponentWithApollo({ restJob: { retry_path: 'retry/path' } }); + expect(findRetryButton().exists()).toBe(true); + }); + + it('renders a cancel button with a path', async () => { + await createComponentWithApollo({ restJob: { cancel_path: 'cancel/path' } }); + expect(findCancelButton().exists()).toBe(true); + }); + + it('renders an erase button with a path', async () => { + await createComponentWithApollo({ restJob: { erase_path: 'erase/path' } }); + expect(findEraseButton().exists()).toBe(true); + }); + + it('should render link to new issue', async () => { + await createComponentWithApollo({ restJob: { new_issue_path: 'new/issue/path' } }); + expect(findNewIssueButton().attributes('href')).toBe('new/issue/path'); + }); + + it('should render terminal link', async () => { + await createComponentWithApollo({ restJob: { terminal_path: 'terminal/path' } }); + expect(findTerminalLink().attributes('href')).toBe('terminal/path'); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js new file mode 100644 index 00000000000..e188d99b8b1 --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_job_details_container_spec.js @@ -0,0 +1,134 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DetailRow from '~/ci/job_details/components/sidebar/sidebar_detail_row.vue'; +import SidebarJobDetailsContainer from '~/ci/job_details/components/sidebar/sidebar_job_details_container.vue'; +import createStore from '~/ci/job_details/store'; +import job from 'jest/ci/jobs_mock_data'; + +describe('Job Sidebar Details Container', () => { + let store; + let wrapper; + + const findJobTimeout = () => wrapper.findByTestId('job-timeout'); + const findJobTags = () => wrapper.findByTestId('job-tags'); + const findAllDetailsRow = () => wrapper.findAllComponents(DetailRow); + + const createWrapper = ({ props = {} } = {}) => { + store = createStore(); + wrapper = extendedWrapper( + shallowMount(SidebarJobDetailsContainer, { + propsData: props, + store, + stubs: { + DetailRow, + }, + }), + ); + }; + + describe('when no details are available', () => { + beforeEach(() => { + createWrapper(); + }); + + it('should render an empty container', () => { + expect(wrapper.html()).toBe(''); + }); + + 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 }); + + expect(findAllDetailsRow()).toHaveLength(0); + }, + ); + }); + + describe('when some of the details are available', () => { + beforeEach(createWrapper); + + it.each([ + ['duration', 'Elapsed time: 6 seconds'], + ['erased_at', 'Erased: 3 weeks ago'], + ['finished_at', 'Finished: 3 weeks ago'], + ['queued_duration', 'Queued: 9 seconds'], + ['id', 'Job ID: #4757'], + ['runner', 'Runner: #1 (ABCDEFGH) local ci runner'], + ['coverage', 'Coverage: 20%'], + ])('uses %s to render job-%s', async (detail, value) => { + await store.dispatch('receiveJobSuccess', { [detail]: job[detail] }); + const detailsRow = findAllDetailsRow(); + + expect(detailsRow).toHaveLength(1); + expect(detailsRow.at(0).text()).toBe(value); + }); + + it('only renders tags', async () => { + const { tags } = job; + await store.dispatch('receiveJobSuccess', { tags }); + const tagsComponent = findJobTags(); + + expect(tagsComponent.text()).toBe('Tags: tag'); + }); + }); + + describe('when all the info are available', () => { + it('renders all the details components', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', job); + + expect(findAllDetailsRow()).toHaveLength(8); + }); + + describe('duration row', () => { + it('renders all the details components', async () => { + createWrapper(); + await store.dispatch('receiveJobSuccess', job); + + expect(findAllDetailsRow().at(0).text()).toBe('Duration: 6 seconds'); + }); + }); + }); + + describe('timeout', () => { + const { + metadata: { timeout_human_readable, timeout_source }, + } = job; + + beforeEach(createWrapper); + + it('does not render if metadata is empty', async () => { + const metadata = {}; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(wrapper.html()).toBe(''); + expect(detailsRow.exists()).toBe(false); + }); + + it('uses metadata to render timeout', async () => { + const metadata = { timeout_human_readable }; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(detailsRow).toHaveLength(1); + expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s'); + }); + + it('uses metadata to render timeout and the source', async () => { + const metadata = { timeout_human_readable, timeout_source }; + await store.dispatch('receiveJobSuccess', { metadata }); + const detailsRow = findAllDetailsRow(); + + expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s (from runner)'); + }); + + it('should not render when no time is provided', async () => { + const metadata = { timeout_source }; + await store.dispatch('receiveJobSuccess', { metadata }); + + expect(findJobTimeout().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_spec.js new file mode 100644 index 00000000000..88e1f41b270 --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_spec.js @@ -0,0 +1,222 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import ArtifactsBlock from '~/ci/job_details/components/sidebar/artifacts_block.vue'; +import ExternalLinksBlock from '~/ci/job_details/components/sidebar/external_links_block.vue'; +import JobRetryForwardDeploymentModal from '~/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue'; +import JobsContainer from '~/ci/job_details/components/sidebar/jobs_container.vue'; +import Sidebar from '~/ci/job_details/components/sidebar/sidebar.vue'; +import StagesDropdown from '~/ci/job_details/components/sidebar/stages_dropdown.vue'; +import createStore from '~/ci/job_details/store'; +import job, { jobsInStage } from 'jest/ci/jobs_mock_data'; + +describe('Sidebar details block', () => { + let mock; + let store; + let wrapper; + + const forwardDeploymentFailure = 'forward_deployment_failure'; + const findModal = () => wrapper.findComponent(JobRetryForwardDeploymentModal); + const findArtifactsBlock = () => wrapper.findComponent(ArtifactsBlock); + const findExternalLinksBlock = () => wrapper.findComponent(ExternalLinksBlock); + const findJobStagesDropdown = () => wrapper.findComponent(StagesDropdown); + const findJobsContainer = () => wrapper.findComponent(JobsContainer); + + const createWrapper = (props) => { + store = createStore(); + + store.state.job = job; + + wrapper = extendedWrapper( + shallowMount(Sidebar, { + propsData: { + ...props, + }, + + store, + }), + ); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet().reply(HTTP_STATUS_OK, { + name: job.stage, + }); + }); + + describe('forward deployment failure', () => { + describe('when the relevant data is missing', () => { + it.each` + retryPath | failureReason + ${null} | ${null} + ${''} | ${''} + ${job.retry_path} | ${''} + ${''} | ${forwardDeploymentFailure} + ${job.retry_path} | ${'unmet_prerequisites'} + `( + 'should not render the modal when path and failure are $retryPath, $failureReason', + async ({ retryPath, failureReason }) => { + createWrapper(); + await store.dispatch('receiveJobSuccess', { + ...job, + failure_reason: failureReason, + retry_path: retryPath, + }); + expect(findModal().exists()).toBe(false); + }, + ); + }); + + describe('when there is the relevant error', () => { + beforeEach(() => { + createWrapper(); + return store.dispatch('receiveJobSuccess', { + ...job, + failure_reason: forwardDeploymentFailure, + }); + }); + + it('should render the modal', () => { + expect(findModal().exists()).toBe(true); + }); + }); + }); + + describe('stages dropdown', () => { + beforeEach(() => { + createWrapper(); + return store.dispatch('receiveJobSuccess', job); + }); + + describe('with stages', () => { + it('renders value provided as selectedStage as selected', () => { + expect(findJobStagesDropdown().props('selectedStage')).toBe(job.stage); + }); + }); + + describe('without jobs for stages', () => { + it('does not render jobs container', () => { + expect(findJobsContainer().exists()).toBe(false); + }); + }); + + describe('with jobs for stages', () => { + beforeEach(() => { + return store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); + }); + + it('renders list of jobs', () => { + expect(findJobsContainer().exists()).toBe(true); + }); + }); + + describe('when job data changes', () => { + const stageArg = job.pipeline.details.stages.find((stage) => stage.name === job.stage); + + beforeEach(() => { + jest.spyOn(store, 'dispatch'); + }); + + describe('and the job stage is currently selected', () => { + describe('when the status changed', () => { + it('refetch the jobs list for the stage', async () => { + await store.dispatch('receiveJobSuccess', { ...job, status: 'new' }); + + expect(store.dispatch).toHaveBeenNthCalledWith(2, 'fetchJobsForStage', { ...stageArg }); + }); + }); + + describe('when the status did not change', () => { + it('does not refetch the jobs list for the stage', async () => { + await store.dispatch('receiveJobSuccess', { ...job }); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenNthCalledWith(1, 'receiveJobSuccess', { + ...job, + }); + }); + }); + }); + + describe('and the job stage is not currently selected', () => { + it('does not refetch the jobs list for the stage', async () => { + // Setting stage to `random` on the job means that we are looking + // at `build` stage currently, but the job we are seeing in the logs + // belong to `random`, so we shouldn't have to refetch + await store.dispatch('receiveJobSuccess', { ...job, stage: 'random' }); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenNthCalledWith(1, 'receiveJobSuccess', { + ...job, + stage: 'random', + }); + }); + }); + }); + }); + + describe('artifacts', () => { + beforeEach(() => { + createWrapper(); + }); + + it('artifacts are not shown if there are no properties other than locked', () => { + expect(findArtifactsBlock().exists()).toBe(false); + }); + + it('artifacts are shown if present', async () => { + store.state.job.artifact = { + download_path: '/root/ci-project/-/jobs/1960/artifacts/download', + browse_path: '/root/ci-project/-/jobs/1960/artifacts/browse', + keep_path: '/root/ci-project/-/jobs/1960/artifacts/keep', + expire_at: '2021-03-23T17:57:11.211Z', + expired: false, + locked: false, + }; + + await nextTick(); + + expect(findArtifactsBlock().exists()).toBe(true); + }); + }); + + describe('external links', () => { + beforeEach(() => { + createWrapper(); + }); + + it('external links block is not shown if there are no external links', () => { + expect(findExternalLinksBlock().exists()).toBe(false); + }); + + it('external links block is shown if there are external links', async () => { + store.state.job.annotations = [ + { + name: 'external_links', + data: [ + { + external_link: { + label: 'URL 1', + url: 'https://url1.example.com/', + }, + }, + { + external_link: { + label: 'URL 2', + url: 'https://url2.example.com/', + }, + }, + ], + }, + ]; + + await nextTick(); + + expect(findExternalLinksBlock().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js b/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js new file mode 100644 index 00000000000..e007896c81e --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/stages_dropdown_spec.js @@ -0,0 +1,192 @@ +import { GlDisclosureDropdown, GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { Mousetrap } from '~/lib/mousetrap'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import StagesDropdown from '~/ci/job_details/components/sidebar/stages_dropdown.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import * as copyToClipboard from '~/behaviors/copy_to_clipboard'; +import { + mockPipelineWithoutRef, + mockPipelineWithoutMR, + mockPipelineWithAttachedMR, + mockPipelineDetached, +} from 'jest/ci/jobs_mock_data'; + +describe('Stages Dropdown', () => { + let wrapper; + + const findStatus = () => wrapper.findComponent(CiBadgeLink); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findSelectedStageText = () => findDropdown().props('toggleText'); + + const findPipelineInfoText = () => wrapper.findByTestId('pipeline-info').text(); + + const createComponent = (props) => { + wrapper = extendedWrapper( + shallowMount(StagesDropdown, { + propsData: { + stages: [], + selectedStage: 'deploy', + ...props, + }, + stubs: { + GlSprintf, + GlLink, + }, + }), + ); + }; + + describe('without a merge request pipeline', () => { + beforeEach(() => { + createComponent({ + pipeline: mockPipelineWithoutMR, + stages: [{ name: 'build' }, { name: 'test' }], + }); + }); + + it('renders pipeline status', () => { + expect(findStatus().props('status')).toBe(mockPipelineWithoutMR.details.status); + expect(findStatus().props('size')).toBe('sm'); + }); + + it('renders dropdown with stages', () => { + expect(findDropdown().props('items')).toEqual([ + expect.objectContaining({ text: 'build' }), + expect.objectContaining({ text: 'test' }), + ]); + }); + + it('renders selected stage', () => { + expect(findSelectedStageText()).toBe('deploy'); + }); + }); + + describe('pipelineInfo', () => { + const allElements = [ + 'pipeline-path', + 'mr-link', + 'source-ref-link', + 'copy-source-ref-link', + 'source-branch-link', + 'copy-source-branch-link', + 'target-branch-link', + 'copy-target-branch-link', + ]; + describe.each([ + [ + 'does not have a ref', + { + pipeline: mockPipelineWithoutRef, + text: `Pipeline #${mockPipelineWithoutRef.id}`, + foundElements: [ + { testId: 'pipeline-path', props: [{ href: mockPipelineWithoutRef.path }] }, + ], + }, + ], + [ + 'hasRef but not triggered by MR', + { + pipeline: mockPipelineWithoutMR, + text: `Pipeline #${mockPipelineWithoutMR.id} for ${mockPipelineWithoutMR.ref.name}`, + foundElements: [ + { testId: 'pipeline-path', props: [{ href: mockPipelineWithoutMR.path }] }, + { testId: 'source-ref-link', props: [{ href: mockPipelineWithoutMR.ref.path }] }, + { testId: 'copy-source-ref-link', props: [{ text: mockPipelineWithoutMR.ref.name }] }, + ], + }, + ], + [ + 'hasRef and MR but not MR pipeline', + { + pipeline: mockPipelineDetached, + text: `Pipeline #${mockPipelineDetached.id} for !${mockPipelineDetached.merge_request.iid} with ${mockPipelineDetached.merge_request.source_branch}`, + foundElements: [ + { testId: 'pipeline-path', props: [{ href: mockPipelineDetached.path }] }, + { testId: 'mr-link', props: [{ href: mockPipelineDetached.merge_request.path }] }, + { + testId: 'source-branch-link', + props: [{ href: mockPipelineDetached.merge_request.source_branch_path }], + }, + { + testId: 'copy-source-branch-link', + props: [{ text: mockPipelineDetached.merge_request.source_branch }], + }, + ], + }, + ], + [ + 'hasRef and MR and MR pipeline', + { + pipeline: mockPipelineWithAttachedMR, + text: `Pipeline #${mockPipelineWithAttachedMR.id} for !${mockPipelineWithAttachedMR.merge_request.iid} with ${mockPipelineWithAttachedMR.merge_request.source_branch} into ${mockPipelineWithAttachedMR.merge_request.target_branch}`, + foundElements: [ + { testId: 'pipeline-path', props: [{ href: mockPipelineWithAttachedMR.path }] }, + { testId: 'mr-link', props: [{ href: mockPipelineWithAttachedMR.merge_request.path }] }, + { + testId: 'source-branch-link', + props: [{ href: mockPipelineWithAttachedMR.merge_request.source_branch_path }], + }, + { + testId: 'copy-source-branch-link', + props: [{ text: mockPipelineWithAttachedMR.merge_request.source_branch }], + }, + { + testId: 'target-branch-link', + props: [{ href: mockPipelineWithAttachedMR.merge_request.target_branch_path }], + }, + { + testId: 'copy-target-branch-link', + props: [{ text: mockPipelineWithAttachedMR.merge_request.target_branch }], + }, + ], + }, + ], + ])('%s', (_, { pipeline, text, foundElements }) => { + beforeEach(() => { + createComponent({ + pipeline, + }); + }); + + it('should render the text', () => { + expect(findPipelineInfoText()).toMatchInterpolatedText(text); + }); + + it('should find components with props', () => { + foundElements.forEach((element) => { + element.props.forEach((prop) => { + const key = Object.keys(prop)[0]; + expect(wrapper.findByTestId(element.testId).attributes(key)).toBe(prop[key]); + }); + }); + }); + + it('should not find components', () => { + const foundTestIds = foundElements.map((element) => element.testId); + allElements + .filter((testId) => !foundTestIds.includes(testId)) + .forEach((testId) => { + expect(wrapper.findByTestId(testId).exists()).toBe(false); + }); + }); + }); + }); + + describe('mousetrap', () => { + it.each([ + ['copy-source-ref-link', mockPipelineWithoutMR], + ['copy-source-branch-link', mockPipelineWithAttachedMR], + ])( + 'calls clickCopyToClipboardButton with `%s` button when `b` is pressed', + (button, pipeline) => { + const copyToClipboardMock = jest.spyOn(copyToClipboard, 'clickCopyToClipboardButton'); + createComponent({ pipeline }); + + Mousetrap.trigger('b'); + + expect(copyToClipboardMock).toHaveBeenCalledWith(wrapper.findByTestId(button).element); + }, + ); + }); +}); diff --git a/spec/frontend/ci/job_details/components/sidebar/trigger_block_spec.js b/spec/frontend/ci/job_details/components/sidebar/trigger_block_spec.js new file mode 100644 index 00000000000..f2b00c42d53 --- /dev/null +++ b/spec/frontend/ci/job_details/components/sidebar/trigger_block_spec.js @@ -0,0 +1,81 @@ +import { GlButton, GlTableLite } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import TriggerBlock from '~/ci/job_details/components/sidebar/trigger_block.vue'; + +describe('Trigger block', () => { + let wrapper; + + const findRevealButton = () => wrapper.findComponent(GlButton); + const findVariableTable = () => wrapper.findComponent(GlTableLite); + const findShortToken = () => wrapper.find('[data-testid="trigger-short-token"]'); + const findVariableValue = (index) => + wrapper.findAll('[data-testid="trigger-build-value"]').at(index); + const findVariableKey = (index) => wrapper.findAll('[data-testid="trigger-build-key"]').at(index); + + const createComponent = (props) => { + wrapper = mount(TriggerBlock, { + propsData: { + ...props, + }, + }); + }; + + describe('with short token and no variables', () => { + it('renders short token', () => { + createComponent({ + trigger: { + short_token: '0a666b2', + variables: [], + }, + }); + + expect(findShortToken().text()).toContain('0a666b2'); + }); + }); + + describe('without variables or short token', () => { + beforeEach(() => { + createComponent({ trigger: { variables: [] } }); + }); + + it('does not render short token', () => { + expect(findShortToken().exists()).toBe(false); + }); + + it('does not render variables', () => { + expect(findRevealButton().exists()).toBe(false); + expect(findVariableTable().exists()).toBe(false); + }); + }); + + describe('with variables', () => { + describe('hide/reveal variables', () => { + it('should toggle variables on click', async () => { + const hiddenValue = '••••••'; + const gcsVar = { key: 'UPLOAD_TO_GCS', value: 'false', public: false }; + const s3Var = { key: 'UPLOAD_TO_S3', value: 'true', public: false }; + + createComponent({ + trigger: { + variables: [gcsVar, s3Var], + }, + }); + + expect(findRevealButton().text()).toBe('Reveal values'); + + expect(findVariableValue(0).text()).toBe(hiddenValue); + expect(findVariableValue(1).text()).toBe(hiddenValue); + + expect(findVariableKey(0).text()).toBe(gcsVar.key); + expect(findVariableKey(1).text()).toBe(s3Var.key); + + await findRevealButton().trigger('click'); + + expect(findRevealButton().text()).toBe('Hide values'); + + expect(findVariableValue(0).text()).toBe(gcsVar.value); + expect(findVariableValue(1).text()).toBe(s3Var.value); + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/stuck_block_spec.js b/spec/frontend/ci/job_details/components/stuck_block_spec.js new file mode 100644 index 00000000000..ec3b2d45a68 --- /dev/null +++ b/spec/frontend/ci/job_details/components/stuck_block_spec.js @@ -0,0 +1,94 @@ +import { GlBadge, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StuckBlock from '~/ci/job_details/components/stuck_block.vue'; + +describe('Stuck Block Job component', () => { + let wrapper; + + const createWrapper = (props) => { + wrapper = shallowMount(StuckBlock, { + propsData: { + ...props, + }, + }); + }; + + const tags = ['docker', 'gitlab-org']; + + const findStuckNoActiveRunners = () => + wrapper.find('[data-testid="job-stuck-no-active-runners"]'); + const findStuckNoRunners = () => wrapper.find('[data-testid="job-stuck-no-runners"]'); + const findStuckWithTags = () => wrapper.find('[data-testid="job-stuck-with-tags"]'); + const findRunnerPathLink = () => wrapper.findComponent(GlLink); + const findAllBadges = () => wrapper.findAllComponents(GlBadge); + + describe('with no runners for project', () => { + beforeEach(() => { + createWrapper({ + hasOfflineRunnersForProject: true, + runnersPath: '/root/project/runners#js-runners-settings', + }); + }); + + it('renders only information about project not having runners', () => { + expect(findStuckNoRunners().exists()).toBe(true); + expect(findStuckWithTags().exists()).toBe(false); + expect(findStuckNoActiveRunners().exists()).toBe(false); + }); + + it('renders link to runners page', () => { + expect(findRunnerPathLink().attributes('href')).toBe( + '/root/project/runners#js-runners-settings', + ); + }); + }); + + describe('with tags', () => { + beforeEach(() => { + createWrapper({ + hasOfflineRunnersForProject: false, + tags, + runnersPath: '/root/project/runners#js-runners-settings', + }); + }); + + it('renders information about the tags not being set', () => { + expect(findStuckWithTags().exists()).toBe(true); + expect(findStuckNoActiveRunners().exists()).toBe(false); + expect(findStuckNoRunners().exists()).toBe(false); + }); + + it('renders tags', () => { + findAllBadges().wrappers.forEach((badgeElt, index) => { + return expect(badgeElt.text()).toBe(tags[index]); + }); + }); + + it('renders link to runners page', () => { + expect(findRunnerPathLink().attributes('href')).toBe( + '/root/project/runners#js-runners-settings', + ); + }); + }); + + describe('without active runners', () => { + beforeEach(() => { + createWrapper({ + hasOfflineRunnersForProject: false, + runnersPath: '/root/project/runners#js-runners-settings', + }); + }); + + it('renders information about project not having runners', () => { + expect(findStuckNoActiveRunners().exists()).toBe(true); + expect(findStuckNoRunners().exists()).toBe(false); + expect(findStuckWithTags().exists()).toBe(false); + }); + + it('renders link to runners page', () => { + expect(findRunnerPathLink().attributes('href')).toBe( + '/root/project/runners#js-runners-settings', + ); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/components/unmet_prerequisites_block_spec.js b/spec/frontend/ci/job_details/components/unmet_prerequisites_block_spec.js new file mode 100644 index 00000000000..08966743901 --- /dev/null +++ b/spec/frontend/ci/job_details/components/unmet_prerequisites_block_spec.js @@ -0,0 +1,37 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import UnmetPrerequisitesBlock from '~/ci/job_details/components/unmet_prerequisites_block.vue'; + +describe('Unmet Prerequisites Block Job component', () => { + let wrapper; + const helpPath = '/user/project/clusters/index.html#troubleshooting-failed-deployment-jobs'; + + const createComponent = () => { + wrapper = shallowMount(UnmetPrerequisitesBlock, { + propsData: { + helpPath, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders an alert with the correct message', () => { + const container = wrapper.findComponent(GlAlert); + const alertMessage = + 'This job failed because the necessary resources were not successfully created.'; + + expect(container).not.toBeNull(); + expect(container.text()).toContain(alertMessage); + }); + + it('renders link to help page', () => { + const helpLink = wrapper.findComponent(GlLink); + + expect(helpLink).not.toBeNull(); + expect(helpLink.text()).toContain('More information'); + expect(helpLink.attributes().href).toEqual(helpPath); + }); +}); diff --git a/spec/frontend/ci/job_details/job_app_spec.js b/spec/frontend/ci/job_details/job_app_spec.js new file mode 100644 index 00000000000..c2d91771495 --- /dev/null +++ b/spec/frontend/ci/job_details/job_app_spec.js @@ -0,0 +1,343 @@ +import Vue, { nextTick } from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import EmptyState from '~/ci/job_details/components/empty_state.vue'; +import EnvironmentsBlock from '~/ci/job_details/components/environments_block.vue'; +import ErasedBlock from '~/ci/job_details/components/erased_block.vue'; +import JobApp from '~/ci/job_details/job_app.vue'; +import JobLog from '~/ci/job_details/components/log/log.vue'; +import JobLogTopBar from 'ee_else_ce/ci/job_details/components/job_log_controllers.vue'; +import Sidebar from '~/ci/job_details/components/sidebar/sidebar.vue'; +import StuckBlock from '~/ci/job_details/components/stuck_block.vue'; +import UnmetPrerequisitesBlock from '~/ci/job_details/components/unmet_prerequisites_block.vue'; +import createStore from '~/ci/job_details/store'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { MANUAL_STATUS } from '~/ci/constants'; +import job from 'jest/ci/jobs_mock_data'; +import { mockPendingJobData } from './mock_data'; + +describe('Job App', () => { + Vue.use(Vuex); + + let store; + let wrapper; + let mock; + + const initSettings = { + endpoint: `${TEST_HOST}jobs/123.json`, + pagePath: `${TEST_HOST}jobs/123`, + logState: + 'eyJvZmZzZXQiOjE3NDUxLCJuX29wZW5fdGFncyI6MCwiZmdfY29sb3IiOm51bGwsImJnX2NvbG9yIjpudWxsLCJzdHlsZV9tYXNrIjowfQ%3D%3D', + }; + + const props = { + artifactHelpUrl: 'help/artifact', + deploymentHelpUrl: 'help/deployment', + runnerSettingsUrl: 'settings/ci-cd/runners', + terminalPath: 'jobs/123/terminal', + projectPath: 'user-name/project-name', + subscriptionsMoreMinutesUrl: 'https://customers.gitlab.com/buy_pipeline_minutes', + }; + + const createComponent = () => { + wrapper = shallowMountExtended(JobApp, { + propsData: { ...props }, + store, + }); + }; + + const setupAndMount = async ({ jobData = {}, jobLogData = {} } = {}) => { + mock.onGet(initSettings.endpoint).replyOnce(HTTP_STATUS_OK, { ...job, ...jobData }); + mock.onGet(`${initSettings.pagePath}/trace.json`).reply(HTTP_STATUS_OK, jobLogData); + + const asyncInit = store.dispatch('init', initSettings); + + createComponent(); + + await asyncInit; + jest.runOnlyPendingTimers(); + await axios.waitForAll(); + await nextTick(); + }; + + const findLoadingComponent = () => wrapper.findComponent(GlLoadingIcon); + const findSidebar = () => wrapper.findComponent(Sidebar); + const findStuckBlockComponent = () => wrapper.findComponent(StuckBlock); + const findFailedJobComponent = () => wrapper.findComponent(UnmetPrerequisitesBlock); + const findEnvironmentsBlockComponent = () => wrapper.findComponent(EnvironmentsBlock); + const findErasedBlock = () => wrapper.findComponent(ErasedBlock); + const findEmptyState = () => wrapper.findComponent(EmptyState); + const findJobLog = () => wrapper.findComponent(JobLog); + const findJobLogTopBar = () => wrapper.findComponent(JobLogTopBar); + + const findJobContent = () => wrapper.findByTestId('job-content'); + const findArchivedJob = () => wrapper.findByTestId('archived-job'); + + beforeEach(() => { + mock = new MockAdapter(axios); + store = createStore(); + }); + + afterEach(() => { + mock.restore(); + // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy + wrapper.destroy(); + }); + + describe('while loading', () => { + beforeEach(() => { + store.state.isLoading = true; + createComponent(); + }); + + it('renders loading icon', () => { + expect(findLoadingComponent().exists()).toBe(true); + expect(findSidebar().exists()).toBe(false); + expect(findJobContent().exists()).toBe(false); + }); + }); + + describe('with successful request', () => { + describe('Header section', () => { + describe('job callout message', () => { + it('should not render the reason when reason is absent', () => + setupAndMount().then(() => { + expect(wrapper.vm.shouldRenderCalloutMessage).toBe(false); + })); + + it('should render the reason when reason is present', () => + setupAndMount({ + jobData: { + callout_message: 'There is an unkown failure, please try again', + }, + }).then(() => { + expect(wrapper.vm.shouldRenderCalloutMessage).toBe(true); + })); + }); + }); + + describe('stuck block', () => { + describe('without active runners available', () => { + it('renders stuck block when there are no runners', () => + setupAndMount({ + jobData: { + status: { + group: 'pending', + icon: 'status_pending', + label: 'pending', + text: 'pending', + details_path: 'path', + }, + stuck: true, + runners: { + available: false, + online: false, + }, + tags: [], + }, + }).then(() => { + expect(findStuckBlockComponent().exists()).toBe(true); + })); + }); + + it('does not render stuck block when there are runners', () => + setupAndMount({ + jobData: { + runners: { available: true }, + }, + }).then(() => { + expect(findStuckBlockComponent().exists()).toBe(false); + })); + }); + + describe('unmet prerequisites block', () => { + it('renders unmet prerequisites block when there is an unmet prerequisites failure', () => + setupAndMount({ + jobData: { + status: { + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + details_path: 'path', + illustration: { + content: 'Retry this job in order to create the necessary resources.', + image: 'path', + size: 'svg-430', + title: 'Failed to create resources', + }, + }, + failure_reason: 'unmet_prerequisites', + has_trace: false, + runners: { + available: true, + }, + tags: [], + }, + }).then(() => { + expect(findFailedJobComponent().exists()).toBe(true); + })); + }); + + describe('environments block', () => { + it('renders environment block when job has environment', () => + setupAndMount({ + jobData: { + deployment_status: { + environment: { + environment_path: '/path', + name: 'foo', + }, + }, + }, + }).then(() => { + expect(findEnvironmentsBlockComponent().exists()).toBe(true); + })); + + it('does not render environment block when job has environment', () => + setupAndMount().then(() => { + expect(findEnvironmentsBlockComponent().exists()).toBe(false); + })); + }); + + describe('erased block', () => { + it('renders erased block when `erased` is true', () => + setupAndMount({ + jobData: { + erased_by: { + username: 'root', + web_url: 'gitlab.com/root', + }, + erased_at: '2016-11-07T11:11:16.525Z', + }, + }).then(() => { + expect(findErasedBlock().exists()).toBe(true); + })); + + it('does not render erased block when `erased` is false', () => + setupAndMount({ + jobData: { + erased_at: null, + }, + }).then(() => { + expect(findErasedBlock().exists()).toBe(false); + })); + }); + + describe('empty states block', () => { + it('renders empty state when job does not have log and is not running', () => + setupAndMount({ + jobData: { + has_trace: false, + status: { + group: 'pending', + icon: 'status_pending', + label: 'pending', + text: 'pending', + details_path: 'path', + illustration: { + image: 'path', + size: '340', + title: 'Empty State', + content: 'This is an empty state', + }, + action: { + button_title: 'Retry job', + method: 'post', + path: '/path', + }, + }, + }, + }).then(() => { + expect(findEmptyState().exists()).toBe(true); + })); + + it('does not render empty state when job does not have log but it is running', () => + setupAndMount({ + jobData: { + has_trace: false, + status: { + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + details_path: 'path', + }, + }, + }).then(() => { + expect(findEmptyState().exists()).toBe(false); + })); + + it('does not render empty state when job has log but it is not running', () => + setupAndMount({ jobData: { has_trace: true } }).then(() => { + expect(findEmptyState().exists()).toBe(false); + })); + }); + + describe('sidebar', () => { + it('renders sidebar', async () => { + await setupAndMount(); + + expect(findSidebar().exists()).toBe(true); + }); + }); + }); + + describe('archived job', () => { + beforeEach(() => setupAndMount({ jobData: { archived: true } })); + + it('renders warning about job being archived', () => { + expect(findArchivedJob().exists()).toBe(true); + }); + }); + + describe('non-archived job', () => { + beforeEach(() => setupAndMount()); + + it('does not warning about job being archived', () => { + expect(findArchivedJob().exists()).toBe(false); + }); + }); + + describe('job log', () => { + beforeEach(() => setupAndMount()); + + it('should render job log header', () => { + expect(findJobLogTopBar().exists()).toBe(true); + }); + + it('should render job log', () => { + expect(findJobLog().exists()).toBe(true); + }); + }); + + describe('job log polling', () => { + beforeEach(() => { + jest.spyOn(store, 'dispatch'); + }); + + it('should poll job log by default', async () => { + await setupAndMount({ + jobData: mockPendingJobData, + }); + + expect(store.dispatch).toHaveBeenCalledWith('fetchJobLog'); + }); + + it('should NOT poll job log for manual variables form empty state', async () => { + const manualPendingJobData = mockPendingJobData; + manualPendingJobData.status.group = MANUAL_STATUS; + + await setupAndMount({ + jobData: manualPendingJobData, + }); + + expect(store.dispatch).not.toHaveBeenCalledWith('fetchJobLog'); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/mock_data.js b/spec/frontend/ci/job_details/mock_data.js new file mode 100644 index 00000000000..fb3a361c9c9 --- /dev/null +++ b/spec/frontend/ci/job_details/mock_data.js @@ -0,0 +1,123 @@ +export const mockFullPath = 'Commit451/lab-coat'; +export const mockId = 401; + +export const mockJobResponse = { + data: { + project: { + id: 'gid://gitlab/Project/4', + job: { + id: 'gid://gitlab/Ci::Build/401', + manualJob: true, + manualVariables: { + nodes: [], + __typename: 'CiManualVariableConnection', + }, + name: 'manual_job', + retryable: true, + status: 'SUCCESS', + __typename: 'CiJob', + }, + __typename: 'Project', + }, + }, +}; + +export const mockJobWithVariablesResponse = { + data: { + project: { + id: 'gid://gitlab/Project/4', + job: { + id: 'gid://gitlab/Ci::Build/401', + manualJob: true, + manualVariables: { + nodes: [ + { + id: 'gid://gitlab/Ci::JobVariable/150', + key: 'new key', + value: 'new value', + __typename: 'CiManualVariable', + }, + ], + __typename: 'CiManualVariableConnection', + }, + name: 'manual_job', + retryable: true, + status: 'SUCCESS', + __typename: 'CiJob', + }, + __typename: 'Project', + }, + }, +}; + +export const mockJobPlayMutationData = { + data: { + jobPlay: { + job: { + id: 'gid://gitlab/Ci::Build/401', + manualVariables: { + nodes: [ + { + id: 'gid://gitlab/Ci::JobVariable/151', + key: 'new key', + value: 'new value', + __typename: 'CiManualVariable', + }, + ], + __typename: 'CiManualVariableConnection', + }, + webPath: '/Commit451/lab-coat/-/jobs/401', + __typename: 'CiJob', + }, + errors: [], + __typename: 'JobPlayPayload', + }, + }, +}; + +export const mockJobRetryMutationData = { + data: { + jobRetry: { + job: { + id: 'gid://gitlab/Ci::Build/401', + manualVariables: { + nodes: [ + { + id: 'gid://gitlab/Ci::JobVariable/151', + key: 'new key', + value: 'new value', + __typename: 'CiManualVariable', + }, + ], + __typename: 'CiManualVariableConnection', + }, + webPath: '/Commit451/lab-coat/-/jobs/401', + __typename: 'CiJob', + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const mockPendingJobData = { + has_trace: false, + status: { + group: 'pending', + icon: 'status_pending', + label: 'pending', + text: 'pending', + details_path: 'path', + illustration: { + image: 'path', + size: '340', + title: '', + content: '', + }, + action: { + button_title: 'Retry job', + method: 'post', + path: '/path', + }, + }, +}; diff --git a/spec/frontend/ci/job_details/store/actions_spec.js b/spec/frontend/ci/job_details/store/actions_spec.js new file mode 100644 index 00000000000..bb5c1fe32bd --- /dev/null +++ b/spec/frontend/ci/job_details/store/actions_spec.js @@ -0,0 +1,502 @@ +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import { + setJobEndpoint, + setJobLogOptions, + clearEtagPoll, + stopPolling, + requestJob, + fetchJob, + receiveJobSuccess, + receiveJobError, + scrollTop, + scrollBottom, + requestJobLog, + fetchJobLog, + startPollingJobLog, + stopPollingJobLog, + receiveJobLogSuccess, + receiveJobLogError, + toggleCollapsibleLine, + requestJobsForStage, + fetchJobsForStage, + receiveJobsForStageSuccess, + receiveJobsForStageError, + hideSidebar, + showSidebar, + toggleSidebar, +} from '~/ci/job_details/store/actions'; +import * as types from '~/ci/job_details/store/mutation_types'; +import state from '~/ci/job_details/store/state'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; + +describe('Job State actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('setJobEndpoint', () => { + it('should commit SET_JOB_ENDPOINT mutation', () => { + return testAction( + setJobEndpoint, + 'job/872324.json', + mockedState, + [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }], + [], + ); + }); + }); + + describe('setJobLogOptions', () => { + it('should commit SET_JOB_LOG_OPTIONS mutation', () => { + return testAction( + setJobLogOptions, + { pagePath: 'job/872324/trace.json' }, + mockedState, + [{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], + [], + ); + }); + }); + + describe('hideSidebar', () => { + it('should commit HIDE_SIDEBAR mutation', () => { + return testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], []); + }); + }); + + describe('showSidebar', () => { + it('should commit SHOW_SIDEBAR mutation', () => { + return testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], []); + }); + }); + + describe('toggleSidebar', () => { + describe('when isSidebarOpen is true', () => { + it('should dispatch hideSidebar', () => { + return testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }]); + }); + }); + + describe('when isSidebarOpen is false', () => { + it('should dispatch showSidebar', () => { + mockedState.isSidebarOpen = false; + + return testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }]); + }); + }); + }); + + describe('requestJob', () => { + it('should commit REQUEST_JOB mutation', () => { + return testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], []); + }); + }); + + describe('fetchJob', () => { + let mock; + + beforeEach(() => { + mockedState.jobEndpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + stopPolling(); + clearEtagPoll(); + }); + + describe('success', () => { + it('dispatches requestJob and receiveJobSuccess', () => { + mock + .onGet(`${TEST_HOST}/endpoint.json`) + .replyOnce(HTTP_STATUS_OK, { id: 121212, name: 'karma' }); + + return testAction( + fetchJob, + null, + mockedState, + [], + [ + { + type: 'requestJob', + }, + { + payload: { id: 121212, name: 'karma' }, + type: 'receiveJobSuccess', + }, + ], + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/endpoint.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + }); + + it('dispatches requestJob and receiveJobError', () => { + return testAction( + fetchJob, + null, + mockedState, + [], + [ + { + type: 'requestJob', + }, + { + type: 'receiveJobError', + }, + ], + ); + }); + }); + }); + + describe('receiveJobSuccess', () => { + it('should commit RECEIVE_JOB_SUCCESS mutation', () => { + return testAction( + receiveJobSuccess, + { id: 121232132 }, + mockedState, + [{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }], + [], + ); + }); + }); + + describe('receiveJobError', () => { + it('should commit RECEIVE_JOB_ERROR mutation', () => { + return testAction( + receiveJobError, + null, + mockedState, + [{ type: types.RECEIVE_JOB_ERROR }], + [], + ); + }); + }); + + describe('scrollTop', () => { + it('should dispatch toggleScrollButtons action', () => { + return testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }]); + }); + }); + + describe('scrollBottom', () => { + it('should dispatch toggleScrollButtons action', () => { + return testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }]); + }); + }); + + describe('requestJobLog', () => { + it('should commit REQUEST_JOB_LOG mutation', () => { + return testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], []); + }); + }); + + describe('fetchJobLog', () => { + let mock; + + beforeEach(() => { + mockedState.jobLogEndpoint = `${TEST_HOST}/endpoint`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + stopPolling(); + clearEtagPoll(); + }); + + describe('success', () => { + it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', () => { + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(HTTP_STATUS_OK, { + html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', + complete: true, + }); + + return testAction( + fetchJobLog, + null, + mockedState, + [], + [ + { + type: 'toggleScrollisInBottom', + payload: true, + }, + { + payload: { + html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', + complete: true, + }, + type: 'receiveJobLogSuccess', + }, + { + type: 'stopPollingJobLog', + }, + ], + ); + }); + + describe('when job is incomplete', () => { + let jobLogPayload; + + beforeEach(() => { + jobLogPayload = { + html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', + complete: false, + }; + + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(HTTP_STATUS_OK, jobLogPayload); + }); + + it('dispatches startPollingJobLog', () => { + return testAction( + fetchJobLog, + null, + mockedState, + [], + [ + { type: 'toggleScrollisInBottom', payload: true }, + { type: 'receiveJobLogSuccess', payload: jobLogPayload }, + { type: 'startPollingJobLog' }, + ], + ); + }); + + it('does not dispatch startPollingJobLog when timeout is non-empty', () => { + mockedState.jobLogTimeout = 1; + + return testAction( + fetchJobLog, + null, + mockedState, + [], + [ + { type: 'toggleScrollisInBottom', payload: true }, + { type: 'receiveJobLogSuccess', payload: jobLogPayload }, + ], + ); + }); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + }); + + it('dispatches requestJobLog and receiveJobLogError', () => { + return testAction( + fetchJobLog, + null, + mockedState, + [], + [ + { + type: 'receiveJobLogError', + }, + ], + ); + }); + }); + }); + + describe('startPollingJobLog', () => { + let dispatch; + let commit; + + beforeEach(() => { + dispatch = jest.fn(); + commit = jest.fn(); + + startPollingJobLog({ dispatch, commit }); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should save the timeout id but not call fetchJobLog', () => { + expect(commit).toHaveBeenCalledWith(types.SET_JOB_LOG_TIMEOUT, expect.any(Number)); + expect(commit.mock.calls[0][1]).toBeGreaterThan(0); + + expect(dispatch).not.toHaveBeenCalledWith('fetchJobLog'); + }); + + describe('after timeout has passed', () => { + beforeEach(() => { + jest.advanceTimersByTime(4000); + }); + + it('should clear the timeout id and fetchJobLog', () => { + expect(commit).toHaveBeenCalledWith(types.SET_JOB_LOG_TIMEOUT, 0); + expect(dispatch).toHaveBeenCalledWith('fetchJobLog'); + }); + }); + }); + + describe('stopPollingJobLog', () => { + let origTimeout; + + beforeEach(() => { + // Can't use spyOn(window, 'clearTimeout') because this caused unrelated specs to timeout + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23838#note_280277727 + origTimeout = window.clearTimeout; + window.clearTimeout = jest.fn(); + }); + + afterEach(() => { + window.clearTimeout = origTimeout; + }); + + it('should commit STOP_POLLING_JOB_LOG mutation', async () => { + const jobLogTimeout = 7; + + await testAction( + stopPollingJobLog, + null, + { ...mockedState, jobLogTimeout }, + [{ type: types.SET_JOB_LOG_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_JOB_LOG }], + [], + ); + expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout); + }); + }); + + describe('receiveJobLogSuccess', () => { + it('should commit RECEIVE_JOB_LOG_SUCCESS mutation', () => { + return testAction( + receiveJobLogSuccess, + 'hello world', + mockedState, + [{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }], + [], + ); + }); + }); + + describe('receiveJobLogError', () => { + it('should commit stop polling job log', () => { + return testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }]); + }); + }); + + describe('toggleCollapsibleLine', () => { + it('should commit TOGGLE_COLLAPSIBLE_LINE mutation', () => { + return testAction( + toggleCollapsibleLine, + { isClosed: true }, + mockedState, + [{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }], + [], + ); + }); + }); + + describe('requestJobsForStage', () => { + it('should commit REQUEST_JOBS_FOR_STAGE mutation', () => { + return testAction( + requestJobsForStage, + { name: 'deploy' }, + mockedState, + [{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }], + [], + ); + }); + }); + + describe('fetchJobsForStage', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestJobsForStage and receiveJobsForStageSuccess', () => { + mock.onGet(`${TEST_HOST}/jobs.json`).replyOnce(HTTP_STATUS_OK, { + latest_statuses: [{ id: 121212, name: 'build' }], + retried: [], + }); + + return testAction( + fetchJobsForStage, + { dropdown_path: `${TEST_HOST}/jobs.json` }, + mockedState, + [], + [ + { + type: 'requestJobsForStage', + payload: { dropdown_path: `${TEST_HOST}/jobs.json` }, + }, + { + payload: [{ id: 121212, name: 'build' }], + type: 'receiveJobsForStageSuccess', + }, + ], + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/jobs.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + }); + + it('dispatches requestJobsForStage and receiveJobsForStageError', () => { + return testAction( + fetchJobsForStage, + { dropdown_path: `${TEST_HOST}/jobs.json` }, + mockedState, + [], + [ + { + type: 'requestJobsForStage', + payload: { dropdown_path: `${TEST_HOST}/jobs.json` }, + }, + { + type: 'receiveJobsForStageError', + }, + ], + ); + }); + }); + }); + + describe('receiveJobsForStageSuccess', () => { + it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation', () => { + return testAction( + receiveJobsForStageSuccess, + [{ id: 121212, name: 'karma' }], + mockedState, + [{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }], + [], + ); + }); + }); + + describe('receiveJobsForStageError', () => { + it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation', () => { + return testAction( + receiveJobsForStageError, + null, + mockedState, + [{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/store/getters_spec.js b/spec/frontend/ci/job_details/store/getters_spec.js new file mode 100644 index 00000000000..dfa5f9d4781 --- /dev/null +++ b/spec/frontend/ci/job_details/store/getters_spec.js @@ -0,0 +1,245 @@ +import * as getters from '~/ci/job_details/store/getters'; +import state from '~/ci/job_details/store/state'; + +describe('Job Store Getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('headerTime', () => { + describe('when the job has started key', () => { + it('returns started_at value', () => { + const started = '2018-08-31T16:20:49.023Z'; + const startedAt = '2018-08-31T16:20:49.023Z'; + localState.job.started_at = startedAt; + localState.job.started = started; + + expect(getters.headerTime(localState)).toEqual(startedAt); + }); + }); + + describe('when the job does not have started key', () => { + it('returns created_at value', () => { + const created = '2018-08-31T16:20:49.023Z'; + localState.job.created_at = created; + + expect(getters.headerTime(localState)).toEqual(created); + }); + }); + }); + + describe('shouldRenderCalloutMessage', () => { + describe('with status and callout message', () => { + it('returns true', () => { + localState.job.callout_message = 'Callout message'; + localState.job.status = { icon: 'passed' }; + + expect(getters.shouldRenderCalloutMessage(localState)).toEqual(true); + }); + }); + + describe('without status & with callout message', () => { + it('returns false', () => { + localState.job.callout_message = 'Callout message'; + + expect(getters.shouldRenderCalloutMessage(localState)).toEqual(false); + }); + }); + + describe('with status & without callout message', () => { + it('returns false', () => { + localState.job.status = { icon: 'passed' }; + + expect(getters.shouldRenderCalloutMessage(localState)).toEqual(false); + }); + }); + }); + + describe('shouldRenderTriggeredLabel', () => { + describe('when started equals null', () => { + it('returns false', () => { + localState.job.started_at = null; + + expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(false); + }); + }); + + describe('when started equals string', () => { + it('returns true', () => { + localState.job.started_at = '2018-08-31T16:20:49.023Z'; + + expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(true); + }); + }); + }); + + describe('hasEnvironment', () => { + describe('without `deployment_status`', () => { + it('returns false', () => { + expect(getters.hasEnvironment(localState)).toEqual(false); + }); + }); + + describe('with an empty object for `deployment_status`', () => { + it('returns false', () => { + localState.job.deployment_status = {}; + + expect(getters.hasEnvironment(localState)).toEqual(false); + }); + }); + + describe('when `deployment_status` is defined and not empty', () => { + it('returns true', () => { + localState.job.deployment_status = { + status: 'creating', + environment: { + last_deployment: {}, + }, + }; + + expect(getters.hasEnvironment(localState)).toEqual(true); + }); + }); + }); + + describe('hasJobLog', () => { + describe('when has_trace is true', () => { + it('returns true', () => { + localState.job.has_trace = true; + localState.job.status = {}; + + expect(getters.hasJobLog(localState)).toEqual(true); + }); + }); + + describe('when job is running', () => { + it('returns true', () => { + localState.job.has_trace = false; + localState.job.status = { group: 'running' }; + + expect(getters.hasJobLog(localState)).toEqual(true); + }); + }); + + describe('when has_trace is false and job is not running', () => { + it('returns false', () => { + localState.job.has_trace = false; + localState.job.status = { group: 'pending' }; + + expect(getters.hasJobLog(localState)).toEqual(false); + }); + }); + }); + + describe('emptyStateIllustration', () => { + describe('with defined illustration', () => { + it('returns the state illustration object', () => { + localState.job.status = { + illustration: { + path: 'foo', + }, + }; + + expect(getters.emptyStateIllustration(localState)).toEqual({ path: 'foo' }); + }); + }); + + describe('when illustration is not defined', () => { + it('returns an empty object', () => { + expect(getters.emptyStateIllustration(localState)).toEqual({}); + }); + }); + }); + + describe('shouldRenderSharedRunnerLimitWarning', () => { + describe('without runners information', () => { + it('returns false', () => { + expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(false); + }); + }); + + describe('with runners information', () => { + describe('when used quota is less than limit', () => { + it('returns false', () => { + localState.job.runners = { + quota: { + used: 33, + limit: 2000, + }, + available: true, + online: true, + }; + + expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(false); + }); + }); + + describe('when used quota is equal to limit', () => { + it('returns true', () => { + localState.job.runners = { + quota: { + used: 2000, + limit: 2000, + }, + available: true, + online: true, + }; + + expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(true); + }); + }); + + describe('when used quota is bigger than limit', () => { + it('returns true', () => { + localState.job.runners = { + quota: { + used: 2002, + limit: 2000, + }, + available: true, + online: true, + }; + + expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(true); + }); + }); + }); + }); + + describe('hasOfflineRunnersForProject', () => { + describe('with available and offline runners', () => { + it('returns true', () => { + localState.job.runners = { + available: true, + online: false, + }; + + expect(getters.hasOfflineRunnersForProject(localState)).toEqual(true); + }); + }); + + describe('with non available runners', () => { + it('returns false', () => { + localState.job.runners = { + available: false, + online: false, + }; + + expect(getters.hasOfflineRunnersForProject(localState)).toEqual(false); + }); + }); + + describe('with online runners', () => { + it('returns false', () => { + localState.job.runners = { + available: false, + online: true, + }; + + expect(getters.hasOfflineRunnersForProject(localState)).toEqual(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/store/helpers.js b/spec/frontend/ci/job_details/store/helpers.js new file mode 100644 index 00000000000..6b186e094e7 --- /dev/null +++ b/spec/frontend/ci/job_details/store/helpers.js @@ -0,0 +1,5 @@ +import state from '~/ci/job_details/store/state'; + +export const resetStore = (store) => { + store.replaceState(state()); +}; diff --git a/spec/frontend/ci/job_details/store/mutations_spec.js b/spec/frontend/ci/job_details/store/mutations_spec.js new file mode 100644 index 00000000000..0835c534fb9 --- /dev/null +++ b/spec/frontend/ci/job_details/store/mutations_spec.js @@ -0,0 +1,269 @@ +import * as types from '~/ci/job_details/store/mutation_types'; +import mutations from '~/ci/job_details/store/mutations'; +import state from '~/ci/job_details/store/state'; + +describe('Jobs Store Mutations', () => { + let stateCopy; + + const html = + 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png
    I'; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_JOB_ENDPOINT', () => { + it('should set jobEndpoint', () => { + mutations[types.SET_JOB_ENDPOINT](stateCopy, 'job/21312321.json'); + + expect(stateCopy.jobEndpoint).toEqual('job/21312321.json'); + }); + }); + + describe('HIDE_SIDEBAR', () => { + it('should set isSidebarOpen to false', () => { + mutations[types.HIDE_SIDEBAR](stateCopy); + + expect(stateCopy.isSidebarOpen).toEqual(false); + }); + }); + + describe('SHOW_SIDEBAR', () => { + it('should set isSidebarOpen to true', () => { + mutations[types.SHOW_SIDEBAR](stateCopy); + + expect(stateCopy.isSidebarOpen).toEqual(true); + }); + }); + + describe('RECEIVE_JOB_LOG_SUCCESS', () => { + describe('when job log has state', () => { + it('sets jobLogState', () => { + const stateLog = + 'eyJvZmZzZXQiOjczNDQ1MSwibl9vcGVuX3RhZ3MiOjAsImZnX2NvbG9yIjpudWxsLCJiZ19jb2xvciI6bnVsbCwic3R5bGVfbWFzayI6MH0='; + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { + state: stateLog, + }); + + expect(stateCopy.jobLogState).toEqual(stateLog); + }); + }); + + describe('when jobLogSize is smaller than the total size', () => { + it('sets isJobLogSizeVisible to true', () => { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { total: 51184600, size: 1231 }); + + expect(stateCopy.isJobLogSizeVisible).toEqual(true); + }); + }); + + describe('when jobLogSize is bigger than the total size', () => { + it('sets isJobLogSizeVisible to false', () => { + const copy = { ...stateCopy, jobLogSize: 5118460, size: 2321312 }; + + mutations[types.RECEIVE_JOB_LOG_SUCCESS](copy, { total: 511846 }); + + expect(copy.isJobLogSizeVisible).toEqual(false); + }); + }); + + it('sets job log size and isJobLogComplete', () => { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { + append: true, + html, + size: 511846, + complete: true, + lines: [], + }); + + expect(stateCopy.jobLogSize).toEqual(511846); + expect(stateCopy.isJobLogComplete).toEqual(true); + }); + + 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: 0, + }, + ]); + }); + }); + + describe('when it is 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: 0, + }, + ]); + }); + }); + + describe('when it is 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([]); + }); + }); + }); + }); + }); + + describe('SET_JOB_LOG_TIMEOUT', () => { + it('sets the jobLogTimeout id', () => { + const id = 7; + + expect(stateCopy.jobLogTimeout).not.toEqual(id); + + mutations[types.SET_JOB_LOG_TIMEOUT](stateCopy, id); + + expect(stateCopy.jobLogTimeout).toEqual(id); + }); + }); + + describe('STOP_POLLING_JOB_LOG', () => { + it('sets isJobLogComplete to true', () => { + mutations[types.STOP_POLLING_JOB_LOG](stateCopy); + + expect(stateCopy.isJobLogComplete).toEqual(true); + }); + }); + + describe('TOGGLE_COLLAPSIBLE_LINE', () => { + it('toggles the `isClosed` property of the provided object', () => { + const section = { isClosed: true }; + mutations[types.TOGGLE_COLLAPSIBLE_LINE](stateCopy, section); + expect(section.isClosed).toEqual(false); + }); + }); + + describe('REQUEST_JOB', () => { + it('sets isLoading to true', () => { + mutations[types.REQUEST_JOB](stateCopy); + + expect(stateCopy.isLoading).toEqual(true); + }); + }); + + describe('RECEIVE_JOB_SUCCESS', () => { + it('sets is loading to false', () => { + mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 }); + + expect(stateCopy.isLoading).toEqual(false); + }); + + it('sets hasError to false', () => { + mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 }); + + expect(stateCopy.hasError).toEqual(false); + }); + + it('sets job data', () => { + mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 }); + + expect(stateCopy.job).toEqual({ id: 1312321 }); + }); + + it('sets selectedStage when the selectedStage is empty', () => { + expect(stateCopy.selectedStage).toEqual(''); + mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321, stage: 'deploy' }); + + expect(stateCopy.selectedStage).toEqual('deploy'); + }); + + it('does not set selectedStage when the selectedStage is not More', () => { + stateCopy.selectedStage = 'notify'; + + expect(stateCopy.selectedStage).toEqual('notify'); + mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321, stage: 'deploy' }); + + expect(stateCopy.selectedStage).toEqual('notify'); + }); + }); + + describe('RECEIVE_JOB_ERROR', () => { + it('resets job data', () => { + mutations[types.RECEIVE_JOB_ERROR](stateCopy); + + expect(stateCopy.isLoading).toEqual(false); + expect(stateCopy.job).toEqual({}); + }); + }); + + describe('REQUEST_JOBS_FOR_STAGE', () => { + it('sets isLoadingJobs to true', () => { + mutations[types.REQUEST_JOBS_FOR_STAGE](stateCopy, { name: 'deploy' }); + + expect(stateCopy.isLoadingJobs).toEqual(true); + }); + + it('sets selectedStage', () => { + mutations[types.REQUEST_JOBS_FOR_STAGE](stateCopy, { name: 'deploy' }); + + expect(stateCopy.selectedStage).toEqual('deploy'); + }); + }); + + describe('RECEIVE_JOBS_FOR_STAGE_SUCCESS', () => { + beforeEach(() => { + mutations[types.RECEIVE_JOBS_FOR_STAGE_SUCCESS](stateCopy, [{ name: 'karma' }]); + }); + + it('sets isLoadingJobs to false', () => { + expect(stateCopy.isLoadingJobs).toEqual(false); + }); + + it('sets jobs', () => { + expect(stateCopy.jobs).toEqual([{ name: 'karma' }]); + }); + }); + + describe('RECEIVE_JOBS_FOR_STAGE_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_JOBS_FOR_STAGE_ERROR](stateCopy); + }); + + it('sets isLoadingJobs to false', () => { + expect(stateCopy.isLoadingJobs).toEqual(false); + }); + + it('resets jobs', () => { + expect(stateCopy.jobs).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/store/utils_spec.js b/spec/frontend/ci/job_details/store/utils_spec.js new file mode 100644 index 00000000000..4ffba35761e --- /dev/null +++ b/spec/frontend/ci/job_details/store/utils_spec.js @@ -0,0 +1,510 @@ +import { + logLinesParser, + updateIncrementalJobLog, + parseHeaderLine, + parseLine, + addDurationToHeader, + isCollapsibleSection, + findOffsetAndRemove, + getIncrementalLineNumber, +} from '~/ci/job_details/store/utils'; +import { + utilsMockData, + originalTrace, + regularIncremental, + regularIncrementalRepeated, + headerTrace, + headerTraceIncremental, + collapsibleTrace, + collapsibleTraceIncremental, +} from '../components/log/mock_data'; + +describe('Jobs Store Utils', () => { + describe('parseHeaderLine', () => { + it('returns a new object with the header keys and the provided line parsed', () => { + const headerLine = { content: [{ text: 'foo' }] }; + const parsedHeaderLine = parseHeaderLine(headerLine, 2); + + expect(parsedHeaderLine).toEqual({ + isClosed: false, + isHeader: true, + line: { + ...headerLine, + lineNumber: 2, + }, + lines: [], + }); + }); + + it('pre-closes a section when specified in options', () => { + const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } }; + + const parsedHeaderLine = parseHeaderLine(headerLine, 2); + + expect(parsedHeaderLine.isClosed).toBe(true); + }); + + it('expands all pre-closed sections if hash is present', () => { + const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } }; + + const parsedHeaderLine = parseHeaderLine(headerLine, 2, '#L33'); + + expect(parsedHeaderLine.isClosed).toBe(false); + }); + }); + + describe('parseLine', () => { + it('returns a new object with the lineNumber key added to the provided line object', () => { + const line = { content: [{ text: 'foo' }] }; + const parsed = parseLine(line, 1); + expect(parsed.content).toEqual(line.content); + expect(parsed.lineNumber).toEqual(1); + }); + }); + + describe('addDurationToHeader', () => { + const duration = { + offset: 106, + content: [], + section: 'prepare-script', + section_duration: '00:03', + }; + + it('adds the section duration to the correct header', () => { + const parsed = [ + { + isClosed: false, + isHeader: true, + line: { + section: 'prepare-script', + content: [{ text: 'foo' }], + }, + lines: [], + }, + { + isClosed: false, + isHeader: true, + line: { + section: 'foo-bar', + content: [{ text: 'foo' }], + }, + lines: [], + }, + ]; + + addDurationToHeader(parsed, duration); + + expect(parsed[0].line.section_duration).toEqual(duration.section_duration); + expect(parsed[1].line.section_duration).toEqual(undefined); + }); + + it('does not add the section duration when the headers do not match', () => { + const parsed = [ + { + isClosed: false, + isHeader: true, + line: { + section: 'bar-foo', + content: [{ text: 'foo' }], + }, + lines: [], + }, + { + isClosed: false, + isHeader: true, + line: { + section: 'foo-bar', + content: [{ text: 'foo' }], + }, + lines: [], + }, + ]; + addDurationToHeader(parsed, duration); + + expect(parsed[0].line.section_duration).toEqual(undefined); + expect(parsed[1].line.section_duration).toEqual(undefined); + }); + + it('does not add when content has no headers', () => { + const parsed = [ + { + section: 'bar-foo', + content: [{ text: 'foo' }], + lineNumber: 1, + }, + { + section: 'foo-bar', + content: [{ text: 'foo' }], + lineNumber: 2, + }, + ]; + + addDurationToHeader(parsed, duration); + + expect(parsed[0].line).toEqual(undefined); + expect(parsed[1].line).toEqual(undefined); + }); + }); + + describe('isCollapsibleSection', () => { + const header = { + isHeader: true, + line: { + section: 'foo', + }, + }; + const line = { + lineNumber: 1, + section: 'foo', + content: [], + }; + + it('returns true when line belongs to the last section', () => { + expect(isCollapsibleSection([header], header, { section: 'foo', content: [] })).toEqual(true); + }); + + it('returns false when last line was not an header', () => { + expect(isCollapsibleSection([line], line, { section: 'bar' })).toEqual(false); + }); + + it('returns false when accumulator is empty', () => { + expect(isCollapsibleSection([], { isHeader: true }, { section: 'bar' })).toEqual(false); + }); + + it('returns false when section_duration is defined', () => { + expect(isCollapsibleSection([header], header, { section_duration: '10:00' })).toEqual(false); + }); + + it('returns false when `section` is not a match', () => { + expect(isCollapsibleSection([header], header, { section: 'bar' })).toEqual(false); + }); + + it('returns false when no parameters are provided', () => { + expect(isCollapsibleSection()).toEqual(false); + }); + }); + describe('logLinesParser', () => { + let result; + + beforeEach(() => { + result = logLinesParser(utilsMockData); + }); + + describe('regular line', () => { + it('adds a lineNumber property with correct index', () => { + expect(result[0].lineNumber).toEqual(0); + expect(result[1].line.lineNumber).toEqual(1); + }); + }); + + describe('collapsible section', () => { + it('adds a `isClosed` property', () => { + expect(result[1].isClosed).toEqual(false); + }); + + it('adds a `isHeader` property', () => { + expect(result[1].isHeader).toEqual(true); + }); + + it('creates a lines array property with the content of the collapsible section', () => { + expect(result[1].lines.length).toEqual(2); + expect(result[1].lines[0].content).toEqual(utilsMockData[2].content); + expect(result[1].lines[1].content).toEqual(utilsMockData[3].content); + }); + }); + + describe('section duration', () => { + it('adds the section information to the header section', () => { + expect(result[1].line.section_duration).toEqual(utilsMockData[4].section_duration); + }); + + it('does not add section duration as a line', () => { + expect(result[1].lines.includes(utilsMockData[4])).toEqual(false); + }); + }); + }); + + describe('findOffsetAndRemove', () => { + describe('when last item is header', () => { + const existingLog = [ + { + isHeader: true, + isClosed: false, + line: { content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }, + }, + ]; + + describe('and matches the offset', () => { + it('returns an array with the item removed', () => { + const newData = [{ offset: 10, content: [{ text: 'foobar' }] }]; + const result = findOffsetAndRemove(newData, existingLog); + + expect(result).toEqual([]); + }); + }); + + describe('and does not match the offset', () => { + it('returns the provided existing log', () => { + const newData = [{ offset: 110, content: [{ text: 'foobar' }] }]; + const result = findOffsetAndRemove(newData, existingLog); + + expect(result).toEqual(existingLog); + }); + }); + }); + + describe('when last item is a regular line', () => { + const existingLog = [{ content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }]; + + describe('and matches the offset', () => { + it('returns an array with the item removed', () => { + const newData = [{ offset: 10, content: [{ text: 'foobar' }] }]; + const result = findOffsetAndRemove(newData, existingLog); + + expect(result).toEqual([]); + }); + }); + + describe('and does not match the fofset', () => { + it('returns the provided old log', () => { + const newData = [{ offset: 101, content: [{ text: 'foobar' }] }]; + const result = findOffsetAndRemove(newData, existingLog); + + expect(result).toEqual(existingLog); + }); + }); + }); + + describe('when last item is nested', () => { + const existingLog = [ + { + isHeader: true, + isClosed: false, + lines: [{ offset: 101, content: [{ text: 'foobar' }], lineNumber: 2 }], + line: { + offset: 10, + lineNumber: 1, + section_duration: '10:00', + }, + }, + ]; + + describe('and matches the offset', () => { + it('returns an array with the last nested line item removed', () => { + const newData = [{ offset: 101, content: [{ text: 'foobar' }] }]; + + const result = findOffsetAndRemove(newData, existingLog); + expect(result[0].lines).toEqual([]); + }); + }); + + describe('and does not match the offset', () => { + it('returns the provided old log', () => { + const newData = [{ offset: 120, content: [{ text: 'foobar' }] }]; + + const result = findOffsetAndRemove(newData, existingLog); + expect(result).toEqual(existingLog); + }); + }); + }); + + describe('when no data is provided', () => { + it('returns an empty array', () => { + const result = findOffsetAndRemove(); + expect(result).toEqual([]); + }); + }); + }); + + describe('getIncrementalLineNumber', () => { + describe('when last line is 0', () => { + it('returns 1', () => { + const log = [ + { + content: [], + lineNumber: 0, + }, + ]; + + expect(getIncrementalLineNumber(log)).toEqual(1); + }); + }); + + describe('with unnested line', () => { + it('returns the lineNumber of the last item in the array', () => { + const log = [ + { + content: [], + lineNumber: 10, + }, + { + content: [], + lineNumber: 101, + }, + ]; + + expect(getIncrementalLineNumber(log)).toEqual(102); + }); + }); + + describe('when last line is the header section', () => { + it('returns the lineNumber of the last item in the array', () => { + const log = [ + { + content: [], + lineNumber: 10, + }, + { + isHeader: true, + line: { + lineNumber: 101, + content: [], + }, + lines: [], + }, + ]; + + expect(getIncrementalLineNumber(log)).toEqual(102); + }); + }); + + describe('when last line is a nested line', () => { + it('returns the lineNumber of the last item in the nested array', () => { + const log = [ + { + content: [], + lineNumber: 10, + }, + { + isHeader: true, + line: { + lineNumber: 101, + content: [], + }, + lines: [ + { + lineNumber: 102, + content: [], + }, + { lineNumber: 103, content: [] }, + ], + }, + ]; + + expect(getIncrementalLineNumber(log)).toEqual(104); + }); + }); + }); + + describe('updateIncrementalJobLog', () => { + describe('without repeated section', () => { + it('concats and parses both arrays', () => { + const oldLog = logLinesParser(originalTrace); + const result = updateIncrementalJobLog(regularIncremental, oldLog); + + expect(result).toEqual([ + { + offset: 1, + content: [ + { + text: 'Downloading', + }, + ], + lineNumber: 0, + }, + { + offset: 2, + content: [ + { + text: 'log line', + }, + ], + lineNumber: 1, + }, + ]); + }); + }); + + describe('with regular line repeated offset', () => { + it('updates the last line and formats with the incremental part', () => { + const oldLog = logLinesParser(originalTrace); + const result = updateIncrementalJobLog(regularIncrementalRepeated, oldLog); + + expect(result).toEqual([ + { + offset: 1, + content: [ + { + text: 'log line', + }, + ], + lineNumber: 0, + }, + ]); + }); + }); + + describe('with header line repeated', () => { + it('updates the header line and formats with the incremental part', () => { + const oldLog = logLinesParser(headerTrace); + const result = updateIncrementalJobLog(headerTraceIncremental, oldLog); + + expect(result).toEqual([ + { + isClosed: false, + isHeader: true, + line: { + offset: 1, + section_header: true, + content: [ + { + text: 'updated log line', + }, + ], + section: 'section', + lineNumber: 0, + }, + lines: [], + }, + ]); + }); + }); + + describe('with collapsible line repeated', () => { + it('updates the collapsible line and formats with the incremental part', () => { + const oldLog = logLinesParser(collapsibleTrace); + const result = updateIncrementalJobLog(collapsibleTraceIncremental, oldLog); + + expect(result).toEqual([ + { + isClosed: false, + isHeader: true, + line: { + offset: 1, + section_header: true, + content: [ + { + text: 'log line', + }, + ], + section: 'section', + lineNumber: 0, + }, + lines: [ + { + offset: 2, + content: [ + { + text: 'updated log line', + }, + ], + section: 'section', + lineNumber: 1, + }, + ], + }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/ci/job_details/utils_spec.js b/spec/frontend/ci/job_details/utils_spec.js new file mode 100644 index 00000000000..7b5a97f3939 --- /dev/null +++ b/spec/frontend/ci/job_details/utils_spec.js @@ -0,0 +1,265 @@ +import { compactJobLog, filterAnnotations } from '~/ci/job_details/utils'; +import { mockJobLog } from 'jest/ci/jobs_mock_data'; + +describe('Job utils', () => { + describe('compactJobLog', () => { + it('compacts job log correctly', () => { + const expectedResults = [ + { + content: [ + { + text: 'Running with gitlab-runner 15.0.0 (febb2a09)', + }, + ], + lineNumber: 0, + offset: 0, + }, + { + content: [ + { + text: ' on colima-docker EwM9WzgD', + }, + ], + lineNumber: 1, + offset: 54, + }, + { + content: [ + { + style: 'term-fg-l-cyan term-bold', + text: 'Resolving secrets', + }, + ], + lineNumber: 2, + offset: 91, + section: 'resolve-secrets', + section_duration: '00:00', + section_header: true, + }, + { + content: [ + { + style: 'term-fg-l-cyan term-bold', + text: 'Preparing the "docker" executor', + }, + ], + lineNumber: 4, + offset: 218, + section: 'prepare-executor', + section_duration: '00:01', + section_header: true, + }, + { + content: [ + { + text: 'Using Docker executor with image ruby:2.7 ...', + }, + ], + lineNumber: 5, + offset: 317, + section: 'prepare-executor', + }, + { + content: [ + { + text: 'Pulling docker image ruby:2.7 ...', + }, + ], + lineNumber: 6, + offset: 372, + section: 'prepare-executor', + }, + { + content: [ + { + text: + 'Using docker image sha256:55106bf6ba7f452c38d01ea760affc6ceb67d4b60068ffadab98d1b7b007668c for ruby:2.7 with digest ruby@sha256:23d08a4bae1a12ee3fce017f83204fcf9a02243443e4a516e65e5ff73810a449 ...', + }, + ], + lineNumber: 7, + offset: 415, + section: 'prepare-executor', + }, + { + content: [ + { + style: 'term-fg-l-cyan term-bold', + text: 'Preparing environment', + }, + ], + lineNumber: 9, + offset: 665, + section: 'prepare-script', + section_duration: '00:01', + section_header: true, + }, + { + content: [ + { + text: 'Running on runner-ewm9wzgd-project-20-concurrent-0 via 8ea689ec6969...', + }, + ], + lineNumber: 10, + offset: 752, + section: 'prepare-script', + }, + { + content: [ + { + style: 'term-fg-l-cyan term-bold', + text: 'Getting source from Git repository', + }, + ], + lineNumber: 12, + offset: 865, + section: 'get-sources', + section_duration: '00:01', + section_header: true, + }, + { + content: [ + { + style: 'term-fg-l-green term-bold', + text: 'Fetching changes with git depth set to 20...', + }, + ], + lineNumber: 13, + offset: 962, + section: 'get-sources', + }, + { + content: [ + { + text: 'Reinitialized existing Git repository in /builds/root/ci-project/.git/', + }, + ], + lineNumber: 14, + offset: 1019, + section: 'get-sources', + }, + { + content: [ + { + style: 'term-fg-l-green term-bold', + text: 'Checking out e0f63d76 as main...', + }, + ], + lineNumber: 15, + offset: 1090, + section: 'get-sources', + }, + { + content: [ + { + style: 'term-fg-l-green term-bold', + text: 'Skipping Git submodules setup', + }, + ], + lineNumber: 16, + offset: 1136, + section: 'get-sources', + }, + { + content: [ + { + style: 'term-fg-l-cyan term-bold', + text: 'Executing "step_script" stage of the job script', + }, + ], + lineNumber: 18, + offset: 1217, + section: 'step-script', + section_duration: '00:00', + section_header: true, + }, + { + content: [ + { + text: + 'Using docker image sha256:55106bf6ba7f452c38d01ea760affc6ceb67d4b60068ffadab98d1b7b007668c for ruby:2.7 with digest ruby@sha256:23d08a4bae1a12ee3fce017f83204fcf9a02243443e4a516e65e5ff73810a449 ...', + }, + ], + lineNumber: 19, + offset: 1327, + section: 'step-script', + }, + { + content: [ + { + style: 'term-fg-l-green term-bold', + text: '$ echo "82.71"', + }, + ], + lineNumber: 20, + offset: 1533, + section: 'step-script', + }, + { + content: [ + { + text: '82.71', + }, + ], + lineNumber: 21, + offset: 1560, + section: 'step-script', + }, + { + content: [ + { + style: 'term-fg-l-green term-bold', + text: 'Job succeeded', + }, + ], + lineNumber: 23, + offset: 1605, + }, + ]; + + expect(compactJobLog(mockJobLog)).toStrictEqual(expectedResults); + }); + }); + + describe('filterAnnotations', () => { + it('filters annotations by type', () => { + const data = [ + { + name: 'b', + data: [ + { + dummy: {}, + }, + { + external_link: { + label: 'URL 2', + url: 'https://url2.example.com/', + }, + }, + ], + }, + { + name: 'a', + data: [ + { + external_link: { + label: 'URL 1', + url: 'https://url1.example.com/', + }, + }, + ], + }, + ]; + + expect(filterAnnotations(data, 'external_link')).toEqual([ + { + label: 'URL 1', + url: 'https://url1.example.com/', + }, + { + label: 'URL 2', + url: 'https://url2.example.com/', + }, + ]); + }); + }); +}); diff --git a/spec/frontend/ci/jobs_mock_data.js b/spec/frontend/ci/jobs_mock_data.js new file mode 100644 index 00000000000..c428de3b9d8 --- /dev/null +++ b/spec/frontend/ci/jobs_mock_data.js @@ -0,0 +1,1629 @@ +import mockJobsCount from 'test_fixtures/graphql/jobs/get_jobs_count.query.graphql.json'; +import mockAllJobsCount from 'test_fixtures/graphql/jobs/get_all_jobs_count.query.graphql.json'; +import mockJobsEmpty from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.empty.json'; +import mockAllJobsEmpty from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.empty.json'; +import mockJobsPaginated from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.paginated.json'; +import mockAllJobsPaginated from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.paginated.json'; +import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json'; +import mockAllJobs from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.json'; +import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json'; +import mockCancelableJobsCount from 'test_fixtures/graphql/jobs/get_cancelable_jobs_count.query.graphql.json'; +import { TEST_HOST } from 'spec/test_constants'; +import { TOKEN_TYPE_STATUS } from '~/vue_shared/components/filtered_search_bar/constants'; + +const threeWeeksAgo = new Date(); +threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + +// Fixtures generated at spec/frontend/fixtures/jobs.rb +export const mockJobsResponsePaginated = mockJobsPaginated; +export const mockAllJobsResponsePaginated = mockAllJobsPaginated; +export const mockJobsResponseEmpty = mockJobsEmpty; +export const mockAllJobsResponseEmpty = mockAllJobsEmpty; +export const mockJobsNodes = mockJobs.data.project.jobs.nodes; +export const mockAllJobsNodes = mockAllJobs.data.jobs.nodes; +export const mockJobsNodesAsGuest = mockJobsAsGuest.data.project.jobs.nodes; +export const mockJobsCountResponse = mockJobsCount; +export const mockAllJobsCountResponse = mockAllJobsCount; +export const mockCancelableJobsCountResponse = mockCancelableJobsCount; + +export const stages = [ + { + name: 'build', + title: 'build: running', + groups: [ + { + name: 'build:linux', + size: 1, + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/1180', + illustration: { + image: 'illustrations/pending_job_empty.svg', + size: 'svg-430', + title: 'This job has not started yet', + content: 'This job is in pending state and is waiting to be picked by a runner', + }, + favicon: + '/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', + method: 'post', + }, + }, + jobs: [ + { + id: 1180, + name: 'build:linux', + started: false, + build_path: '/gitlab-org/gitlab-shell/-/jobs/1180', + cancel_path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', + playable: false, + created_at: '2018-09-28T11:09:57.229Z', + updated_at: '2018-09-28T11:09:57.503Z', + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/1180', + illustration: { + image: 'illustrations/pending_job_empty.svg', + size: 'svg-430', + title: 'This job has not started yet', + content: 'This job is in pending state and is waiting to be picked by a runner', + }, + favicon: + '/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'build:osx', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/444', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 444, + name: 'build:osx', + started: '2018-05-18T05:32:20.655Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/444', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', + playable: false, + created_at: '2018-05-18T15:32:54.364Z', + updated_at: '2018-05-18T15:32:54.364Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/444', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + tooltip: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/pipelines/27#build', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_running-9c635b2419a8e1ec991c993061b89cc5aefc0743bb238ecd0c381e7741a70e8c.png', + }, + path: '/gitlab-org/gitlab-shell/pipelines/27#build', + dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=build', + }, + { + name: 'test', + title: 'test: passed with warnings', + groups: [ + { + name: 'jenkins', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: null, + group: 'success', + tooltip: null, + has_details: false, + details_path: null, + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + jobs: [ + { + id: 459, + name: 'jenkins', + started: '2018-05-18T09:32:20.658Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/459', + playable: false, + created_at: '2018-05-18T15:32:55.330Z', + updated_at: '2018-05-18T15:32:55.330Z', + status: { + icon: 'status_success', + text: 'passed', + label: null, + group: 'success', + tooltip: null, + has_details: false, + details_path: null, + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + }, + ], + }, + { + name: 'rspec:linux', + size: 3, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: false, + details_path: null, + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + jobs: [ + { + id: 445, + name: 'rspec:linux 0 3', + started: '2018-05-18T07:32:20.655Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/445', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/445/retry', + playable: false, + created_at: '2018-05-18T15:32:54.425Z', + updated_at: '2018-05-18T15:32:54.425Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/445', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/445/retry', + method: 'post', + }, + }, + }, + { + id: 446, + name: 'rspec:linux 1 3', + started: '2018-05-18T07:32:20.655Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/446', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/446/retry', + playable: false, + created_at: '2018-05-18T15:32:54.506Z', + updated_at: '2018-05-18T15:32:54.506Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/446', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/446/retry', + method: 'post', + }, + }, + }, + { + id: 447, + name: 'rspec:linux 2 3', + started: '2018-05-18T07:32:20.656Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/447', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/447/retry', + playable: false, + created_at: '2018-05-18T15:32:54.572Z', + updated_at: '2018-05-18T15:32:54.572Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/447', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/447/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'rspec:osx', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/452', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/452/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 452, + name: 'rspec:osx', + started: '2018-05-18T07:32:20.657Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/452', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/452/retry', + playable: false, + created_at: '2018-05-18T15:32:54.920Z', + updated_at: '2018-05-18T15:32:54.920Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/452', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/452/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'rspec:windows', + size: 3, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: false, + details_path: null, + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + jobs: [ + { + id: 448, + name: 'rspec:windows 0 3', + started: '2018-05-18T07:32:20.656Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/448', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/448/retry', + playable: false, + created_at: '2018-05-18T15:32:54.639Z', + updated_at: '2018-05-18T15:32:54.639Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/448', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/448/retry', + method: 'post', + }, + }, + }, + { + id: 449, + name: 'rspec:windows 1 3', + started: '2018-05-18T07:32:20.656Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/449', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/449/retry', + playable: false, + created_at: '2018-05-18T15:32:54.703Z', + updated_at: '2018-05-18T15:32:54.703Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/449', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/449/retry', + method: 'post', + }, + }, + }, + { + id: 451, + name: 'rspec:windows 2 3', + started: '2018-05-18T07:32:20.657Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/451', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/451/retry', + playable: false, + created_at: '2018-05-18T15:32:54.853Z', + updated_at: '2018-05-18T15:32:54.853Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/451', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/451/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'spinach:linux', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/453', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/453/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 453, + name: 'spinach:linux', + started: '2018-05-18T07:32:20.657Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/453', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/453/retry', + playable: false, + created_at: '2018-05-18T15:32:54.993Z', + updated_at: '2018-05-18T15:32:54.993Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/453', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/453/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'spinach:osx', + size: 1, + status: { + icon: 'status_warning', + text: 'failed', + label: 'failed (allowed to fail)', + group: 'failed-with-warnings', + tooltip: 'failed - (unknown failure) (allowed to fail)', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/454', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/454/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 454, + name: 'spinach:osx', + started: '2018-05-18T07:32:20.657Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/454', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/454/retry', + playable: false, + created_at: '2018-05-18T15:32:55.053Z', + updated_at: '2018-05-18T15:32:55.053Z', + status: { + icon: 'status_warning', + text: 'failed', + label: 'failed (allowed to fail)', + group: 'failed-with-warnings', + tooltip: 'failed - (unknown failure) (allowed to fail)', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/454', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/454/retry', + method: 'post', + }, + }, + callout_message: 'There is an unknown failure, please try again', + recoverable: true, + }, + ], + }, + ], + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/pipelines/27#test', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/gitlab-org/gitlab-shell/pipelines/27#test', + dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=test', + }, + { + name: 'deploy', + title: 'deploy: running', + groups: [ + { + name: 'production', + size: 1, + status: { + icon: 'status_created', + text: 'created', + label: 'created', + group: 'created', + tooltip: 'created', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/457', + illustration: { + image: 'illustrations/job_not_triggered.svg', + size: 'svg-306', + title: 'This job has not been triggered yet', + content: + 'This job depends on upstream jobs that need to succeed in order for this job to be triggered', + }, + favicon: + '/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/gitlab-org/gitlab-shell/-/jobs/457/cancel', + method: 'post', + }, + }, + jobs: [ + { + id: 457, + name: 'production', + started: false, + build_path: '/gitlab-org/gitlab-shell/-/jobs/457', + cancel_path: '/gitlab-org/gitlab-shell/-/jobs/457/cancel', + playable: false, + created_at: '2018-05-18T15:32:55.259Z', + updated_at: '2018-09-28T11:09:57.454Z', + status: { + icon: 'status_created', + text: 'created', + label: 'created', + group: 'created', + tooltip: 'created', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/457', + illustration: { + image: 'illustrations/job_not_triggered.svg', + size: 'svg-306', + title: 'This job has not been triggered yet', + content: + 'This job depends on upstream jobs that need to succeed in order for this job to be triggered', + }, + favicon: + '/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/gitlab-org/gitlab-shell/-/jobs/457/cancel', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'staging', + size: 1, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/455', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/455/retry', + method: 'post', + }, + }, + jobs: [ + { + id: 455, + name: 'staging', + started: '2018-05-18T09:32:20.658Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/455', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/455/retry', + playable: false, + created_at: '2018-05-18T15:32:55.119Z', + updated_at: '2018-05-18T15:32:55.119Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/455', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/455/retry', + method: 'post', + }, + }, + }, + ], + }, + { + name: 'stop staging', + size: 1, + status: { + icon: 'status_created', + text: 'created', + label: 'created', + group: 'created', + tooltip: 'created', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/456', + illustration: { + image: 'illustrations/job_not_triggered.svg', + size: 'svg-306', + title: 'This job has not been triggered yet', + content: + 'This job depends on upstream jobs that need to succeed in order for this job to be triggered', + }, + favicon: + '/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/gitlab-org/gitlab-shell/-/jobs/456/cancel', + method: 'post', + }, + }, + jobs: [ + { + id: 456, + name: 'stop staging', + started: false, + build_path: '/gitlab-org/gitlab-shell/-/jobs/456', + cancel_path: '/gitlab-org/gitlab-shell/-/jobs/456/cancel', + playable: false, + created_at: '2018-05-18T15:32:55.205Z', + updated_at: '2018-09-28T11:09:57.396Z', + status: { + icon: 'status_created', + text: 'created', + label: 'created', + group: 'created', + tooltip: 'created', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/456', + illustration: { + image: 'illustrations/job_not_triggered.svg', + size: 'svg-306', + title: 'This job has not been triggered yet', + content: + 'This job depends on upstream jobs that need to succeed in order for this job to be triggered', + }, + favicon: + '/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/gitlab-org/gitlab-shell/-/jobs/456/cancel', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + tooltip: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/pipelines/27#deploy', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_running-9c635b2419a8e1ec991c993061b89cc5aefc0743bb238ecd0c381e7741a70e8c.png', + }, + path: '/gitlab-org/gitlab-shell/pipelines/27#deploy', + dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=deploy', + }, + { + name: 'notify', + title: 'notify: manual action', + groups: [ + { + name: 'slack', + size: 1, + status: { + icon: 'status_manual', + text: 'manual', + label: 'manual play action', + group: 'manual', + tooltip: 'manual action', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/458', + illustration: { + image: 'illustrations/manual_action.svg', + size: 'svg-394', + title: 'This job requires a manual action', + content: + 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', + }, + favicon: + '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', + action: { + icon: 'play', + title: 'Play', + path: '/gitlab-org/gitlab-shell/-/jobs/458/play', + method: 'post', + }, + }, + jobs: [ + { + id: 458, + name: 'slack', + started: null, + build_path: '/gitlab-org/gitlab-shell/-/jobs/458', + play_path: '/gitlab-org/gitlab-shell/-/jobs/458/play', + playable: true, + created_at: '2018-05-18T15:32:55.303Z', + updated_at: '2018-05-18T15:34:08.535Z', + status: { + icon: 'status_manual', + text: 'manual', + label: 'manual play action', + group: 'manual', + tooltip: 'manual action', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/458', + illustration: { + image: 'illustrations/manual_action.svg', + size: 'svg-394', + title: 'This job requires a manual action', + content: + 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', + }, + favicon: + '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', + action: { + icon: 'play', + title: 'Play', + path: '/gitlab-org/gitlab-shell/-/jobs/458/play', + method: 'post', + }, + }, + }, + ], + }, + ], + status: { + icon: 'status_manual', + text: 'manual', + label: 'manual action', + group: 'manual', + tooltip: 'manual action', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/pipelines/27#notify', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', + }, + path: '/gitlab-org/gitlab-shell/pipelines/27#notify', + dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=notify', + }, +]; + +export const statuses = { + success: 'SUCCESS', + failed: 'FAILED', + canceled: 'CANCELED', + pending: 'PENDING', + running: 'RUNNING', +}; + +export default { + id: 4757, + artifact: { + locked: false, + }, + name: 'test', + stage: 'build', + build_path: '/root/ci-mock/-/jobs/4757', + retry_path: '/root/ci-mock/-/jobs/4757/retry', + cancel_path: '/root/ci-mock/-/jobs/4757/cancel', + new_issue_path: '/root/ci-mock/issues/new', + playable: false, + complete: true, + created_at: threeWeeksAgo.toISOString(), + updated_at: threeWeeksAgo.toISOString(), + finished_at: threeWeeksAgo.toISOString(), + queued_duration: 9.54, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: `${TEST_HOST}/root/ci-mock/-/jobs/4757`, + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/-/jobs/4757/retry', + method: 'post', + }, + }, + coverage: 20, + erased_at: threeWeeksAgo.toISOString(), + erased: false, + duration: 6.785563, + tags: ['tag'], + user: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + erase_path: '/root/ci-mock/-/jobs/4757/erase', + artifacts: [null], + annotations: [], + runner: { + id: 1, + short_sha: 'ABCDEFGH', + description: 'local ci runner', + edit_path: '/root/ci-mock/runners/1/edit', + }, + pipeline: { + id: 140, + user: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + active: false, + coverage: null, + source: 'unknown', + created_at: '2017-05-24T09:59:58.634Z', + updated_at: '2017-06-01T17:32:00.062Z', + path: '/root/ci-mock/pipelines/140', + flags: { + latest: true, + stuck: false, + yaml_errors: false, + retryable: false, + cancelable: false, + }, + details: { + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + has_details: true, + details_path: '/root/ci-mock/pipelines/140', + favicon: + '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', + }, + duration: 6, + finished_at: '2017-06-01T17:32:00.042Z', + stages: [ + { + dropdown_path: '/jashkenas/underscore/pipelines/16/stage.json?stage=build', + name: 'build', + path: '/jashkenas/underscore/pipelines/16#build', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + }, + title: 'build: passed', + }, + { + dropdown_path: '/jashkenas/underscore/pipelines/16/stage.json?stage=test', + name: 'test', + path: '/jashkenas/underscore/pipelines/16#test', + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + }, + title: 'test: passed with warnings', + }, + ], + }, + ref: { + name: 'abc', + path: '/root/ci-mock/commits/abc', + tag: false, + branch: true, + }, + commit: { + id: 'c58647773a6b5faf066d4ad6ff2c9fbba5f180f6', + short_id: 'c5864777', + title: 'Add new file', + created_at: '2017-05-24T10:59:52.000+01:00', + parent_ids: ['798e5f902592192afaba73f4668ae30e56eae492'], + message: 'Add new file', + author_name: 'Root', + author_email: 'admin@example.com', + authored_date: '2017-05-24T10:59:52.000+01:00', + committer_name: 'Root', + committer_email: 'admin@example.com', + committed_date: '2017-05-24T10:59:52.000+01:00', + author: { + name: 'Root', + username: 'root', + id: 1, + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://localhost:3000/root', + }, + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + commit_url: + 'http://localhost:3000/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6', + commit_path: '/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6', + }, + }, + metadata: { + timeout_human_readable: '1m 40s', + timeout_source: 'runner', + }, + merge_request: { + iid: 2, + path: '/root/ci-mock/merge_requests/2', + }, + raw_path: '/root/ci-mock/builds/4757/raw', + has_trace: true, +}; + +export const failedJobStatus = { + icon: 'status_warning', + text: 'failed', + label: 'failed (allowed to fail)', + group: 'failed-with-warnings', + tooltip: 'failed - (unknown failure) (allowed to fail)', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/454', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/454/retry', + method: 'post', + }, +}; + +export const jobsInStage = { + name: 'build', + title: 'build: running', + latest_statuses: [ + { + id: 1180, + name: 'build:linux', + started: false, + build_path: '/gitlab-org/gitlab-shell/-/jobs/1180', + cancel_path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', + playable: false, + created_at: '2018-09-28T11:09:57.229Z', + updated_at: '2018-09-28T11:09:57.503Z', + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/1180', + illustration: { + image: 'illustrations/pending_job_empty.svg', + size: 'svg-430', + title: 'This job has not started yet', + content: 'This job is in pending state and is waiting to be picked by a runner', + }, + favicon: + '/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', + method: 'post', + }, + }, + }, + { + id: 444, + name: 'build:osx', + started: '2018-05-18T05:32:20.655Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/444', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', + playable: false, + created_at: '2018-05-18T15:32:54.364Z', + updated_at: '2018-05-18T15:32:54.364Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/444', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', + method: 'post', + }, + }, + }, + ], + retried: [ + { + id: 443, + name: 'build:linux', + started: '2018-05-18T06:32:20.655Z', + build_path: '/gitlab-org/gitlab-shell/-/jobs/443', + retry_path: '/gitlab-org/gitlab-shell/-/jobs/443/retry', + playable: false, + created_at: '2018-05-18T15:32:54.296Z', + updated_at: '2018-05-18T15:32:54.296Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed (retried)', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/-/jobs/443', + illustration: { + image: 'illustrations/skipped-job_empty.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/gitlab-org/gitlab-shell/-/jobs/443/retry', + method: 'post', + }, + }, + }, + ], + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + tooltip: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-shell/pipelines/27#build', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_running-9c635b2419a8e1ec991c993061b89cc5aefc0743bb238ecd0c381e7741a70e8c.png', + }, + path: '/gitlab-org/gitlab-shell/pipelines/27#build', + dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=build', +}; + +export const mockPipelineWithoutMR = { + id: 28029444, + details: { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + path: 'pipeline/28029444', + ref: { + name: 'test-branch', + }, +}; + +export const mockPipelineWithoutRef = { + ...mockPipelineWithoutMR, + ref: null, +}; + +export const mockPipelineWithAttachedMR = { + id: 28029444, + details: { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + path: 'pipeline/28029444', + flags: { + merge_request_pipeline: true, + detached_merge_request_pipeline: false, + }, + merge_request: { + iid: 1234, + path: '/root/detached-merge-request-pipelines/-/merge_requests/1', + title: 'Update README.md', + source_branch: 'feature-1234', + source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', + target_branch: 'main', + target_branch_path: '/root/detached-merge-request-pipelines/branches/main', + }, + ref: { + name: 'test-branch', + }, +}; + +export const mockPipelineDetached = { + id: 28029444, + details: { + status: { + details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', + group: 'success', + has_details: true, + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed', + }, + }, + path: 'pipeline/28029444', + flags: { + merge_request_pipeline: false, + detached_merge_request_pipeline: true, + }, + merge_request: { + iid: 1234, + path: '/root/detached-merge-request-pipelines/-/merge_requests/1', + title: 'Update README.md', + source_branch: 'feature-1234', + source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', + target_branch: 'main', + target_branch_path: '/root/detached-merge-request-pipelines/branches/main', + }, + ref: { + name: 'test-branch', + }, +}; + +export const CIJobConnectionIncomingCache = { + __typename: 'CiJobConnection', + pageInfo: { + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjIwNTEifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIxNzMifQ', + }, + nodes: [ + { __ref: 'CiJob:gid://gitlab/Ci::Build/2057' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2056' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2051' }, + ], +}; + +export const CIJobConnectionIncomingCacheRunningStatus = { + __typename: 'CiJobConnection', + pageInfo: { + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjIwNTEifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIxNzMifQ', + }, + nodes: [ + { __ref: 'CiJob:gid://gitlab/Ci::Build/2000' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2001' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2002' }, + ], +}; + +export const CIJobConnectionExistingCache = { + pageInfo: { + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjIwNTEifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIxNzMifQ', + }, + nodes: [ + { __ref: 'CiJob:gid://gitlab/Ci::Build/2100' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2101' }, + { __ref: 'CiJob:gid://gitlab/Ci::Build/2102' }, + ], + statuses: 'PENDING', +}; + +export const mockFailedSearchToken = { + type: TOKEN_TYPE_STATUS, + value: { data: 'FAILED', operator: '=' }, +}; + +export const retryMutationResponse = { + data: { + jobRetry: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1985"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1985', + id: 'pending-1985-1985', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const playMutationResponse = { + data: { + jobPlay: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1986"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1986', + id: 'pending-1986-1986', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const cancelMutationResponse = { + data: { + jobCancel: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1987"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1987', + id: 'pending-1987-1987', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const unscheduleMutationResponse = { + data: { + jobUnschedule: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1988"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1988', + id: 'pending-1988-1988', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +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/ci/jobs_page/components/job_cells/actions_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js new file mode 100644 index 00000000000..1ffd680118e --- /dev/null +++ b/spec/frontend/ci/jobs_page/components/job_cells/actions_cell_spec.js @@ -0,0 +1,240 @@ +import { GlModal } 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 { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import ActionsCell from '~/ci/jobs_page/components/job_cells/actions_cell.vue'; +import eventHub from '~/ci/jobs_page/event_hub'; +import JobPlayMutation from '~/ci/jobs_page/graphql/mutations/job_play.mutation.graphql'; +import JobRetryMutation from '~/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql'; +import JobUnscheduleMutation from '~/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql'; +import JobCancelMutation from '~/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql'; +import { + mockJobsNodes, + mockJobsNodesAsGuest, + playMutationResponse, + retryMutationResponse, + unscheduleMutationResponse, + cancelMutationResponse, +} from 'jest/ci/jobs_mock_data'; + +jest.mock('~/lib/utils/url_utility'); + +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 failedJob = findMockJob('failed'); + 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'); + const findDownloadArtifactsButton = () => wrapper.findByTestId('download-artifacts'); + const findCountdownButton = () => wrapper.findByTestId('countdown'); + const findPlayScheduledJobButton = () => wrapper.findByTestId('play-scheduled'); + const findUnscheduleButton = () => wrapper.findByTestId('unschedule'); + + const findModal = () => wrapper.findComponent(GlModal); + + const playMutationHandler = jest.fn().mockResolvedValue(playMutationResponse); + const retryMutationHandler = jest.fn().mockResolvedValue(retryMutationResponse); + const unscheduleMutationHandler = jest.fn().mockResolvedValue(unscheduleMutationResponse); + const cancelMutationHandler = jest.fn().mockResolvedValue(cancelMutationResponse); + + const $toast = { + show: jest.fn(), + }; + + const createMockApolloProvider = (requestHandlers) => { + return createMockApollo(requestHandlers); + }; + + const createComponent = (job, requestHandlers, props = {}) => { + wrapper = shallowMountExtended(ActionsCell, { + propsData: { + job, + ...props, + }, + apolloProvider: createMockApolloProvider(requestHandlers), + mocks: { + $toast, + }, + }); + }; + + it('displays the artifacts download button with correct link', () => { + createComponent(jobWithArtifact); + + expect(findDownloadArtifactsButton().attributes('href')).toBe( + jobWithArtifact.artifacts.nodes[0].downloadPath, + ); + }); + + it('does not display an artifacts download button', () => { + createComponent(mockJob); + + expect(findDownloadArtifactsButton().exists()).toBe(false); + }); + + it.each` + button | action | jobType + ${findPlayButton} | ${'play'} | ${cannotPlayJob} + ${findRetryButton} | ${'retry'} | ${cannotRetryJob} + ${findPlayScheduledJobButton} | ${'play scheduled'} | ${cannotPlayScheduledJob} + `('does not display the $action button if user cannot update build', ({ button, jobType }) => { + createComponent(jobType); + + expect(button().exists()).toBe(false); + }); + + it.each` + button | action | jobType + ${findPlayButton} | ${'play'} | ${playableJob} + ${findRetryButton} | ${'retry'} | ${retryableJob} + ${findDownloadArtifactsButton} | ${'download artifacts'} | ${jobWithArtifact} + ${findCancelButton} | ${'cancel'} | ${cancelableJob} + `('displays the $action button', ({ button, jobType }) => { + createComponent(jobType); + + expect(button().exists()).toBe(true); + }); + + it.each` + button | action | jobType | mutationFile | handler | jobId + ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${playableJob.id} + ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${retryableJob.id} + ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} | ${cancelableJob.id} + `('performs the $action mutation', ({ button, jobType, mutationFile, handler, jobId }) => { + createComponent(jobType, [[mutationFile, handler]]); + + button().vm.$emit('click'); + + expect(handler).toHaveBeenCalledWith({ id: jobId }); + }); + + it.each` + button | action | jobType | mutationFile | handler + ${findUnscheduleButton} | ${'unschedule'} | ${scheduledJob} | ${JobUnscheduleMutation} | ${unscheduleMutationHandler} + ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} + `( + 'the mutation action $action emits the jobActionPerformed event', + async ({ button, jobType, mutationFile, handler }) => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + createComponent(jobType, [[mutationFile, handler]]); + + button().vm.$emit('click'); + + await waitForPromises(); + + expect(eventHub.$emit).toHaveBeenCalledWith('jobActionPerformed'); + expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated + }, + ); + + it.each` + button | action | jobType | mutationFile | handler | redirectLink + ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${'/root/project/-/jobs/1986'} + ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${'/root/project/-/jobs/1985'} + `( + 'the mutation action $action redirects to the job', + async ({ button, jobType, mutationFile, handler, redirectLink }) => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + createComponent(jobType, [[mutationFile, handler]]); + + button().vm.$emit('click'); + + await waitForPromises(); + + expect(redirectTo).toHaveBeenCalledWith(redirectLink); // eslint-disable-line import/no-deprecated + expect(eventHub.$emit).not.toHaveBeenCalled(); + }, + ); + + it.each` + button | action | jobType + ${findPlayButton} | ${'play'} | ${playableJob} + ${findRetryButton} | ${'retry'} | ${retryableJob} + ${findCancelButton} | ${'cancel'} | ${cancelableJob} + ${findUnscheduleButton} | ${'unschedule'} | ${scheduledJob} + `('disables the $action button after first request', async ({ button, jobType }) => { + createComponent(jobType); + + expect(button().props('disabled')).toBe(false); + + button().vm.$emit('click'); + + await waitForPromises(); + + expect(button().props('disabled')).toBe(true); + }); + + describe('Retry button title', () => { + it('displays retry title when job has failed and is retryable', () => { + createComponent(failedJob); + + expect(findRetryButton().attributes('title')).toBe('Retry'); + }); + + it('displays run again title when job has passed and is retryable', () => { + createComponent(retryableJob); + + expect(findRetryButton().attributes('title')).toBe('Run again'); + }); + }); + + describe('Scheduled Jobs', () => { + const today = () => new Date('2021-08-31'); + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(today); + }); + + it('displays the countdown, play and unschedule buttons', () => { + createComponent(scheduledJob); + + expect(findCountdownButton().exists()).toBe(true); + expect(findPlayScheduledJobButton().exists()).toBe(true); + expect(findUnscheduleButton().exists()).toBe(true); + }); + + it('unschedules a job', () => { + createComponent(scheduledJob, [[JobUnscheduleMutation, unscheduleMutationHandler]]); + + findUnscheduleButton().vm.$emit('click'); + + expect(unscheduleMutationHandler).toHaveBeenCalledWith({ + id: scheduledJob.id, + }); + }); + + it('shows the play job confirmation modal', async () => { + createComponent(scheduledJob); + + findPlayScheduledJobButton().vm.$emit('click'); + + await nextTick(); + + expect(findModal().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/jobs_page/components/job_cells/duration_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/duration_cell_spec.js new file mode 100644 index 00000000000..21f14ba0c98 --- /dev/null +++ b/spec/frontend/ci/jobs_page/components/job_cells/duration_cell_spec.js @@ -0,0 +1,77 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import DurationCell from '~/ci/jobs_page/components/job_cells/duration_cell.vue'; + +describe('Duration Cell', () => { + let wrapper; + + const findJobDuration = () => wrapper.findByTestId('job-duration'); + const findJobFinishedTime = () => wrapper.findByTestId('job-finished-time'); + const findDurationIcon = () => wrapper.findByTestId('duration-icon'); + const findFinishedTimeIcon = () => wrapper.findByTestId('finished-time-icon'); + + const createComponent = (props) => { + wrapper = extendedWrapper( + shallowMount(DurationCell, { + propsData: { + job: { + ...props, + }, + }, + }), + ); + }; + + it('does not display duration or finished time when no properties are present', () => { + createComponent(); + + expect(findJobDuration().exists()).toBe(false); + expect(findJobFinishedTime().exists()).toBe(false); + }); + + it('displays duration and finished time when both properties are present', () => { + const props = { + duration: 7, + finishedAt: '2021-04-26T13:37:52Z', + }; + + createComponent(props); + + expect(findJobDuration().exists()).toBe(true); + expect(findJobFinishedTime().exists()).toBe(true); + }); + + it('displays only the duration of the job when the duration property is present', () => { + const props = { + duration: 7, + }; + + createComponent(props); + + expect(findJobDuration().exists()).toBe(true); + expect(findJobFinishedTime().exists()).toBe(false); + }); + + it('displays only the finished time of the job when the finshedAt property is present', () => { + const props = { + finishedAt: '2021-04-26T13:37:52Z', + }; + + createComponent(props); + + expect(findJobFinishedTime().exists()).toBe(true); + expect(findJobDuration().exists()).toBe(false); + }); + + it('displays icons for finished time and duration', () => { + const props = { + duration: 7, + finishedAt: '2021-04-26T13:37:52Z', + }; + + createComponent(props); + + expect(findFinishedTimeIcon().props('name')).toBe('calendar'); + expect(findDurationIcon().props('name')).toBe('timer'); + }); +}); diff --git a/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js new file mode 100644 index 00000000000..cb8f6ed8f9b --- /dev/null +++ b/spec/frontend/ci/jobs_page/components/job_cells/job_cell_spec.js @@ -0,0 +1,142 @@ +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import JobCell from '~/ci/jobs_page/components/job_cells/job_cell.vue'; +import { mockJobsNodes, mockJobsNodesAsGuest } from 'jest/ci/jobs_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'); + const findJobSha = () => wrapper.findByTestId('job-sha'); + const findLabelIcon = () => wrapper.findByTestId('label-icon'); + const findForkIcon = () => wrapper.findByTestId('fork-icon'); + const findStuckIcon = () => wrapper.findByTestId('stuck-icon'); + const findAllTagBadges = () => wrapper.findAllByTestId('job-tag-badge'); + + const findBadgeById = (id) => wrapper.findByTestId(id); + + const createComponent = (job = mockJob) => { + wrapper = extendedWrapper( + shallowMount(JobCell, { + propsData: { + job, + }, + }), + ); + }; + + describe('Job Id', () => { + it('displays the job id and links to the job', () => { + createComponent(); + + const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}`; + + expect(findJobIdLink().text()).toBe(expectedJobId); + expect(findJobIdLink().attributes('href')).toBe(mockJob.detailedStatus.detailsPath); + expect(findJobIdNoLink().exists()).toBe(false); + }); + + it('display the job id with no link', () => { + createComponent(jobAsGuest); + + const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}`; + + expect(findJobIdNoLink().text()).toBe(expectedJobId); + expect(findJobIdNoLink().exists()).toBe(true); + expect(findJobIdLink().exists()).toBe(false); + }); + }); + + describe('Ref of the job', () => { + it('displays the ref name and links to the ref', () => { + createComponent(); + + expect(findJobRef().text()).toBe(mockJob.refName); + expect(findJobRef().attributes('href')).toBe(mockJob.refPath); + }); + + it('displays fork icon when job is not created by tag', () => { + createComponent(); + + expect(findForkIcon().exists()).toBe(true); + expect(findLabelIcon().exists()).toBe(false); + }); + + it('displays label icon when job is created by a tag', () => { + createComponent(jobCreatedByTag); + + expect(findLabelIcon().exists()).toBe(true); + expect(findForkIcon().exists()).toBe(false); + }); + }); + + describe('Commit of the job', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the sha and links to the commit', () => { + expect(findJobSha().text()).toBe(mockJob.shortSha); + expect(findJobSha().attributes('href')).toBe(mockJob.commitPath); + }); + }); + + describe('Job badges', () => { + it('displays tags of the job', () => { + const mockJobWithTags = { + tags: ['tag-1', 'tag-2', 'tag-3'], + }; + + createComponent(mockJobWithTags); + + expect(findAllTagBadges()).toHaveLength(mockJobWithTags.tags.length); + }); + + it.each` + testId | text + ${'manual-job-badge'} | ${'manual'} + ${'triggered-job-badge'} | ${'triggered'} + ${'fail-job-badge'} | ${'allowed to fail'} + ${'delayed-job-badge'} | ${'delayed'} + `('displays the static $text badge', ({ testId, text }) => { + createComponent({ + manualJob: true, + triggered: true, + allowFailure: true, + scheduledAt: '2021-03-09T14:58:50+00:00', + }); + + expect(findBadgeById(testId).exists()).toBe(true); + expect(findBadgeById(testId).text()).toBe(text); + }); + }); + + describe('Job icons', () => { + it('stuck icon is not shown if job is not stuck', () => { + createComponent(); + + expect(findStuckIcon().exists()).toBe(false); + }); + + 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/ci/jobs_page/components/job_cells/pipeline_cell_spec.js b/spec/frontend/ci/jobs_page/components/job_cells/pipeline_cell_spec.js new file mode 100644 index 00000000000..6b212846897 --- /dev/null +++ b/spec/frontend/ci/jobs_page/components/job_cells/pipeline_cell_spec.js @@ -0,0 +1,78 @@ +import { GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import PipelineCell from '~/ci/jobs_page/components/job_cells/pipeline_cell.vue'; + +const mockJobWithoutUser = { + id: 'gid://gitlab/Ci::Build/2264', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/460', + path: '/root/ci-project/-/pipelines/460', + }, +}; + +const mockJobWithUser = { + id: 'gid://gitlab/Ci::Build/2264', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/460', + path: '/root/ci-project/-/pipelines/460', + user: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + webPath: '/root', + }, + }, +}; + +describe('Pipeline Cell', () => { + let wrapper; + + const findPipelineId = () => wrapper.findByTestId('pipeline-id'); + const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link'); + const findUserAvatar = () => wrapper.findComponent(GlAvatar); + + const createComponent = (props = mockJobWithUser) => { + wrapper = extendedWrapper( + shallowMount(PipelineCell, { + propsData: { + job: props, + }, + }), + ); + }; + + describe('Pipeline Id', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the pipeline id and links to the pipeline', () => { + const expectedPipelineId = `#${getIdFromGraphQLId(mockJobWithUser.pipeline.id)}`; + + expect(findPipelineId().text()).toBe(expectedPipelineId); + expect(findPipelineId().attributes('href')).toBe(mockJobWithUser.pipeline.path); + }); + }); + + describe('Pipeline created by', () => { + const apiWrapperText = 'API'; + + it('shows and links to the pipeline user', () => { + createComponent(); + + expect(findPipelineUserLink().exists()).toBe(true); + expect(findPipelineUserLink().attributes('href')).toBe(mockJobWithUser.pipeline.user.webPath); + expect(findUserAvatar().attributes('src')).toBe(mockJobWithUser.pipeline.user.avatarUrl); + expect(wrapper.text()).not.toContain(apiWrapperText); + }); + + it('shows pipeline was created by the API', () => { + createComponent(mockJobWithoutUser); + + expect(findPipelineUserLink().exists()).toBe(false); + expect(findUserAvatar().exists()).toBe(false); + expect(wrapper.text()).toContain(apiWrapperText); + }); + }); +}); diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js new file mode 100644 index 00000000000..f4893c4077f --- /dev/null +++ b/spec/frontend/ci/jobs_page/components/jobs_table_empty_state_spec.js @@ -0,0 +1,37 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import JobsTableEmptyState from '~/ci/jobs_page/components/jobs_table_empty_state.vue'; + +describe('Jobs table empty state', () => { + let wrapper; + + const pipelineEditorPath = '/root/project/-/ci/editor'; + const emptyStateSvgPath = 'assets/jobs-empty-state.svg'; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = () => { + wrapper = shallowMount(JobsTableEmptyState, { + provide: { + pipelineEditorPath, + emptyStateSvgPath, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('displays empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + + it('links to the pipeline editor', () => { + expect(findEmptyState().props('primaryButtonLink')).toBe(pipelineEditorPath); + }); + + it('shows an empty state image', () => { + expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath); + }); +}); diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js new file mode 100644 index 00000000000..3adb95bf371 --- /dev/null +++ b/spec/frontend/ci/jobs_page/components/jobs_table_spec.js @@ -0,0 +1,107 @@ +import { GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import { DEFAULT_FIELDS_ADMIN } from '~/ci/admin/jobs_table/constants'; +import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; +import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; +import { mockJobsNodes, mockAllJobsNodes } from 'jest/ci/jobs_mock_data'; + +describe('Jobs Table', () => { + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); + const findTableRows = () => wrapper.findAllByTestId('jobs-table-row'); + const findJobStage = () => wrapper.findByTestId('job-stage-name'); + const findJobName = () => wrapper.findByTestId('job-name'); + const findJobProject = () => wrapper.findComponent(ProjectCell); + const findJobRunner = () => wrapper.findComponent(RunnerCell); + const findAllCoverageJobs = () => wrapper.findAllByTestId('job-coverage'); + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + mount(JobsTable, { + propsData: { + ...props, + }, + }), + ); + }; + + describe('jobs table', () => { + beforeEach(() => { + createComponent({ jobs: mockJobsNodes }); + }); + + it('displays the jobs table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('displays correct number of job rows', () => { + expect(findTableRows()).toHaveLength(mockJobsNodes.length); + }); + + it('displays job status', () => { + expect(findCiBadgeLink().exists()).toBe(true); + }); + + it('displays the job stage and name', () => { + const [firstJob] = mockJobsNodes; + + 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 = mockJobsNodes.filter((job) => job.coverage !== null); + + jobsThatHaveCoverage.forEach((job, index) => { + expect(findAllCoverageJobs().at(index).text()).toBe(`${job.coverage}%`); + }); + expect(findAllCoverageJobs()).toHaveLength(jobsThatHaveCoverage.length); + }); + + describe('when stage of a job is missing', () => { + it('shows no stage', () => { + const stagelessJob = { ...mockJobsNodes[0], stage: null }; + createComponent({ jobs: [stagelessJob] }); + + expect(findJobStage().exists()).toBe(false); + }); + }); + }); + + describe('regular user', () => { + beforeEach(() => { + createComponent({ jobs: mockJobsNodes }); + }); + + it('hides the job runner', () => { + expect(findJobRunner().exists()).toBe(false); + }); + + it('hides the job project link', () => { + expect(findJobProject().exists()).toBe(false); + }); + }); + + describe('admin mode', () => { + beforeEach(() => { + createComponent({ jobs: mockAllJobsNodes, tableFields: DEFAULT_FIELDS_ADMIN, admin: true }); + }); + + it('displays the runner cell', () => { + expect(findJobRunner().exists()).toBe(true); + }); + + it('displays the project cell', () => { + expect(findJobProject().exists()).toBe(true); + }); + + it('displays correct number of job rows', () => { + expect(findTableRows()).toHaveLength(mockAllJobsNodes.length); + }); + }); +}); diff --git a/spec/frontend/ci/jobs_page/components/jobs_table_tabs_spec.js b/spec/frontend/ci/jobs_page/components/jobs_table_tabs_spec.js new file mode 100644 index 00000000000..c36f3841890 --- /dev/null +++ b/spec/frontend/ci/jobs_page/components/jobs_table_tabs_spec.js @@ -0,0 +1,81 @@ +import { GlTab } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import JobsTableTabs from '~/ci/jobs_page/components/jobs_table_tabs.vue'; +import CancelJobs from '~/ci/admin/jobs_table/components/cancel_jobs.vue'; + +describe('Jobs Table Tabs', () => { + let wrapper; + + const defaultProps = { + allJobsCount: 286, + loading: false, + }; + + const adminProps = { + ...defaultProps, + showCancelAllJobsButton: true, + }; + + const statuses = { + success: 'SUCCESS', + failed: 'FAILED', + canceled: 'CANCELED', + }; + + const findAllTab = () => wrapper.findByTestId('jobs-all-tab'); + const findFinishedTab = () => wrapper.findByTestId('jobs-finished-tab'); + const findCancelJobsButton = () => wrapper.findAllComponents(CancelJobs); + + const triggerTabChange = (index) => wrapper.findAllComponents(GlTab).at(index).vm.$emit('click'); + + const createComponent = (props = defaultProps) => { + wrapper = extendedWrapper( + mount(JobsTableTabs, { + provide: { + jobStatuses: { + ...statuses, + }, + }, + propsData: { + ...props, + }, + }), + ); + }; + + beforeEach(() => { + createComponent(); + }); + + it('displays All tab with count', () => { + expect(trimText(findAllTab().text())).toBe(`All ${defaultProps.allJobsCount}`); + }); + + it('displays Finished tab with no count', () => { + expect(findFinishedTab().text()).toBe('Finished'); + }); + + it.each` + tabIndex | expectedScope + ${0} | ${null} + ${1} | ${[statuses.success, statuses.failed, statuses.canceled]} + `('emits fetchJobsByStatus with $expectedScope on tab change', ({ tabIndex, expectedScope }) => { + triggerTabChange(tabIndex); + + expect(wrapper.emitted()).toEqual({ fetchJobsByStatus: [[expectedScope]] }); + }); + + it('does not displays cancel all jobs button', () => { + expect(findCancelJobsButton().exists()).toBe(false); + }); + + describe('admin mode', () => { + it('displays cancel all jobs button', () => { + createComponent(adminProps); + + expect(findCancelJobsButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/jobs_page/graphql/cache_config_spec.js b/spec/frontend/ci/jobs_page/graphql/cache_config_spec.js new file mode 100644 index 00000000000..cfbd77f4154 --- /dev/null +++ b/spec/frontend/ci/jobs_page/graphql/cache_config_spec.js @@ -0,0 +1,106 @@ +import cacheConfig from '~/ci/jobs_page/graphql/cache_config'; +import { + CIJobConnectionExistingCache, + CIJobConnectionIncomingCache, + CIJobConnectionIncomingCacheRunningStatus, +} from 'jest/ci/jobs_mock_data'; + +const firstLoadArgs = { first: 3, statuses: 'PENDING' }; +const runningArgs = { first: 3, statuses: 'RUNNING' }; + +describe('jobs/components/table/graphql/cache_config', () => { + describe('when fetching data with the same statuses', () => { + it('should contain cache nodes and a status when merging caches on first load', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { + args: firstLoadArgs, + }); + + expect(res.nodes).toHaveLength(CIJobConnectionIncomingCache.nodes.length); + expect(res.statuses).toBe('PENDING'); + }); + + it('should add to existing caches when merging caches after first load', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + CIJobConnectionIncomingCache, + { + args: firstLoadArgs, + }, + ); + + expect(res.nodes).toHaveLength( + CIJobConnectionIncomingCache.nodes.length + CIJobConnectionExistingCache.nodes.length, + ); + }); + + it('should not add to existing cache if the incoming elements are the same', () => { + // simulate that this is the last page + const finalExistingCache = { + ...CIJobConnectionExistingCache, + pageInfo: { + hasNextPage: false, + }, + }; + + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + finalExistingCache, + { + args: firstLoadArgs, + }, + ); + + expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length); + }); + + it('should contain the pageInfo key as part of the result', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { + args: firstLoadArgs, + }); + + expect(res.pageInfo).toEqual( + expect.objectContaining({ + __typename: 'PageInfo', + endCursor: 'eyJpZCI6IjIwNTEifQ', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjIxNzMifQ', + }), + ); + }); + }); + + describe('when fetching data with different statuses', () => { + it('should reset cache when a cache already exists', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + CIJobConnectionIncomingCacheRunningStatus, + { + args: runningArgs, + }, + ); + + expect(res.nodes).not.toEqual(CIJobConnectionExistingCache.nodes); + expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length); + }); + }); + + describe('when incoming data has no nodes', () => { + it('should return existing cache', () => { + const res = cacheConfig.typePolicies.CiJobConnection.merge( + CIJobConnectionExistingCache, + { __typename: 'CiJobConnection', count: 500 }, + { + args: { statuses: 'SUCCESS' }, + }, + ); + + const expectedResponse = { + ...CIJobConnectionExistingCache, + statuses: 'SUCCESS', + }; + + expect(res).toEqual(expectedResponse); + }); + }); +}); diff --git a/spec/frontend/ci/jobs_page/job_page_app_spec.js b/spec/frontend/ci/jobs_page/job_page_app_spec.js new file mode 100644 index 00000000000..77443c9d490 --- /dev/null +++ b/spec/frontend/ci/jobs_page/job_page_app_spec.js @@ -0,0 +1,338 @@ +import { GlAlert, GlEmptyState, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { s__ } from '~/locale'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { TEST_HOST } from 'spec/test_constants'; +import { createAlert } from '~/alert'; +import getJobsQuery from '~/ci/jobs_page/graphql/queries/get_jobs.query.graphql'; +import getJobsCountQuery from '~/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import JobsTableApp from '~/ci/jobs_page/jobs_page_app.vue'; +import JobsTableTabs from '~/ci/jobs_page/components/jobs_table_tabs.vue'; +import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue'; +import JobsSkeletonLoader from '~/ci/admin/jobs_table/components/jobs_skeleton_loader.vue'; +import * as urlUtils from '~/lib/utils/url_utility'; +import { + mockJobsResponsePaginated, + mockJobsResponseEmpty, + mockFailedSearchToken, + mockJobsCountResponse, +} from 'jest/ci/jobs_mock_data'; + +const projectPath = 'gitlab-org/gitlab'; +Vue.use(VueApollo); + +jest.mock('~/alert'); + +describe('Job table app', () => { + let wrapper; + + const successHandler = jest.fn().mockResolvedValue(mockJobsResponsePaginated); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const emptyHandler = jest.fn().mockResolvedValue(mockJobsResponseEmpty); + + const countSuccessHandler = jest.fn().mockResolvedValue(mockJobsCountResponse); + + const findSkeletonLoader = () => wrapper.findComponent(JobsSkeletonLoader); + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findTable = () => wrapper.findComponent(JobsTable); + const findTabs = () => wrapper.findComponent(JobsTableTabs); + const findAlert = () => wrapper.findComponent(GlAlert); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch); + + const triggerInfiniteScroll = () => + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); + + const createMockApolloProvider = (handler, countHandler) => { + const requestHandlers = [ + [getJobsQuery, handler], + [getJobsCountQuery, countHandler], + ]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = ({ + handler = successHandler, + countHandler = countSuccessHandler, + mountFn = shallowMount, + } = {}) => { + wrapper = mountFn(JobsTableApp, { + provide: { + fullPath: projectPath, + }, + apolloProvider: createMockApolloProvider(handler, countHandler), + }); + }; + + describe('loading state', () => { + it('should display skeleton loader when loading', () => { + createComponent(); + + 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); + expect(findLoadingSpinner().exists()).toBe(false); + }); + }); + + describe('loaded state', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('should display the jobs table with data', () => { + expect(findTable().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + expect(findLoadingSpinner().exists()).toBe(false); + }); + + it('should refetch jobs query on fetchJobsByStatus event', async () => { + expect(successHandler).toHaveBeenCalledTimes(1); + + await findTabs().vm.$emit('fetchJobsByStatus'); + + expect(successHandler).toHaveBeenCalledTimes(2); + }); + + it('avoids refetch jobs query when scope has not changed', async () => { + expect(successHandler).toHaveBeenCalledTimes(1); + + await findTabs().vm.$emit('fetchJobsByStatus', null); + + expect(successHandler).toHaveBeenCalledTimes(1); + }); + + it('should refetch jobs count query when the amount jobs and count do not match', async () => { + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + + // after applying filter a new count is fetched + findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(countSuccessHandler).toHaveBeenCalledTimes(2); + + // tab is switched to `finished`, no count + await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']); + + // tab is switched back to `all`, the old filter count has to be overwritten with new count + await findTabs().vm.$emit('fetchJobsByStatus', null); + + expect(countSuccessHandler).toHaveBeenCalledTimes(3); + }); + + describe('when infinite scrolling is triggered', () => { + it('does not display a skeleton loader', () => { + triggerInfiniteScroll(); + + 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).toHaveBeenLastCalledWith({ + first: pageSize, + fullPath: projectPath, + after: mockJobsResponsePaginated.data.project.jobs.pageInfo.endCursor, + }); + }); + }); + }); + + describe('error state', () => { + it('should show an alert if there is an error fetching the jobs data', async () => { + createComponent({ handler: failedHandler }); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was an error fetching the jobs for your project.'); + expect(findTable().exists()).toBe(false); + }); + + it('should show an alert if there is an error fetching the jobs count data', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findAlert().text()).toBe( + 'There was an error fetching the number of jobs for your project.', + ); + }); + + it('jobs table should still load if count query fails', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findTable().exists()).toBe(true); + }); + + it('jobs count should be zero if count query fails', async () => { + createComponent({ handler: successHandler, countHandler: failedHandler }); + + await waitForPromises(); + + expect(findTabs().props('allJobsCount')).toBe(0); + }); + }); + + describe('empty state', () => { + it('should display empty state if there are no jobs and tab scope is null', async () => { + createComponent({ handler: emptyHandler, mountFn: mount }); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + }); + + it('should not display empty state if there are jobs and tab scope is not null', async () => { + createComponent({ handler: successHandler, mountFn: mount }); + + await waitForPromises(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTable().exists()).toBe(true); + }); + }); + + describe('filtered search', () => { + it('should display filtered search', () => { + createComponent(); + + expect(findFilteredSearch().exists()).toBe(true); + }); + + // this test should be updated once BE supports tab and filtered search filtering + // https://gitlab.com/gitlab-org/gitlab/-/issues/356210 + it.each` + scope | shouldDisplay + ${null} | ${true} + ${['FAILED', 'SUCCESS', 'CANCELED']} | ${false} + `( + 'with tab scope $scope the filtered search displays $shouldDisplay', + async ({ scope, shouldDisplay }) => { + createComponent(); + + await waitForPromises(); + + await findTabs().vm.$emit('fetchJobsByStatus', scope); + + expect(findFilteredSearch().exists()).toBe(shouldDisplay); + }, + ); + + it('refetches jobs query when filtering', async () => { + createComponent(); + + expect(successHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(successHandler).toHaveBeenCalledTimes(2); + }); + + it('refetches jobs count query when filtering', async () => { + createComponent(); + + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(countSuccessHandler).toHaveBeenCalledTimes(2); + }); + + it('shows raw text warning when user inputs raw text', async () => { + const expectedWarning = { + message: s__( + 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', + ), + type: 'warning', + }; + + createComponent(); + + expect(successHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']); + + expect(createAlert).toHaveBeenCalledWith(expectedWarning); + expect(successHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + }); + + it('updates URL query string when filtering jobs by status', async () => { + createComponent(); + + jest.spyOn(urlUtils, 'updateHistory'); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?statuses=FAILED`, + }); + }); + + it('resets query param after clearing tokens', () => { + createComponent(); + + jest.spyOn(urlUtils, 'updateHistory'); + + findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + + expect(successHandler).toHaveBeenCalledWith({ + first: 30, + fullPath: 'gitlab-org/gitlab', + statuses: 'FAILED', + }); + expect(countSuccessHandler).toHaveBeenCalledWith({ + fullPath: 'gitlab-org/gitlab', + statuses: 'FAILED', + }); + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?statuses=FAILED`, + }); + + findFilteredSearch().vm.$emit('filterJobsBySearch', []); + + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/`, + }); + + expect(successHandler).toHaveBeenCalledWith({ + first: 30, + fullPath: 'gitlab-org/gitlab', + statuses: null, + }); + expect(countSuccessHandler).toHaveBeenCalledWith({ + fullPath: 'gitlab-org/gitlab', + statuses: null, + }); + }); + }); +}); diff --git a/spec/frontend/ci/merge_requests/components/pipelines_table_wrapper_spec.js b/spec/frontend/ci/merge_requests/components/pipelines_table_wrapper_spec.js new file mode 100644 index 00000000000..df9bf2a4235 --- /dev/null +++ b/spec/frontend/ci/merge_requests/components/pipelines_table_wrapper_spec.js @@ -0,0 +1,117 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import { createAlert } from '~/alert'; +import PipelinesTableWrapper from '~/ci/merge_requests/components/pipelines_table_wrapper.vue'; +import getMergeRequestsPipelines from '~/ci/merge_requests/graphql/queries/get_merge_request_pipelines.query.graphql'; + +import { mergeRequestPipelinesResponse } from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); + +const pipelinesLength = mergeRequestPipelinesResponse.data.project.mergeRequest.pipelines.count; + +let wrapper; +let mergeRequestPipelinesRequest; +let apolloMock; + +const defaultProvide = { + graphqlPath: '/api/graphql/', + mergeRequestId: 1, + targetProjectFullPath: '/group/project', +}; + +const createComponent = () => { + const handlers = [[getMergeRequestsPipelines, mergeRequestPipelinesRequest]]; + + apolloMock = createMockApollo(handlers); + + wrapper = shallowMount(PipelinesTableWrapper, { + apolloProvider: apolloMock, + provide: { + ...defaultProvide, + }, + }); + + return waitForPromises(); +}; + +const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); +const findPipelineList = () => wrapper.findAll('li'); + +beforeEach(() => { + mergeRequestPipelinesRequest = jest.fn(); + mergeRequestPipelinesRequest.mockResolvedValue(mergeRequestPipelinesResponse); +}); +afterEach(() => { + apolloMock = null; + createAlert.mockClear(); +}); + +describe('PipelinesTableWrapper component', () => { + describe('When queries are loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render the pipeline list', () => { + expect(findPipelineList()).toHaveLength(0); + }); + }); + + describe('When there is an error fetching pipelines', () => { + beforeEach(async () => { + mergeRequestPipelinesRequest.mockRejectedValueOnce({ error: 'API error message' }); + await createComponent(); + }); + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: "There was an error fetching this merge request's pipelines.", + }); + }); + }); + + describe('When queries have loaded', () => { + beforeEach(async () => { + await createComponent(); + }); + + it('does not render the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders a pipeline list', () => { + expect(findPipelineList()).toHaveLength(pipelinesLength); + }); + }); + + describe('polling', () => { + beforeEach(async () => { + await createComponent(); + }); + + it('polls every 10 seconds', () => { + expect(mergeRequestPipelinesRequest).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(5000); + + expect(mergeRequestPipelinesRequest).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(5000); + + expect(mergeRequestPipelinesRequest).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/spec/frontend/ci/merge_requests/mock_data.js b/spec/frontend/ci/merge_requests/mock_data.js new file mode 100644 index 00000000000..1d8fdb88aa3 --- /dev/null +++ b/spec/frontend/ci/merge_requests/mock_data.js @@ -0,0 +1,30 @@ +const createMergeRequestPipelines = (count = 30) => { + const pipelines = []; + + for (let i = 0; i < count; i += 1) { + pipelines.push({ + id: i, + iid: i + 10, + path: `/project/pipelines/${i}`, + }); + } + + return { + count, + nodes: pipelines, + }; +}; + +export const mergeRequestPipelinesResponse = { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/1', + mergeRequest: { + __typename: 'MergeRequest', + id: 'gid://gitlab/MergeRequest/1', + pipelines: createMergeRequestPipelines(), + }, + }, + }, +}; diff --git a/spec/frontend/ci/mixins/delayed_job_mixin_spec.js b/spec/frontend/ci/mixins/delayed_job_mixin_spec.js new file mode 100644 index 00000000000..a1dab55bd07 --- /dev/null +++ b/spec/frontend/ci/mixins/delayed_job_mixin_spec.js @@ -0,0 +1,119 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; + +describe('DelayedJobMixin', () => { + let wrapper; + const dummyComponent = { + props: { + job: { + type: Object, + required: true, + }, + }, + mixins: [delayedJobMixin], + template: '
    {{remainingTime}}
    ', + }; + + describe('if job is empty object', () => { + beforeEach(() => { + wrapper = shallowMount(dummyComponent, { + propsData: { + job: {}, + }, + }); + }); + + it('sets remaining time to 00:00:00', () => { + expect(wrapper.text()).toBe('00:00:00'); + }); + + it('does not update remaining time after mounting', async () => { + await nextTick(); + + expect(wrapper.text()).toBe('00:00:00'); + }); + }); + + describe('in REST component', () => { + describe('if job is delayed job', () => { + let remainingTimeInMilliseconds = 42000; + + beforeEach(async () => { + jest + .spyOn(Date, 'now') + .mockImplementation( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds, + ); + + wrapper = shallowMount(dummyComponent, { + propsData: { + job: delayedJobFixture, + }, + }); + + await nextTick(); + }); + + it('sets remaining time', () => { + expect(wrapper.text()).toBe('00:00:42'); + }); + + it('updates remaining time', async () => { + remainingTimeInMilliseconds = 41000; + jest.advanceTimersByTime(1000); + + await nextTick(); + expect(wrapper.text()).toBe('00:00:41'); + }); + }); + }); + + describe('in GraphQL component', () => { + const mockGraphQlJob = { + name: 'build_b', + scheduledAt: new Date(delayedJobFixture.scheduled_at), + status: { + icon: 'status_success', + tooltip: 'passed', + hasDetails: true, + detailsPath: '/root/abcd-dag/-/jobs/1515', + group: 'success', + action: null, + }, + }; + + describe('if job is delayed job', () => { + let remainingTimeInMilliseconds = 42000; + + beforeEach(async () => { + jest + .spyOn(Date, 'now') + .mockImplementation( + () => mockGraphQlJob.scheduledAt.getTime() - remainingTimeInMilliseconds, + ); + + wrapper = shallowMount(dummyComponent, { + propsData: { + job: mockGraphQlJob, + }, + }); + + await nextTick(); + }); + + it('sets remaining time', () => { + expect(wrapper.text()).toBe('00:00:42'); + }); + + it('updates remaining time', async () => { + remainingTimeInMilliseconds = 41000; + jest.advanceTimersByTime(1000); + + await nextTick(); + expect(wrapper.text()).toBe('00:00:41'); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/dag/components/__snapshots__/dag_graph_spec.js.snap b/spec/frontend/ci/pipeline_details/dag/components/__snapshots__/dag_graph_spec.js.snap new file mode 100644 index 00000000000..624c89a237c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/components/__snapshots__/dag_graph_spec.js.snap @@ -0,0 +1,743 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The DAG graph in the basic case renders the graph svg 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + build_a +
    +
    + +
    + test_a +
    +
    + +
    + test_b +
    +
    + +
    + post_test_a +
    +
    + +
    + post_test_b +
    +
    + +
    + post_test_c +
    +
    + +
    + staging_a +
    +
    + +
    + staging_b +
    +
    + +
    + canary_a +
    +
    + +
    + canary_c +
    +
    + +
    + production_a +
    +
    + +
    + production_d +
    +
    +
    +
    +`; diff --git a/spec/frontend/ci/pipeline_details/dag/components/dag_annotations_spec.js b/spec/frontend/ci/pipeline_details/dag/components/dag_annotations_spec.js new file mode 100644 index 00000000000..d1c338e50c6 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/components/dag_annotations_spec.js @@ -0,0 +1,98 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DagAnnotations from '~/ci/pipeline_details/dag/components/dag_annotations.vue'; +import { singleNote, multiNote } from '../mock_data'; + +describe('The DAG annotations', () => { + let wrapper; + + const getColorBlock = () => wrapper.find('[data-testid="dag-color-block"]'); + const getAllColorBlocks = () => wrapper.findAll('[data-testid="dag-color-block"]'); + const getTextBlock = () => wrapper.find('[data-testid="dag-note-text"]'); + const getAllTextBlocks = () => wrapper.findAll('[data-testid="dag-note-text"]'); + const getToggleButton = () => wrapper.findComponent(GlButton); + + const createComponent = (propsData = {}, method = shallowMount) => { + wrapper = method(DagAnnotations, { + propsData, + data() { + return { + showList: true, + }; + }, + }); + }; + + describe('when there is one annotation', () => { + const currentNote = singleNote['dag-link103']; + + beforeEach(() => { + createComponent({ annotations: singleNote }); + }); + + it('displays the color block', () => { + expect(getColorBlock().exists()).toBe(true); + }); + + it('displays the text block', () => { + expect(getTextBlock().exists()).toBe(true); + expect(getTextBlock().text()).toBe(`${currentNote.source.name} → ${currentNote.target.name}`); + }); + + it('does not display the list toggle link', () => { + expect(getToggleButton().exists()).toBe(false); + }); + }); + + describe('when there are multiple annoataions', () => { + beforeEach(() => { + createComponent({ annotations: multiNote }); + }); + + it('displays a color block for each link', () => { + expect(getAllColorBlocks().length).toBe(Object.keys(multiNote).length); + }); + + it('displays a text block for each link', () => { + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + + Object.values(multiNote).forEach((item, idx) => { + expect(getAllTextBlocks().at(idx).text()).toBe(`${item.source.name} → ${item.target.name}`); + }); + }); + + it('displays the list toggle link', () => { + expect(getToggleButton().exists()).toBe(true); + expect(getToggleButton().text()).toBe('Hide list'); + }); + }); + + describe('the list toggle', () => { + beforeEach(() => { + createComponent({ annotations: multiNote }, mount); + }); + + describe('clicking hide', () => { + it('hides listed items and changes text to show', async () => { + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + expect(getToggleButton().text()).toBe('Hide list'); + getToggleButton().trigger('click'); + await nextTick(); + expect(getAllTextBlocks().length).toBe(0); + expect(getToggleButton().text()).toBe('Show list'); + }); + }); + + describe('clicking show', () => { + it('shows listed items and changes text to hide', async () => { + getToggleButton().trigger('click'); + getToggleButton().trigger('click'); + + await nextTick(); + expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); + expect(getToggleButton().text()).toBe('Hide list'); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/dag/components/dag_graph_spec.js b/spec/frontend/ci/pipeline_details/dag/components/dag_graph_spec.js new file mode 100644 index 00000000000..aff83c00e79 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/components/dag_graph_spec.js @@ -0,0 +1,209 @@ +import { shallowMount } from '@vue/test-utils'; +import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/ci/pipeline_details/dag/constants'; +import DagGraph from '~/ci/pipeline_details/dag/components/dag_graph.vue'; +import { createSankey } from '~/ci/pipeline_details/dag/utils/drawing_utils'; +import { highlightIn, highlightOut } from '~/ci/pipeline_details/dag/utils/interactions'; +import { removeOrphanNodes } from '~/ci/pipeline_details/utils/parsing_utils'; +import { parsedData } from '../mock_data'; + +describe('The DAG graph', () => { + let wrapper; + + const getGraph = () => wrapper.find('.dag-graph-container > svg'); + const getAllLinks = () => wrapper.findAll(`.${LINK_SELECTOR}`); + const getAllNodes = () => wrapper.findAll(`.${NODE_SELECTOR}`); + const getAllLabels = () => wrapper.findAll('foreignObject'); + + const createComponent = (propsData = {}) => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = shallowMount(DagGraph, { + attachTo: document.body, + propsData, + data() { + return { + color: () => {}, + width: 0, + height: 0, + }; + }, + }); + }; + + beforeEach(() => { + createComponent({ graphData: parsedData }); + }); + + describe('in the basic case', () => { + beforeEach(() => { + /* + The graph uses random to offset links. To keep the snapshot consistent, + we mock Math.random. Wheeeee! + */ + const randomNumber = jest.spyOn(global.Math, 'random'); + randomNumber.mockImplementation(() => 0.2); + createComponent({ graphData: parsedData }); + }); + + it('renders the graph svg', () => { + expect(getGraph().exists()).toBe(true); + expect(getGraph().html()).toMatchSnapshot(); + }); + }); + + describe('links', () => { + it('renders the expected number of links', () => { + expect(getAllLinks()).toHaveLength(parsedData.links.length); + }); + + it('renders the expected number of gradients', () => { + expect(wrapper.findAll('linearGradient')).toHaveLength(parsedData.links.length); + }); + + it('renders the expected number of clip paths', () => { + expect(wrapper.findAll('clipPath')).toHaveLength(parsedData.links.length); + }); + }); + + describe('nodes and labels', () => { + const sankeyNodes = createSankey()(parsedData).nodes; + const processedNodes = removeOrphanNodes(sankeyNodes); + + describe('nodes', () => { + it('renders the expected number of nodes', () => { + expect(getAllNodes()).toHaveLength(processedNodes.length); + }); + }); + + describe('labels', () => { + it('renders the expected number of labels as foreignObjects', () => { + expect(getAllLabels()).toHaveLength(processedNodes.length); + }); + + it('renders the title as text', () => { + expect(getAllLabels().at(0).text()).toBe(parsedData.nodes[0].name); + }); + }); + }); + + describe('interactions', () => { + const strokeOpacity = (opacity) => `stroke-opacity: ${opacity};`; + const baseOpacity = () => wrapper.vm.$options.viewOptions.baseOpacity; + + describe('links', () => { + const liveLink = () => getAllLinks().at(4); + const otherLink = () => getAllLinks().at(1); + + describe('on hover', () => { + it('sets the link opacity to baseOpacity and background links to 0.2', () => { + liveLink().trigger('mouseover'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + }); + + it('reverts the styles on mouseout', () => { + liveLink().trigger('mouseover'); + liveLink().trigger('mouseout'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + }); + }); + + describe('on click', () => { + describe('toggles link liveness', () => { + it('turns link on', () => { + liveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + }); + + it('turns link off on second click', () => { + liveLink().trigger('click'); + liveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + }); + }); + + it('the link remains live even after mouseout', () => { + liveLink().trigger('click'); + liveLink().trigger('mouseout'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + }); + + it('preserves state when multiple links are toggled on and off', () => { + const anotherLiveLink = () => getAllLinks().at(2); + + liveLink().trigger('click'); + anotherLiveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + + anotherLiveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); + expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); + + liveLink().trigger('click'); + expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); + }); + }); + }); + + describe('nodes', () => { + const liveNode = () => getAllNodes().at(10); + const anotherLiveNode = () => getAllNodes().at(5); + const nodesNotHighlighted = () => getAllNodes().filter((n) => !n.classes(IS_HIGHLIGHTED)); + const linksNotHighlighted = () => getAllLinks().filter((n) => !n.classes(IS_HIGHLIGHTED)); + const nodesHighlighted = () => getAllNodes().filter((n) => n.classes(IS_HIGHLIGHTED)); + const linksHighlighted = () => getAllLinks().filter((n) => n.classes(IS_HIGHLIGHTED)); + + describe('on click', () => { + it('highlights the clicked node and predecessors', () => { + liveNode().trigger('click'); + + expect(nodesNotHighlighted().length < getAllNodes().length).toBe(true); + expect(linksNotHighlighted().length < getAllLinks().length).toBe(true); + + linksHighlighted().wrappers.forEach((link) => { + expect(link.attributes('style')).toBe(strokeOpacity(highlightIn)); + }); + + nodesHighlighted().wrappers.forEach((node) => { + expect(node.attributes('stroke')).not.toBe('#f2f2f2'); + }); + + linksNotHighlighted().wrappers.forEach((link) => { + expect(link.attributes('style')).toBe(strokeOpacity(highlightOut)); + }); + + nodesNotHighlighted().wrappers.forEach((node) => { + expect(node.attributes('stroke')).toBe('#f2f2f2'); + }); + }); + + it('toggles path off on second click', () => { + liveNode().trigger('click'); + liveNode().trigger('click'); + + expect(nodesNotHighlighted().length).toBe(getAllNodes().length); + expect(linksNotHighlighted().length).toBe(getAllLinks().length); + }); + + it('preserves state when multiple nodes are toggled on and off', () => { + anotherLiveNode().trigger('click'); + liveNode().trigger('click'); + anotherLiveNode().trigger('click'); + expect(nodesNotHighlighted().length < getAllNodes().length).toBe(true); + expect(linksNotHighlighted().length < getAllLinks().length).toBe(true); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/dag/dag_spec.js b/spec/frontend/ci/pipeline_details/dag/dag_spec.js new file mode 100644 index 00000000000..de9490be607 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/dag_spec.js @@ -0,0 +1,168 @@ +import { GlAlert, GlEmptyState } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/ci/pipeline_details/dag/constants'; +import Dag from '~/ci/pipeline_details/dag/dag.vue'; +import DagAnnotations from '~/ci/pipeline_details/dag/components/dag_annotations.vue'; +import DagGraph from '~/ci/pipeline_details/dag/components/dag_graph.vue'; + +import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/ci/pipeline_details/constants'; +import { + mockParsedGraphQLNodes, + tooSmallGraph, + unparseableGraph, + graphWithoutDependencies, + singleNote, + multiNote, +} from './mock_data'; + +describe('Pipeline DAG graph wrapper', () => { + let wrapper; + const getAlert = () => wrapper.findComponent(GlAlert); + const getAllAlerts = () => wrapper.findAllComponents(GlAlert); + const getGraph = () => wrapper.findComponent(DagGraph); + const getNotes = () => wrapper.findComponent(DagAnnotations); + const getErrorText = (type) => wrapper.vm.$options.errorTexts[type]; + const getEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = ({ + graphData = mockParsedGraphQLNodes, + provideOverride = {}, + method = shallowMount, + } = {}) => { + wrapper = method(Dag, { + provide: { + pipelineProjectPath: 'root/abc-dag', + pipelineIid: '1', + emptySvgPath: '/my-svg', + dagDocPath: '/my-doc', + ...provideOverride, + }, + data() { + return { + graphData, + showFailureAlert: false, + }; + }, + }); + }; + + describe('when a query argument is undefined', () => { + beforeEach(() => { + createComponent({ + provideOverride: { pipelineProjectPath: undefined }, + graphData: null, + }); + }); + + it('does not render the graph', () => { + expect(getGraph().exists()).toBe(false); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('when all query variables are defined', () => { + describe('but the parse fails', () => { + beforeEach(() => { + createComponent({ + graphData: unparseableGraph, + }); + }); + + it('shows the PARSE_FAILURE alert and not the graph', () => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(PARSE_FAILURE)); + expect(getGraph().exists()).toBe(false); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('parse succeeds', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('shows the graph', () => { + expect(getGraph().exists()).toBe(true); + }); + + it('does not render the empty state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('parse succeeds, but the resulting graph is too small', () => { + beforeEach(() => { + createComponent({ + graphData: tooSmallGraph, + }); + }); + + it('shows the UNSUPPORTED_DATA alert and not the graph', () => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe(getErrorText(UNSUPPORTED_DATA)); + expect(getGraph().exists()).toBe(false); + }); + + it('does not show the empty dag graph state', () => { + expect(getEmptyState().exists()).toBe(false); + }); + }); + + describe('the returned data is empty', () => { + beforeEach(() => { + createComponent({ + method: mount, + graphData: graphWithoutDependencies, + }); + }); + + it('does not render an error alert or the graph', () => { + expect(getAllAlerts().length).toBe(0); + expect(getGraph().exists()).toBe(false); + }); + + it('shows the empty dag graph state', () => { + expect(getEmptyState().exists()).toBe(true); + }); + }); + }); + + describe('annotations', () => { + beforeEach(() => { + createComponent(); + }); + + it('toggles on link mouseover and mouseout', async () => { + const currentNote = singleNote['dag-link103']; + + expect(getNotes().exists()).toBe(false); + + getGraph().vm.$emit('update-annotation', { type: ADD_NOTE, data: currentNote }); + await nextTick(); + expect(getNotes().exists()).toBe(true); + + getGraph().vm.$emit('update-annotation', { type: REMOVE_NOTE, data: currentNote }); + await nextTick(); + expect(getNotes().exists()).toBe(false); + }); + + it('toggles on node and link click', async () => { + expect(getNotes().exists()).toBe(false); + + getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: multiNote }); + await nextTick(); + expect(getNotes().exists()).toBe(true); + + getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: {} }); + await nextTick(); + expect(getNotes().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/dag/mock_data.js b/spec/frontend/ci/pipeline_details/dag/mock_data.js new file mode 100644 index 00000000000..f27e7cf3d6b --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/mock_data.js @@ -0,0 +1,674 @@ +export const tooSmallGraph = [ + { + category: 'test', + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + category: 'test', + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], + }, + { + category: 'fixtures', + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + { + category: 'un-needed', + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, +]; + +export const graphWithoutDependencies = [ + { + category: 'test', + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + category: 'test', + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec' }], + }, + { + category: 'fixtures', + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + { + category: 'un-needed', + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, +]; + +export const unparseableGraph = [ + { + name: 'test', + groups: [ + { + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2', needs: ['frontend fixtures'] }, { name: 'jest 2/2' }], + }, + { + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], + }, + ], + }, + { + name: 'un-needed', + groups: [ + { + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, + ], + }, +]; + +/* + This represents data that has been parsed by the wrapper +*/ +export const parsedData = { + nodes: [ + { + name: 'build_a', + size: 1, + jobs: [ + { + name: 'build_a', + }, + ], + category: 'build', + }, + { + name: 'build_b', + size: 1, + jobs: [ + { + name: 'build_b', + }, + ], + category: 'build', + }, + { + name: 'test_a', + size: 1, + jobs: [ + { + name: 'test_a', + needs: ['build_a'], + }, + ], + category: 'test', + }, + { + name: 'test_b', + size: 1, + jobs: [ + { + name: 'test_b', + }, + ], + category: 'test', + }, + { + name: 'test_c', + size: 1, + jobs: [ + { + name: 'test_c', + }, + ], + category: 'test', + }, + { + name: 'test_d', + size: 1, + jobs: [ + { + name: 'test_d', + }, + ], + category: 'test', + }, + { + name: 'post_test_a', + size: 1, + jobs: [ + { + name: 'post_test_a', + }, + ], + category: 'post-test', + }, + { + name: 'post_test_b', + size: 1, + jobs: [ + { + name: 'post_test_b', + }, + ], + category: 'post-test', + }, + { + name: 'post_test_c', + size: 1, + jobs: [ + { + name: 'post_test_c', + needs: ['test_a', 'test_b'], + }, + ], + category: 'post-test', + }, + { + name: 'staging_a', + size: 1, + jobs: [ + { + name: 'staging_a', + needs: ['post_test_a'], + }, + ], + category: 'staging', + }, + { + name: 'staging_b', + size: 1, + jobs: [ + { + name: 'staging_b', + needs: ['post_test_b'], + }, + ], + category: 'staging', + }, + { + name: 'staging_c', + size: 1, + jobs: [ + { + name: 'staging_c', + }, + ], + category: 'staging', + }, + { + name: 'staging_d', + size: 1, + jobs: [ + { + name: 'staging_d', + }, + ], + category: 'staging', + }, + { + name: 'staging_e', + size: 1, + jobs: [ + { + name: 'staging_e', + }, + ], + category: 'staging', + }, + { + name: 'canary_a', + size: 1, + jobs: [ + { + name: 'canary_a', + needs: ['staging_a', 'staging_b'], + }, + ], + category: 'canary', + }, + { + name: 'canary_b', + size: 1, + jobs: [ + { + name: 'canary_b', + }, + ], + category: 'canary', + }, + { + name: 'canary_c', + size: 1, + jobs: [ + { + name: 'canary_c', + needs: ['staging_b'], + }, + ], + category: 'canary', + }, + { + name: 'production_a', + size: 1, + jobs: [ + { + name: 'production_a', + needs: ['canary_a'], + }, + ], + category: 'production', + }, + { + name: 'production_b', + size: 1, + jobs: [ + { + name: 'production_b', + }, + ], + category: 'production', + }, + { + name: 'production_c', + size: 1, + jobs: [ + { + name: 'production_c', + }, + ], + category: 'production', + }, + { + name: 'production_d', + size: 1, + jobs: [ + { + name: 'production_d', + needs: ['canary_c'], + }, + ], + category: 'production', + }, + ], + links: [ + { + source: 'build_a', + target: 'test_a', + value: 10, + }, + { + source: 'test_a', + target: 'post_test_c', + value: 10, + }, + { + source: 'test_b', + target: 'post_test_c', + value: 10, + }, + { + source: 'post_test_a', + target: 'staging_a', + value: 10, + }, + { + source: 'post_test_b', + target: 'staging_b', + value: 10, + }, + { + source: 'staging_a', + target: 'canary_a', + value: 10, + }, + { + source: 'staging_b', + target: 'canary_a', + value: 10, + }, + { + source: 'staging_b', + target: 'canary_c', + value: 10, + }, + { + source: 'canary_a', + target: 'production_a', + value: 10, + }, + { + source: 'canary_c', + target: 'production_d', + value: 10, + }, + ], +}; + +export const singleNote = { + 'dag-link103': { + uid: 'dag-link103', + source: { + name: 'canary_a', + color: '#b31756', + }, + target: { + name: 'production_a', + color: '#b24800', + }, + }, +}; + +export const multiNote = { + ...singleNote, + 'dag-link104': { + uid: 'dag-link104', + source: { + name: 'build_a', + color: '#e17223', + }, + target: { + name: 'test_c', + color: '#006887', + }, + }, + 'dag-link105': { + uid: 'dag-link105', + source: { + name: 'test_c', + color: '#006887', + }, + target: { + name: 'post_test_c', + color: '#3547de', + }, + }, +}; + +export const missingJob = 'missing_job'; + +/* + It is important that the base include parallel jobs + as well as non-parallel jobs with spaces in the name to prevent + us relying on spaces as an indicator. +*/ + +export const mockParsedGraphQLNodes = [ + { + category: 'build', + name: 'build_a', + size: 1, + jobs: [ + { + name: 'build_a', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'build', + name: 'build_b', + size: 1, + jobs: [ + { + name: 'build_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_a', + size: 1, + jobs: [ + { + name: 'test_a', + needs: ['build_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_b', + size: 1, + jobs: [ + { + name: 'test_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_c', + size: 1, + jobs: [ + { + name: 'test_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_d', + size: 1, + jobs: [ + { + name: 'test_d', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_a', + size: 1, + jobs: [ + { + name: 'post_test_a', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_b', + size: 1, + jobs: [ + { + name: 'post_test_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_c', + size: 1, + jobs: [ + { + name: 'post_test_c', + needs: ['test_b', 'test_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_a', + size: 1, + jobs: [ + { + name: 'staging_a', + needs: ['post_test_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_b', + size: 1, + jobs: [ + { + name: 'staging_b', + needs: ['post_test_b'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_c', + size: 1, + jobs: [ + { + name: 'staging_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_d', + size: 1, + jobs: [ + { + name: 'staging_d', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_e', + size: 1, + jobs: [ + { + name: 'staging_e', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_a', + size: 1, + jobs: [ + { + name: 'canary_a', + needs: ['staging_b', 'staging_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_b', + size: 1, + jobs: [ + { + name: 'canary_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_c', + size: 1, + jobs: [ + { + name: 'canary_c', + needs: ['staging_b'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_a', + size: 1, + jobs: [ + { + name: 'production_a', + needs: ['canary_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_b', + size: 1, + jobs: [ + { + name: 'production_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_c', + size: 1, + jobs: [ + { + name: 'production_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_d', + size: 1, + jobs: [ + { + name: 'production_d', + needs: ['canary_c'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_e', + size: 1, + jobs: [ + { + name: 'production_e', + needs: [missingJob], + }, + ], + __typename: 'CiGroup', + }, +]; diff --git a/spec/frontend/ci/pipeline_details/dag/utils/drawing_utils_spec.js b/spec/frontend/ci/pipeline_details/dag/utils/drawing_utils_spec.js new file mode 100644 index 00000000000..aea8e894bd4 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/dag/utils/drawing_utils_spec.js @@ -0,0 +1,57 @@ +import { createSankey } from '~/ci/pipeline_details/dag/utils/drawing_utils'; +import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; +import { mockParsedGraphQLNodes } from '../mock_data'; + +describe('DAG visualization drawing utilities', () => { + const parsed = parseData(mockParsedGraphQLNodes); + + const layoutSettings = { + width: 200, + height: 200, + nodeWidth: 10, + nodePadding: 20, + paddingForLabels: 100, + }; + + const sankeyLayout = createSankey(layoutSettings)(parsed); + + describe('createSankey', () => { + it('returns a nodes data structure with expected d3-added properties', () => { + const exampleNode = sankeyLayout.nodes[0]; + expect(exampleNode).toHaveProperty('sourceLinks'); + expect(exampleNode).toHaveProperty('targetLinks'); + expect(exampleNode).toHaveProperty('depth'); + expect(exampleNode).toHaveProperty('layer'); + expect(exampleNode).toHaveProperty('x0'); + expect(exampleNode).toHaveProperty('x1'); + expect(exampleNode).toHaveProperty('y0'); + expect(exampleNode).toHaveProperty('y1'); + }); + + it('returns a links data structure with expected d3-added properties', () => { + const exampleLink = sankeyLayout.links[0]; + expect(exampleLink).toHaveProperty('source'); + expect(exampleLink).toHaveProperty('target'); + expect(exampleLink).toHaveProperty('width'); + expect(exampleLink).toHaveProperty('y0'); + expect(exampleLink).toHaveProperty('y1'); + }); + + describe('data structure integrity', () => { + const newObject = { name: 'bad-actor' }; + + beforeEach(() => { + sankeyLayout.nodes.unshift(newObject); + }); + + it('sankey does not propagate changes back to the original', () => { + expect(sankeyLayout.nodes[0]).toBe(newObject); + expect(parsed.nodes[0]).not.toBe(newObject); + }); + + afterEach(() => { + sankeyLayout.nodes.shift(); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/__snapshots__/links_inner_spec.js.snap b/spec/frontend/ci/pipeline_details/graph/components/__snapshots__/links_inner_spec.js.snap new file mode 100644 index 00000000000..b31c0e59a33 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/__snapshots__/links_inner_spec.js.snap @@ -0,0 +1,110 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Links Inner component with a large number of needs matches snapshot and has expected path 1`] = ` +
    + + + + + + + +
    +`; + +exports[`Links Inner component with a parallel need matches snapshot and has expected path 1`] = ` +
    + + + +
    +`; + +exports[`Links Inner component with one need matches snapshot and has expected path 1`] = ` +
    + + + +
    +`; + +exports[`Links Inner component with same stage needs matches snapshot and has expected path 1`] = ` +
    + + + + +
    +`; diff --git a/spec/frontend/ci/pipeline_details/graph/components/action_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/action_component_spec.js new file mode 100644 index 00000000000..9e177156d0e --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/action_component_spec.js @@ -0,0 +1,116 @@ +import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; + +describe('pipeline graph action component', () => { + let wrapper; + let mock; + const findButton = () => wrapper.findComponent(GlButton); + const findTooltipWrapper = () => wrapper.find('[data-testid="ci-action-icon-tooltip-wrapper"]'); + + const defaultProps = { + tooltipText: 'bar', + link: 'foo', + actionIcon: 'cancel', + }; + + const createComponent = ({ props } = {}) => { + wrapper = mount(ActionComponent, { + propsData: { ...defaultProps, ...props }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onPost('foo.json').reply(HTTP_STATUS_OK); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('render', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render the provided title as a bootstrap tooltip', () => { + expect(findTooltipWrapper().attributes('title')).toBe('bar'); + }); + + it('should update bootstrap tooltip when title changes', async () => { + wrapper.setProps({ tooltipText: 'changed' }); + + await nextTick(); + expect(findTooltipWrapper().attributes('title')).toBe('changed'); + }); + + it('should render an svg', () => { + expect(wrapper.find('.ci-action-icon-wrapper').exists()).toBe(true); + expect(wrapper.find('svg').exists()).toBe(true); + }); + }); + + describe('on click', () => { + beforeEach(() => { + createComponent(); + }); + + it('emits `pipelineActionRequestComplete` after a successful request', async () => { + findButton().trigger('click'); + + await waitForPromises(); + + expect(wrapper.emitted().pipelineActionRequestComplete).toHaveLength(1); + }); + + it('renders a loading icon while waiting for request', async () => { + findButton().trigger('click'); + + await nextTick(); + expect(wrapper.find('.js-action-icon-loading').exists()).toBe(true); + }); + }); + + describe('when has a confirmation modal', () => { + beforeEach(() => { + createComponent({ props: { withConfirmationModal: true, shouldTriggerClick: false } }); + }); + + describe('and a first click is initiated', () => { + beforeEach(async () => { + findButton().trigger('click'); + + await waitForPromises(); + }); + + it('emits `showActionConfirmationModal` event', () => { + expect(wrapper.emitted().showActionConfirmationModal).toHaveLength(1); + }); + + it('does not emit `pipelineActionRequestComplete` event', () => { + expect(wrapper.emitted().pipelineActionRequestComplete).toBeUndefined(); + }); + }); + + describe('and the `shouldTriggerClick` value becomes true', () => { + beforeEach(async () => { + await wrapper.setProps({ shouldTriggerClick: true }); + }); + + it('does not emit `showActionConfirmationModal` event', () => { + expect(wrapper.emitted().showActionConfirmationModal).toBeUndefined(); + }); + + it('emits `actionButtonClicked` event', () => { + expect(wrapper.emitted().actionButtonClicked).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js new file mode 100644 index 00000000000..a98e79c69fe --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/graph_component_spec.js @@ -0,0 +1,182 @@ +import { shallowMount } from '@vue/test-utils'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { LAYER_VIEW, STAGE_VIEW } from '~/ci/pipeline_details/graph/constants'; +import PipelineGraph from '~/ci/pipeline_details/graph/components/graph_component.vue'; +import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; +import LinkedPipelinesColumn from '~/ci/pipeline_details/graph/components/linked_pipelines_column.vue'; +import StageColumnComponent from '~/ci/pipeline_details/graph/components/stage_column_component.vue'; +import { calculatePipelineLayersInfo } from '~/ci/pipeline_details/graph/utils'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; + +import { generateResponse, pipelineWithUpstreamDownstream } from '../mock_data'; + +describe('graph component', () => { + let wrapper; + + const findDownstreamColumn = () => wrapper.findByTestId('downstream-pipelines'); + const findLinkedColumns = () => wrapper.findAllComponents(LinkedPipelinesColumn); + const findLinksLayer = () => wrapper.findComponent(LinksLayer); + const findStageColumns = () => wrapper.findAllComponents(StageColumnComponent); + const findStageNameInJob = () => wrapper.findByTestId('stage-name-in-job'); + + const defaultProps = { + pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), + showLinks: false, + viewType: STAGE_VIEW, + configPaths: { + metricsPath: '', + graphqlResourceEtag: 'this/is/a/path', + }, + }; + + const defaultData = { + measurements: { + width: 800, + height: 800, + }, + }; + + const createComponent = ({ + data = {}, + mountFn = shallowMount, + props = {}, + stubOverride = {}, + } = {}) => { + wrapper = mountFn(PipelineGraph, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return { + ...defaultData, + ...data, + }; + }, + stubs: { + 'links-inner': true, + 'linked-pipeline': true, + 'job-item': true, + 'job-group-dropdown': true, + ...stubOverride, + }, + }); + }; + + describe('with data', () => { + beforeEach(() => { + createComponent({ mountFn: mountExtended }); + }); + + it('renders the main columns in the graph', () => { + expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); + }); + + it('renders the links layer', () => { + expect(findLinksLayer().exists()).toBe(true); + }); + + it('does not display stage name on the job in default (stage) mode', () => { + expect(findStageNameInJob().exists()).toBe(false); + }); + + describe('when column requests a refresh', () => { + beforeEach(() => { + findStageColumns().at(0).vm.$emit('refreshPipelineGraph'); + }); + + it('refreshPipelineGraph is emitted', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); + }); + + describe('when column request an update to the retry confirmation modal', () => { + beforeEach(() => { + findStageColumns().at(0).vm.$emit('setSkipRetryModal'); + }); + + it('setSkipRetryModal is emitted', () => { + expect(wrapper.emitted().setSkipRetryModal).toHaveLength(1); + }); + }); + + describe('when links are present', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + stubOverride: { 'job-item': false }, + data: { hoveredJobName: 'test_a' }, + }); + findLinksLayer().vm.$emit('highlightedJobsChange', ['test_c', 'build_c']); + }); + + it('dims unrelated jobs', () => { + const unrelatedJob = wrapper.findComponent(JobItem); + expect(findLinksLayer().emitted().highlightedJobsChange).toHaveLength(1); + expect(unrelatedJob.classes('gl-opacity-3')).toBe(true); + }); + }); + }); + + describe('when linked pipelines are not present', () => { + beforeEach(() => { + createComponent({ mountFn: mountExtended }); + }); + + it('should not render a linked pipelines column', () => { + expect(findLinkedColumns()).toHaveLength(0); + }); + }); + + describe('when linked pipelines are present', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse) }, + }); + }); + + it('should render linked pipelines columns', () => { + expect(findLinkedColumns()).toHaveLength(2); + }); + }); + + describe('in layers mode', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + stubOverride: { + 'job-item': false, + 'job-group-dropdown': false, + }, + props: { + viewType: LAYER_VIEW, + computedPipelineInfo: calculatePipelineLayersInfo(defaultProps.pipeline, 'layer', ''), + }, + }); + }); + + it('displays the stage name on the job', () => { + expect(findStageNameInJob().exists()).toBe(true); + }); + }); + + describe('downstream pipelines', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + props: { + pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse), + }, + }); + }); + + it('filters pipelines spawned from the same trigger job', () => { + // The mock data has one downstream with `retried: true and one + // with retried false. We filter the `retried: true` out so we + // should only pass one downstream + expect(findDownstreamColumn().props().linkedPipelines).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/graph_view_selector_spec.js b/spec/frontend/ci/pipeline_details/graph/components/graph_view_selector_spec.js new file mode 100644 index 00000000000..bf98995de9c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/graph_view_selector_spec.js @@ -0,0 +1,217 @@ +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { LAYER_VIEW, STAGE_VIEW } from '~/ci/pipeline_details/graph/constants'; +import GraphViewSelector from '~/ci/pipeline_details/graph/components/graph_view_selector.vue'; + +describe('the graph view selector component', () => { + let wrapper; + + const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]'); + const findViewTypeSelector = () => wrapper.findComponent(GlButtonGroup); + const findStageViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(0); + const findLayerViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(1); + const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]'); + const findToggleLoader = () => findDependenciesToggle().findComponent(GlLoadingIcon); + const findHoverTip = () => wrapper.findComponent(GlAlert); + + const defaultProps = { + showLinks: false, + tipPreviouslyDismissed: false, + type: STAGE_VIEW, + }; + + const defaultData = { + hoverTipDismissed: false, + isToggleLoading: false, + isSwitcherLoading: false, + showLinksActive: false, + }; + + const createComponent = ({ data = {}, mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(GraphViewSelector, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return { + ...defaultData, + ...data, + }; + }, + }); + }; + + describe('when showing stage view', () => { + beforeEach(() => { + createComponent({ mountFn: mount }); + }); + + it('shows the Stage view button as selected', () => { + expect(findStageViewButton().classes('selected')).toBe(true); + }); + + it('shows the Job dependencies view button not selected', () => { + expect(findLayerViewButton().exists()).toBe(true); + expect(findLayerViewButton().classes('selected')).toBe(false); + }); + + it('does not show the Job dependencies (links) toggle', () => { + expect(findDependenciesToggle().exists()).toBe(false); + }); + }); + + describe('when showing Job dependencies view', () => { + beforeEach(() => { + createComponent({ + mountFn: mount, + props: { + type: LAYER_VIEW, + }, + }); + }); + + it('shows the Job dependencies view as selected', () => { + expect(findLayerViewButton().classes('selected')).toBe(true); + }); + + it('shows the Stage button as not selected', () => { + expect(findStageViewButton().exists()).toBe(true); + expect(findStageViewButton().classes('selected')).toBe(false); + }); + + it('shows the Job dependencies (links) toggle', () => { + expect(findDependenciesToggle().exists()).toBe(true); + }); + }); + + describe('events', () => { + beforeEach(() => { + createComponent({ + mountFn: mount, + props: { + type: LAYER_VIEW, + }, + }); + }); + + it('shows loading state and emits updateViewType when view type toggled', async () => { + expect(wrapper.emitted().updateViewType).toBeUndefined(); + expect(findSwitcherLoader().exists()).toBe(false); + + await findStageViewButton().trigger('click'); + /* + Loading happens before the event is emitted or timers are run. + Then we run the timer because the event is emitted in setInterval + which is what gives the loader a chace to show up. + */ + expect(findSwitcherLoader().exists()).toBe(true); + jest.runOnlyPendingTimers(); + + expect(wrapper.emitted().updateViewType).toHaveLength(1); + expect(wrapper.emitted().updateViewType).toEqual([[STAGE_VIEW]]); + }); + + it('shows loading state and emits updateShowLinks when show links toggle is clicked', async () => { + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + expect(findToggleLoader().exists()).toBe(false); + + await findDependenciesToggle().vm.$emit('change', true); + /* + Loading happens before the event is emitted or timers are run. + Then we run the timer because the event is emitted in setInterval + which is what gives the loader a chace to show up. + */ + expect(findToggleLoader().exists()).toBe(true); + jest.runOnlyPendingTimers(); + + expect(wrapper.emitted().updateShowLinksState).toHaveLength(1); + expect(wrapper.emitted().updateShowLinksState).toEqual([[true]]); + }); + + it('does not emit an event if the click occurs on the currently selected view button', async () => { + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + + await findLayerViewButton().trigger('click'); + + expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); + }); + }); + + describe('hover tip callout', () => { + describe('when links are live and it has not been previously dismissed', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + type: LAYER_VIEW, + }, + data: { + showLinksActive: true, + }, + mountFn: mount, + }); + }); + + it('is displayed', () => { + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + }); + + it('emits dismissHoverTip event when the tip is dismissed', async () => { + expect(wrapper.emitted().dismissHoverTip).toBeUndefined(); + await findHoverTip().find('button').trigger('click'); + expect(wrapper.emitted().dismissHoverTip).toHaveLength(1); + }); + + it('is displayed at first then hidden on swith to STAGE_VIEW then displayed on switch to LAYER_VIEW', async () => { + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + + await findStageViewButton().trigger('click'); + expect(findHoverTip().exists()).toBe(false); + + await findLayerViewButton().trigger('click'); + expect(findHoverTip().exists()).toBe(true); + expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); + }); + }); + + describe('when links are live and it has been previously dismissed', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + tipPreviouslyDismissed: true, + type: LAYER_VIEW, + }, + data: { + showLinksActive: true, + }, + }); + }); + + it('is not displayed', () => { + expect(findHoverTip().exists()).toBe(false); + }); + }); + + describe('when links are not live', () => { + beforeEach(() => { + createComponent({ + props: { + showLinks: true, + type: LAYER_VIEW, + }, + data: { + showLinksActive: false, + }, + }); + }); + + it('is not displayed', () => { + expect(findHoverTip().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_group_dropdown_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_group_dropdown_spec.js new file mode 100644 index 00000000000..d5a1cfffe68 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/job_group_dropdown_spec.js @@ -0,0 +1,84 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import JobGroupDropdown from '~/ci/pipeline_details/graph/components/job_group_dropdown.vue'; + +describe('job group dropdown component', () => { + const group = { + jobs: [ + { + id: 4256, + name: '', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + has_details: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, + }, + { + id: 4299, + name: 'test', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4299', + has_details: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4299/retry', + method: 'post', + }, + }, + }, + ], + name: 'rspec:linux', + size: 2, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4256', + has_details: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, + }; + + let wrapper; + const findButton = () => wrapper.find('button'); + + const createComponent = ({ mountFn = shallowMount }) => { + wrapper = mountFn(JobGroupDropdown, { propsData: { group } }); + }; + + beforeEach(() => { + createComponent({ mountFn: mount }); + }); + + it('renders button with group name and size', () => { + expect(findButton().text()).toContain(group.name); + expect(findButton().text()).toContain(group.size.toString()); + }); + + it('renders dropdown with jobs', () => { + expect(wrapper.findAll('.scrollable-menu>ul>li').length).toBe(group.jobs.length); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js new file mode 100644 index 00000000000..107f0df5c02 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/job_item_spec.js @@ -0,0 +1,492 @@ +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import { GlBadge, GlModal, GlToast } from '@gitlab/ui'; +import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; +import axios from '~/lib/utils/axios_utils'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; + +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + delayedJob, + mockJob, + mockJobWithoutDetails, + mockJobWithUnauthorizedAction, + mockFailedJob, + triggerJob, + triggerJobWithRetryAction, +} from '../mock_data'; + +describe('pipeline graph job item', () => { + useLocalStorageSpy(); + Vue.use(GlToast); + + let wrapper; + let mockAxios; + + const findJobWithoutLink = () => wrapper.findByTestId('job-without-link'); + const findJobWithLink = () => wrapper.findByTestId('job-with-link'); + const findActionVueComponent = () => wrapper.findComponent(ActionComponent); + const findActionComponent = () => wrapper.findByTestId('ci-action-component'); + const findBadge = () => wrapper.findComponent(GlBadge); + const findJobLink = () => wrapper.findByTestId('job-with-link'); + const findModal = () => wrapper.findComponent(GlModal); + + const clickOnModalPrimaryBtn = () => findModal().vm.$emit('primary'); + const clickOnModalCancelBtn = () => findModal().vm.$emit('hide'); + const clickOnModalCloseBtn = () => findModal().vm.$emit('close'); + + const myCustomClass1 = 'my-class-1'; + const myCustomClass2 = 'my-class-2'; + + const defaultProps = { + job: mockJob, + }; + + const createWrapper = ({ props, data, mountFn = mountExtended, mocks = {} } = {}) => { + wrapper = mountFn(JobItem, { + data() { + return { + ...data, + }; + }, + propsData: { + ...defaultProps, + ...props, + }, + mocks: { + ...mocks, + }, + }); + }; + + const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + describe('name with link', () => { + it('should render the job name and status with a link', async () => { + createWrapper(); + + await nextTick(); + const link = findJobLink(); + + expect(link.attributes('href')).toBe(mockJob.status.detailsPath); + + expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`); + + expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + + expect(wrapper.text()).toBe(mockJob.name); + }); + }); + + describe('name without link', () => { + beforeEach(() => { + createWrapper({ + props: { + job: mockJobWithoutDetails, + cssClassJobName: 'css-class-job-name', + jobHovered: 'test', + }, + }); + }); + + it('should render status and name', () => { + expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + expect(findJobLink().exists()).toBe(false); + + expect(wrapper.text()).toBe(mockJobWithoutDetails.name); + }); + + it('should apply hover class and provided class name', () => { + expect(findJobWithoutLink().classes()).toContain('css-class-job-name'); + }); + }); + + describe('action icon', () => { + it('should render the action icon', () => { + createWrapper(); + + const actionComponent = findActionComponent(); + + expect(actionComponent.exists()).toBe(true); + expect(actionComponent.props('actionIcon')).toBe('retry'); + expect(actionComponent.attributes('disabled')).toBeUndefined(); + }); + + it('should render disabled action icon when user cannot run the action', () => { + createWrapper({ + props: { + job: mockJobWithUnauthorizedAction, + }, + }); + + const actionComponent = findActionComponent(); + + expect(actionComponent.exists()).toBe(true); + expect(actionComponent.props('actionIcon')).toBe('stop'); + expect(actionComponent.attributes('disabled')).toBeDefined(); + }); + + it('action icon tooltip text when job has passed but can be ran again', () => { + createWrapper({ props: { job: mockJob } }); + + expect(findActionComponent().props('tooltipText')).toBe('Run again'); + }); + + it('action icon tooltip text when job has failed and can be retried', () => { + createWrapper({ props: { job: mockFailedJob } }); + + expect(findActionComponent().props('tooltipText')).toBe('Retry'); + }); + }); + + describe('job style', () => { + beforeEach(() => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: 'css-class-job-name', + }, + }); + }); + + it('should render provided class name', () => { + expect(findJobLink().classes()).toContain('css-class-job-name'); + }); + + it('does not show a badge on the job item', () => { + expect(findBadge().exists()).toBe(false); + }); + + it('does not apply the trigger job class', () => { + expect(findJobWithLink().classes()).not.toContain('gl-rounded-lg'); + }); + }); + + describe('status label', () => { + it('should not render status label when it is not provided', () => { + createWrapper({ + props: { + job: { + id: 4258, + name: 'test', + status: { + icon: 'status_success', + }, + }, + }, + }); + + expect(findJobWithoutLink().attributes('title')).toBe('test'); + }); + + it('should not render status label when it is provided', () => { + createWrapper({ + props: { + job: { + id: 4259, + name: 'test', + status: { + icon: 'status_success', + label: 'success', + tooltip: 'success', + }, + }, + }, + }); + + expect(findJobWithoutLink().attributes('title')).toBe('test - success'); + }); + }); + + describe('for delayed job', () => { + it('displays remaining time in tooltip', () => { + createWrapper({ + props: { + job: delayedJob, + }, + }); + + expect(findJobWithLink().attributes('title')).toBe( + `delayed job - delayed manual action (00:00:00)`, + ); + }); + }); + + describe('trigger job', () => { + describe('card', () => { + beforeEach(() => { + createWrapper({ + props: { + job: triggerJob, + }, + }); + }); + + it('shows a badge on the job item', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe('Trigger job'); + }); + + it('applies a rounded corner style instead of the usual pill shape', () => { + expect(findJobWithoutLink().classes()).toContain('gl-rounded-lg'); + }); + }); + + describe('when retrying', () => { + const mockToastShow = jest.fn(); + + beforeEach(async () => { + createWrapper({ + mountFn: shallowMountExtended, + props: { + skipRetryModal: true, + job: triggerJobWithRetryAction, + }, + mocks: { + $toast: { + show: mockToastShow, + }, + }, + }); + + await findActionVueComponent().vm.$emit('pipelineActionRequestComplete'); + await nextTick(); + }); + + it('shows a toast message that the downstream is being created', () => { + expect(mockToastShow).toHaveBeenCalledTimes(1); + }); + }); + + describe('highlighting', () => { + it.each` + job | jobName | expanded | link + ${mockJob} | ${mockJob.name} | ${true} | ${true} + ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false} + `( + `trigger job should stay highlighted when downstream is expanded`, + ({ job, jobName, expanded, link }) => { + createWrapper({ + props: { + job, + pipelineExpanded: { jobName, expanded }, + }, + }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).toContain(triggerActiveClass); + }, + ); + + it.each` + job | jobName | expanded | link + ${mockJob} | ${mockJob.name} | ${false} | ${true} + ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false} + `( + `trigger job should not be highlighted when downstream is not expanded`, + ({ job, jobName, expanded, link }) => { + createWrapper({ + props: { + job, + pipelineExpanded: { jobName, expanded }, + }, + }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).not.toContain(triggerActiveClass); + }, + ); + }); + }); + + describe('job classes', () => { + it('job class is shown', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: 'my-class', + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain('my-class'); + + expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); + }); + + it('job class is shown, along with hover', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: 'my-class', + sourceJobHovered: mockJob.name, + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain('my-class'); + expect(jobLinkEl.classes()).toContain(triggerActiveClass); + }); + + it('multiple job classes are shown', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: [myCustomClass1, myCustomClass2], + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain(myCustomClass1); + expect(jobLinkEl.classes()).toContain(myCustomClass2); + + expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); + }); + + it('multiple job classes are shown conditionally', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: { [myCustomClass1]: true, [myCustomClass2]: true }, + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain(myCustomClass1); + expect(jobLinkEl.classes()).toContain(myCustomClass2); + + expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); + }); + + it('multiple job classes are shown, along with a hover', () => { + createWrapper({ + props: { + job: mockJob, + cssClassJobName: [myCustomClass1, myCustomClass2], + sourceJobHovered: mockJob.name, + }, + }); + + const jobLinkEl = findJobLink(); + + expect(jobLinkEl.classes()).toContain(myCustomClass1); + expect(jobLinkEl.classes()).toContain(myCustomClass2); + expect(jobLinkEl.classes()).toContain(triggerActiveClass); + }); + }); + + describe('confirmation modal', () => { + describe('when clicking on the action component', () => { + it.each` + skipRetryModal | exists | visibilityText + ${false} | ${true} | ${'shows'} + ${true} | ${false} | ${'hides'} + `( + '$visibilityText the modal when `skipRetryModal` is $skipRetryModal', + async ({ exists, skipRetryModal }) => { + createWrapper({ + props: { + skipRetryModal, + job: triggerJobWithRetryAction, + }, + }); + await findActionComponent().trigger('click'); + + expect(findModal().exists()).toBe(exists); + }, + ); + }); + + describe('when showing the modal', () => { + it.each` + buttonName | shouldTriggerActionClick | actionBtn + ${'primary'} | ${true} | ${clickOnModalPrimaryBtn} + ${'cancel'} | ${false} | ${clickOnModalCancelBtn} + ${'close'} | ${false} | ${clickOnModalCloseBtn} + `( + 'clicking on $buttonName will pass down shouldTriggerActionClick as $shouldTriggerActionClick to the action component', + async ({ shouldTriggerActionClick, actionBtn }) => { + createWrapper({ + props: { + skipRetryModal: false, + job: triggerJobWithRetryAction, + }, + }); + await findActionComponent().trigger('click'); + + await actionBtn(); + + expect(findActionComponent().props().shouldTriggerClick).toBe(shouldTriggerActionClick); + }, + ); + }); + + describe('when not checking the "do not show this again" checkbox', () => { + it.each` + actionName | actionBtn + ${'closing'} | ${clickOnModalCloseBtn} + ${'cancelling'} | ${clickOnModalCancelBtn} + ${'confirming'} | ${clickOnModalPrimaryBtn} + `( + 'does not emit any event and will not modify localstorage on $actionName', + async ({ actionBtn }) => { + createWrapper({ + props: { + skipRetryModal: false, + job: triggerJobWithRetryAction, + }, + }); + await findActionComponent().trigger('click'); + await actionBtn(); + + expect(wrapper.emitted().setSkipRetryModal).toBeUndefined(); + expect(localStorage.setItem).not.toHaveBeenCalled(); + }, + ); + }); + + describe('when checking the "do not show this again" checkbox', () => { + it.each` + actionName | actionBtn + ${'closing'} | ${clickOnModalCloseBtn} + ${'cancelling'} | ${clickOnModalCancelBtn} + ${'confirming'} | ${clickOnModalPrimaryBtn} + `( + 'emits "setSkipRetryModal" and set local storage key on $actionName the modal', + async ({ actionBtn }) => { + // We are passing the checkbox as a slot to the GlModal. + // The way GlModal is mounted, we can neither click on the box + // or emit an event directly. We therefore set the data property + // as it would be if the box was checked. + createWrapper({ + data: { + currentSkipModalValue: true, + }, + props: { + skipRetryModal: false, + job: triggerJobWithRetryAction, + }, + }); + await findActionComponent().trigger('click'); + await actionBtn(); + + expect(wrapper.emitted().setSkipRetryModal).toHaveLength(1); + expect(localStorage.setItem).toHaveBeenCalledWith('skip_retry_modal', 'true'); + }, + ); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js new file mode 100644 index 00000000000..ca201aee648 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/job_name_component_spec.js @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils'; +import jobNameComponent from '~/ci/common/private/job_name_component.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +describe('job name component', () => { + let wrapper; + + const propsData = { + name: 'foo', + status: { + icon: 'status_success', + group: 'success', + }, + }; + + beforeEach(() => { + wrapper = mount(jobNameComponent, { + propsData, + }); + }); + + it('should render the provided name', () => { + expect(wrapper.text()).toBe(propsData.name); + }); + + it('should render an icon with the provided status', () => { + expect(wrapper.findComponent(CiIcon).exists()).toBe(true); + expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js new file mode 100644 index 00000000000..5541b0db54a --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipeline_spec.js @@ -0,0 +1,464 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; +import { createWrapper } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/ci/pipeline_details/graph/constants'; +import LinkedPipelineComponent from '~/ci/pipeline_details/graph/components/linked_pipeline.vue'; +import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; +import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import mockPipeline from './linked_pipelines_mock_data'; + +describe('Linked pipeline', () => { + let wrapper; + let requestHandlers; + + const downstreamProps = { + pipeline: { + ...mockPipeline, + multiproject: false, + }, + columnTitle: 'Downstream', + type: DOWNSTREAM, + expanded: false, + isLoading: false, + }; + + const upstreamProps = { + ...downstreamProps, + columnTitle: 'Upstream', + type: UPSTREAM, + }; + + const findButton = () => wrapper.findComponent(GlButton); + const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline'); + const findCardTooltip = () => wrapper.findComponent(GlTooltip); + const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title'); + const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button'); + const findLinkedPipeline = () => wrapper.findComponent({ ref: 'linkedPipeline' }); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findPipelineLabel = () => wrapper.findByTestId('downstream-pipeline-label'); + const findPipelineLink = () => wrapper.findByTestId('pipelineLink'); + const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline'); + + const defaultHandlers = { + cancelPipeline: jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [] } } }), + retryPipeline: jest.fn().mockResolvedValue({ data: { pipelineRetry: { errors: [] } } }), + }; + + const createMockApolloProvider = (handlers) => { + Vue.use(VueApollo); + + requestHandlers = handlers; + return createMockApollo([ + [CancelPipelineMutation, requestHandlers.cancelPipeline], + [RetryPipelineMutation, requestHandlers.retryPipeline], + ]); + }; + + const createComponent = ({ propsData, handlers = defaultHandlers }) => { + const mockApollo = createMockApolloProvider(handlers); + + wrapper = mountExtended(LinkedPipelineComponent, { + propsData, + apolloProvider: mockApollo, + }); + }; + + describe('rendered output', () => { + const props = { + pipeline: mockPipeline, + columnTitle: 'Downstream', + type: DOWNSTREAM, + expanded: false, + isLoading: false, + }; + + beforeEach(() => { + createComponent({ propsData: props }); + }); + + it('should render the project name', () => { + expect(wrapper.text()).toContain(props.pipeline.project.name); + }); + + it('should render an svg within the status container', () => { + const pipelineStatusElement = wrapper.findComponent(CiIcon); + + expect(pipelineStatusElement.find('svg').exists()).toBe(true); + }); + + it('should render the pipeline status icon svg', () => { + expect(wrapper.find('.ci-status-icon-success svg').exists()).toBe(true); + }); + + it('should have a ci-status child component', () => { + expect(wrapper.findComponent(CiIcon).exists()).toBe(true); + }); + + it('should render the pipeline id', () => { + expect(wrapper.text()).toContain(`#${props.pipeline.id}`); + }); + + it('adds the card tooltip text to the DOM', () => { + expect(findCardTooltip().exists()).toBe(true); + + 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.toString()); + }); + + it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { + expect(findPipelineLabel().text()).toBe('Multi-project'); + }); + }); + + describe('upstream pipelines', () => { + beforeEach(() => { + createComponent({ propsData: upstreamProps }); + }); + + it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { + expect(findPipelineLabel().exists()).toBe(true); + }); + + it('upstream pipeline should contain the correct link', () => { + expect(findPipelineLink().attributes('href')).toBe(upstreamProps.pipeline.path); + }); + + it('applies the reverse-row css class to the card', () => { + expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row-reverse'); + expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row'); + }); + }); + + describe('downstream pipelines', () => { + describe('styling', () => { + beforeEach(() => { + createComponent({ propsData: downstreamProps }); + }); + + it('parent/child label container should exist', () => { + expect(findPipelineLabel().exists()).toBe(true); + }); + + it('should display child label when pipeline project id is the same as triggered pipeline project id', () => { + expect(findPipelineLabel().exists()).toBe(true); + }); + + it('should have the name of the trigger job on the card when it is a child pipeline', () => { + expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name); + }); + + it('downstream pipeline should contain the correct link', () => { + expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path); + }); + + it('applies the flex-row css class to the card', () => { + expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row'); + expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row-reverse'); + }); + }); + + describe('action button', () => { + describe('with permissions', () => { + describe('on an upstream', () => { + describe('when retryable', () => { + beforeEach(() => { + const retryablePipeline = { + ...upstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + createComponent({ propsData: retryablePipeline }); + }); + + it('does not show the retry or cancel button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); + }); + }); + }); + + describe('on a downstream', () => { + const retryablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + describe('when retryable', () => { + beforeEach(() => { + createComponent({ propsData: retryablePipeline }); + }); + + it('shows only the retry button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(true); + }); + + it.each` + findElement | name + ${findRetryButton} | ${'retry button'} + ${findExpandButton} | ${'expand button'} + `('hides the card tooltip when $name is hovered', async ({ findElement }) => { + expect(findCardTooltip().exists()).toBe(true); + + await findElement().trigger('mouseover'); + + expect(findCardTooltip().exists()).toBe(false); + }); + + describe('and the retry button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + await findRetryButton().trigger('click'); + }); + + it('calls the retry mutation', () => { + expect(requestHandlers.retryPipeline).toHaveBeenCalledTimes(1); + expect(requestHandlers.retryPipeline).toHaveBeenCalledWith({ + id: 'gid://gitlab/Ci::Pipeline/195', + }); + }); + + it('emits the refreshPipelineGraph event', async () => { + await waitForPromises(); + expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1); + }); + }); + + describe('on failure', () => { + beforeEach(async () => { + createComponent({ + propsData: retryablePipeline, + handlers: { + retryPipeline: jest.fn().mockRejectedValue({ errors: [] }), + cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }), + }, + }); + + await findRetryButton().trigger('click'); + }); + + it('emits an error event', async () => { + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]); + }); + }); + }); + }); + + describe('when cancelable', () => { + const cancelablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true }, + }; + + beforeEach(() => { + createComponent({ propsData: cancelablePipeline }); + }); + + it('shows only the cancel button', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findRetryButton().exists()).toBe(false); + }); + + it.each` + findElement | name + ${findCancelButton} | ${'cancel button'} + ${findExpandButton} | ${'expand button'} + `('hides the card tooltip when $name is hovered', async ({ findElement }) => { + expect(findCardTooltip().exists()).toBe(true); + + await findElement().trigger('mouseover'); + + expect(findCardTooltip().exists()).toBe(false); + }); + + describe('and the cancel button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + await findCancelButton().trigger('click'); + }); + + it('calls the cancel mutation', () => { + expect(requestHandlers.cancelPipeline).toHaveBeenCalledTimes(1); + expect(requestHandlers.cancelPipeline).toHaveBeenCalledWith({ + id: 'gid://gitlab/Ci::Pipeline/195', + }); + }); + it('emits the refreshPipelineGraph event', async () => { + await waitForPromises(); + expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1); + }); + }); + + describe('on failure', () => { + beforeEach(async () => { + createComponent({ + propsData: cancelablePipeline, + handlers: { + retryPipeline: jest.fn().mockRejectedValue({ errors: [] }), + cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }), + }, + }); + + await findCancelButton().trigger('click'); + }); + + it('emits an error event', async () => { + await waitForPromises(); + expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]); + }); + }); + }); + }); + + describe('when both cancellable and retryable', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true, retryable: true }, + }; + + createComponent({ propsData: pipelineWithTwoActions }); + }); + + it('only shows the cancel button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(true); + }); + }); + }); + }); + + describe('without permissions', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { + ...mockPipeline, + cancelable: true, + retryable: true, + userPermissions: { updatePipeline: false }, + }, + }; + + createComponent({ propsData: pipelineWithTwoActions }); + }); + + it('does not show any action button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + }); + }); + }); + + describe('expand button', () => { + it.each` + pipelineType | chevronPosition | buttonBorderClasses | expanded + ${downstreamProps} | ${'chevron-lg-right'} | ${'gl-border-l-0!'} | ${false} + ${downstreamProps} | ${'chevron-lg-left'} | ${'gl-border-l-0!'} | ${true} + ${upstreamProps} | ${'chevron-lg-left'} | ${'gl-border-r-0!'} | ${false} + ${upstreamProps} | ${'chevron-lg-right'} | ${'gl-border-r-0!'} | ${true} + `( + '$pipelineType.columnTitle pipeline button icon should be $chevronPosition with $buttonBorderClasses if expanded state is $expanded', + ({ pipelineType, chevronPosition, buttonBorderClasses, expanded }) => { + createComponent({ propsData: { ...pipelineType, expanded } }); + expect(findExpandButton().props('icon')).toBe(chevronPosition); + expect(findExpandButton().classes()).toContain(buttonBorderClasses); + }, + ); + + describe('shadow border', () => { + beforeEach(() => { + createComponent({ propsData: downstreamProps }); + }); + + it.each` + activateEventName | deactivateEventName + ${'mouseover'} | ${'mouseout'} + ${'focus'} | ${'blur'} + `( + 'applies the class on $activateEventName and removes it on $deactivateEventName', + async ({ activateEventName, deactivateEventName }) => { + const shadowClass = 'gl-shadow-none!'; + + expect(findExpandButton().classes()).toContain(shadowClass); + + await findExpandButton().vm.$emit(activateEventName); + expect(findExpandButton().classes()).not.toContain(shadowClass); + + await findExpandButton().vm.$emit(deactivateEventName); + expect(findExpandButton().classes()).toContain(shadowClass); + }, + ); + }); + }); + + describe('when isLoading is true', () => { + const props = { + pipeline: mockPipeline, + columnTitle: 'Downstream', + type: DOWNSTREAM, + expanded: false, + isLoading: true, + }; + + beforeEach(() => { + createComponent({ propsData: props }); + }); + + it('loading icon is visible', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('on click/hover', () => { + const props = { + pipeline: mockPipeline, + columnTitle: 'Downstream', + type: DOWNSTREAM, + expanded: false, + isLoading: false, + }; + + beforeEach(() => { + createComponent({ propsData: props }); + }); + + it('emits `pipelineClicked` event', () => { + findButton().trigger('click'); + + expect(wrapper.emitted('pipelineClicked')).toHaveLength(1); + }); + + it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, async () => { + const root = createWrapper(wrapper.vm.$root); + await findButton().vm.$emit('click'); + + expect(root.emitted(BV_HIDE_TOOLTIP)).toHaveLength(1); + }); + + it('should emit downstreamHovered with job name on mouseover', () => { + findLinkedPipeline().trigger('mouseover'); + expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['test_c']]); + }); + + it('should emit downstreamHovered with empty string on mouseleave', () => { + findLinkedPipeline().trigger('mouseleave'); + expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['']]); + }); + + it('should emit pipelineExpanded with job name and expanded state on click', () => { + findExpandButton().trigger('click'); + expect(wrapper.emitted('pipelineExpandToggle')).toStrictEqual([['test_c', true]]); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_column_spec.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_column_spec.js new file mode 100644 index 00000000000..30f05baceab --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_column_spec.js @@ -0,0 +1,214 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import { + DOWNSTREAM, + UPSTREAM, + LAYER_VIEW, + STAGE_VIEW, +} from '~/ci/pipeline_details/graph/constants'; +import PipelineGraph from '~/ci/pipeline_details/graph/components/graph_component.vue'; +import LinkedPipeline from '~/ci/pipeline_details/graph/components/linked_pipeline.vue'; +import LinkedPipelinesColumn from '~/ci/pipeline_details/graph/components/linked_pipelines_column.vue'; +import * as parsingUtils from '~/ci/pipeline_details/utils/parsing_utils'; +import { LOAD_FAILURE } from '~/ci/pipeline_details/constants'; + +import { pipelineWithUpstreamDownstream, wrappedPipelineReturn } from '../mock_data'; + +const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse); + +describe('Linked Pipelines Column', () => { + const defaultProps = { + columnTitle: 'Downstream', + linkedPipelines: processedPipeline.downstream, + showLinks: false, + type: DOWNSTREAM, + viewType: STAGE_VIEW, + configPaths: { + metricsPath: '', + graphqlResourceEtag: 'this/is/a/path', + }, + }; + + let wrapper; + const findLinkedColumnTitle = () => wrapper.find('[data-testid="linked-column-title"]'); + const findLinkedPipelineElements = () => wrapper.findAllComponents(LinkedPipeline); + const findPipelineGraph = () => wrapper.findComponent(PipelineGraph); + const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); + + Vue.use(VueApollo); + + const createComponent = ({ apolloProvider, mountFn = shallowMount, props = {} } = {}) => { + wrapper = mountFn(LinkedPipelinesColumn, { + apolloProvider, + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const createComponentWithApollo = ({ + mountFn = shallowMount, + getPipelineDetailsHandler = jest.fn().mockResolvedValue(wrappedPipelineReturn), + props = {}, + } = {}) => { + const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; + + const apolloProvider = createMockApollo(requestHandlers); + createComponent({ apolloProvider, mountFn, props }); + }; + + describe('it renders correctly', () => { + beforeEach(() => { + createComponentWithApollo(); + }); + + it('renders the pipeline title', () => { + expect(findLinkedColumnTitle().text()).toBe(defaultProps.columnTitle); + }); + + it('renders the correct number of linked pipelines', () => { + expect(findLinkedPipelineElements()).toHaveLength(defaultProps.linkedPipelines.length); + }); + }); + + describe('click action', () => { + const clickExpandButton = async () => { + await findExpandButton().trigger('click'); + await waitForPromises(); + }; + + describe('layer type rendering', () => { + let layersFn; + + beforeEach(() => { + layersFn = jest.spyOn(parsingUtils, 'listByLayers'); + createComponentWithApollo({ mountFn: mount }); + }); + + it('calls listByLayers only once no matter how many times view is switched', async () => { + expect(layersFn).not.toHaveBeenCalled(); + await clickExpandButton(); + await wrapper.setProps({ viewType: LAYER_VIEW }); + await nextTick(); + expect(layersFn).toHaveBeenCalledTimes(1); + await wrapper.setProps({ viewType: STAGE_VIEW }); + await wrapper.setProps({ viewType: LAYER_VIEW }); + await wrapper.setProps({ viewType: STAGE_VIEW }); + expect(layersFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('when graph does not use needs', () => { + beforeEach(() => { + const nonNeedsResponse = { ...wrappedPipelineReturn }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + createComponentWithApollo({ + props: { + viewType: LAYER_VIEW, + }, + getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), + mountFn: mount, + }); + }); + + it('shows the stage view, even when the main graph view type is layers', async () => { + await clickExpandButton(); + expect(findPipelineGraph().props('viewType')).toBe(STAGE_VIEW); + }); + }); + + describe('downstream', () => { + describe('when successful', () => { + beforeEach(() => { + createComponentWithApollo({ mountFn: mount }); + }); + + it('toggles the pipeline visibility', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(true); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + + describe('on error', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + }); + + it('emits the error', async () => { + await clickExpandButton(); + expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]); + }); + + it('does not show the pipeline', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + }); + + describe('upstream', () => { + const upstreamProps = { + columnTitle: 'Upstream', + /* + Because the IDs need to match to work, rather + than make new mock data, we are representing + the upstream pipeline with the downstream data. + */ + linkedPipelines: processedPipeline.downstream, + type: UPSTREAM, + }; + + describe('when successful', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + props: upstreamProps, + }); + }); + + it('toggles the pipeline visibility', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(true); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + + describe('on error', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + props: upstreamProps, + }); + }); + + it('emits the error', async () => { + await clickExpandButton(); + expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]); + }); + + it('does not show the pipeline', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_mock_data.js b/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_mock_data.js new file mode 100644 index 00000000000..f7f5738e46d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/linked_pipelines_mock_data.js @@ -0,0 +1,27 @@ +export default { + __typename: 'Pipeline', + id: 195, + iid: '5', + retryable: false, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + path: '/root/elemenohpee/-/pipelines/195', + status: { + __typename: 'DetailedStatus', + group: 'success', + label: 'passed', + icon: 'status_success', + }, + sourceJob: { + __typename: 'CiJob', + name: 'test_c', + }, + project: { + __typename: 'Project', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + }, + multiproject: true, +}; diff --git a/spec/frontend/ci/pipeline_details/graph/components/links_inner_spec.js b/spec/frontend/ci/pipeline_details/graph/components/links_inner_spec.js new file mode 100644 index 00000000000..655b2ac74ac --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/links_inner_spec.js @@ -0,0 +1,223 @@ +import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; +import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; +import { createJobsHash } from '~/ci/pipeline_details/utils'; +import { + jobRect, + largePipelineData, + parallelNeedData, + pipelineData, + pipelineDataWithNoNeeds, + rootRect, + sameStageNeeds, +} from 'jest/ci/pipeline_editor/components/graph/mock_data'; + +describe('Links Inner component', () => { + const containerId = 'pipeline-graph-container'; + const defaultProps = { + containerId, + containerMeasurements: { width: 1019, height: 445 }, + pipelineId: 1, + pipelineData: [], + totalGroups: 10, + }; + + let wrapper; + + const createComponent = (props) => { + const currentPipelineData = props?.pipelineData || defaultProps.pipelineData; + wrapper = shallowMount(LinksInner, { + propsData: { + ...defaultProps, + ...props, + linksData: parseData(currentPipelineData.flatMap(({ groups }) => groups)).links, + }, + }); + }; + + const findLinkSvg = () => wrapper.find('#link-svg'); + const findAllLinksPath = () => findLinkSvg().findAll('path'); + + // We create fixture so that each job has an empty div that represent + // the JobPill in the DOM. Each `JobPill` would have different coordinates, + // so we increment their coordinates on each iteration to simulate different positions. + const setHTMLFixtureLocal = ({ stages }) => { + const jobs = createJobsHash(stages); + const arrayOfJobs = Object.keys(jobs); + + const linksHtmlElements = arrayOfJobs.map((job) => { + return `
    `; + }); + + setHTMLFixture(`
    ${linksHtmlElements.join(' ')}
    `); + + // We are mocking the clientRect data of each job and the container ID. + jest + .spyOn(document.getElementById(containerId), 'getBoundingClientRect') + .mockImplementation(() => rootRect); + + arrayOfJobs.forEach((job, index) => { + jest + .spyOn( + document.getElementById(`${job}-${defaultProps.pipelineId}`), + 'getBoundingClientRect', + ) + .mockImplementation(() => { + const newValue = 10 * index; + const { left, right, top, bottom, x, y } = jobRect; + return { + ...jobRect, + left: left + newValue, + right: right + newValue, + top: top + newValue, + bottom: bottom + newValue, + x: x + newValue, + y: y + newValue, + }; + }); + }); + }; + + afterEach(() => { + resetHTMLFixture(); + }); + + describe('basic SVG creation', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an SVG of the right size', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findLinkSvg().attributes('width')).toBe( + `${defaultProps.containerMeasurements.width}px`, + ); + expect(findLinkSvg().attributes('height')).toBe( + `${defaultProps.containerMeasurements.height}px`, + ); + }); + }); + + describe('no pipeline data', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the component', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findAllLinksPath()).toHaveLength(0); + }); + }); + + describe('pipeline data with no needs', () => { + beforeEach(() => { + createComponent({ pipelineData: pipelineDataWithNoNeeds.stages }); + }); + + it('renders no links', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findAllLinksPath()).toHaveLength(0); + }); + }); + + describe('with one need', () => { + beforeEach(() => { + setHTMLFixtureLocal(pipelineData); + createComponent({ pipelineData: pipelineData.stages }); + }); + + it('renders one link', () => { + expect(findAllLinksPath()).toHaveLength(1); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with a parallel need', () => { + beforeEach(() => { + setHTMLFixtureLocal(parallelNeedData); + createComponent({ pipelineData: parallelNeedData.stages }); + }); + + it('renders only one link for all the same parallel jobs', () => { + expect(findAllLinksPath()).toHaveLength(1); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with same stage needs', () => { + beforeEach(() => { + setHTMLFixtureLocal(sameStageNeeds); + createComponent({ pipelineData: sameStageNeeds.stages }); + }); + + it('renders the correct number of links', () => { + expect(findAllLinksPath()).toHaveLength(2); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with a large number of needs', () => { + beforeEach(() => { + setHTMLFixtureLocal(largePipelineData); + createComponent({ pipelineData: largePipelineData.stages }); + }); + + it('renders the correct number of links', () => { + expect(findAllLinksPath()).toHaveLength(5); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('interactions', () => { + beforeEach(() => { + setHTMLFixtureLocal(largePipelineData); + createComponent({ pipelineData: largePipelineData.stages }); + }); + + it('highlight needs on hover', async () => { + const firstLink = findAllLinksPath().at(0); + + const defaultColorClass = 'gl-stroke-gray-200'; + const hoverColorClass = 'gl-stroke-blue-400'; + + expect(firstLink.classes(defaultColorClass)).toBe(true); + expect(firstLink.classes(hoverColorClass)).toBe(false); + + // Because there is a watcher, we need to set the props after the component + // has mounted. + await wrapper.setProps({ highlightedJob: 'test_1' }); + + expect(firstLink.classes(defaultColorClass)).toBe(false); + expect(firstLink.classes(hoverColorClass)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js b/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js new file mode 100644 index 00000000000..cc79205ec41 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/components/stage_column_component_spec.js @@ -0,0 +1,228 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import JobItem from '~/ci/pipeline_details/graph/components/job_item.vue'; +import StageColumnComponent from '~/ci/pipeline_details/graph/components/stage_column_component.vue'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; + +const mockJob = { + id: 4250, + name: 'test', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4250', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4250/retry', + method: 'post', + }, + }, +}; + +const mockGroups = Array(4) + .fill(0) + .map((item, idx) => { + return { ...mockJob, jobs: [mockJob], id: idx, name: `fish-${idx}` }; + }); + +const defaultProps = { + name: 'Fish', + groups: mockGroups, + pipelineId: 159, + userPermissions: { + updatePipeline: true, + }, +}; + +describe('stage column component', () => { + let wrapper; + + const findStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]'); + const findStageColumnGroup = () => wrapper.find('[data-testid="stage-column-group"]'); + const findAllStageColumnGroups = () => wrapper.findAll('[data-testid="stage-column-group"]'); + const findJobItem = () => wrapper.findComponent(JobItem); + const findActionComponent = () => wrapper.findComponent(ActionComponent); + + const createComponent = ({ method = shallowMount, props = {} } = {}) => { + wrapper = method(StageColumnComponent, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('should render provided title', () => { + expect(findStageColumnTitle().text()).toBe(defaultProps.name); + }); + + it('should render the provided groups', () => { + expect(findAllStageColumnGroups().length).toBe(mockGroups.length); + }); + + it('should emit updateMeasurements event on mount', () => { + expect(wrapper.emitted().updateMeasurements).toHaveLength(1); + }); + }); + + describe('when job notifies action is complete', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { + groups: [ + { + title: 'Fish', + size: 1, + jobs: [mockJob], + }, + ], + }, + }); + findJobItem().vm.$emit('pipelineActionRequestComplete'); + }); + + it('emits refreshPipelineGraph', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); + }); + + describe('job', () => { + describe('text handling', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { + groups: [ + { + ...mockJob, + name: '', + jobs: [ + { + id: 4259, + name: '', + status: { + icon: 'status_success', + label: 'success', + tooltip: '', + }, + }, + ], + }, + ], + name: 'test ', + }, + }); + }); + + it('escapes name', () => { + expect(findStageColumnTitle().html()).toContain( + 'test <img src=x onerror=alert(document.domain)>', + ); + }); + + it('escapes id', () => { + expect(findStageColumnGroup().attributes('id')).toBe( + 'ci-badge-<img src=x onerror=alert(document.domain)>', + ); + }); + }); + + describe('interactions', () => { + beforeEach(() => { + createComponent({ method: mount }); + }); + + it('emits jobHovered event on mouseenter and mouseleave', async () => { + await findStageColumnGroup().trigger('mouseenter'); + expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name]]); + await findStageColumnGroup().trigger('mouseleave'); + expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name], ['']]); + }); + }); + }); + + describe('with action', () => { + const defaults = { + groups: [ + { + id: 4259, + name: '', + status: { + icon: 'status_success', + label: 'success', + tooltip: '', + }, + jobs: [mockJob], + }, + ], + title: 'test', + hasTriggeredBy: false, + action: { + icon: 'play', + title: 'Play all', + path: 'action', + }, + }; + + it('renders action button if permissions are permitted', () => { + createComponent({ + method: mount, + props: { + ...defaults, + }, + }); + + expect(findActionComponent().exists()).toBe(true); + }); + + it('does not render action button if permissions are not permitted', () => { + createComponent({ + method: mount, + props: { + ...defaults, + userPermissions: { + updatePipeline: false, + }, + }, + }); + + expect(findActionComponent().exists()).toBe(false); + }); + }); + + describe('without action', () => { + beforeEach(() => { + createComponent({ + method: mount, + props: { + groups: [ + { + id: 4259, + name: '', + status: { + icon: 'status_success', + label: 'success', + tooltip: '', + }, + jobs: [mockJob], + }, + ], + title: 'test', + hasTriggeredBy: false, + }, + }); + }); + + it('does not render action button', () => { + expect(findActionComponent().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/graph_component_wrapper_spec.js b/spec/frontend/ci/pipeline_details/graph/graph_component_wrapper_spec.js new file mode 100644 index 00000000000..372ed2a4e1c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/graph_component_wrapper_spec.js @@ -0,0 +1,603 @@ +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_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'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; +import * as perfUtils from '~/performance/utils'; +import { + ACTION_FAILURE, + LAYER_VIEW, + STAGE_VIEW, + VIEW_TYPE_KEY, +} from '~/ci/pipeline_details/graph/constants'; +import PipelineGraph from '~/ci/pipeline_details/graph/components/graph_component.vue'; +import PipelineGraphWrapper from '~/ci/pipeline_details/graph/graph_component_wrapper.vue'; +import GraphViewSelector from '~/ci/pipeline_details/graph/components/graph_view_selector.vue'; +import * as Api from '~/ci/pipeline_details/graph/api_utils'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; +import * as parsingUtils from '~/ci/pipeline_details/utils/parsing_utils'; +import getPipelineHeaderData from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql'; +import * as sentryUtils from '~/ci/utils'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { mockRunningPipelineHeaderData } from '../mock_data'; +import { + mapCallouts, + mockCalloutsResponse, + mockPipelineResponseWithTooManyJobs, +} from './mock_data'; + +const defaultProvide = { + graphqlResourceEtag: 'frog/amphibirama/etag/', + metricsPath: '', + pipelineProjectPath: 'frog/amphibirama', + pipelineIid: '22', +}; + +describe('Pipeline graph wrapper', () => { + Vue.use(VueApollo); + useLocalStorageSpy(); + + let wrapper; + let requestHandlers; + let pipelineDetailsHandler; + + const findAlert = () => wrapper.findByTestId('error-alert'); + const findJobCountWarning = () => wrapper.findByTestId('job-count-warning'); + const findDependenciesToggle = () => wrapper.findByTestId('show-links-toggle'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLinksLayer = () => wrapper.findComponent(LinksLayer); + const findGraph = () => wrapper.findComponent(PipelineGraph); + const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title'); + const findViewSelector = () => wrapper.findComponent(GraphViewSelector); + const findViewSelectorToggle = () => findViewSelector().findComponent(GlToggle); + const findViewSelectorTrip = () => findViewSelector().findComponent(GlAlert); + const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + + const createComponent = ({ + apolloProvider, + data = {}, + provide = {}, + mountFn = shallowMountExtended, + } = {}) => { + wrapper = mountFn(PipelineGraphWrapper, { + provide: { + ...defaultProvide, + ...provide, + }, + apolloProvider, + data() { + return { + ...data, + }; + }, + }); + }; + + const createComponentWithApollo = ({ + calloutsList = [], + data = {}, + mountFn = shallowMountExtended, + provide = {}, + } = {}) => { + const callouts = mapCallouts(calloutsList); + + requestHandlers = { + getUserCalloutsHandler: jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)), + getPipelineHeaderDataHandler: jest.fn().mockResolvedValue(mockRunningPipelineHeaderData), + getPipelineDetailsHandler: pipelineDetailsHandler, + }; + + const handlers = [ + [getPipelineHeaderData, requestHandlers.getPipelineHeaderDataHandler], + [getPipelineDetails, requestHandlers.getPipelineDetailsHandler], + [getUserCallouts, requestHandlers.getUserCalloutsHandler], + ]; + + const apolloProvider = createMockApollo(handlers); + createComponent({ apolloProvider, data, provide, mountFn }); + }; + + beforeEach(() => { + pipelineDetailsHandler = jest.fn(); + pipelineDetailsHandler.mockResolvedValue(mockPipelineResponse); + }); + + describe('when data is loading', () => { + beforeEach(() => { + createComponentWithApollo(); + }); + + it('displays the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not display the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('does not display the graph', () => { + expect(findGraph().exists()).toBe(false); + }); + + it('skips querying headerPipeline', () => { + expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(true); + }); + }); + + describe('when data has loaded', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + }); + + it('does not display the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('does not display the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('displays the graph', () => { + expect(findGraph().exists()).toBe(true); + }); + + it('passes the etag resource and metrics path to the graph', () => { + expect(findGraph().props('configPaths')).toMatchObject({ + graphqlResourceEtag: defaultProvide.graphqlResourceEtag, + metricsPath: defaultProvide.metricsPath, + }); + }); + }); + + describe('when a stage has 100 jobs or more', () => { + beforeEach(async () => { + pipelineDetailsHandler.mockResolvedValue(mockPipelineResponseWithTooManyJobs); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('show a warning alert', () => { + expect(findJobCountWarning().exists()).toBe(true); + expect(findJobCountWarning().props().title).toBe( + 'Only the first 100 jobs per stage are displayed', + ); + }); + }); + + describe('when there is an error', () => { + beforeEach(async () => { + pipelineDetailsHandler.mockRejectedValue(new Error('GraphQL error')); + createComponentWithApollo(); + await waitForPromises(); + }); + + it('does not display the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays the alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('does not display the graph', () => { + expect(findGraph().exists()).toBe(false); + }); + }); + + describe('when there is no pipeline iid available', () => { + beforeEach(async () => { + createComponentWithApollo({ + provide: { + pipelineIid: '', + }, + }); + await waitForPromises(); + }); + + it('does not display the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays the no iid alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe( + 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', + ); + }); + + it('does not display the graph', () => { + expect(findGraph().exists()).toBe(false); + }); + }); + + describe('events', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + }); + describe('when receiving `setSkipRetryModal` event', () => { + it('passes down `skipRetryModal` value as true', async () => { + expect(findGraph().props('skipRetryModal')).toBe(false); + + await findGraph().vm.$emit('setSkipRetryModal'); + + expect(findGraph().props('skipRetryModal')).toBe(true); + }); + }); + }); + + describe('when there is an error with an action in the graph', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + await findGraph().vm.$emit('error', { type: ACTION_FAILURE }); + }); + + it('does not display the loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays the action error alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('An error occurred while performing this action.'); + }); + + it('displays the graph', () => { + expect(findGraph().exists()).toBe(true); + }); + }); + + describe('when refresh action is emitted', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + findGraph().vm.$emit('refreshPipelineGraph'); + }); + + it('calls refetch', () => { + expect(requestHandlers.getPipelineHeaderDataHandler).toHaveBeenCalledWith({ + fullPath: 'frog/amphibirama', + iid: '22', + }); + expect(requestHandlers.getPipelineDetailsHandler).toHaveBeenCalledTimes(2); + expect(requestHandlers.getUserCalloutsHandler).toHaveBeenCalledWith({}); + }); + }); + + describe('when query times out', () => { + const advanceApolloTimers = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }; + + beforeEach(async () => { + const errorData = { + data: { + project: { + pipelines: null, + }, + }, + errors: [{ message: 'timeout' }], + }; + + pipelineDetailsHandler + .mockResolvedValueOnce(errorData) + .mockResolvedValueOnce(mockPipelineResponse) + .mockResolvedValueOnce(errorData); + + createComponentWithApollo(); + await waitForPromises(); + }); + + it('shows correct errors and does not overwrite populated data when data is empty', async () => { + /* fails at first, shows error, no data yet */ + expect(findAlert().exists()).toBe(true); + expect(findGraph().exists()).toBe(false); + + /* succeeds, clears error, shows graph */ + await advanceApolloTimers(); + expect(findAlert().exists()).toBe(false); + expect(findGraph().exists()).toBe(true); + + /* fails again, alert returns but data persists */ + await advanceApolloTimers(); + expect(findAlert().exists()).toBe(true); + expect(findGraph().exists()).toBe(true); + }); + }); + + describe('view dropdown', () => { + describe('default', () => { + let layersFn; + beforeEach(async () => { + layersFn = jest.spyOn(parsingUtils, 'listByLayers'); + createComponentWithApollo({ + mountFn: mountExtended, + }); + + await waitForPromises(); + }); + + it('appears when pipeline uses needs', () => { + expect(findViewSelector().exists()).toBe(true); + }); + + it('switches between views', async () => { + expect(findStageColumnTitle().text()).toBe('deploy'); + + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + + expect(findStageColumnTitle().text()).toBe(''); + }); + + it('saves the view type to local storage', async () => { + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + expect(localStorage.setItem.mock.calls).toEqual([[VIEW_TYPE_KEY, LAYER_VIEW]]); + }); + + it('calls listByLayers only once no matter how many times view is switched', async () => { + expect(layersFn).not.toHaveBeenCalled(); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + expect(layersFn).toHaveBeenCalledTimes(1); + await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW); + await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); + await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW); + expect(layersFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('when layers view is selected', () => { + beforeEach(async () => { + createComponentWithApollo({ + data: { + currentViewType: LAYER_VIEW, + }, + mountFn: mountExtended, + }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('sets showLinks to true', async () => { + /* This spec uses .props for performance reasons. */ + expect(findLinksLayer().exists()).toBe(true); + expect(findLinksLayer().props('showLinks')).toBe(false); + expect(findViewSelector().props('type')).toBe(LAYER_VIEW); + await findDependenciesToggle().vm.$emit('change', true); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + expect(wrapper.findComponent(LinksLayer).props('showLinks')).toBe(true); + }); + }); + + describe('when layers view is selected, and links are active', () => { + beforeEach(async () => { + createComponentWithApollo({ + data: { + currentViewType: LAYER_VIEW, + showLinks: true, + }, + mountFn: mountExtended, + }); + + await waitForPromises(); + }); + + it('shows the hover tip in the view selector', async () => { + await findViewSelectorToggle().vm.$emit('change', true); + expect(findViewSelectorTrip().exists()).toBe(true); + }); + }); + + describe('when hover tip would otherwise show, but it has been previously dismissed', () => { + beforeEach(async () => { + createComponentWithApollo({ + data: { + currentViewType: LAYER_VIEW, + showLinks: true, + }, + mountFn: mountExtended, + calloutsList: ['pipeline_needs_hover_tip'.toUpperCase()], + }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('does not show the hover tip', async () => { + await findViewSelectorToggle().vm.$emit('change', true); + expect(findViewSelectorTrip().exists()).toBe(false); + }); + }); + + describe('when feature flag is on and local storage is set', () => { + beforeEach(async () => { + localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); + + createComponentWithApollo({ + mountFn: mountExtended, + }); + + await waitForPromises(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('sets the asString prop on the LocalStorageSync component', () => { + expect(getLocalStorageSync().props('asString')).toBe(true); + }); + + it('reads the view type from localStorage when available', () => { + const viewSelectorNeedsSegment = wrapper + .findComponent(GlButtonGroup) + .findAllComponents(GlButton) + .at(1); + expect(viewSelectorNeedsSegment.classes()).toContain('selected'); + }); + }); + + describe('when feature flag is on and local storage is set, but the graph does not use needs', () => { + beforeEach(async () => { + const nonNeedsResponse = { ...mockPipelineResponse }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); + + pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); + createComponentWithApollo({ + mountFn: mountExtended, + }); + + await waitForPromises(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('still passes stage type to graph', () => { + expect(findGraph().props('viewType')).toBe(STAGE_VIEW); + }); + }); + + describe('when feature flag is on but pipeline does not use needs', () => { + beforeEach(async () => { + const nonNeedsResponse = { ...mockPipelineResponse }; + nonNeedsResponse.data.project.pipeline.usesNeeds = false; + + pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); + createComponentWithApollo({ + mountFn: mountExtended, + }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('does not appear when pipeline does not use needs', () => { + expect(findViewSelector().exists()).toBe(false); + }); + }); + }); + + describe('performance metrics', () => { + const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json'; + let markAndMeasure; + let reportToSentry; + let reportPerformance; + let mock; + + beforeEach(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); + markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure'); + reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry'); + reportPerformance = jest.spyOn(Api, 'reportPerformance'); + }); + + describe('with no metrics path', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + }); + + it('is not called', () => { + expect(markAndMeasure).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + }); + }); + + describe('with metrics path', () => { + const duration = 500; + const numLinks = 3; + const totalGroups = 7; + const metricsData = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / totalGroups, + }, + ], + }; + + describe('when no duration is obtained', () => { + beforeEach(async () => { + stubPerformanceWebAPI(); + + createComponentWithApollo({ + provide: { + metricsPath, + glFeatures: { + pipelineGraphLayersView: true, + }, + }, + data: { + currentViewType: LAYER_VIEW, + }, + }); + + await waitForPromises(); + }); + + it('attempts to collect metrics', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).not.toHaveBeenCalled(); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + + describe('with duration and no error', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + mock.onPost(metricsPath).reply(HTTP_STATUS_OK, {}); + + jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { + return [{ duration }]; + }); + + createComponentWithApollo({ + provide: { + metricsPath, + }, + data: { + currentViewType: LAYER_VIEW, + }, + }); + await waitForPromises(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('calls reportPerformance with expected arguments', () => { + expect(markAndMeasure).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalled(); + expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData); + expect(reportToSentry).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/graph/mock_data.js b/spec/frontend/ci/pipeline_details/graph/mock_data.js new file mode 100644 index 00000000000..a880a9cf4b0 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/graph/mock_data.js @@ -0,0 +1,383 @@ +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import { unwrapPipelineData } from '~/ci/pipeline_details/graph/utils'; +import { BUILD_KIND, BRIDGE_KIND, RETRY_ACTION_TITLE } from '~/ci/pipeline_details/graph/constants'; + +// We mock this instead of using fixtures for performance reason. +const mockPipelineResponseCopy = JSON.parse(JSON.stringify(mockPipelineResponse)); +const groups = new Array(100).fill({ + ...mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes[0], +}); +mockPipelineResponseCopy.data.project.pipeline.stages.nodes[0].groups.nodes = groups; +export const mockPipelineResponseWithTooManyJobs = mockPipelineResponseCopy; + +export const downstream = { + nodes: [ + { + id: 175, + iid: '31', + path: '/root/elemenohpee/-/pipelines/175', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + status: { + id: '70', + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: { + name: 'test_c', + id: '71', + retried: false, + __typename: 'CiJob', + }, + project: { + id: 'gid://gitlab/Project/25', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + __typename: 'Project', + }, + __typename: 'Pipeline', + multiproject: true, + }, + { + id: 181, + iid: '27', + path: '/root/abcd-dag/-/pipelines/181', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + status: { + id: '72', + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: '73', + name: 'test_d', + retried: true, + __typename: 'CiJob', + }, + project: { + id: 'gid://gitlab/Project/23', + name: 'abcd-dag', + fullPath: 'root/abcd-dag', + __typename: 'Project', + }, + __typename: 'Pipeline', + multiproject: false, + }, + ], +}; + +export const upstream = { + id: 161, + iid: '24', + path: '/root/abcd-dag/-/pipelines/161', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + status: { + id: '74', + group: 'success', + label: 'passed', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + sourceJob: null, + project: { + id: 'gid://gitlab/Project/23', + name: 'abcd-dag', + fullPath: 'root/abcd-dag', + __typename: 'Project', + }, + __typename: 'Pipeline', + multiproject: true, +}; + +export const wrappedPipelineReturn = { + data: { + project: { + __typename: 'Project', + id: '75', + pipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/175', + iid: '38', + complete: true, + usesNeeds: true, + userPermissions: { + __typename: 'PipelinePermissions', + updatePipeline: true, + }, + downstream: { + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + __typename: 'PipelineConnection', + nodes: [], + }, + upstream: { + id: 'gid://gitlab/Ci::Pipeline/174', + iid: '37', + path: '/root/elemenohpee/-/pipelines/174', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, + __typename: 'Pipeline', + status: { + __typename: 'DetailedStatus', + id: '77', + group: 'success', + label: 'passed', + icon: 'status_success', + }, + sourceJob: { + name: 'test_c', + id: '78', + retried: false, + __typename: 'CiJob', + }, + project: { + id: 'gid://gitlab/Project/25', + name: 'elemenohpee', + fullPath: 'root/elemenohpee', + __typename: 'Project', + }, + }, + stages: { + __typename: 'CiStageConnection', + nodes: [ + { + name: 'build', + __typename: 'CiStage', + id: '79', + status: { + action: null, + id: '80', + __typename: 'DetailedStatus', + }, + groups: { + __typename: 'CiGroupConnection', + nodes: [ + { + __typename: 'CiGroup', + id: '81', + status: { + __typename: 'DetailedStatus', + id: '82', + label: 'passed', + group: 'success', + icon: 'status_success', + }, + name: 'build_n', + size: 1, + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + id: '83', + kind: BUILD_KIND, + name: 'build_n', + scheduledAt: null, + needs: { + __typename: 'CiBuildNeedConnection', + nodes: [], + }, + previousStageJobsOrNeeds: { + __typename: 'CiJobConnection', + nodes: [], + }, + status: { + __typename: 'DetailedStatus', + id: '84', + icon: 'status_success', + tooltip: 'passed', + label: 'passed', + hasDetails: true, + detailsPath: '/root/elemenohpee/-/jobs/1662', + group: 'success', + action: { + __typename: 'StatusAction', + id: '85', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/elemenohpee/-/jobs/1662/retry', + title: 'Retry', + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + }, +}; + +export const generateResponse = (raw, mockPath) => unwrapPipelineData(mockPath, raw.data); + +export const pipelineWithUpstreamDownstream = (base) => { + const pip = { ...base }; + pip.data.project.pipeline.downstream = downstream; + pip.data.project.pipeline.upstream = upstream; + + return generateResponse(pip, 'root/abcd-dag'); +}; + +export const mapCallouts = (callouts) => + callouts.map((callout) => { + return { featureName: callout, __typename: 'UserCallout' }; + }); + +export const mockCalloutsResponse = (mappedCallouts) => ({ + data: { + currentUser: { + id: 45, + __typename: 'User', + callouts: { + id: 5, + __typename: 'UserCalloutConnection', + nodes: mappedCallouts, + }, + }, + }, +}); + +export const delayedJob = { + __typename: 'CiJob', + kind: BUILD_KIND, + name: 'delayed job', + scheduledAt: '2015-07-03T10:01:00.000Z', + needs: [], + status: { + __typename: 'DetailedStatus', + icon: 'status_scheduled', + tooltip: 'delayed manual action (%{remainingTime})', + hasDetails: true, + detailsPath: '/root/kinder-pipe/-/jobs/5339', + group: 'scheduled', + action: { + __typename: 'StatusAction', + icon: 'time-out', + title: 'Unschedule', + path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule', + buttonTitle: 'Unschedule job', + }, + }, +}; + +export const mockJob = { + id: 4256, + name: 'test', + kind: BUILD_KIND, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + tooltip: 'passed', + group: 'success', + detailsPath: '/root/ci-mock/builds/4256', + hasDetails: true, + action: { + icon: 'retry', + title: 'Retry', + path: '/root/ci-mock/builds/4256/retry', + method: 'post', + }, + }, +}; + +export const mockJobWithoutDetails = { + id: 4257, + name: 'job_without_details', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + detailsPath: '/root/ci-mock/builds/4257', + hasDetails: false, + }, +}; + +export const mockJobWithUnauthorizedAction = { + id: 4258, + name: 'stop-environment', + status: { + icon: 'status_manual', + label: 'manual stop action (not allowed)', + tooltip: 'manual action', + group: 'manual', + detailsPath: '/root/ci-mock/builds/4258', + hasDetails: true, + action: null, + }, +}; + +export const triggerJob = { + id: 4259, + name: 'trigger', + kind: BRIDGE_KIND, + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + action: null, + }, +}; + +export const triggerJobWithRetryAction = { + ...triggerJob, + status: { + ...triggerJob.status, + action: { + icon: 'retry', + title: RETRY_ACTION_TITLE, + path: '/root/ci-mock/builds/4259/retry', + method: 'post', + }, + }, +}; + +export const mockFailedJob = { + id: 3999, + name: 'failed job', + kind: BUILD_KIND, + status: { + id: 'failed-3999-3999', + icon: 'status_failed', + tooltip: 'failed - (stuck or timeout failure)', + hasDetails: true, + detailsPath: '/root/ci-project/-/jobs/3999', + group: 'failed', + label: 'failed', + action: { + id: 'Ci::BuildPresenter-failed-3999', + buttonTitle: 'Retry this job', + icon: 'retry', + path: '/root/ci-project/-/jobs/3999/retry', + title: 'Retry', + }, + }, +}; diff --git a/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js new file mode 100644 index 00000000000..6e13658a773 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/header/pipeline_details_header_spec.js @@ -0,0 +1,452 @@ +import { GlAlert, GlBadge, GlLoadingIcon, GlModal, GlSprintf } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import PipelineDetailsHeader from '~/ci/pipeline_details/header/pipeline_details_header.vue'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import cancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; +import deletePipelineMutation from '~/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql'; +import retryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; +import getPipelineDetailsQuery from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql'; +import { + pipelineHeaderSuccess, + pipelineHeaderRunning, + pipelineHeaderRunningWithDuration, + pipelineHeaderFailed, + pipelineRetryMutationResponseSuccess, + pipelineCancelMutationResponseSuccess, + pipelineDeleteMutationResponseSuccess, + pipelineRetryMutationResponseFailed, + pipelineCancelMutationResponseFailed, + pipelineDeleteMutationResponseFailed, +} from '../mock_data'; + +Vue.use(VueApollo); + +describe('Pipeline details header', () => { + let wrapper; + let glModalDirective; + + const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess); + const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning); + const runningHandlerWithDuration = jest.fn().mockResolvedValue(pipelineHeaderRunningWithDuration); + const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed); + + const retryMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineRetryMutationResponseSuccess); + const cancelMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineCancelMutationResponseSuccess); + const deleteMutationHandlerSuccess = jest + .fn() + .mockResolvedValue(pipelineDeleteMutationResponseSuccess); + const retryMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineRetryMutationResponseFailed); + const cancelMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineCancelMutationResponseFailed); + const deleteMutationHandlerFailed = jest + .fn() + .mockResolvedValue(pipelineDeleteMutationResponseFailed); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findStatus = () => wrapper.findComponent(CiBadgeLink); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAllBadges = () => wrapper.findAllComponents(GlBadge); + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findCreatedTimeAgo = () => wrapper.findByTestId('pipeline-created-time-ago'); + const findFinishedTimeAgo = () => wrapper.findByTestId('pipeline-finished-time-ago'); + const findPipelineName = () => wrapper.findByTestId('pipeline-name'); + const findCommitTitle = () => wrapper.findByTestId('pipeline-commit-title'); + const findTotalJobs = () => wrapper.findByTestId('total-jobs'); + const findComputeMinutes = () => wrapper.findByTestId('compute-minutes'); + const findCommitLink = () => wrapper.findByTestId('commit-link'); + const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text(); + const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text(); + const findRetryButton = () => wrapper.findByTestId('retry-pipeline'); + const findCancelButton = () => wrapper.findByTestId('cancel-pipeline'); + const findDeleteButton = () => wrapper.findByTestId('delete-pipeline'); + const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link'); + const findPipelineDuration = () => wrapper.findByTestId('pipeline-duration-text'); + + const defaultHandlers = [[getPipelineDetailsQuery, successHandler]]; + + const defaultProvideOptions = { + pipelineIid: 1, + paths: { + pipelinesPath: '/namespace/my-project/-/pipelines', + fullProject: '/namespace/my-project', + triggeredByPath: '', + }, + }; + + const defaultProps = { + name: 'Ruby 3.0 master branch pipeline', + totalJobs: '50', + computeMinutes: '0.65', + yamlErrors: 'errors', + failureReason: 'pipeline failed', + badges: { + schedule: true, + child: false, + latest: true, + mergeTrainPipeline: false, + invalid: false, + failed: false, + autoDevops: false, + detached: false, + stuck: false, + }, + refText: + 'Related merge request !1 to merge test', + }; + + const createMockApolloProvider = (handlers) => { + return createMockApollo(handlers); + }; + + const createComponent = (handlers = defaultHandlers, props = defaultProps) => { + glModalDirective = jest.fn(); + + wrapper = shallowMountExtended(PipelineDetailsHeader, { + provide: { + ...defaultProvideOptions, + }, + propsData: { + ...props, + }, + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + stubs: { GlSprintf }, + apolloProvider: createMockApolloProvider(handlers), + }); + }; + + describe('loading state', () => { + it('shows a loading state while graphQL is fetching initial data', () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('defaults', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('does not display loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('displays pipeline status', () => { + expect(findStatus().exists()).toBe(true); + }); + + it('displays pipeline name', () => { + expect(findPipelineName().text()).toBe(defaultProps.name); + }); + + it('displays total jobs', () => { + expect(findTotalJobs().text()).toBe('50 Jobs'); + }); + + it('has link to commit', () => { + const { + data: { + project: { pipeline }, + }, + } = pipelineHeaderSuccess; + + expect(findCommitLink().attributes('href')).toBe(pipeline.commit.webPath); + }); + + it('displays correct badges', () => { + expect(findAllBadges()).toHaveLength(2); + expect(wrapper.findByText('latest').exists()).toBe(true); + expect(wrapper.findByText('Scheduled').exists()).toBe(true); + }); + + it('displays ref text', () => { + expect(findPipelineRefText()).toBe('Related merge request !1 to merge test'); + }); + + it('displays pipeline user link with required user popover attributes', () => { + const { + data: { + project: { + pipeline: { user }, + }, + }, + } = pipelineHeaderSuccess; + + const userId = getIdFromGraphQLId(user.id).toString(); + + expect(findPipelineUserLink().classes()).toContain('js-user-link'); + expect(findPipelineUserLink().attributes('data-user-id')).toBe(userId); + expect(findPipelineUserLink().attributes('data-username')).toBe(user.username); + expect(findPipelineUserLink().attributes('href')).toBe(user.webUrl); + }); + }); + + describe('without pipeline name', () => { + it('displays commit title', async () => { + createComponent(defaultHandlers, { ...defaultProps, name: '' }); + + await waitForPromises(); + + const expectedTitle = pipelineHeaderSuccess.data.project.pipeline.commit.title; + + expect(findPipelineName().exists()).toBe(false); + expect(findCommitTitle().text()).toBe(expectedTitle); + }); + }); + + describe('finished pipeline', () => { + it('displays compute minutes when not zero', async () => { + createComponent(); + + await waitForPromises(); + + expect(findComputeMinutes().text()).toBe('0.65'); + }); + + it('does not display compute minutes when zero', async () => { + createComponent(defaultHandlers, { ...defaultProps, computeMinutes: '0.0' }); + + await waitForPromises(); + + expect(findComputeMinutes().exists()).toBe(false); + }); + + it('does not display created time ago', async () => { + createComponent(); + + await waitForPromises(); + + expect(findCreatedTimeAgo().exists()).toBe(false); + }); + + it('displays finished time ago', async () => { + createComponent(); + + await waitForPromises(); + + expect(findFinishedTimeAgo().exists()).toBe(true); + }); + + it('displays pipeline duartion text', async () => { + createComponent(); + + await waitForPromises(); + + expect(findPipelineDuration().text()).toBe( + '120 minutes 10 seconds, queued for 3,600 seconds', + ); + }); + }); + + describe('running pipeline', () => { + beforeEach(async () => { + createComponent([[getPipelineDetailsQuery, runningHandler]]); + + await waitForPromises(); + }); + + it('does not display compute minutes', () => { + expect(findComputeMinutes().exists()).toBe(false); + }); + + it('does not display finished time ago', () => { + expect(findFinishedTimeAgo().exists()).toBe(false); + }); + + it('does not display pipeline duration text', () => { + expect(findPipelineDuration().exists()).toBe(false); + }); + + it('displays pipeline running text', () => { + expect(findPipelineRunningText()).toBe('In progress, queued for 3,600 seconds'); + }); + + it('displays created time ago', () => { + expect(findCreatedTimeAgo().exists()).toBe(true); + }); + }); + + describe('running pipeline with duration', () => { + beforeEach(async () => { + createComponent([[getPipelineDetailsQuery, runningHandlerWithDuration]]); + + await waitForPromises(); + }); + + it('does not display pipeline duration text', () => { + expect(findPipelineDuration().exists()).toBe(false); + }); + }); + + describe('actions', () => { + describe('retry action', () => { + beforeEach(async () => { + createComponent([ + [getPipelineDetailsQuery, failedHandler], + [retryPipelineMutation, retryMutationHandlerSuccess], + ]); + + await waitForPromises(); + }); + + it('should call retryPipeline Mutation with pipeline id', () => { + findRetryButton().vm.$emit('click'); + + expect(retryMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderFailed.data.project.pipeline.id, + }); + expect(findAlert().exists()).toBe(false); + }); + + it('should render retry action tooltip', () => { + expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); + }); + }); + + describe('retry action failed', () => { + beforeEach(async () => { + createComponent([ + [getPipelineDetailsQuery, failedHandler], + [retryPipelineMutation, retryMutationHandlerFailed], + ]); + + await waitForPromises(); + }); + + it('should display error message on failure', async () => { + findRetryButton().vm.$emit('click'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + + it('retry button loading state should reset on error', async () => { + findRetryButton().vm.$emit('click'); + + await nextTick(); + + expect(findRetryButton().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findRetryButton().props('loading')).toBe(false); + }); + }); + + describe('cancel action', () => { + it('should call cancelPipeline Mutation with pipeline id', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findCancelButton().vm.$emit('click'); + + expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderRunning.data.project.pipeline.id, + }); + expect(findAlert().exists()).toBe(false); + }); + + it('should render cancel action tooltip', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerSuccess], + ]); + + await waitForPromises(); + + expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); + }); + + it('should display error message on failure', async () => { + createComponent([ + [getPipelineDetailsQuery, runningHandler], + [cancelPipelineMutation, cancelMutationHandlerFailed], + ]); + + await waitForPromises(); + + findCancelButton().vm.$emit('click'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('delete action', () => { + it('displays delete modal when clicking on delete and does not call the delete action', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findDeleteButton().vm.$emit('click'); + + const modalId = 'pipeline-delete-modal'; + + expect(findDeleteModal().props('modalId')).toBe(modalId); + expect(glModalDirective).toHaveBeenCalledWith(modalId); + expect(deleteMutationHandlerSuccess).not.toHaveBeenCalled(); + expect(findAlert().exists()).toBe(false); + }); + + it('should call deletePipeline Mutation with pipeline id when modal is submitted', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerSuccess], + ]); + + await waitForPromises(); + + findDeleteModal().vm.$emit('primary'); + + expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ + id: pipelineHeaderSuccess.data.project.pipeline.id, + }); + }); + + it('should display error message on failure', async () => { + createComponent([ + [getPipelineDetailsQuery, successHandler], + [deletePipelineMutation, deleteMutationHandlerFailed], + ]); + + await waitForPromises(); + + findDeleteModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js b/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js new file mode 100644 index 00000000000..7110a35ad4e --- /dev/null +++ b/spec/frontend/ci/pipeline_details/jobs/components/failed_jobs_table_spec.js @@ -0,0 +1,141 @@ +import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { createAlert } from '~/alert'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import FailedJobsTable from '~/ci/pipeline_details/jobs/components/failed_jobs_table.vue'; +import RetryFailedJobMutation from '~/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { + successRetryMutationResponse, + failedRetryMutationResponse, + mockFailedJobsData, + mockFailedJobsDataNoPermission, +} from '../../mock_data'; + +jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility'); + +Vue.use(VueApollo); + +describe('Failed Jobs Table', () => { + let wrapper; + + const successRetryMutationHandler = jest.fn().mockResolvedValue(successRetryMutationResponse); + const failedRetryMutationHandler = jest.fn().mockResolvedValue(failedRetryMutationResponse); + + const findJobsTable = () => wrapper.findComponent(GlTableLite); + const findRetryButton = () => wrapper.findComponent(GlButton); + const findJobLink = () => wrapper.findComponent(GlLink); + const findJobLog = () => wrapper.findByTestId('job-log'); + const findSummary = (index) => wrapper.findAllByTestId('job-trace-summary').at(index); + const findFirstFailureMessage = () => wrapper.findAllByTestId('job-failure-message').at(0); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[RetryFailedJobMutation, resolver]]; + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver, failedJobsData = mockFailedJobsData) => { + wrapper = mountExtended(FailedJobsTable, { + propsData: { + failedJobs: failedJobsData, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + it('displays the failed jobs table', () => { + createComponent(); + + expect(findJobsTable().exists()).toBe(true); + }); + + it('displays failed job summary', () => { + createComponent(); + + expect(findSummary(0).text()).toBe('Html Summary'); + }); + + it('displays no job log when no trace', () => { + createComponent(); + + expect(findSummary(1).text()).toBe('No job log'); + }); + + it('displays failure reason', () => { + createComponent(); + + expect(findFirstFailureMessage().text()).toBe('Job failed'); + }); + + it('calls the retry failed job mutation and tracks the click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(successRetryMutationHandler); + + findRetryButton().trigger('click'); + + expect(successRetryMutationHandler).toHaveBeenCalledWith({ + id: mockFailedJobsData[0].id, + }); + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry', { + label: TRACKING_CATEGORIES.failed, + }); + + unmockTracking(); + }); + + it('redirects to the new job after the mutation', async () => { + const { + data: { + jobRetry: { job }, + }, + } = successRetryMutationResponse; + + createComponent(successRetryMutationHandler); + + findRetryButton().trigger('click'); + + await waitForPromises(); + + expect(redirectTo).toHaveBeenCalledWith(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated + }); + + it('shows error message if the retry failed job mutation fails', async () => { + createComponent(failedRetryMutationHandler); + + findRetryButton().trigger('click'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem retrying the failed job.', + }); + }); + + it('hides the job log and retry button if a user does not have permission', () => { + createComponent([[]], mockFailedJobsDataNoPermission); + + expect(findJobLog().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); + }); + + it('displays the job log and retry button if a user has permission', () => { + createComponent(); + + expect(findJobLog().exists()).toBe(true); + expect(findRetryButton().exists()).toBe(true); + }); + + it('job name links to the correct job', () => { + createComponent(); + + expect(findJobLink().attributes('href')).toBe(mockFailedJobsData[0].detailedStatus.detailsPath); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/jobs/failed_jobs_app_spec.js b/spec/frontend/ci/pipeline_details/jobs/failed_jobs_app_spec.js new file mode 100644 index 00000000000..17b43aa422b --- /dev/null +++ b/spec/frontend/ci/pipeline_details/jobs/failed_jobs_app_spec.js @@ -0,0 +1,80 @@ +import { GlLoadingIcon } 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 { createAlert } from '~/alert'; +import FailedJobsApp from '~/ci/pipeline_details/jobs/failed_jobs_app.vue'; +import FailedJobsTable from '~/ci/pipeline_details/jobs/components/failed_jobs_table.vue'; +import GetFailedJobsQuery from '~/ci/pipeline_details/jobs/graphql/queries/get_failed_jobs.query.graphql'; +import { mockFailedJobsQueryResponse } from 'jest/ci/pipeline_details/mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); + +describe('Failed Jobs App', () => { + let wrapper; + let resolverSpy; + + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findJobsTable = () => wrapper.findComponent(FailedJobsTable); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[GetFailedJobsQuery, resolver]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver) => { + wrapper = shallowMount(FailedJobsApp, { + provide: { + fullPath: 'root/ci-project', + pipelineIid: 1, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + beforeEach(() => { + resolverSpy = jest.fn().mockResolvedValue(mockFailedJobsQueryResponse); + }); + + describe('loading spinner', () => { + it('displays loading spinner when fetching failed jobs', () => { + createComponent(resolverSpy); + + 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); + }); + }); + + it('displays the failed jobs table', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findJobsTable().exists()).toBe(true); + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('handles query fetch error correctly', async () => { + resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + createComponent(resolverSpy); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem fetching the failed jobs.', + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/jobs/jobs_app_spec.js b/spec/frontend/ci/pipeline_details/jobs/jobs_app_spec.js new file mode 100644 index 00000000000..4a3a901502e --- /dev/null +++ b/spec/frontend/ci/pipeline_details/jobs/jobs_app_spec.js @@ -0,0 +1,127 @@ +import { GlIntersectionObserver, GlSkeletonLoader, GlLoadingIcon } 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 { createAlert } from '~/alert'; +import JobsApp from '~/ci/pipeline_details/jobs/jobs_app.vue'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import getPipelineJobsQuery from '~/ci/pipeline_details/jobs/graphql/queries/get_pipeline_jobs.query.graphql'; +import { mockPipelineJobsQueryResponse } from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); + +describe('Jobs app', () => { + let wrapper; + let resolverSpy; + + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const findJobsTable = () => wrapper.findComponent(JobsTable); + + const triggerInfiniteScroll = () => + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); + + const createMockApolloProvider = (resolver) => { + const requestHandlers = [[getPipelineJobsQuery, resolver]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver) => { + wrapper = shallowMount(JobsApp, { + provide: { + projectPath: 'root/ci-project', + pipelineIid: 1, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + beforeEach(() => { + resolverSpy = jest.fn().mockResolvedValue(mockPipelineJobsQueryResponse); + }); + + describe('loading spinner', () => { + const setup = async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + triggerInfiniteScroll(); + }; + + it('displays loading spinner when fetching more jobs', async () => { + await setup(); + + 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); + expect(findSkeletonLoader().exists()).toBe(false); + }); + }); + + it('displays the skeleton loader', () => { + createComponent(resolverSpy); + + expect(findSkeletonLoader().exists()).toBe(true); + expect(findJobsTable().exists()).toBe(false); + }); + + it('displays the jobs table', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findJobsTable().exists()).toBe(true); + expect(findSkeletonLoader().exists()).toBe(false); + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('handles job fetch error correctly', async () => { + resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + createComponent(resolverSpy); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while fetching the pipelines jobs.', + }); + }); + + it('handles infinite scrolling by calling fetchMore', async () => { + createComponent(resolverSpy); + await waitForPromises(); + + triggerInfiniteScroll(); + await waitForPromises(); + + expect(resolverSpy).toHaveBeenCalledWith({ + after: 'eyJpZCI6Ijg0NyJ9', + fullPath: 'root/ci-project', + iid: 1, + }); + }); + + it('does not display skeleton loader again after fetchMore', async () => { + createComponent(resolverSpy); + + expect(findSkeletonLoader().exists()).toBe(true); + await waitForPromises(); + + triggerInfiniteScroll(); + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/linked_pipelines_mock.json b/spec/frontend/ci/pipeline_details/linked_pipelines_mock.json new file mode 100644 index 00000000000..a68283032d2 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/linked_pipelines_mock.json @@ -0,0 +1,3569 @@ +{ + "id": 23211253, + "user": { + "id": 3585, + "name": "Achilleas Pipinellis", + "username": "axil", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png", + "web_url": "https://gitlab.com/axil", + "status_tooltip_html": "🍕", + "path": "/axil" + }, + "active": false, + "coverage": null, + "source": "push", + "created_at": "2018-06-05T11:31:30.452Z", + "updated_at": "2018-10-31T16:35:31.305Z", + "path": "/gitlab-org/gitlab-runner/pipelines/23211253", + "flags": { + "latest": false, + "stuck": false, + "auto_devops": false, + "merge_request": false, + "yaml_errors": false, + "retryable": false, + "cancelable": false, + "failure_reason": false + }, + "details": { + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "duration": 53, + "finished_at": "2018-10-31T16:35:31.299Z", + "stages": [ + { + "name": "prebuild", + "title": "prebuild: passed", + "groups": [ + { + "name": "review-docs-deploy", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 72469032, + "name": "review-docs-deploy", + "started": "2018-10-31T16:34:58.778Z", + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry", + "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "playable": true, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.495Z", + "updated_at": "2018-10-31T16:35:31.251Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild" + }, + { + "name": "test", + "title": "test: passed", + "groups": [ + { + "name": "docs check links", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 72469033, + "name": "docs check links", + "started": "2018-06-05T11:31:33.240Z", + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.627Z", + "updated_at": "2018-06-05T11:31:54.363Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test" + }, + { + "name": "cleanup", + "title": "cleanup: skipped", + "groups": [ + { + "name": "review-docs-cleanup", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual stop action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "stop", + "title": "Stop", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "method": "post", + "button_title": "Stop this environment" + } + }, + "jobs": [ + { + "id": 72469034, + "name": "review-docs-cleanup", + "started": null, + "archived": false, + "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "playable": true, + "scheduled": false, + "created_at": "2018-06-05T11:31:30.760Z", + "updated_at": "2018-06-05T11:31:56.037Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual stop action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "stop", + "title": "Stop", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "method": "post", + "button_title": "Stop this environment" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup", + "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "review-docs-cleanup", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", + "playable": true, + "scheduled": false + }, + { + "name": "review-docs-deploy", + "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", + "playable": true, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "ref": { + "name": "docs/add-development-guide-to-readme", + "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme", + "tag": false, + "branch": true, + "merge_request": false + }, + "commit": { + "id": "8083eb0a920572214d0dccedd7981f05d535ad46", + "short_id": "8083eb0a", + "title": "Add link to development guide in readme", + "created_at": "2018-06-05T11:30:48.000Z", + "parent_ids": [ + "1d7cf79b5a1a2121b9474ac20d61c1b8f621289d" + ], + "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n", + "author_name": "Achilleas Pipinellis", + "author_email": "axil@gitlab.com", + "authored_date": "2018-06-05T11:30:48.000Z", + "committer_name": "Achilleas Pipinellis", + "committer_email": "axil@gitlab.com", + "committed_date": "2018-06-05T11:30:48.000Z", + "author": { + "id": 3585, + "name": "Achilleas Pipinellis", + "username": "axil", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png", + "web_url": "https://gitlab.com/axil", + "status_tooltip_html": null, + "path": "/axil" + }, + "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80&d=identicon", + "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46", + "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46" + }, + "project": { + "id": 1794617 + }, + "triggered_by": { + "id": 12, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 11421321982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 1149822131854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 11498285523424, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1149846949786, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 11498282342357, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "project": { + "id": 1794617, + "name": "Test", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered_by": { + "id": 349932310342451, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 11421321982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 1149822131854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 11498285523424, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1149846949786, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 11498282342357, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + } + }, + "triggered": [ + + ] + }, + "triggered": [ + { + "id": 34993051, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982855, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 114984694, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982857, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114982858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered": [ + { + } + ] + }, + { + "id": 34993052, + "user": { + "id": 376774, + "name": "Alessio Caiazza", + "username": "nolith", + "state": "active", + "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", + "web_url": "https://gitlab.com/nolith", + "status_tooltip_html": null, + "path": "/nolith" + }, + "active": false, + "coverage": null, + "source": "pipeline", + "path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "details": { + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "duration": 118, + "finished_at": "2018-10-31T16:41:40.615Z", + "stages": [ + { + "name": "build-images", + "title": "build-images: skipped", + "groups": [ + { + "name": "image:bootstrap", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982853, + "name": "image:bootstrap", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.704Z", + "updated_at": "2018-10-31T16:35:24.118Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:builder-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 114982854, + "name": "image:builder-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.728Z", + "updated_at": "2018-10-31T16:35:24.070Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + }, + { + "name": "image:nginx-onbuild", + "size": 1, + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 1224982855, + "name": "image:nginx-onbuild", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.753Z", + "updated_at": "2018-10-31T16:35:24.033Z", + "status": { + "icon": "status_manual", + "text": "manual", + "label": "manual play action", + "group": "manual", + "tooltip": "manual action", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" + }, + { + "name": "build", + "title": "build: failed", + "groups": [ + { + "name": "compile_dev", + "size": 1, + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 1123984694, + "name": "compile_dev", + "started": "2018-10-31T16:39:41.598Z", + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:39:41.138Z", + "updated_at": "2018-10-31T16:41:40.072Z", + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed - (script failure)", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "recoverable": false + } + ] + } + ], + "status": { + "icon": "status_failed", + "text": "failed", + "label": "failed", + "group": "failed", + "tooltip": "failed", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" + }, + { + "name": "deploy", + "title": "deploy: skipped", + "groups": [ + { + "name": "review", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 1143232982857, + "name": "review", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.805Z", + "updated_at": "2018-10-31T16:41:40.569Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "review_stop", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 114921313182858, + "name": "review_stop", + "started": null, + "archived": false, + "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "playable": false, + "scheduled": false, + "created_at": "2018-10-31T16:35:23.840Z", + "updated_at": "2018-10-31T16:41:40.480Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", + "illustration": { + "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "illustration": null, + "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", + "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" + } + ], + "artifacts": [ + + ], + "manual_actions": [ + { + "name": "image:bootstrap", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:builder-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", + "playable": true, + "scheduled": false + }, + { + "name": "image:nginx-onbuild", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", + "playable": true, + "scheduled": false + }, + { + "name": "review_stop", + "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", + "playable": false, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + }, + "triggered": [ + { + "id": 26, + "user": null, + "active": false, + "coverage": null, + "source": "push", + "created_at": "2019-01-06T17:48:37.599Z", + "updated_at": "2019-01-06T17:48:38.371Z", + "path": "/h5bp/html5-boilerplate/pipelines/26", + "flags": { + "latest": true, + "stuck": false, + "auto_devops": false, + "merge_request": false, + "yaml_errors": false, + "retryable": true, + "cancelable": false, + "failure_reason": false + }, + "details": { + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "duration": null, + "finished_at": "2019-01-06T17:48:38.370Z", + "stages": [ + { + "name": "build", + "title": "build: passed", + "groups": [ + { + "name": "build:linux", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/526", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 526, + "name": "build:linux", + "started": "2019-01-06T08:48:20.236Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/526", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.806Z", + "updated_at": "2019-01-06T17:48:37.806Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/526", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/526/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "build:osx", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/527", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 527, + "name": "build:osx", + "started": "2019-01-06T07:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/527", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.846Z", + "updated_at": "2019-01-06T17:48:37.846Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/527", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/527/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#build", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#build", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build" + }, + { + "name": "test", + "title": "test: passed with warnings", + "groups": [ + { + "name": "jenkins", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": null, + "group": "success", + "tooltip": null, + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 546, + "name": "jenkins", + "started": "2019-01-06T11:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/546", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.359Z", + "updated_at": "2019-01-06T17:48:38.359Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": null, + "group": "success", + "tooltip": null, + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + } + } + ] + }, + { + "name": "rspec:linux", + "size": 3, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 528, + "name": "rspec:linux 0 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/528", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.885Z", + "updated_at": "2019-01-06T17:48:37.885Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/528", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 529, + "name": "rspec:linux 1 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/529", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/529/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.907Z", + "updated_at": "2019-01-06T17:48:37.907Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/529", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/529/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 530, + "name": "rspec:linux 2 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/530", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/530/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.927Z", + "updated_at": "2019-01-06T17:48:37.927Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/530", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/530/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "rspec:osx", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/535", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 535, + "name": "rspec:osx", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/535", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.018Z", + "updated_at": "2019-01-06T17:48:38.018Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/535", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/535/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "rspec:windows", + "size": 3, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": false, + "details_path": null, + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "jobs": [ + { + "id": 531, + "name": "rspec:windows 0 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/531", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/531/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.944Z", + "updated_at": "2019-01-06T17:48:37.944Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/531", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/531/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 532, + "name": "rspec:windows 1 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/532", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/532/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.962Z", + "updated_at": "2019-01-06T17:48:37.962Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/532", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/532/retry", + "method": "post", + "button_title": "Retry this job" + } + } + }, + { + "id": 534, + "name": "rspec:windows 2 3", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/534", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/534/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:37.999Z", + "updated_at": "2019-01-06T17:48:37.999Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/534", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/534/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "spinach:linux", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/536", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 536, + "name": "spinach:linux", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/536", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.050Z", + "updated_at": "2019-01-06T17:48:38.050Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/536", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/536/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "spinach:osx", + "size": 1, + "status": { + "icon": "status_warning", + "text": "failed", + "label": "failed (allowed to fail)", + "group": "failed-with-warnings", + "tooltip": "failed - (unknown failure) (allowed to fail)", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/537", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 537, + "name": "spinach:osx", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/537", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.069Z", + "updated_at": "2019-01-06T17:48:38.069Z", + "status": { + "icon": "status_warning", + "text": "failed", + "label": "failed (allowed to fail)", + "group": "failed-with-warnings", + "tooltip": "failed - (unknown failure) (allowed to fail)", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/537", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/537/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "callout_message": "There is an unknown failure, please try again", + "recoverable": true + } + ] + } + ], + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#test", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#test", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test" + }, + { + "name": "security", + "title": "security: passed", + "groups": [ + { + "name": "container_scanning", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/541", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 541, + "name": "container_scanning", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/541", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.186Z", + "updated_at": "2019-01-06T17:48:38.186Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/541", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/541/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "dast", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/538", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 538, + "name": "dast", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/538", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.087Z", + "updated_at": "2019-01-06T17:48:38.087Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/538", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/538/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "dependency_scanning", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/540", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 540, + "name": "dependency_scanning", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/540", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.153Z", + "updated_at": "2019-01-06T17:48:38.153Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/540", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/540/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "sast", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/539", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 539, + "name": "sast", + "started": "2019-01-06T09:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/539", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.121Z", + "updated_at": "2019-01-06T17:48:38.121Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/539", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/539/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#security", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#security", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security" + }, + { + "name": "deploy", + "title": "deploy: passed", + "groups": [ + { + "name": "production", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/544", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 544, + "name": "production", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/544", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.313Z", + "updated_at": "2019-01-06T17:48:38.313Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/544", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + }, + { + "name": "staging", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/542", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "method": "post", + "button_title": "Retry this job" + } + }, + "jobs": [ + { + "id": 542, + "name": "staging", + "started": "2019-01-06T11:48:20.237Z", + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/542", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.219Z", + "updated_at": "2019-01-06T17:48:38.219Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/542", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job does not have a trace." + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "retry", + "title": "Retry", + "path": "/h5bp/html5-boilerplate/-/jobs/542/retry", + "method": "post", + "button_title": "Retry this job" + } + } + } + ] + }, + { + "name": "stop staging", + "size": 1, + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/543", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + }, + "jobs": [ + { + "id": 543, + "name": "stop staging", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/543", + "playable": false, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.283Z", + "updated_at": "2019-01-06T17:48:38.283Z", + "status": { + "icon": "status_skipped", + "text": "skipped", + "label": "skipped", + "group": "skipped", + "tooltip": "skipped", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/543", + "illustration": { + "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", + "size": "svg-430", + "title": "This job has been skipped" + }, + "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#deploy", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#deploy", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy" + }, + { + "name": "notify", + "title": "notify: passed", + "groups": [ + { + "name": "slack", + "size": 1, + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/545", + "illustration": { + "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "method": "post", + "button_title": "Run job" + } + }, + "jobs": [ + { + "id": 545, + "name": "slack", + "started": null, + "archived": false, + "build_path": "/h5bp/html5-boilerplate/-/jobs/545", + "retry_path": "/h5bp/html5-boilerplate/-/jobs/545/retry", + "play_path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "playable": true, + "scheduled": false, + "created_at": "2019-01-06T17:48:38.341Z", + "updated_at": "2019-01-06T17:48:38.341Z", + "status": { + "icon": "status_success", + "text": "passed", + "label": "manual play action", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/-/jobs/545", + "illustration": { + "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", + "size": "svg-394", + "title": "This job requires a manual action", + "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" + }, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", + "action": { + "icon": "play", + "title": "Play", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "method": "post", + "button_title": "Run job" + } + } + } + ] + } + ], + "status": { + "icon": "status_success", + "text": "passed", + "label": "passed", + "group": "success", + "tooltip": "passed", + "has_details": true, + "details_path": "/h5bp/html5-boilerplate/pipelines/26#notify", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + }, + "path": "/h5bp/html5-boilerplate/pipelines/26#notify", + "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify" + } + ], + "artifacts": [ + { + "name": "build:linux", + "expired": null, + "expire_at": null, + "path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/download", + "browse_path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse" + }, + { + "name": "build:osx", + "expired": null, + "expire_at": null, + "path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/download", + "browse_path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse" + } + ], + "manual_actions": [ + { + "name": "stop staging", + "path": "/h5bp/html5-boilerplate/-/jobs/543/play", + "playable": false, + "scheduled": false + }, + { + "name": "production", + "path": "/h5bp/html5-boilerplate/-/jobs/544/play", + "playable": false, + "scheduled": false + }, + { + "name": "slack", + "path": "/h5bp/html5-boilerplate/-/jobs/545/play", + "playable": true, + "scheduled": false + } + ], + "scheduled_actions": [ + + ] + }, + "ref": { + "name": "master", + "path": "/h5bp/html5-boilerplate/commits/master", + "tag": false, + "branch": true, + "merge_request": false + }, + "commit": { + "id": "bad98c453eab56d20057f3929989251d45cd1a8b", + "short_id": "bad98c45", + "title": "remove instances of shrink-to-fit=no (#2103)", + "created_at": "2018-12-17T20:52:18.000Z", + "parent_ids": [ + "49130f6cfe9ff1f749015d735649a2bc6f66cf3a" + ], + "message": "remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.", + "author_name": "Scott O'Hara", + "author_email": "scottaohara@users.noreply.github.com", + "authored_date": "2018-12-17T20:52:18.000Z", + "committer_name": "Rob Larsen", + "committer_email": "rob@drunkenfist.com", + "committed_date": "2018-12-17T20:52:18.000Z", + "author": null, + "author_gravatar_url": "https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80&d=identicon", + "commit_url": "http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b", + "commit_path": "/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b" + }, + "retry_path": "/h5bp/html5-boilerplate/pipelines/26/retry", + "triggered_by": { + "id": 4, + "user": null, + "active": false, + "coverage": null, + "source": "push", + "path": "/gitlab-org/gitlab-test/pipelines/4", + "details": { + "status": { + "icon": "status_warning", + "text": "passed", + "label": "passed with warnings", + "group": "success-with-warnings", + "tooltip": "passed", + "has_details": true, + "details_path": "/gitlab-org/gitlab-test/pipelines/4", + "illustration": null, + "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" + } + }, + "project": { + "id": 1, + "name": "Gitlab Test", + "full_path": "/gitlab-org/gitlab-test", + "full_name": "Gitlab Org / Gitlab Test" + } + }, + "triggered": [ + + ], + "project": { + "id": 1794617, + "name": "GitLab Docs", + "full_path": "/gitlab-com/gitlab-docs", + "full_name": "GitLab.com / GitLab Docs" + } + } + ] + } + ] +} diff --git a/spec/frontend/ci/pipeline_details/mock_data.js b/spec/frontend/ci/pipeline_details/mock_data.js new file mode 100644 index 00000000000..e32d0a0df47 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/mock_data.js @@ -0,0 +1,1277 @@ +import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json'; +import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json'; +import pipelineHeaderRunningWithDuration from 'test_fixtures/graphql/pipelines/pipeline_header_running_with_duration.json'; +import pipelineHeaderFailed from 'test_fixtures/graphql/pipelines/pipeline_header_failed.json'; + +const PIPELINE_RUNNING = 'RUNNING'; +const PIPELINE_CANCELED = 'CANCELED'; +const PIPELINE_FAILED = 'FAILED'; + +const threeWeeksAgo = new Date(); +threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + +export { + pipelineHeaderSuccess, + pipelineHeaderRunning, + pipelineHeaderRunningWithDuration, + pipelineHeaderFailed, +}; + +export const pipelineRetryMutationResponseSuccess = { + data: { pipelineRetry: { errors: [] } }, +}; + +export const pipelineRetryMutationResponseFailed = { + data: { pipelineRetry: { errors: ['error'] } }, +}; + +export const pipelineCancelMutationResponseSuccess = { + data: { pipelineCancel: { errors: [] } }, +}; + +export const pipelineCancelMutationResponseFailed = { + data: { pipelineCancel: { errors: ['error'] } }, +}; + +export const pipelineDeleteMutationResponseSuccess = { + data: { pipelineDestroy: { errors: [] } }, +}; + +export const pipelineDeleteMutationResponseFailed = { + data: { pipelineDestroy: { errors: ['error'] } }, +}; + +export const mockPipelineHeader = { + detailedStatus: {}, + id: 123, + userPermissions: { + destroyPipeline: true, + updatePipeline: true, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + id: 'user-1', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, +}; + +export const mockFailedPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_FAILED, + retryable: true, + cancelable: false, + detailedStatus: { + id: 'status-1', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + detailsPath: 'path', + }, +}; + +export const mockFailedPipelineNoPermissions = { + id: 123, + userPermissions: { + destroyPipeline: false, + updatePipeline: false, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + id: 'user-1', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, + status: PIPELINE_RUNNING, + retryable: true, + cancelable: false, + detailedStatus: { + id: 'status-1', + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + +export const mockRunningPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_RUNNING, + retryable: false, + cancelable: true, + detailedStatus: { + id: 'status-1', + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + +export const mockRunningPipelineNoPermissions = { + id: 123, + userPermissions: { + destroyPipeline: false, + updatePipeline: false, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + id: 'user-1', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, + status: PIPELINE_RUNNING, + retryable: false, + cancelable: true, + detailedStatus: { + id: 'status-1', + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + +export const mockCancelledPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_CANCELED, + retryable: true, + cancelable: false, + detailedStatus: { + id: 'status-1', + group: 'cancelled', + icon: 'status_cancelled', + label: 'cancelled', + text: 'cancelled', + detailsPath: 'path', + }, +}; + +export const mockSuccessfulPipelineHeader = { + ...mockPipelineHeader, + status: 'SUCCESS', + retryable: false, + cancelable: false, + detailedStatus: { + id: 'status-1', + group: 'success', + icon: 'status_success', + label: 'success', + text: 'success', + detailsPath: 'path', + }, +}; + +export const mockRunningPipelineHeaderData = { + data: { + project: { + id: '1', + pipeline: { + ...mockRunningPipelineHeader, + iid: '28', + user: { + id: 'user-1', + name: 'Foo', + username: 'foobar', + webPath: '/foo', + webUrl: '/foo', + email: 'foo@bar.com', + avatarUrl: 'link', + status: null, + __typename: 'UserCore', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; + +export const users = [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/root', + }, + { + id: 10, + name: 'Angel Spinka', + username: 'shalonda', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/709df1b65ad06764ee2b0edf1b49fc27?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/shalonda', + }, + { + id: 11, + name: 'Art Davis', + username: 'deja.green', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/bb56834c061522760e7a6dd7d431a306?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/deja.green', + }, + { + id: 32, + name: 'Arnold Mante', + username: 'reported_user_10', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/ab558033a82466d7905179e837d7723a?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_10', + }, + { + id: 38, + name: 'Cher Wintheiser', + username: 'reported_user_16', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/2640356e8b5bc4314133090994ed162b?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_16', + }, + { + id: 39, + name: 'Bethel Wolf', + username: 'reported_user_17', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/4b948694fadba4b01e4acfc06b065e8e?s=80\u0026d=identicon', + web_url: 'http://192.168.1.22:3000/reported_user_17', + }, +]; + +export const branches = [ + { + name: 'branch-1', + commit: { + id: '21fb056cc47dcf706670e6de635b1b326490ebdc', + short_id: '21fb056c', + created_at: '2020-05-07T10:58:28.000-04:00', + parent_ids: null, + title: 'Add new file', + message: 'Add new file', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-05-07T10:58:28.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-05-07T10:58:28.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/21fb056cc47dcf706670e6de635b1b326490ebdc', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-1', + }, + { + name: 'branch-10', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: null, + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-10', + }, + { + name: 'branch-11', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: null, + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + merged: false, + protected: false, + developers_can_push: false, + developers_can_merge: false, + can_push: true, + default: false, + web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-11', + }, +]; + +export const tags = [ + { + name: 'tag-3', + message: '', + target: '66673b07efef254dab7d537f0433a40e61cf84fe', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: ['def28bf679235071140180495f25b657e2203587'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + release: null, + protected: false, + }, + { + name: 'tag-2', + message: '', + target: '66673b07efef254dab7d537f0433a40e61cf84fe', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: ['def28bf679235071140180495f25b657e2203587'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + release: null, + protected: false, + }, + { + name: 'tag-1', + message: '', + target: '66673b07efef254dab7d537f0433a40e61cf84fe', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: ['def28bf679235071140180495f25b657e2203587'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + release: null, + protected: false, + }, + { + name: 'main-tag', + message: '', + target: '66673b07efef254dab7d537f0433a40e61cf84fe', + commit: { + id: '66673b07efef254dab7d537f0433a40e61cf84fe', + short_id: '66673b07', + created_at: '2020-03-16T11:04:46.000-04:00', + parent_ids: ['def28bf679235071140180495f25b657e2203587'], + title: 'Update .gitlab-ci.yml', + message: 'Update .gitlab-ci.yml', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2020-03-16T11:04:46.000-04:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2020-03-16T11:04:46.000-04:00', + web_url: + 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', + }, + release: null, + protected: false, + }, +]; + +export const mockSearch = [ + { type: 'username', value: { data: 'root', operator: '=' } }, + { type: 'ref', value: { data: 'main', operator: '=' } }, + { type: 'status', value: { data: 'pending', operator: '=' } }, +]; + +export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; + +export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag']; + +export const mockPipelineJobsQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + __typename: 'Project', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/224', + __typename: 'Pipeline', + jobs: { + __typename: 'CiJobConnection', + pageInfo: { + endCursor: 'eyJpZCI6Ijg0NyJ9', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjYyMCJ9', + __typename: 'PageInfo', + }, + nodes: [ + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/620/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + id: 'success-620-620', + detailsPath: '/root/ci-project/-/jobs/620', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed (retried)', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/620', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '5acce24b', + commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', + stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, + name: 'coverage_job', + duration: 4, + finishedAt: '2021-12-06T14:13:49Z', + coverage: 82.71, + retryable: false, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, + __typename: 'CiJob', + }, + { + artifacts: { + nodes: [ + { + downloadPath: '/root/ci-project/-/jobs/619/artifacts/download?file_type=trace', + fileType: 'TRACE', + __typename: 'CiJobArtifact', + }, + ], + __typename: 'CiJobArtifactConnection', + }, + allowFailure: false, + status: 'SUCCESS', + scheduledAt: null, + manualJob: false, + triggered: null, + createdByTag: false, + detailedStatus: { + id: 'success-619-619', + detailsPath: '/root/ci-project/-/jobs/619', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'passed', + tooltip: 'passed (retried)', + action: null, + __typename: 'DetailedStatus', + }, + id: 'gid://gitlab/Ci::Build/619', + refName: 'main', + refPath: '/root/ci-project/-/commits/main', + tags: [], + shortSha: '5acce24b', + commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', + stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, + name: 'test_job_two', + duration: 4, + finishedAt: '2021-12-06T14:13:44Z', + coverage: null, + retryable: false, + playable: false, + cancelable: false, + active: false, + stuck: false, + userPermissions: { + readBuild: true, + readJobArtifacts: true, + updateBuild: true, + __typename: 'JobPermissions', + }, + __typename: 'CiJob', + }, + ], + }, + }, + }, + }, +}; + +export const mockPipeline = (projectPath) => { + return { + pipeline: { + id: 1, + user: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: '', + web_url: 'http://0.0.0.0:3000/root', + show_status: false, + path: '/root', + }, + active: false, + source: 'merge_request_event', + created_at: '2021-10-19T21:17:38.698Z', + updated_at: '2021-10-21T18:00:42.758Z', + path: 'foo', + flags: {}, + merge_request: { + iid: 1, + path: `/${projectPath}/1`, + title: 'commit', + source_branch: 'test-commit-name', + source_branch_path: `/${projectPath}`, + target_branch: 'main', + target_branch_path: `/${projectPath}/-/commit/main`, + }, + ref: { + name: 'refs/merge-requests/1/head', + path: `/${projectPath}/-/commits/refs/merge-requests/1/head`, + tag: false, + branch: false, + merge_request: true, + }, + commit: { + id: 'fd6df5b3229e213c97d308844a6f3e7fd71e8f8c', + short_id: 'fd6df5b3', + created_at: '2021-10-19T21:17:12.000+00:00', + parent_ids: ['7147906b84306e83cb3fec6582a25390b75713c6'], + title: 'Commit', + message: 'Commit', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2021-10-19T21:17:12.000+00:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2021-10-19T21:17:12.000+00:00', + trailers: {}, + web_url: '', + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: '', + web_url: '', + show_status: false, + path: '/root', + }, + author_gravatar_url: '', + commit_url: `/${projectPath}/fd6df5b3229e213c97d308844a6f3e7fd71e8f8c`, + commit_path: `/${projectPath}/commit/fd6df5b3229e213c97d308844a6f3e7fd71e8f8c`, + }, + project: { + full_path: `/${projectPath}`, + }, + triggered_by: null, + triggered: [], + }, + pipelineScheduleUrl: 'foo', + pipelineKey: 'id', + viewType: 'root', + }; +}; + +export const mockPipelineTag = () => { + return { + pipeline: { + id: 311, + iid: 37, + user: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + active: false, + source: 'push', + name: 'Build pipeline', + created_at: '2022-02-02T15:39:04.012Z', + updated_at: '2022-02-02T15:40:59.573Z', + path: '/root/mr-widgets/-/pipelines/311', + flags: { + stuck: false, + auto_devops: false, + merge_request: false, + yaml_errors: false, + retryable: true, + cancelable: false, + failure_reason: false, + detached_merge_request_pipeline: false, + merge_request_pipeline: false, + merge_train_pipeline: false, + latest: true, + }, + details: { + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + stages: [ + { + name: 'accessibility', + title: 'accessibility: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311#accessibility', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/311#accessibility', + dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=accessibility', + }, + { + name: 'validate', + title: 'validate: passed with warnings', + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311#validate', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/311#validate', + dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=validate', + }, + { + name: 'test', + title: 'test: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311#test', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/311#test', + dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=test', + }, + { + name: 'build', + title: 'build: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/311#build', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/311#build', + dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=build', + }, + ], + duration: 93, + finished_at: '2022-02-02T15:40:59.384Z', + event_type_name: 'Pipeline', + manual_actions: [], + scheduled_actions: [], + }, + ref: { + name: 'test', + path: '/root/mr-widgets/-/commits/test', + tag: true, + branch: false, + merge_request: false, + }, + commit: { + id: '9b92b4f730d1611bd9a086ca221ae206d5da1e59', + short_id: '9b92b4f7', + created_at: '2022-01-13T13:59:03.000+00:00', + parent_ids: ['0ba763634114e207dc72c65c8e9459556b1204fb'], + title: 'Update hello_world.js', + message: 'Update hello_world.js', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2022-01-13T13:59:03.000+00:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2022-01-13T13:59:03.000+00:00', + trailers: {}, + web_url: + 'http://gdk.test:3000/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', + author: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commit_url: + 'http://gdk.test:3000/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', + commit_path: '/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', + }, + retry_path: '/root/mr-widgets/-/pipelines/311/retry', + delete_path: '/root/mr-widgets/-/pipelines/311', + failed_builds: [ + { + id: 1696, + name: 'fmt', + started: '2022-02-02T15:39:45.192Z', + complete: true, + archived: false, + build_path: '/root/mr-widgets/-/jobs/1696', + retry_path: '/root/mr-widgets/-/jobs/1696/retry', + playable: false, + scheduled: false, + created_at: '2022-02-02T15:39:04.136Z', + updated_at: '2022-02-02T15:39:57.969Z', + status: { + icon: 'status_warning', + text: 'failed', + label: 'failed (allowed to fail)', + group: 'failed-with-warnings', + tooltip: 'failed - (script failure) (allowed to fail)', + has_details: true, + details_path: '/root/mr-widgets/-/jobs/1696', + illustration: { + image: + '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/mr-widgets/-/jobs/1696/retry', + method: 'post', + button_title: 'Retry this job', + }, + }, + recoverable: false, + }, + ], + project: { + id: 23, + name: 'mr-widgets', + full_path: '/root/mr-widgets', + full_name: 'Administrator / mr-widgets', + }, + triggered_by: null, + triggered: [], + }, + pipelineScheduleUrl: 'foo', + pipelineKey: 'id', + viewType: 'root', + }; +}; + +export const mockPipelineBranch = () => { + return { + pipeline: { + id: 268, + iid: 34, + user: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + active: false, + source: 'push', + name: 'Build pipeline', + created_at: '2022-01-14T17:40:27.866Z', + updated_at: '2022-01-14T18:02:35.850Z', + path: '/root/mr-widgets/-/pipelines/268', + flags: { + stuck: false, + auto_devops: false, + merge_request: false, + yaml_errors: false, + retryable: true, + cancelable: false, + failure_reason: false, + detached_merge_request_pipeline: false, + merge_request_pipeline: false, + merge_train_pipeline: false, + latest: true, + }, + details: { + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/268', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + stages: [ + { + name: 'validate', + title: 'validate: passed with warnings', + status: { + icon: 'status_warning', + text: 'passed', + label: 'passed with warnings', + group: 'success-with-warnings', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/268#validate', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/268#validate', + dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=validate', + }, + { + name: 'test', + title: 'test: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/268#test', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/268#test', + dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=test', + }, + { + name: 'build', + title: 'build: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/mr-widgets/-/pipelines/268#build', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/mr-widgets/-/pipelines/268#build', + dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=build', + }, + ], + duration: 75, + finished_at: '2022-01-14T18:02:35.842Z', + event_type_name: 'Pipeline', + manual_actions: [], + scheduled_actions: [], + }, + ref: { + name: 'update-ci', + path: '/root/mr-widgets/-/commits/update-ci', + tag: false, + branch: true, + merge_request: false, + }, + commit: { + id: '96aef9ecec5752c09371c1ade5fc77860aafc863', + short_id: '96aef9ec', + created_at: '2022-01-14T17:40:26.000+00:00', + parent_ids: ['06860257572d4cf84b73806250b78169050aed83'], + title: 'Update main.tf', + message: 'Update main.tf', + author_name: 'Administrator', + author_email: 'admin@example.com', + authored_date: '2022-01-14T17:40:26.000+00:00', + committer_name: 'Administrator', + committer_email: 'admin@example.com', + committed_date: '2022-01-14T17:40:26.000+00:00', + trailers: {}, + web_url: + 'http://gdk.test:3000/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', + author: { + id: 1, + username: 'root', + name: 'Administrator', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://gdk.test:3000/root', + show_status: false, + path: '/root', + }, + author_gravatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + commit_url: + 'http://gdk.test:3000/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', + commit_path: '/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', + }, + retry_path: '/root/mr-widgets/-/pipelines/268/retry', + delete_path: '/root/mr-widgets/-/pipelines/268', + failed_builds: [ + { + id: 1260, + name: 'fmt', + started: '2022-01-14T17:40:36.435Z', + complete: true, + archived: false, + build_path: '/root/mr-widgets/-/jobs/1260', + retry_path: '/root/mr-widgets/-/jobs/1260/retry', + playable: false, + scheduled: false, + created_at: '2022-01-14T17:40:27.879Z', + updated_at: '2022-01-14T17:40:42.129Z', + status: { + icon: 'status_warning', + text: 'failed', + label: 'failed (allowed to fail)', + group: 'failed-with-warnings', + tooltip: 'failed - (script failure) (allowed to fail)', + has_details: true, + details_path: '/root/mr-widgets/-/jobs/1260', + illustration: { + image: + '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', + size: 'svg-430', + title: 'This job does not have a trace.', + }, + favicon: + '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', + action: { + icon: 'retry', + title: 'Retry', + path: '/root/mr-widgets/-/jobs/1260/retry', + method: 'post', + button_title: 'Retry this job', + }, + }, + recoverable: false, + }, + ], + project: { + id: 23, + name: 'mr-widgets', + full_path: '/root/mr-widgets', + full_name: 'Administrator / mr-widgets', + }, + triggered_by: null, + triggered: [], + }, + pipelineScheduleUrl: 'foo', + pipelineKey: 'id', + viewType: 'root', + }; +}; + +export const mockFailedJobsQueryResponse = { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/20', + pipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/300', + jobs: { + __typename: 'CiJobConnection', + nodes: [ + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1848-1848', + detailsPath: '/root/ci-project/-/jobs/1848', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure)', + action: { + __typename: 'StatusAction', + id: 'Ci::Build-failed-1848', + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/ci-project/-/jobs/1848/retry', + title: 'Retry', + }, + }, + id: 'gid://gitlab/Ci::Build/1848', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: true, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + trace: { + htmlSummary: 'Html Summary', + }, + failureMessage: 'Failed', + }, + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1710-1710', + detailsPath: '/root/ci-project/-/jobs/1710', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure) (retried)', + action: null, + }, + id: 'gid://gitlab/Ci::Build/1710', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: false, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + trace: null, + failureMessage: 'Failed', + }, + ], + }, + }, + }, + }, +}; + +export const mockFailedJobsData = [ + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1848-1848', + detailsPath: '/root/ci-project/-/jobs/1848', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure)', + action: { + __typename: 'StatusAction', + id: 'Ci::Build-failed-1848', + buttonTitle: 'Retry this job', + icon: 'retry', + method: 'post', + path: '/root/ci-project/-/jobs/1848/retry', + title: 'Retry', + }, + }, + id: 'gid://gitlab/Ci::Build/1848', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: true, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + trace: { + htmlSummary: 'Html Summary', + }, + failureMessage: 'Job failed', + _showDetails: true, + }, + { + __typename: 'CiJob', + status: 'FAILED', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'failed-1710-1710', + detailsPath: '/root/ci-project/-/jobs/1710', + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure) (retried)', + action: null, + }, + id: 'gid://gitlab/Ci::Build/1710', + stage: { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/358', + name: 'build', + }, + name: 'wait_job', + retryable: false, + userPermissions: { + __typename: 'JobPermissions', + readBuild: true, + updateBuild: true, + }, + trace: null, + failureMessage: 'Job failed', + _showDetails: true, + }, +]; + +export const mockFailedJobsDataNoPermission = [ + { + ...mockFailedJobsData[0], + userPermissions: { __typename: 'JobPermissions', readBuild: false, updateBuild: false }, + }, +]; + +export const successRetryMutationResponse = { + data: { + jobRetry: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1985"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1985', + id: 'pending-1985-1985', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const failedRetryMutationResponse = { + data: { + jobRetry: { + job: {}, + errors: ['New Error'], + __typename: 'JobRetryPayload', + }, + }, +}; diff --git a/spec/frontend/ci/pipeline_details/pipeline_tabs_spec.js b/spec/frontend/ci/pipeline_details/pipeline_tabs_spec.js new file mode 100644 index 00000000000..8d67cdef05c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipeline_tabs_spec.js @@ -0,0 +1,63 @@ +import { createAppOptions } from '~/ci/pipeline_details/pipeline_tabs'; + +jest.mock('~/lib/utils/url_utility', () => ({ + removeParams: () => 'gitlab.com', + joinPaths: () => {}, + setUrlFragment: () => {}, +})); + +jest.mock('~/ci/pipeline_details/utils', () => ({ + getPipelineDefaultTab: () => '', +})); + +describe('~/ci/pipeline_details/pipeline_tabs.js', () => { + describe('createAppOptions', () => { + const SELECTOR = 'SELECTOR'; + + let el; + + const createElement = () => { + el = document.createElement('div'); + el.id = SELECTOR; + el.dataset.canGenerateCodequalityReports = 'true'; + el.dataset.codequalityReportDownloadPath = 'codequalityReportDownloadPath'; + el.dataset.downloadablePathForReportType = 'downloadablePathForReportType'; + el.dataset.exposeSecurityDashboard = 'true'; + el.dataset.exposeLicenseScanningData = 'true'; + el.dataset.failedJobsCount = 1; + el.dataset.graphqlResourceEtag = 'graphqlResourceEtag'; + el.dataset.pipelineIid = '123'; + el.dataset.pipelineProjectPath = 'pipelineProjectPath'; + + document.body.appendChild(el); + }; + + afterEach(() => { + el = null; + }); + + it("extracts the properties from the element's dataset", () => { + createElement(); + const options = createAppOptions(`#${SELECTOR}`, null); + + expect(options).toMatchObject({ + el, + provide: { + canGenerateCodequalityReports: true, + codequalityReportDownloadPath: 'codequalityReportDownloadPath', + downloadablePathForReportType: 'downloadablePathForReportType', + exposeSecurityDashboard: true, + exposeLicenseScanningData: true, + failedJobsCount: '1', + graphqlResourceEtag: 'graphqlResourceEtag', + pipelineIid: '123', + pipelineProjectPath: 'pipelineProjectPath', + }, + }); + }); + + it('returns `null` if el does not exist', () => { + expect(createAppOptions('foo', null)).toBe(null); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/pipelines_store_spec.js b/spec/frontend/ci/pipeline_details/pipelines_store_spec.js new file mode 100644 index 00000000000..43e605f4306 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/pipelines_store_spec.js @@ -0,0 +1,80 @@ +import PipelineStore from '~/ci/pipeline_details/stores/pipelines_store'; + +describe('Pipelines Store', () => { + let store; + + beforeEach(() => { + store = new PipelineStore(); + }); + + it('should be initialized with an empty state', () => { + expect(store.state.pipelines).toEqual([]); + expect(store.state.count).toEqual({}); + expect(store.state.pageInfo).toEqual({}); + }); + + describe('storePipelines', () => { + it('should use the default parameter if none is provided', () => { + store.storePipelines(); + + expect(store.state.pipelines).toEqual([]); + }); + + it('should store the provided array', () => { + const array = [ + { id: 1, status: 'running' }, + { id: 2, status: 'success' }, + ]; + store.storePipelines(array); + + expect(store.state.pipelines).toEqual(array); + }); + }); + + describe('storeCount', () => { + it('should use the default parameter if none is provided', () => { + store.storeCount(); + + expect(store.state.count).toEqual({}); + }); + + it('should store the provided count', () => { + const count = { all: 20, finished: 10 }; + store.storeCount(count); + + expect(store.state.count).toEqual(count); + }); + }); + + describe('storePagination', () => { + it('should use the default parameter if none is provided', () => { + store.storePagination(); + + expect(store.state.pageInfo).toEqual({}); + }); + + it('should store pagination information normalized and parsed', () => { + const pagination = { + 'X-nExt-pAge': '2', + 'X-page': '1', + 'X-Per-Page': '1', + 'X-Prev-Page': '2', + 'X-TOTAL': '37', + 'X-Total-Pages': '2', + }; + + const expectedResult = { + perPage: 1, + page: 1, + total: 37, + totalPages: 2, + nextPage: 2, + previousPage: 2, + }; + + store.storePagination(pagination); + + expect(store.state.pageInfo).toEqual(expectedResult); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js b/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js new file mode 100644 index 00000000000..0f1835b7ec8 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/tabs/pipeline_tabs_spec.js @@ -0,0 +1,114 @@ +import { GlTab } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import PipelineTabs from '~/ci/pipeline_details/tabs/pipeline_tabs.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; + +describe('The Pipeline Tabs', () => { + let wrapper; + let trackingSpy; + + const $router = { push: jest.fn() }; + + const findDagTab = () => wrapper.findByTestId('dag-tab'); + const findFailedJobsTab = () => wrapper.findByTestId('failed-jobs-tab'); + const findJobsTab = () => wrapper.findByTestId('jobs-tab'); + const findPipelineTab = () => wrapper.findByTestId('pipeline-tab'); + const findTestsTab = () => wrapper.findByTestId('tests-tab'); + + const findFailedJobsBadge = () => wrapper.findByTestId('failed-builds-counter'); + const findJobsBadge = () => wrapper.findByTestId('builds-counter'); + const findTestsBadge = () => wrapper.findByTestId('tests-counter'); + + const defaultProvide = { + defaultTabValue: '', + failedJobsCount: 1, + totalJobCount: 10, + testsCount: 123, + }; + + const createComponent = (provide = {}) => { + wrapper = shallowMountExtended(PipelineTabs, { + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + GlTab, + RouterView: true, + }, + mocks: { + $router, + }, + }); + }; + + describe('Tabs', () => { + it.each` + tabName | tabComponent + ${'Pipeline'} | ${findPipelineTab} + ${'Dag'} | ${findDagTab} + ${'Jobs'} | ${findJobsTab} + ${'Failed Jobs'} | ${findFailedJobsTab} + ${'Tests'} | ${findTestsTab} + `('shows $tabName tab', ({ tabComponent }) => { + createComponent(); + + expect(tabComponent().exists()).toBe(true); + }); + + describe('with no failed jobs', () => { + beforeEach(() => { + createComponent({ failedJobsCount: 0 }); + }); + + it('hides the failed jobs tab', () => { + expect(findFailedJobsTab().exists()).toBe(false); + }); + }); + }); + + describe('Tabs badges', () => { + it.each` + tabName | badgeComponent | badgeText + ${'Jobs'} | ${findJobsBadge} | ${String(defaultProvide.totalJobCount)} + ${'Failed Jobs'} | ${findFailedJobsBadge} | ${String(defaultProvide.failedJobsCount)} + ${'Tests'} | ${findTestsBadge} | ${String(defaultProvide.testsCount)} + `('shows badge for $tabName with the correct text', ({ badgeComponent, badgeText }) => { + createComponent(); + + expect(badgeComponent().exists()).toBe(true); + expect(badgeComponent().text()).toBe(badgeText); + }); + }); + + describe('Tab tracking', () => { + beforeEach(() => { + createComponent(); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks failed jobs tab click', () => { + findFailedJobsTab().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { + label: TRACKING_CATEGORIES.failed, + }); + }); + + it('tracks tests tab click', () => { + findTestsTab().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { + label: TRACKING_CATEGORIES.tests, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/empty_state_spec.js b/spec/frontend/ci/pipeline_details/test_reports/empty_state_spec.js new file mode 100644 index 00000000000..ed1d6bc7d37 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/empty_state_spec.js @@ -0,0 +1,45 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EmptyState, { i18n } from '~/ci/pipeline_details/test_reports/empty_state.vue'; + +describe('Test report empty state', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = ({ hasTestReport = true } = {}) => { + wrapper = shallowMount(EmptyState, { + provide: { + emptyStateImagePath: '/image/path', + hasTestReport, + }, + stubs: { + GlEmptyState, + }, + }); + }; + + describe('when pipeline has a test report', () => { + it('should render empty test report message', () => { + createComponent(); + + expect(findEmptyState().props()).toMatchObject({ + primaryButtonText: i18n.noTestsButton, + description: i18n.noTestsDescription, + title: i18n.noTestsTitle, + }); + }); + }); + + describe('when pipeline does not have a test report', () => { + it('should render no test report message', () => { + createComponent({ hasTestReport: false }); + + expect(findEmptyState().props()).toMatchObject({ + primaryButtonText: i18n.noReportsButton, + description: i18n.noReportsDescription, + title: i18n.noReportsTitle, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/mock_data.js b/spec/frontend/ci/pipeline_details/test_reports/mock_data.js new file mode 100644 index 00000000000..7c9f9287c86 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/mock_data.js @@ -0,0 +1,31 @@ +import { TestStatus } from '~/ci/pipeline_details/constants'; + +export default [ + { + classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', + execution_time: 0, + name: 'Test#skipped text', + stack_trace: null, + status: TestStatus.SKIPPED, + system_output: null, + }, + { + classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', + execution_time: 0, + name: 'Test#error text', + stack_trace: null, + status: TestStatus.ERROR, + system_output: null, + }, + { + classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', + execution_time: 0, + name: 'Test#unknown text', + stack_trace: null, + status: TestStatus.UNKNOWN, + system_output: null, + }, +]; diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/actions_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/actions_spec.js new file mode 100644 index 00000000000..6636a7f1ed6 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/actions_spec.js @@ -0,0 +1,149 @@ +import MockAdapter from 'axios-mock-adapter'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import { TEST_HOST } from 'helpers/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import * as actions from '~/ci/pipeline_details/stores/test_reports/actions'; +import * as types from '~/ci/pipeline_details/stores/test_reports/mutation_types'; + +jest.mock('~/alert'); + +describe('Actions TestReports Store', () => { + let mock; + let state; + + const summary = { total_count: 1 }; + + const suiteEndpoint = `${TEST_HOST}/tests/suite.json`; + const summaryEndpoint = `${TEST_HOST}/test_reports/summary.json`; + const defaultState = { + suiteEndpoint, + summaryEndpoint, + testReports: {}, + selectedSuite: null, + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = { ...defaultState }; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('fetch report summary', () => { + beforeEach(() => { + mock.onGet(summaryEndpoint).replyOnce(HTTP_STATUS_OK, summary, {}); + }); + + it('sets testReports and shows tests', () => { + return testAction( + actions.fetchSummary, + null, + state, + [{ type: types.SET_SUMMARY, payload: summary }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + ); + }); + + it('should create alert on API error', async () => { + await testAction( + actions.fetchSummary, + null, + { summaryEndpoint: null }, + [], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + ); + expect(createAlert).toHaveBeenCalled(); + }); + }); + + describe('fetch test suite', () => { + beforeEach(() => { + const buildIds = [1]; + testReports.test_suites[0].build_ids = buildIds; + mock + .onGet(suiteEndpoint, { params: { build_ids: buildIds } }) + .replyOnce(HTTP_STATUS_OK, testReports.test_suites[0], {}); + }); + + it('sets test suite and shows tests', () => { + const suite = testReports.test_suites[0]; + const index = 0; + + return testAction( + actions.fetchTestSuite, + index, + { ...state, testReports }, + [{ type: types.SET_SUITE, payload: { suite, index } }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + ); + }); + + it('should call SET_SUITE_ERROR on error', () => { + const index = 0; + + return testAction( + actions.fetchTestSuite, + index, + { ...state, testReports, suiteEndpoint: null }, + [{ type: types.SET_SUITE_ERROR, payload: expect.any(Error) }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + ); + }); + + describe('when we already have the suite data', () => { + it('should not fetch suite', () => { + const index = 0; + testReports.test_suites[0].hasFullSuite = true; + + return testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], []); + }); + }); + }); + + describe('set selected suite index', () => { + it('sets selectedSuiteIndex', () => { + const selectedSuiteIndex = 0; + + return testAction( + actions.setSelectedSuiteIndex, + selectedSuiteIndex, + { ...state, hasFullReport: true }, + [{ type: types.SET_SELECTED_SUITE_INDEX, payload: selectedSuiteIndex }], + [], + ); + }); + }); + + describe('remove selected suite index', () => { + it('sets selectedSuiteIndex to null', () => { + return testAction( + actions.removeSelectedSuiteIndex, + {}, + state, + [{ type: types.SET_SELECTED_SUITE_INDEX, payload: null }], + [], + ); + }); + }); + + describe('toggles loading', () => { + it('sets isLoading to true', () => { + return testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], []); + }); + + it('toggles isLoading to false', () => { + return testAction( + actions.toggleLoading, + {}, + { ...state, isLoading: true }, + [{ type: types.TOGGLE_LOADING }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/getters_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/getters_spec.js new file mode 100644 index 00000000000..e52e9a07ae0 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/getters_spec.js @@ -0,0 +1,171 @@ +import testReports from 'test_fixtures/pipelines/test_report.json'; +import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; +import { + iconForTestStatus, + formatFilePath, + formattedTime, +} from '~/ci/pipeline_details/stores/test_reports/utils'; + +describe('Getters TestReports Store', () => { + let state; + + const defaultState = { + blobPath: '/test/blob/path', + testReports, + selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage: 2, + }, + }; + + const emptyState = { + blobPath: '', + testReports: {}, + selectedSuite: null, + pageInfo: { + page: 1, + perPage: 2, + }, + }; + + beforeEach(() => { + state = { + testReports, + }; + }); + + const setupState = (testState = defaultState) => { + state = testState; + }; + + describe('getTestSuites', () => { + it('should return the test suites', () => { + setupState(); + + const suites = getters.getTestSuites(state); + const expected = testReports.test_suites.map((x) => ({ + ...x, + formattedTime: formattedTime(x.total_time), + })); + + expect(suites).toEqual(expected); + }); + + it('should return an empty array when testReports is empty', () => { + setupState(emptyState); + + expect(getters.getTestSuites(state)).toEqual([]); + }); + }); + + describe('getSelectedSuite', () => { + it('should return the selected suite', () => { + setupState(); + + const selectedSuite = getters.getSelectedSuite(state); + const expected = testReports.test_suites[state.selectedSuiteIndex]; + + expect(selectedSuite).toEqual(expected); + }); + }); + + describe('getSuiteTests', () => { + it('should return the current page of test cases inside the suite', () => { + setupState(); + + const cases = getters.getSuiteTests(state); + const expected = testReports.test_suites[0].test_cases + .map((x) => ({ + ...x, + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); + + expect(cases).toEqual(expected); + }); + + it('should return an empty array when testReports is empty', () => { + setupState(emptyState); + + expect(getters.getSuiteTests(state)).toEqual([]); + }); + + describe('when a test case classname property is null', () => { + it('should return an empty string value for the classname property', () => { + const testCases = testReports.test_suites[0].test_cases; + setupState({ + ...defaultState, + testReports: { + ...testReports, + test_suites: [ + { + test_cases: testCases.map((testCase) => ({ + ...testCase, + classname: null, + })), + }, + ], + }, + }); + + const expected = testCases + .map((x) => ({ + ...x, + classname: '', + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); + + expect(getters.getSuiteTests(state)).toEqual(expected); + }); + }); + + describe('when a test case name property is null', () => { + it('should return an empty string value for the name property', () => { + const testCases = testReports.test_suites[0].test_cases; + setupState({ + ...defaultState, + testReports: { + ...testReports, + test_suites: [ + { + test_cases: testCases.map((testCase) => ({ + ...testCase, + name: null, + })), + }, + ], + }, + }); + + const expected = testCases + .map((x) => ({ + ...x, + name: '', + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); + + expect(getters.getSuiteTests(state)).toEqual(expected); + }); + }); + }); + + describe('getSuiteTestCount', () => { + it('should return the total number of test cases', () => { + setupState(); + + const testCount = getters.getSuiteTestCount(state); + const expected = testReports.test_suites[0].test_cases.length; + + expect(testCount).toEqual(expected); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/mutations_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/mutations_spec.js new file mode 100644 index 00000000000..d58515dcc6d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/mutations_spec.js @@ -0,0 +1,114 @@ +import testReports from 'test_fixtures/pipelines/test_report.json'; +import * as types from '~/ci/pipeline_details/stores/test_reports/mutation_types'; +import mutations from '~/ci/pipeline_details/stores/test_reports/mutations'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); + +describe('Mutations TestReports Store', () => { + let mockState; + + const defaultState = { + endpoint: '', + testReports: {}, + selectedSuite: null, + isLoading: false, + pageInfo: { + page: 1, + perPage: 2, + }, + }; + + beforeEach(() => { + mockState = { ...defaultState }; + }); + + describe('set page', () => { + it('should set the current page to display', () => { + const pageToDisplay = 3; + mutations[types.SET_PAGE](mockState, pageToDisplay); + + expect(mockState.pageInfo.page).toEqual(pageToDisplay); + }); + }); + + describe('set suite', () => { + it('should set the suite at the given index', () => { + mockState.testReports = testReports; + const suite = { name: 'test_suite' }; + const index = 0; + const expectedState = { ...mockState }; + expectedState.testReports.test_suites[index] = { suite, hasFullSuite: true }; + mutations[types.SET_SUITE](mockState, { suite, index }); + + expect(mockState.testReports.test_suites[index]).toEqual( + expectedState.testReports.test_suites[index], + ); + }); + }); + + describe('set suite error', () => { + it('should set the error message in state if provided', () => { + const message = 'Test report artifacts not found'; + + mutations[types.SET_SUITE_ERROR](mockState, { + response: { data: { errors: message } }, + }); + + expect(mockState.errorMessage).toBe(message); + }); + + it('should show an alert otherwise', () => { + mutations[types.SET_SUITE_ERROR](mockState, {}); + + expect(createAlert).toHaveBeenCalled(); + }); + }); + + describe('set selected suite index', () => { + it('should set selectedSuiteIndex', () => { + const selectedSuiteIndex = 0; + mutations[types.SET_SELECTED_SUITE_INDEX](mockState, selectedSuiteIndex); + + expect(mockState.selectedSuiteIndex).toEqual(selectedSuiteIndex); + }); + }); + + describe('set summary', () => { + it('should set summary', () => { + const summary = { + total: { time: 0, count: 10, success: 1, failed: 2, skipped: 3, error: 4 }, + }; + const expectedSummary = { + ...summary, + total_time: 0, + total_count: 10, + success_count: 1, + failed_count: 2, + skipped_count: 3, + error_count: 4, + }; + mutations[types.SET_SUMMARY](mockState, summary); + + expect(mockState.testReports).toEqual(expectedSummary); + }); + }); + + describe('toggle loading', () => { + it('should set to true', () => { + const expectedState = { ...mockState, isLoading: true }; + mutations[types.TOGGLE_LOADING](mockState); + + expect(mockState.isLoading).toEqual(expectedState.isLoading); + }); + + it('should toggle back to false', () => { + const expectedState = { ...mockState, isLoading: false }; + mockState.isLoading = true; + + mutations[types.TOGGLE_LOADING](mockState); + + expect(mockState.isLoading).toEqual(expectedState.isLoading); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js b/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js new file mode 100644 index 00000000000..c0ffc2b34fb --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/stores/utils_spec.js @@ -0,0 +1,40 @@ +import { formatFilePath, formattedTime } from '~/ci/pipeline_details/stores/test_reports/utils'; + +describe('Test reports utils', () => { + 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(formatFilePath(file)).toBe(expected); + }); + }); + + describe('formattedTime', () => { + describe('when time is smaller than a second', () => { + it('should return time in milliseconds fixed to 2 decimals', () => { + const result = formattedTime(0.4815162342); + expect(result).toBe('481.52ms'); + }); + }); + + describe('when time is equal to a second', () => { + it('should return time in seconds fixed to 2 decimals', () => { + const result = formattedTime(1); + expect(result).toBe('1.00s'); + }); + }); + + describe('when time is greater than a second', () => { + it('should return time in seconds fixed to 2 decimals', () => { + const result = formattedTime(4.815162342); + expect(result).toBe('4.82s'); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_case_details_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_case_details_spec.js new file mode 100644 index 00000000000..0f651b9d456 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_case_details_spec.js @@ -0,0 +1,149 @@ +import { GlModal, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TestCaseDetails from '~/ci/pipeline_details/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; + const defaultTestCase = { + classname: 'spec.test_spec', + name: 'Test#something cool', + file: '~/index.js', + filePath: '/src/javascripts/index.js', + formattedTime: '10.04ms', + recent_failures: { + count: 2, + base_branch: 'main', + }, + 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'); + const findFileLink = () => wrapper.findComponent(GlLink); + const findDuration = () => wrapper.findByTestId('test-case-duration'); + const findRecentFailures = () => wrapper.findByTestId('test-case-recent-failures'); + const findAttachmentUrl = () => wrapper.findByTestId('test-case-attachment-url'); + const findSystemOutput = () => wrapper.findByTestId('test-case-trace'); + + const createComponent = (testCase = {}) => { + wrapper = extendedWrapper( + shallowMount(TestCaseDetails, { + propsData: { + modalId: 'my-modal', + testCase: { + ...defaultTestCase, + ...testCase, + }, + }, + stubs: { CodeBlock, GlModal }, + }), + ); + }; + + describe('required details', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the test case classname as modal title', () => { + expect(findModal().props('title')).toBe(defaultTestCase.classname); + }); + + it('renders the test case name', () => { + expect(findName().text()).toBe(defaultTestCase.name); + }); + + it('renders the test case file', () => { + expect(findFile().text()).toBe(defaultTestCase.file); + 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); + }); + }); + + describe('when test case has execution time instead of formatted time', () => { + beforeEach(() => { + createComponent({ ...defaultTestCase, formattedTime: null, execution_time: 17 }); + }); + + it('renders the test case duration', () => { + expect(findDuration().text()).toBe('17 s'); + }); + }); + + describe('when test case has recent failures', () => { + describe('has only 1 recent failure', () => { + it('renders the recent failure', () => { + createComponent({ recent_failures: { ...defaultTestCase.recent_failures, count: 1 } }); + + expect(findRecentFailures().text()).toContain( + `Failed 1 time in ${defaultTestCase.recent_failures.base_branch} in the last 14 days`, + ); + }); + }); + + describe('has more than 1 recent failure', () => { + it('renders the recent failures', () => { + createComponent(); + + expect(findRecentFailures().text()).toContain( + `Failed ${defaultTestCase.recent_failures.count} times in ${defaultTestCase.recent_failures.base_branch} in the last 14 days`, + ); + }); + }); + }); + + describe('when test case does not have recent failures', () => { + it('does not render the recent failures', () => { + createComponent({ recent_failures: null }); + + expect(findRecentFailures().exists()).toBe(false); + }); + }); + + describe('when test case has attachment URL', () => { + it('renders the attachment URL as a link', () => { + const expectedUrl = '/my/path.jpg'; + createComponent({ attachment_url: expectedUrl }); + const attachmentUrl = findAttachmentUrl(); + + expect(attachmentUrl.exists()).toBe(true); + expect(attachmentUrl.attributes('href')).toBe(expectedUrl); + }); + }); + + describe('when test case does not have attachment URL', () => { + it('does not render the attachment URL', () => { + createComponent({ attachment_url: null }); + + expect(findAttachmentUrl().exists()).toBe(false); + }); + }); + + describe('when test case has system output', () => { + it('renders the test case system output', () => { + createComponent(); + + expect(findSystemOutput().text()).toContain(defaultTestCase.system_output); + }); + }); + + describe('when test case does not have system output', () => { + it('does not render the test case system output', () => { + createComponent({ system_output: null }); + + expect(findSystemOutput().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js new file mode 100644 index 00000000000..8ff060026da --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_reports_spec.js @@ -0,0 +1,125 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import EmptyState from '~/ci/pipeline_details/test_reports/empty_state.vue'; +import TestReports from '~/ci/pipeline_details/test_reports/test_reports.vue'; +import TestSummary from '~/ci/pipeline_details/test_reports/test_summary.vue'; +import TestSummaryTable from '~/ci/pipeline_details/test_reports/test_summary_table.vue'; +import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; + +Vue.use(Vuex); + +describe('Test reports app', () => { + let wrapper; + let store; + + const loadingSpinner = () => wrapper.findComponent(GlLoadingIcon); + const testsDetail = () => wrapper.findByTestId('tests-detail'); + const emptyState = () => wrapper.findComponent(EmptyState); + const testSummary = () => wrapper.findComponent(TestSummary); + const testSummaryTable = () => wrapper.findComponent(TestSummaryTable); + + const actionSpies = { + fetchTestSuite: jest.fn(), + fetchSummary: jest.fn(), + setSelectedSuiteIndex: jest.fn(), + removeSelectedSuiteIndex: jest.fn(), + }; + + const createComponent = ({ state = {} } = {}) => { + store = new Vuex.Store({ + modules: { + testReports: { + namespaced: true, + state: { + isLoading: false, + selectedSuiteIndex: null, + testReports, + ...state, + }, + actions: actionSpies, + getters, + }, + }, + }); + + jest.spyOn(store, 'registerModule').mockReturnValue(null); + + wrapper = extendedWrapper( + shallowMount(TestReports, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, + store, + }), + ); + }; + + describe('when component is created', () => { + it('should call fetchSummary when pipeline has test report', () => { + createComponent(); + + expect(actionSpies.fetchSummary).toHaveBeenCalled(); + }); + }); + + describe('when loading', () => { + beforeEach(() => createComponent({ state: { isLoading: true } })); + + it('shows the loading spinner', () => { + expect(emptyState().exists()).toBe(false); + expect(testsDetail().exists()).toBe(false); + expect(loadingSpinner().exists()).toBe(true); + }); + }); + + describe('when the api returns no data', () => { + it('displays empty state component', () => { + createComponent({ state: { testReports: {} } }); + + expect(emptyState().exists()).toBe(true); + }); + }); + + describe('when the api returns data', () => { + beforeEach(() => createComponent()); + + it('sets testReports and shows tests', () => { + expect(wrapper.vm.testReports).toEqual(expect.any(Object)); + expect(wrapper.vm.showTests).toBe(true); + }); + + it('shows tests details', () => { + expect(testsDetail().exists()).toBe(true); + }); + }); + + describe('when a suite is clicked', () => { + beforeEach(() => { + createComponent({ state: { hasFullReport: true } }); + testSummaryTable().vm.$emit('row-click', 0); + }); + + it('should call setSelectedSuiteIndex and fetchTestSuite', () => { + expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); + expect(actionSpies.fetchTestSuite).toHaveBeenCalled(); + }); + }); + + describe('when clicking back to summary', () => { + beforeEach(() => { + createComponent({ state: { selectedSuiteIndex: 0 } }); + testSummary().vm.$emit('on-back-click'); + }); + + it('should call removeSelectedSuiteIndex', () => { + expect(actionSpies.removeSelectedSuiteIndex).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js new file mode 100644 index 00000000000..5bdea6bbcbf --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_suite_table_spec.js @@ -0,0 +1,169 @@ +import { GlButton, GlFriendlyWrap, GlLink, GlPagination, GlEmptyState } from '@gitlab/ui'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SuiteTable, { i18n } from '~/ci/pipeline_details/test_reports/test_suite_table.vue'; +import { TestStatus } from '~/ci/pipeline_details/constants'; +import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; +import { formatFilePath } from '~/ci/pipeline_details/stores/test_reports/utils'; +import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from '~/ci/pipeline_details/stores/test_reports/constants'; +import skippedTestCases from './mock_data'; + +Vue.use(Vuex); + +describe('Test reports suite table', () => { + let wrapper; + let store; + + const { + test_suites: [testSuite], + } = testReports; + + testSuite.test_cases = [...testSuite.test_cases, ...skippedTestCases]; + const testCases = testSuite.test_cases; + const blobPath = '/test/blob/path'; + + const noCasesMessage = () => wrapper.findByTestId('no-test-cases'); + const artifactsExpiredMessage = () => wrapper.findByTestId('artifacts-expired'); + const artifactsExpiredEmptyState = () => wrapper.findComponent(GlEmptyState); + const allCaseRows = () => wrapper.findAllByTestId('test-case-row'); + const findCaseRowAtIndex = (index) => wrapper.findAllByTestId('test-case-row').at(index); + const findLinkForRow = (row) => row.findComponent(GlLink); + const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); + + const createComponent = ({ suite = testSuite, perPage = 20, errorMessage } = {}) => { + store = new Vuex.Store({ + modules: { + testReports: { + namespaced: true, + state: { + blobPath, + testReports: { + test_suites: [suite], + }, + selectedSuiteIndex: 0, + pageInfo: { + page: 1, + perPage, + }, + errorMessage, + }, + getters, + }, + }, + }); + + wrapper = shallowMountExtended(SuiteTable, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, + store, + stubs: { GlFriendlyWrap }, + }); + }; + + it('should render a message when there are no test cases', () => { + createComponent({ suite: [] }); + + expect(noCasesMessage().exists()).toBe(true); + expect(artifactsExpiredMessage().exists()).toBe(false); + }); + + it('should render an empty state when artifacts have expired', () => { + createComponent({ suite: [], errorMessage: ARTIFACTS_EXPIRED_ERROR_MESSAGE }); + const emptyState = artifactsExpiredEmptyState(); + + expect(noCasesMessage().exists()).toBe(false); + expect(artifactsExpiredMessage().exists()).toBe(true); + + expect(emptyState.exists()).toBe(true); + expect(emptyState.props('title')).toBe(i18n.expiredArtifactsTitle); + }); + + describe('when a test suite is supplied', () => { + beforeEach(() => createComponent()); + + it('renders the correct number of rows', () => { + expect(allCaseRows()).toHaveLength(testCases.length); + }); + + it.each([ + TestStatus.ERROR, + TestStatus.FAILED, + TestStatus.SKIPPED, + TestStatus.SUCCESS, + 'unknown', + ])('renders the correct icon for test case with %s status', (status) => { + const test = testCases.findIndex((x) => x.status === status); + const row = findCaseRowAtIndex(test); + + expect(findIconForRow(row, status).exists()).toBe(true); + }); + + it('renders the file name for the test with a copy button', () => { + const { file } = testCases[0]; + const relativeFile = formatFilePath(file); + const filePath = `${blobPath}/${relativeFile}`; + const row = findCaseRowAtIndex(0); + const fileLink = findLinkForRow(row); + const button = row.findComponent(GlButton); + + expect(fileLink.attributes('href')).toBe(filePath); + expect(row.text()).toContain(file); + expect(button.exists()).toBe(true); + expect(button.attributes('data-clipboard-text')).toBe(file); + }); + }); + + describe('when a test suite has more test cases than the pagination size', () => { + const perPage = 2; + + beforeEach(() => { + createComponent({ testSuite, perPage }); + }); + + it('renders one page of test cases', () => { + expect(allCaseRows().length).toBe(perPage); + }); + + it('renders a pagination component', () => { + expect(wrapper.findComponent(GlPagination).exists()).toBe(true); + }); + }); + + describe('when a test case classname property is null', () => { + it('still renders all test cases', () => { + createComponent({ + testSuite: { + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + classname: null, + })), + }, + }); + + expect(allCaseRows()).toHaveLength(testCases.length); + }); + }); + + describe('when a test case name property is null', () => { + it('still renders all test cases', () => { + createComponent({ + testSuite: { + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + name: null, + })), + }, + }); + + expect(allCaseRows()).toHaveLength(testCases.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_summary_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_summary_spec.js new file mode 100644 index 00000000000..f9182d52c8a --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_summary_spec.js @@ -0,0 +1,106 @@ +import { mount } from '@vue/test-utils'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import Summary from '~/ci/pipeline_details/test_reports/test_summary.vue'; +import { formattedTime } from '~/ci/pipeline_details/stores/test_reports/utils'; + +describe('Test reports summary', () => { + let wrapper; + + const { + test_suites: [testSuite], + } = testReports; + + const backButton = () => wrapper.find('.js-back-button'); + const totalTests = () => wrapper.find('.js-total-tests'); + const failedTests = () => wrapper.find('.js-failed-tests'); + const erroredTests = () => wrapper.find('.js-errored-tests'); + const successRate = () => wrapper.find('.js-success-rate'); + const duration = () => wrapper.find('.js-duration'); + + const defaultProps = { + report: testSuite, + showBack: false, + }; + + const createComponent = (props) => { + wrapper = mount(Summary, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('should not render', () => { + beforeEach(() => { + createComponent(); + }); + + it('a back button by default', () => { + expect(backButton().exists()).toBe(false); + }); + }); + + describe('should render', () => { + beforeEach(() => { + createComponent(); + }); + + it('a back button and emit on-back-click event', () => { + createComponent({ + showBack: true, + }); + + expect(backButton().exists()).toBe(true); + }); + }); + + describe('when a report is supplied', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the correct total', () => { + expect(totalTests().text()).toBe('4 tests'); + }); + + it('displays the correct failure count', () => { + expect(failedTests().text()).toBe('2 failures'); + }); + + it('displays the correct error count', () => { + expect(erroredTests().text()).toBe('0 errors'); + }); + + it('calculates and displays percentages correctly', () => { + expect(successRate().text()).toBe('50% success rate'); + }); + + it('displays the correctly formatted duration', () => { + expect(duration().text()).toBe(formattedTime(testSuite.total_time)); + }); + }); + + describe('success percentage calculation', () => { + it.each` + name | successCount | totalCount | skippedCount | result + ${'displays 0 when there are no tests'} | ${0} | ${0} | ${0} | ${'0'} + ${'displays whole number when possible'} | ${10} | ${50} | ${0} | ${'20'} + ${'excludes skipped tests from total'} | ${10} | ${50} | ${5} | ${'22.22'} + ${'rounds to 0.01'} | ${1} | ${16604} | ${0} | ${'0.01'} + ${'correctly rounds to 50'} | ${8302} | ${16604} | ${0} | ${'50'} + ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${0} | ${'99.99'} + ${'correctly displays 100'} | ${16604} | ${16604} | ${0} | ${'100'} + `('$name', ({ successCount, totalCount, skippedCount, result }) => { + createComponent({ + report: { + success_count: successCount, + skipped_count: skippedCount, + total_count: totalCount, + }, + }); + + expect(successRate().text()).toBe(`${result}% success rate`); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/test_reports/test_summary_table_spec.js b/spec/frontend/ci/pipeline_details/test_reports/test_summary_table_spec.js new file mode 100644 index 00000000000..bb62fbcb32c --- /dev/null +++ b/spec/frontend/ci/pipeline_details/test_reports/test_summary_table_spec.js @@ -0,0 +1,100 @@ +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import testReports from 'test_fixtures/pipelines/test_report.json'; +import SummaryTable from '~/ci/pipeline_details/test_reports/test_summary_table.vue'; +import * as getters from '~/ci/pipeline_details/stores/test_reports/getters'; + +Vue.use(Vuex); + +describe('Test reports summary table', () => { + let wrapper; + let store; + + const allSuitesRows = () => wrapper.findAll('.js-suite-row'); + const noSuitesToShow = () => wrapper.find('.js-no-tests-suites'); + + const defaultProps = { + testReports, + }; + + const createComponent = (reports = null) => { + store = new Vuex.Store({ + modules: { + testReports: { + namespaced: true, + state: { + testReports: reports || testReports, + }, + getters, + }, + }, + }); + + wrapper = mount(SummaryTable, { + provide: { + blobPath: '/blob/path', + summaryEndpoint: '/summary.json', + suiteEndpoint: '/suite.json', + }, + propsData: defaultProps, + store, + }); + }; + + describe('when test reports are supplied', () => { + beforeEach(() => createComponent()); + const findErrorIcon = () => wrapper.findComponent({ ref: 'suiteErrorIcon' }); + + it('renders the correct number of rows', () => { + expect(noSuitesToShow().exists()).toBe(false); + expect(allSuitesRows().length).toBe(testReports.test_suites.length); + }); + + describe('when there is a suite error', () => { + beforeEach(() => { + createComponent({ + test_suites: [ + { + ...testReports.test_suites[0], + suite_error: 'Suite Error', + }, + ], + }); + }); + + it('renders error icon', () => { + expect(findErrorIcon().exists()).toBe(true); + expect(findErrorIcon().attributes('title')).toEqual('Suite Error'); + }); + }); + + describe('when there is not a suite error', () => { + beforeEach(() => { + createComponent({ + test_suites: [ + { + ...testReports.test_suites[0], + suite_error: null, + }, + ], + }); + }); + + it('does not render error icon', () => { + expect(findErrorIcon().exists()).toBe(false); + }); + }); + }); + + describe('when there are no test suites', () => { + beforeEach(() => { + createComponent({ test_suites: [] }); + }); + + it('displays the no suites to show message', () => { + expect(noSuitesToShow().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/utils/index_spec.js b/spec/frontend/ci/pipeline_details/utils/index_spec.js new file mode 100644 index 00000000000..61230cb52e6 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/utils/index_spec.js @@ -0,0 +1,201 @@ +import { + createJobsHash, + generateJobNeedsDict, + getPipelineDefaultTab, +} from '~/ci/pipeline_details/utils'; +import { validPipelineTabNames, pipelineTabName } from '~/ci/pipeline_details/constants'; + +describe('utils functions', () => { + const jobName1 = 'build_1'; + const jobName2 = 'build_2'; + const jobName3 = 'test_1'; + const jobName4 = 'deploy_1'; + const job1 = { name: jobName1, script: 'echo hello', stage: 'build' }; + const job2 = { name: jobName2, script: 'echo build', stage: 'build' }; + const job3 = { + name: jobName3, + script: 'echo test', + stage: 'test', + needs: [jobName1, jobName2], + }; + const job4 = { + name: jobName4, + script: 'echo deploy', + stage: 'deploy', + needs: [jobName3], + }; + const userDefinedStage = 'myStage'; + + const pipelineGraphData = { + stages: [ + { + name: userDefinedStage, + groups: [], + }, + { + name: job4.stage, + groups: [ + { + name: jobName4, + jobs: [{ ...job4 }], + }, + ], + }, + { + name: job1.stage, + groups: [ + { + name: jobName1, + jobs: [{ ...job1 }], + }, + { + name: jobName2, + jobs: [{ ...job2 }], + }, + ], + }, + { + name: job3.stage, + groups: [ + { + name: jobName3, + jobs: [{ ...job3 }], + }, + ], + }, + ], + }; + + describe('createJobsHash', () => { + it('returns an empty object if there are no jobs received as argument', () => { + expect(createJobsHash([])).toEqual({}); + }); + + it('returns a hash with the jobname as key and all its data as value', () => { + const jobs = { + [jobName1]: { jobs: [job1], name: jobName1, needs: [] }, + [jobName2]: { jobs: [job2], name: jobName2, needs: [] }, + [jobName3]: { jobs: [job3], name: jobName3, needs: job3.needs }, + [jobName4]: { jobs: [job4], name: jobName4, needs: job4.needs }, + }; + + expect(createJobsHash(pipelineGraphData.stages)).toEqual(jobs); + }); + }); + + describe('generateJobNeedsDict', () => { + it('generates an empty object if it receives no jobs', () => { + expect(generateJobNeedsDict({})).toEqual({}); + }); + + it('generates a dict with empty needs if there are no dependencies', () => { + const smallGraph = { + [jobName1]: job1, + [jobName2]: job2, + }; + + expect(generateJobNeedsDict(smallGraph)).toEqual({ + [jobName1]: [], + [jobName2]: [], + }); + }); + + it('generates a dict where key is the a job and its value is an array of all its needs', () => { + const jobsWithNeeds = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: job4, + }; + + expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ + [jobName1]: [], + [jobName2]: [], + [jobName3]: [jobName1, jobName2], + [jobName4]: [jobName3, jobName1, jobName2], + }); + }); + + it('removes needs which are not in the data', () => { + const inexistantJobName = 'job5'; + const jobsWithNeeds = { + [jobName1]: job1, + [jobName2]: job2, + [jobName3]: job3, + [jobName4]: { + name: jobName4, + script: 'echo deploy', + stage: 'deploy', + needs: [inexistantJobName], + }, + }; + + expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ + [jobName1]: [], + [jobName2]: [], + [jobName3]: [jobName1, jobName2], + [jobName4]: [], + }); + }); + + it('handles parallel jobs by adding the group name as a need', () => { + const size = 3; + const jobOptimize1 = 'optimize_1'; + const jobPrepareA = 'prepare_a'; + const jobPrepareA1 = `${jobPrepareA} 1/${size}`; + const jobPrepareA2 = `${jobPrepareA} 2/${size}`; + const jobPrepareA3 = `${jobPrepareA} 3/${size}`; + + const jobsParallel = { + [jobOptimize1]: { + jobs: [job1], + name: [jobOptimize1], + needs: [jobPrepareA1, jobPrepareA2, jobPrepareA3], + }, + [jobPrepareA]: { jobs: [], name: jobPrepareA, needs: [], size }, + [jobPrepareA1]: { jobs: [], name: jobPrepareA, needs: [], size }, + [jobPrepareA2]: { jobs: [], name: jobPrepareA, needs: [], size }, + [jobPrepareA3]: { jobs: [], name: jobPrepareA, needs: [], size }, + }; + + expect(generateJobNeedsDict(jobsParallel)).toEqual({ + [jobOptimize1]: [ + jobPrepareA1, + // This is the important part, the `jobPrepareA` group name has been + // added to our list of needs. + jobPrepareA, + jobPrepareA2, + jobPrepareA3, + ], + [jobPrepareA]: [], + [jobPrepareA1]: [], + [jobPrepareA2]: [], + [jobPrepareA3]: [], + }); + }); + }); + + describe('getPipelineDefaultTab', () => { + const baseUrl = 'http://gitlab.com/user/multi-projects-small/-/pipelines/332/'; + it('returns pipeline tab name if there is only the base url', () => { + expect(getPipelineDefaultTab(baseUrl)).toBe(pipelineTabName); + }); + + it('returns null if there was no valid last url part', () => { + expect(getPipelineDefaultTab(`${baseUrl}something`)).toBe(null); + }); + + it('returns the correct tab name if present', () => { + validPipelineTabNames.forEach((tabName) => { + expect(getPipelineDefaultTab(`${baseUrl}${tabName}`)).toBe(tabName); + }); + }); + + it('returns the right value even with query params', () => { + const [tabName] = validPipelineTabNames; + expect(getPipelineDefaultTab(`${baseUrl}${tabName}?query="something"&query2="else"`)).toBe( + tabName, + ); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/utils/parsing_utils_spec.js b/spec/frontend/ci/pipeline_details/utils/parsing_utils_spec.js new file mode 100644 index 00000000000..9390f076d3d --- /dev/null +++ b/spec/frontend/ci/pipeline_details/utils/parsing_utils_spec.js @@ -0,0 +1,191 @@ +import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; +import { createSankey } from '~/ci/pipeline_details/dag/utils/drawing_utils'; +import { + makeLinksFromNodes, + filterByAncestors, + generateColumnsFromLayersListBare, + keepLatestDownstreamPipelines, + listByLayers, + parseData, + removeOrphanNodes, + getMaxNodes, +} from '~/ci/pipeline_details/utils/parsing_utils'; +import { createNodeDict } from '~/ci/pipeline_details/utils'; + +import { mockDownstreamPipelinesRest } from '../../../vue_merge_request_widget/mock_data'; +import { mockDownstreamPipelinesGraphql } from '../../../commit/mock_data'; +import { mockParsedGraphQLNodes, missingJob } from '../dag/mock_data'; +import { generateResponse } from '../graph/mock_data'; + +describe('DAG visualization parsing utilities', () => { + const nodeDict = createNodeDict(mockParsedGraphQLNodes); + const unfilteredLinks = makeLinksFromNodes(mockParsedGraphQLNodes, nodeDict); + const parsed = parseData(mockParsedGraphQLNodes); + + describe('makeLinksFromNodes', () => { + it('returns the expected link structure', () => { + expect(unfilteredLinks[0]).toHaveProperty('source', 'build_a'); + expect(unfilteredLinks[0]).toHaveProperty('target', 'test_a'); + expect(unfilteredLinks[0]).toHaveProperty('value', 10); + }); + + it('does not generate a link for non-existing jobs', () => { + const sources = unfilteredLinks.map(({ source }) => source); + + expect(sources.includes(missingJob)).toBe(false); + }); + }); + + describe('filterByAncestors', () => { + const allLinks = [ + { source: 'job1', target: 'job4' }, + { source: 'job1', target: 'job2' }, + { source: 'job2', target: 'job4' }, + ]; + + const dedupedLinks = [ + { source: 'job1', target: 'job2' }, + { source: 'job2', target: 'job4' }, + ]; + + const nodeLookup = { + job1: { + name: 'job1', + }, + job2: { + name: 'job2', + needs: ['job1'], + }, + job4: { + name: 'job4', + needs: ['job1', 'job2'], + category: 'build', + }, + }; + + it('dedupes links', () => { + expect(filterByAncestors(allLinks, nodeLookup)).toMatchObject(dedupedLinks); + }); + }); + + describe('parseData parent function', () => { + it('returns an object containing a list of nodes and links', () => { + // an array of nodes exist and the values are defined + expect(parsed).toHaveProperty('nodes'); + expect(Array.isArray(parsed.nodes)).toBe(true); + expect(parsed.nodes.filter(Boolean)).not.toHaveLength(0); + + // an array of links exist and the values are defined + expect(parsed).toHaveProperty('links'); + expect(Array.isArray(parsed.links)).toBe(true); + expect(parsed.links.filter(Boolean)).not.toHaveLength(0); + }); + }); + + describe('removeOrphanNodes', () => { + it('removes sankey nodes that have no needs and are not needed', () => { + const layoutSettings = { + width: 200, + height: 200, + nodeWidth: 10, + nodePadding: 20, + paddingForLabels: 100, + }; + + const sankeyLayout = createSankey(layoutSettings)(parsed); + const cleanedNodes = removeOrphanNodes(sankeyLayout.nodes); + /* + These lengths are determined by the mock data. + If the data changes, the numbers may also change. + */ + expect(parsed.nodes).toHaveLength(mockParsedGraphQLNodes.length); + expect(cleanedNodes).toHaveLength(12); + }); + }); + + describe('getMaxNodes', () => { + it('returns the number of nodes in the most populous generation', () => { + const layerNodes = [ + { layer: 0 }, + { layer: 0 }, + { layer: 1 }, + { layer: 1 }, + { layer: 0 }, + { layer: 3 }, + { layer: 2 }, + { layer: 4 }, + { layer: 1 }, + { layer: 3 }, + { layer: 4 }, + ]; + expect(getMaxNodes(layerNodes)).toBe(3); + }); + }); + + describe('generateColumnsFromLayersList', () => { + const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); + const { pipelineLayers } = listByLayers(pipeline); + const columns = generateColumnsFromLayersListBare(pipeline, pipelineLayers); + + it('returns stage-like objects with default name, id, and status', () => { + columns.forEach((col, idx) => { + expect(col).toMatchObject({ + name: '', + status: { action: null }, + id: `layer-${idx}`, + }); + }); + }); + + it('creates groups that match the list created in listByLayers', () => { + columns.forEach((col, idx) => { + const groupNames = col.groups.map(({ name }) => name); + expect(groupNames).toEqual(pipelineLayers[idx]); + }); + }); + + it('looks up the correct group object', () => { + columns.forEach((col) => { + col.groups.forEach((group) => { + const groupStage = pipeline.stages.find((el) => el.name === group.stageName); + const groupObject = groupStage.groups.find((el) => el.name === group.name); + expect(group).toBe(groupObject); + }); + }); + }); + }); +}); + +describe('linked pipeline utilities', () => { + describe('keepLatestDownstreamPipelines', () => { + it('filters data from GraphQL', () => { + const downstream = mockDownstreamPipelinesGraphql().nodes; + const latestDownstream = keepLatestDownstreamPipelines(downstream); + + expect(downstream).toHaveLength(3); + expect(latestDownstream).toHaveLength(1); + }); + + it('filters data from REST', () => { + const downstream = mockDownstreamPipelinesRest(); + const latestDownstream = keepLatestDownstreamPipelines(downstream); + + expect(downstream).toHaveLength(2); + expect(latestDownstream).toHaveLength(1); + }); + + it('returns downstream pipelines if sourceJob.retried is null', () => { + const downstream = mockDownstreamPipelinesGraphql({ includeSourceJobRetried: false }).nodes; + const latestDownstream = keepLatestDownstreamPipelines(downstream); + + expect(latestDownstream).toHaveLength(downstream.length); + }); + + it('returns downstream pipelines if source_job.retried is null', () => { + const downstream = mockDownstreamPipelinesRest({ includeSourceJobRetried: false }); + const latestDownstream = keepLatestDownstreamPipelines(downstream); + + expect(latestDownstream).toHaveLength(downstream.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_details/utils/unwrapping_utils_spec.js b/spec/frontend/ci/pipeline_details/utils/unwrapping_utils_spec.js new file mode 100644 index 00000000000..99ee2eff1e4 --- /dev/null +++ b/spec/frontend/ci/pipeline_details/utils/unwrapping_utils_spec.js @@ -0,0 +1,127 @@ +import { + unwrapGroups, + unwrapNodesWithName, + unwrapStagesWithNeeds, +} from '~/ci/pipeline_details/utils/unwrapping_utils'; + +const groupsArray = [ + { + name: 'build_a', + size: 1, + status: { + label: 'passed', + group: 'success', + icon: 'status_success', + }, + }, + { + name: 'bob_the_build', + size: 1, + status: { + label: 'passed', + group: 'success', + icon: 'status_success', + }, + }, +]; + +const basicStageInfo = { + name: 'center_stage', + status: { + action: null, + }, +}; + +const stagesAndGroups = [ + { + ...basicStageInfo, + groups: { + nodes: groupsArray, + }, + }, +]; + +const needArray = [ + { + name: 'build_b', + }, +]; + +const elephantArray = [ + { + name: 'build_b', + elephant: 'gray', + }, +]; + +const baseJobs = { + name: 'test_d', + status: { + icon: 'status_success', + tooltip: null, + hasDetails: true, + detailsPath: '/root/abcd-dag/-/pipelines/162', + group: 'success', + action: null, + }, +}; + +const jobArrayWithNeeds = [ + { + ...baseJobs, + needs: { + nodes: needArray, + }, + }, +]; + +const jobArrayWithElephant = [ + { + ...baseJobs, + needs: { + nodes: elephantArray, + }, + }, +]; + +const completeMock = [ + { + ...basicStageInfo, + groups: { + nodes: groupsArray.map((group) => ({ ...group, jobs: { nodes: jobArrayWithNeeds } })), + }, + }, +]; + +describe('Shared pipeline unwrapping utils', () => { + describe('unwrapGroups', () => { + it('takes stages without nodes and returns the unwrapped groups', () => { + expect(unwrapGroups(stagesAndGroups)[0].node.groups).toEqual(groupsArray); + }); + + it('keeps other stage properties intact', () => { + expect(unwrapGroups(stagesAndGroups)[0].node).toMatchObject(basicStageInfo); + }); + }); + + describe('unwrapNodesWithName', () => { + it('works with no field argument', () => { + expect(unwrapNodesWithName(jobArrayWithNeeds, 'needs')[0].needs).toEqual([needArray[0].name]); + }); + + it('works with custom field argument', () => { + expect(unwrapNodesWithName(jobArrayWithElephant, 'needs', 'elephant')[0].needs).toEqual([ + elephantArray[0].elephant, + ]); + }); + }); + + describe('unwrapStagesWithNeeds', () => { + it('removes nodes from groups, jobs, and needs', () => { + const firstProcessedGroup = unwrapStagesWithNeeds(completeMock)[0].groups[0]; + expect(firstProcessedGroup).toMatchObject(groupsArray[0]); + expect(firstProcessedGroup.jobs[0]).toMatchObject(baseJobs); + expect(firstProcessedGroup.jobs[0].needs[0]).toBe(needArray[0].name); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/graph/mock_data.js b/spec/frontend/ci/pipeline_editor/components/graph/mock_data.js new file mode 100644 index 00000000000..db77e0a0573 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/graph/mock_data.js @@ -0,0 +1,283 @@ +export const yamlString = `stages: +- empty +- build +- test +- deploy +- final + +include: +- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml' + +build_a: + stage: build + script: echo hello +build_b: + stage: build + script: echo hello +build_c: + stage: build + script: echo hello +build_d: + stage: Queen + script: echo hello + +test_a: + stage: test + script: ls + needs: [build_a, build_b, build_c] +test_b: + stage: test + script: ls + needs: [build_a, build_b, build_d] +test_c: + stage: test + script: ls + needs: [build_a, build_b, build_c] + +deploy_a: + stage: deploy + script: echo hello +`; + +export const pipelineDataWithNoNeeds = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + ], + }, + ], +}; + +export const pipelineData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test' }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_1'] }], + }, + ], + }, + ], +}; + +export const invalidNeedsData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test' }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['invalid_job'] }], + }, + ], + }, + ], +}; + +export const parallelNeedData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + parallel: 3, + jobs: [ + { script: 'echo hello', stage: 'build', name: 'build_1 1/3' }, + { script: 'echo hello', stage: 'build', name: 'build_1 2/3' }, + { script: 'echo hello', stage: 'build', name: 'build_1 3/3' }, + ], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test', needs: ['build_1'] }], + }, + ], + }, + ], +}; + +export const sameStageNeeds = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build', name: 'build_1' }], + }, + ], + }, + { + name: 'build', + groups: [ + { + name: 'build_2', + jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_1'] }], + }, + ], + }, + { + name: 'build', + groups: [ + { + name: 'build_3', + jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_2'] }], + }, + ], + }, + ], +}; + +export const largePipelineData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + { + name: 'build_2', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + { + name: 'build_3', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test', needs: ['build_2'] }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test', needs: ['build_2'] }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_1'] }], + }, + { + name: 'deploy_2', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['build_3'] }], + }, + { + name: 'deploy_3', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_2'] }], + }, + ], + }, + ], +}; + +export const singleStageData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + ], +}; + +export const rootRect = { + bottom: 463, + height: 271, + left: 236, + right: 1252, + top: 192, + width: 1016, + x: 236, + y: 192, +}; + +export const jobRect = { + bottom: 312, + height: 24, + left: 308, + right: 428, + top: 288, + width: 120, + x: 308, + y: 288, +}; diff --git a/spec/frontend/ci/pipeline_editor/components/graph/pipeline_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/graph/pipeline_graph_spec.js new file mode 100644 index 00000000000..95edfb01cf0 --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/graph/pipeline_graph_spec.js @@ -0,0 +1,100 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; +import { CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants'; +import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; +import JobPill from '~/ci/pipeline_editor/components/graph/job_pill.vue'; +import PipelineGraph from '~/ci/pipeline_editor/components/graph/pipeline_graph.vue'; +import StageName from '~/ci/pipeline_editor/components/graph/stage_name.vue'; +import { pipelineData, singleStageData } from './mock_data'; + +describe('pipeline graph component', () => { + const defaultProps = { pipelineData }; + let wrapper; + + const containerId = 'pipeline-graph-container-0'; + setHTMLFixture(`
    `); + + const createComponent = (props = defaultProps) => { + return shallowMount(PipelineGraph, { + propsData: { + ...props, + }, + stubs: { LinksLayer, LinksInner }, + data() { + return { + measurements: { + width: 1000, + height: 1000, + }, + }; + }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findAllJobPills = () => wrapper.findAllComponents(JobPill); + const findAllStageNames = () => wrapper.findAllComponents(StageName); + const findLinksLayer = () => wrapper.findComponent(LinksLayer); + const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); + + describe('with `VALID` status', () => { + beforeEach(() => { + wrapper = createComponent({ + pipelineData: { + status: CI_CONFIG_STATUS_VALID, + stages: [{ name: 'hello', groups: [] }], + }, + }); + }); + + it('renders the graph with no status error', () => { + expect(findAlert().exists()).toBe(false); + expect(findPipelineGraph().exists()).toBe(true); + expect(findLinksLayer().exists()).toBe(true); + }); + }); + + describe('with only one stage', () => { + beforeEach(() => { + wrapper = createComponent({ pipelineData: singleStageData }); + }); + + it('renders the right number of stage titles', () => { + const expectedStagesLength = singleStageData.stages.length; + + expect(findAllStageNames()).toHaveLength(expectedStagesLength); + }); + + it('renders the right number of job pills', () => { + // We count the number of jobs in the mock data + const expectedJobsLength = singleStageData.stages.reduce((acc, val) => { + return acc + val.groups.length; + }, 0); + + expect(findAllJobPills()).toHaveLength(expectedJobsLength); + }); + }); + + describe('with multiple stages and jobs', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the right number of stage titles', () => { + const expectedStagesLength = pipelineData.stages.length; + + expect(findAllStageNames()).toHaveLength(expectedStagesLength); + }); + + it('renders the right number of job pills', () => { + // We count the number of jobs in the mock data + const expectedJobsLength = pipelineData.stages.reduce((acc, val) => { + return acc + val.groups.length; + }, 0); + + expect(findAllJobPills()).toHaveLength(expectedJobsLength); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js index a651664851e..655bfe538c6 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlCard } from '@gitlab/ui'; import PipelineEditorHeader from '~/ci/pipeline_editor/components/header/pipeline_editor_header.vue'; import PipelineStatus from '~/ci/pipeline_editor/components/header/pipeline_status.vue'; import ValidationSegment from '~/ci/pipeline_editor/components/header/validation_segment.vue'; @@ -20,6 +21,9 @@ describe('Pipeline editor header', () => { isNewCiConfigFile: false, ...props, }, + stubs: { + GlCard, + }, }); }; diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js index f5e0b65d615..4ec1dd4b605 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js @@ -4,8 +4,8 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; -import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; import { PIPELINE_FAILURE } from '~/ci/pipeline_editor/constants'; import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data'; diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js index 3bbe14adb88..1a2ed60a6f4 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js @@ -6,8 +6,9 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineStatus, { i18n } from '~/ci/pipeline_editor/components/header/pipeline_status.vue'; import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; -import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; +import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data'; Vue.use(VueApollo); @@ -21,6 +22,16 @@ describe('Pipeline Status', () => { const handlers = [[getPipelineQuery, mockPipelineQuery]]; mockApollo = createMockApollo(handlers); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getPipelineEtag, + data: { + etags: { + __typename: 'EtagValues', + pipeline: 'pipelines/1', + }, + }, + }); + wrapper = shallowMount(PipelineStatus, { apolloProvider: mockApollo, propsData: { diff --git a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js index 77252a5c0b6..69e91f11309 100644 --- a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -19,7 +19,7 @@ import { VALIDATE_TAB, VALIDATE_TAB_BADGE_DISMISSED_KEY, } from '~/ci/pipeline_editor/constants'; -import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import PipelineGraph from '~/ci/pipeline_editor/components/graph/pipeline_graph.vue'; import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql'; import { mockBlobContentQueryResponse, diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js index 007abde939f..e08c35f1555 100644 --- a/spec/frontend/ci/pipeline_editor/mock_data.js +++ b/spec/frontend/ci/pipeline_editor/mock_data.js @@ -1,5 +1,5 @@ import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants'; -import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; +import { unwrapStagesWithNeeds } from '~/ci/pipeline_details/utils/unwrapping_utils'; import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; export const commonOptions = { diff --git a/spec/frontend/ci/pipeline_mini_graph/job_item_spec.js b/spec/frontend/ci/pipeline_mini_graph/job_item_spec.js new file mode 100644 index 00000000000..9c14e75caa4 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/job_item_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import JobItem from '~/ci/pipeline_mini_graph/job_item.vue'; + +describe('JobItem', () => { + let wrapper; + + const defaultProps = { + job: { id: '3' }, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(JobItem, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the received HTML', () => { + expect(wrapper.html()).toContain(defaultProps.job.id); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js new file mode 100644 index 00000000000..916f3053153 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js @@ -0,0 +1,122 @@ +import { mount } from '@vue/test-utils'; +import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineStages from '~/ci/pipeline_mini_graph/pipeline_stages.vue'; +import mockLinkedPipelines from './linked_pipelines_mock_data'; + +const mockStages = pipelines[0].details.stages; + +describe('Legacy Pipeline Mini Graph', () => { + let wrapper; + + const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); + const findPipelineStages = () => wrapper.findComponent(PipelineStages); + + const findLinkedPipelineUpstream = () => + wrapper.findComponent('[data-testid="pipeline-mini-graph-upstream"]'); + const findLinkedPipelineDownstream = () => + wrapper.findComponent('[data-testid="pipeline-mini-graph-downstream"]'); + const findDownstreamArrowIcon = () => wrapper.find('[data-testid="downstream-arrow-icon"]'); + const findUpstreamArrowIcon = () => wrapper.find('[data-testid="upstream-arrow-icon"]'); + + const createComponent = (props = {}) => { + wrapper = mount(LegacyPipelineMiniGraph, { + propsData: { + stages: mockStages, + ...props, + }, + }); + }; + + describe('rendered state without upstream or downstream pipelines', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render the pipeline stages', () => { + expect(findPipelineStages().exists()).toBe(true); + }); + + it('should have the correct props', () => { + expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ + downstreamPipelines: [], + isMergeTrain: false, + pipelinePath: '', + stages: expect.any(Array), + updateDropdown: false, + upstreamPipeline: undefined, + }); + }); + + it('should have no linked pipelines', () => { + expect(findLinkedPipelineDownstream().exists()).toBe(false); + expect(findLinkedPipelineUpstream().exists()).toBe(false); + }); + + it('should not render arrow icons', () => { + expect(findUpstreamArrowIcon().exists()).toBe(false); + expect(findDownstreamArrowIcon().exists()).toBe(false); + }); + }); + + describe('rendered state with upstream pipeline', () => { + beforeEach(() => { + createComponent({ + upstreamPipeline: mockLinkedPipelines.triggered_by, + }); + }); + + it('should have the correct props', () => { + expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ + downstreamPipelines: [], + isMergeTrain: false, + pipelinePath: '', + stages: expect.any(Array), + updateDropdown: false, + upstreamPipeline: expect.any(Object), + }); + }); + + it('should render the upstream linked pipelines mini list only', () => { + expect(findLinkedPipelineUpstream().exists()).toBe(true); + expect(findLinkedPipelineDownstream().exists()).toBe(false); + }); + + it('should render an upstream arrow icon only', () => { + expect(findDownstreamArrowIcon().exists()).toBe(false); + expect(findUpstreamArrowIcon().exists()).toBe(true); + expect(findUpstreamArrowIcon().props('name')).toBe('long-arrow'); + }); + }); + + describe('rendered state with downstream pipelines', () => { + beforeEach(() => { + createComponent({ + downstreamPipelines: mockLinkedPipelines.triggered, + pipelinePath: 'my/pipeline/path', + }); + }); + + it('should have the correct props', () => { + expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ + downstreamPipelines: expect.any(Array), + isMergeTrain: false, + pipelinePath: 'my/pipeline/path', + stages: expect.any(Array), + updateDropdown: false, + upstreamPipeline: undefined, + }); + }); + + it('should render the downstream linked pipelines mini list only', () => { + expect(findLinkedPipelineDownstream().exists()).toBe(true); + expect(findLinkedPipelineUpstream().exists()).toBe(false); + }); + + it('should render a downstream arrow icon only', () => { + expect(findUpstreamArrowIcon().exists()).toBe(false); + expect(findDownstreamArrowIcon().exists()).toBe(true); + expect(findDownstreamArrowIcon().props('name')).toBe('long-arrow'); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js new file mode 100644 index 00000000000..30a0b868c5f --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/legacy_pipeline_stage_spec.js @@ -0,0 +1,247 @@ +import { GlDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue'; +import eventHub from '~/ci/event_hub'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stageReply } from './mock_data'; + +const dropdownPath = 'path.json'; + +describe('Pipelines stage component', () => { + let wrapper; + let mock; + let glTooltipDirectiveMock; + + const createComponent = (props = {}) => { + glTooltipDirectiveMock = jest.fn(); + wrapper = mount(LegacyPipelineStage, { + attachTo: document.body, + directives: { + GlTooltip: glTooltipDirectiveMock, + }, + propsData: { + stage: { + status: { + group: 'success', + icon: 'status_success', + title: 'success', + }, + dropdown_path: dropdownPath, + }, + updateDropdown: false, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + eventHub.$emit.mockRestore(); + mock.restore(); + // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy + wrapper.destroy(); + }); + + const findCiActionBtn = () => wrapper.find('.js-ci-action'); + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); + const findDropdownMenu = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); + const findDropdownMenuTitle = () => + wrapper.find('[data-testid="pipeline-stage-dropdown-menu-title"]'); + const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); + const findLoadingState = () => wrapper.find('[data-testid="pipeline-stage-loading-state"]'); + + const openStageDropdown = async () => { + await findDropdownToggle().trigger('click'); + await waitForPromises(); + await nextTick(); + }; + + describe('loading state', () => { + beforeEach(async () => { + createComponent({ updateDropdown: true }); + + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + + await openStageDropdown(); + }); + + it('displays loading state while jobs are being fetched', async () => { + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findLoadingState().exists()).toBe(true); + expect(findLoadingState().text()).toBe(LegacyPipelineStage.i18n.loadingText); + }); + + it('does not display loading state after jobs have been fetched', async () => { + await waitForPromises(); + + expect(findLoadingState().exists()).toBe(false); + }); + }); + + describe('default appearance', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets up the tooltip to not have a show delay animation', () => { + expect(glTooltipDirectiveMock.mock.calls[0][1].modifiers.ds0).toBe(true); + }); + + it('renders a dropdown with the status icon', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdownToggle().exists()).toBe(true); + expect(findCiIcon().exists()).toBe(true); + }); + + it('renders a borderless ci-icon', () => { + expect(findCiIcon().exists()).toBe(true); + expect(findCiIcon().props('isBorderless')).toBe(true); + expect(findCiIcon().classes('borderless')).toBe(true); + }); + + it('renders a ci-icon with a custom border class', () => { + expect(findCiIcon().exists()).toBe(true); + expect(findCiIcon().classes('gl-border')).toBe(true); + }); + }); + + describe('when user opens dropdown and stage request is successful', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent(); + + await openStageDropdown(); + await jest.runAllTimers(); + await axios.waitForAll(); + }); + + it('renders the received data and emits the correct events', () => { + expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); + expect(findDropdownMenuTitle().text()).toContain(stageReply.name); + expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + expect(wrapper.emitted('miniGraphStageClick')).toEqual([[]]); + }); + + it('refreshes when updateDropdown is set to true', async () => { + expect(mock.history.get).toHaveLength(1); + + wrapper.setProps({ updateDropdown: true }); + await axios.waitForAll(); + + expect(mock.history.get).toHaveLength(2); + }); + }); + + describe('when user opens dropdown and stage request fails', () => { + it('should close the dropdown', async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + await waitForPromises(); + + expect(findDropdown().classes('show')).toBe(false); + }); + }); + + describe('update endpoint correctly', () => { + beforeEach(async () => { + const copyStage = { ...stageReply }; + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(HTTP_STATUS_OK, copyStage); + createComponent({ + stage: { + status: { + group: 'running', + icon: 'status_running', + title: 'running', + }, + dropdown_path: 'bar.json', + }, + }); + await axios.waitForAll(); + }); + + it('should update the stage to request the new endpoint provided', async () => { + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(findDropdownMenu().text()).toContain('this is the updated content'); + }); + }); + + describe('job update in dropdown', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(HTTP_STATUS_OK); + + createComponent(); + await waitForPromises(); + await nextTick(); + }); + + const clickCiAction = async () => { + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + await findCiActionBtn().trigger('click'); + }; + + it('keeps dropdown open when job item action is clicked', async () => { + await clickCiAction(); + await waitForPromises(); + + expect(findDropdown().classes('show')).toBe(true); + }); + }); + + describe('With merge trains enabled', () => { + it('shows a warning on the dropdown', async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent({ + isMergeTrain: true, + }); + + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + const warning = findMergeTrainWarning(); + + expect(warning.text()).toBe('Merge train pipeline jobs can not be retried'); + }); + }); + + describe('With merge trains disabled', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('does not show a warning on the dropdown', () => { + const warning = findMergeTrainWarning(); + + expect(warning.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js new file mode 100644 index 00000000000..0396029cdaf --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mini_list_spec.js @@ -0,0 +1,166 @@ +import { mount } from '@vue/test-utils'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LinkedPipelinesMiniList from '~/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue'; +import mockData from './linked_pipelines_mock_data'; + +describe('Linked pipeline mini list', () => { + let wrapper; + + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findCiIcons = () => wrapper.findAllComponents(CiIcon); + const findLinkedPipelineCounter = () => wrapper.find('[data-testid="linked-pipeline-counter"]'); + const findLinkedPipelineMiniItem = () => + wrapper.find('[data-testid="linked-pipeline-mini-item"]'); + const findLinkedPipelineMiniItems = () => + wrapper.findAll('[data-testid="linked-pipeline-mini-item"]'); + const findLinkedPipelineMiniList = () => wrapper.findComponent(LinkedPipelinesMiniList); + + const createComponent = (props = {}) => { + wrapper = mount(LinkedPipelinesMiniList, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + ...props, + }, + }); + }; + + describe('when passed an upstream pipeline as prop', () => { + beforeEach(() => { + createComponent({ + triggeredBy: [mockData.triggered_by], + }); + }); + + it('should render one linked pipeline item', () => { + expect(findLinkedPipelineMiniItem().exists()).toBe(true); + }); + + it('should render a linked pipeline with the correct href', () => { + expect(findLinkedPipelineMiniItem().exists()).toBe(true); + + expect(findLinkedPipelineMiniItem().attributes('href')).toBe( + '/gitlab-org/gitlab-foss/-/pipelines/129', + ); + }); + + it('should render one ci status icon', () => { + expect(findCiIcon().exists()).toBe(true); + }); + + it('should render a borderless ci-icon', () => { + expect(findCiIcon().exists()).toBe(true); + + expect(findCiIcon().props('isBorderless')).toBe(true); + expect(findCiIcon().classes('borderless')).toBe(true); + }); + + it('should render a ci-icon with a custom border class', () => { + expect(findCiIcon().exists()).toBe(true); + + expect(findCiIcon().classes('gl-border')).toBe(true); + }); + + it('should render the correct ci status icon', () => { + expect(findCiIcon().classes('ci-status-icon-running')).toBe(true); + }); + + it('should have an activated tooltip', () => { + expect(findLinkedPipelineMiniItem().exists()).toBe(true); + const tooltip = getBinding(findLinkedPipelineMiniItem().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe('GitLabCE - running'); + }); + + it('should correctly set is-upstream', () => { + expect(findLinkedPipelineMiniList().exists()).toBe(true); + + expect(findLinkedPipelineMiniList().classes('is-upstream')).toBe(true); + }); + + it('should correctly compute shouldRenderCounter', () => { + expect(findLinkedPipelineMiniList().vm.shouldRenderCounter).toBe(false); + }); + + it('should not render the pipeline counter', () => { + expect(findLinkedPipelineCounter().exists()).toBe(false); + }); + }); + + describe('when passed downstream pipelines as props', () => { + beforeEach(() => { + createComponent({ + triggered: mockData.triggered, + pipelinePath: 'my/pipeline/path', + }); + }); + + it('should render three linked pipeline items', () => { + expect(findLinkedPipelineMiniItems().exists()).toBe(true); + expect(findLinkedPipelineMiniItems().length).toBe(3); + }); + + it('should render three ci status icons', () => { + expect(findCiIcons().exists()).toBe(true); + expect(findCiIcons().length).toBe(3); + }); + + it('should render the correct ci status icon', () => { + expect(findCiIcon().classes('ci-status-icon-running')).toBe(true); + }); + + it('should have an activated tooltip', () => { + expect(findLinkedPipelineMiniItem().exists()).toBe(true); + const tooltip = getBinding(findLinkedPipelineMiniItem().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe('GitLabCE - running'); + }); + + it('should correctly set is-downstream', () => { + expect(findLinkedPipelineMiniList().exists()).toBe(true); + + expect(findLinkedPipelineMiniList().classes('is-downstream')).toBe(true); + }); + + it('should render a borderless ci-icon', () => { + expect(findCiIcon().exists()).toBe(true); + + expect(findCiIcon().props('isBorderless')).toBe(true); + expect(findCiIcon().classes('borderless')).toBe(true); + }); + + it('should render a ci-icon with a custom border class', () => { + expect(findCiIcon().exists()).toBe(true); + + expect(findCiIcon().classes('gl-border')).toBe(true); + }); + + it('should render the pipeline counter', () => { + expect(findLinkedPipelineCounter().exists()).toBe(true); + }); + + it('should correctly compute shouldRenderCounter', () => { + expect(findLinkedPipelineMiniList().vm.shouldRenderCounter).toBe(true); + }); + + it('should correctly trim linkedPipelines', () => { + expect(findLinkedPipelineMiniList().props('triggered').length).toBe(6); + expect(findLinkedPipelineMiniList().vm.linkedPipelinesTrimmed.length).toBe(3); + }); + + it('should set the correct pipeline path', () => { + expect(findLinkedPipelineCounter().exists()).toBe(true); + + expect(findLinkedPipelineCounter().attributes('href')).toBe('my/pipeline/path'); + }); + + it('should render the correct counterTooltipText', () => { + expect(findLinkedPipelineCounter().exists()).toBe(true); + const tooltip = getBinding(findLinkedPipelineCounter().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(findLinkedPipelineMiniList().vm.counterTooltipText); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mock_data.js b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mock_data.js new file mode 100644 index 00000000000..117c7f2ae52 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/linked_pipelines_mock_data.js @@ -0,0 +1,407 @@ +export default { + triggered_by: { + id: 129, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/129', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/129', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: '7-5-stable', + path: '/gitlab-org/gitlab-foss/commits/7-5-stable', + tag: false, + branch: true, + }, + commit: { + id: '23433d4d8b20d7e45c103d0b6048faad38a130ab', + short_id: '23433d4d', + title: 'Version 7.5.0.rc1', + created_at: '2014-11-17T15:44:14.000+01:00', + parent_ids: ['30ac909f30f58d319b42ed1537664483894b18cd'], + message: 'Version 7.5.0.rc1\n', + author_name: 'Jacob Vosmaer', + author_email: 'contact@jacobvosmaer.nl', + authored_date: '2014-11-17T15:44:14.000+01:00', + committer_name: 'Jacob Vosmaer', + committer_email: 'contact@jacobvosmaer.nl', + committed_date: '2014-11-17T15:44:14.000+01:00', + author_gravatar_url: + 'http://www.gravatar.com/avatar/e66d11c0eedf8c07b3b18fca46599807?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/23433d4d8b20d7e45c103d0b6048faad38a130ab', + commit_path: '/gitlab-org/gitlab-foss/commit/23433d4d8b20d7e45c103d0b6048faad38a130ab', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/129/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/129/cancel', + created_at: '2017-05-24T14:46:20.090Z', + updated_at: '2017-05-24T14:46:29.906Z', + }, + triggered: [ + { + id: 132, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/132', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/132', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: 'b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + short_id: 'b9d58c4c', + title: 'getting user keys publically through http without any authentication, the github…', + created_at: '2013-10-03T12:50:33.000+05:30', + parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], + message: + 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n\nchangelog updated to include ssh key retrieval feature update\n', + author_name: 'devaroop', + author_email: 'devaroop123@yahoo.co.in', + authored_date: '2013-10-02T20:39:29.000+05:30', + committer_name: 'devaroop', + committer_email: 'devaroop123@yahoo.co.in', + committed_date: '2013-10-03T12:50:33.000+05:30', + author_gravatar_url: + 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + commit_path: '/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/132/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/132/cancel', + created_at: '2017-05-24T14:46:24.644Z', + updated_at: '2017-05-24T14:48:55.226Z', + }, + { + id: 133, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/133', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/133', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: 'b6bd4856a33df3d144be66c4ed1f1396009bb08b', + short_id: 'b6bd4856', + title: 'getting user keys publically through http without any authentication, the github…', + created_at: '2013-10-02T20:39:29.000+05:30', + parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], + message: + 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n', + author_name: 'devaroop', + author_email: 'devaroop123@yahoo.co.in', + authored_date: '2013-10-02T20:39:29.000+05:30', + committer_name: 'devaroop', + committer_email: 'devaroop123@yahoo.co.in', + committed_date: '2013-10-02T20:39:29.000+05:30', + author_gravatar_url: + 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', + commit_path: '/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/133/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/133/cancel', + created_at: '2017-05-24T14:46:24.648Z', + updated_at: '2017-05-24T14:48:59.673Z', + }, + { + id: 130, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/130', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/130', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: '6d7ced4a2311eeff037c5575cca1868a6d3f586f', + short_id: '6d7ced4a', + title: 'Whitespace fixes to patch', + created_at: '2013-10-08T13:53:22.000-05:00', + parent_ids: ['1875141a963a4238bda29011d8f7105839485253'], + message: 'Whitespace fixes to patch\n', + author_name: 'Dale Hamel', + author_email: 'dale.hamel@srvthe.net', + authored_date: '2013-10-08T13:53:22.000-05:00', + committer_name: 'Dale Hamel', + committer_email: 'dale.hamel@invenia.ca', + committed_date: '2013-10-08T13:53:22.000-05:00', + author_gravatar_url: + 'http://www.gravatar.com/avatar/cd08930e69fa5ad1a669206e7bafe476?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', + commit_path: '/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/130/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/130/cancel', + created_at: '2017-05-24T14:46:24.630Z', + updated_at: '2017-05-24T14:49:45.091Z', + }, + { + id: 131, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/132', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/132', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: 'b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + short_id: 'b9d58c4c', + title: 'getting user keys publically through http without any authentication, the github…', + created_at: '2013-10-03T12:50:33.000+05:30', + parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], + message: + 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n\nchangelog updated to include ssh key retrieval feature update\n', + author_name: 'devaroop', + author_email: 'devaroop123@yahoo.co.in', + authored_date: '2013-10-02T20:39:29.000+05:30', + committer_name: 'devaroop', + committer_email: 'devaroop123@yahoo.co.in', + committed_date: '2013-10-03T12:50:33.000+05:30', + author_gravatar_url: + 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + commit_path: '/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/132/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/132/cancel', + created_at: '2017-05-24T14:46:24.644Z', + updated_at: '2017-05-24T14:48:55.226Z', + }, + { + id: 134, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/133', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/133', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: 'b6bd4856a33df3d144be66c4ed1f1396009bb08b', + short_id: 'b6bd4856', + title: 'getting user keys publically through http without any authentication, the github…', + created_at: '2013-10-02T20:39:29.000+05:30', + parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], + message: + 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n', + author_name: 'devaroop', + author_email: 'devaroop123@yahoo.co.in', + authored_date: '2013-10-02T20:39:29.000+05:30', + committer_name: 'devaroop', + committer_email: 'devaroop123@yahoo.co.in', + committed_date: '2013-10-02T20:39:29.000+05:30', + author_gravatar_url: + 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', + commit_path: '/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/133/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/133/cancel', + created_at: '2017-05-24T14:46:24.648Z', + updated_at: '2017-05-24T14:48:59.673Z', + }, + { + id: 135, + active: true, + path: '/gitlab-org/gitlab-foss/-/pipelines/130', + project: { + name: 'GitLabCE', + }, + details: { + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + has_details: true, + details_path: '/gitlab-org/gitlab-foss/-/pipelines/130', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + }, + flags: { + latest: false, + triggered: false, + stuck: false, + yaml_errors: false, + retryable: true, + cancelable: true, + }, + ref: { + name: 'crowd', + path: '/gitlab-org/gitlab-foss/commits/crowd', + tag: false, + branch: true, + }, + commit: { + id: '6d7ced4a2311eeff037c5575cca1868a6d3f586f', + short_id: '6d7ced4a', + title: 'Whitespace fixes to patch', + created_at: '2013-10-08T13:53:22.000-05:00', + parent_ids: ['1875141a963a4238bda29011d8f7105839485253'], + message: 'Whitespace fixes to patch\n', + author_name: 'Dale Hamel', + author_email: 'dale.hamel@srvthe.net', + authored_date: '2013-10-08T13:53:22.000-05:00', + committer_name: 'Dale Hamel', + committer_email: 'dale.hamel@invenia.ca', + committed_date: '2013-10-08T13:53:22.000-05:00', + author_gravatar_url: + 'http://www.gravatar.com/avatar/cd08930e69fa5ad1a669206e7bafe476?s=80&d=identicon', + commit_url: + 'http://localhost:3000/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', + commit_path: '/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', + }, + retry_path: '/gitlab-org/gitlab-foss/-/pipelines/130/retry', + cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/130/cancel', + created_at: '2017-05-24T14:46:24.630Z', + updated_at: '2017-05-24T14:49:45.091Z', + }, + ], +}; diff --git a/spec/frontend/ci/pipeline_mini_graph/mock_data.js b/spec/frontend/ci/pipeline_mini_graph/mock_data.js new file mode 100644 index 00000000000..231375b40dd --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/mock_data.js @@ -0,0 +1,252 @@ +export const mockDownstreamPipelinesGraphql = ({ includeSourceJobRetried = true } = {}) => ({ + nodes: [ + { + id: 'gid://gitlab/Ci::Pipeline/612', + path: '/root/job-log-sections/-/pipelines/612', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-612-612', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/532', + retried: includeSourceJobRetried ? false : null, + }, + __typename: 'Pipeline', + }, + { + id: 'gid://gitlab/Ci::Pipeline/611', + path: '/root/job-log-sections/-/pipelines/611', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-611-611', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/531', + retried: includeSourceJobRetried ? true : null, + }, + __typename: 'Pipeline', + }, + { + id: 'gid://gitlab/Ci::Pipeline/609', + path: '/root/job-log-sections/-/pipelines/609', + project: { + id: 'gid://gitlab/Project/21', + name: 'job-log-sections', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-609-609', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + sourceJob: { + id: 'gid://gitlab/Ci::Bridge/530', + retried: includeSourceJobRetried ? true : null, + }, + __typename: 'Pipeline', + }, + ], + __typename: 'PipelineConnection', +}); + +const upstream = { + id: 'gid://gitlab/Ci::Pipeline/610', + path: '/root/trigger-downstream/-/pipelines/610', + project: { + id: 'gid://gitlab/Project/21', + name: 'trigger-downstream', + __typename: 'Project', + }, + detailedStatus: { + id: 'success-610-610', + group: 'success', + icon: 'status_success', + label: 'passed', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', +}; + +export const mockPipelineStagesQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + stages: { + nodes: [ + { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/409', + name: 'build', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-409-409', + icon: 'status_success', + group: 'success', + }, + }, + ], + }, + }, + }, + }, +}; + +export const mockPipelineStatusResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + detailedStatus: { + id: 'pending-320-320', + detailsPath: '/root/ci-project/-/pipelines/320', + icon: 'status_pending', + group: 'pending', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; + +export const mockUpstreamDownstreamQueryResponse = { + data: { + project: { + id: '1', + pipeline: { + id: 'pipeline-1', + path: '/root/ci-project/-/pipelines/790', + downstream: mockDownstreamPipelinesGraphql(), + upstream, + }, + __typename: 'Project', + }, + }, +}; + +export const linkedPipelinesFetchError = 'There was a problem fetching linked pipelines.'; +export const stagesFetchError = 'There was a problem fetching the pipeline stages.'; + +export const stageReply = { + name: 'deploy', + title: 'deploy: running', + latest_statuses: [ + { + id: 928, + name: 'stop staging', + started: false, + build_path: '/twitter/flight/-/jobs/928', + cancel_path: '/twitter/flight/-/jobs/928/cancel', + playable: false, + created_at: '2018-04-04T20:02:02.728Z', + updated_at: '2018-04-04T20:02:02.766Z', + status: { + icon: 'status_pending', + text: 'pending', + label: 'pending', + group: 'pending', + tooltip: 'pending', + has_details: true, + details_path: '/twitter/flight/-/jobs/928', + favicon: + '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico', + action: { + icon: 'cancel', + title: 'Cancel', + path: '/twitter/flight/-/jobs/928/cancel', + method: 'post', + }, + }, + }, + { + id: 926, + name: 'production', + started: false, + build_path: '/twitter/flight/-/jobs/926', + retry_path: '/twitter/flight/-/jobs/926/retry', + play_path: '/twitter/flight/-/jobs/926/play', + playable: true, + created_at: '2018-04-04T20:00:57.202Z', + updated_at: '2018-04-04T20:11:13.110Z', + status: { + icon: 'status_canceled', + text: 'canceled', + label: 'manual play action', + group: 'canceled', + tooltip: 'canceled', + has_details: true, + details_path: '/twitter/flight/-/jobs/926', + favicon: + '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico', + action: { + icon: 'play', + title: 'Play', + path: '/twitter/flight/-/jobs/926/play', + method: 'post', + }, + }, + }, + { + id: 217, + name: 'staging', + started: '2018-03-07T08:41:46.234Z', + build_path: '/twitter/flight/-/jobs/217', + retry_path: '/twitter/flight/-/jobs/217/retry', + playable: false, + created_at: '2018-03-07T14:41:58.093Z', + updated_at: '2018-03-07T14:41:58.093Z', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/twitter/flight/-/jobs/217', + favicon: + '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', + action: { + icon: 'retry', + title: 'Retry', + path: '/twitter/flight/-/jobs/217/retry', + method: 'post', + }, + }, + }, + ], + status: { + icon: 'status_running', + text: 'running', + label: 'running', + group: 'running', + tooltip: 'running', + has_details: true, + details_path: '/twitter/flight/pipelines/13#deploy', + favicon: + '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', + }, + path: '/twitter/flight/pipelines/13#deploy', + dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy', +}; diff --git a/spec/frontend/ci/pipeline_mini_graph/pipeline_mini_graph_spec.js b/spec/frontend/ci/pipeline_mini_graph/pipeline_mini_graph_spec.js new file mode 100644 index 00000000000..6833726a297 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/pipeline_mini_graph_spec.js @@ -0,0 +1,123 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; + +import { createAlert } from '~/alert'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; +import * as sharedGraphQlUtils from '~/graphql_shared/utils'; + +import { + linkedPipelinesFetchError, + stagesFetchError, + mockPipelineStagesQueryResponse, + mockUpstreamDownstreamQueryResponse, +} from './mock_data'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +describe('PipelineMiniGraph', () => { + let wrapper; + let linkedPipelinesResponse; + let pipelineStagesResponse; + + const fullPath = 'gitlab-org/gitlab'; + const iid = '315'; + const pipelineEtag = '/api/graphql:pipelines/id/315'; + + const createComponent = ({ + pipelineStagesHandler = pipelineStagesResponse, + linkedPipelinesHandler = linkedPipelinesResponse, + } = {}) => { + const handlers = [ + [getLinkedPipelinesQuery, linkedPipelinesHandler], + [getPipelineStagesQuery, pipelineStagesHandler], + ]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(PipelineMiniGraph, { + propsData: { + fullPath, + iid, + pipelineEtag, + }, + apolloProvider: mockApollo, + }); + + return waitForPromises(); + }; + + const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(() => { + linkedPipelinesResponse = jest.fn().mockResolvedValue(mockUpstreamDownstreamQueryResponse); + pipelineStagesResponse = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse); + }); + + describe('when initial queries are loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows a loading icon and no mini graph', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findLegacyPipelineMiniGraph().exists()).toBe(false); + }); + }); + + describe('when queries have loaded', () => { + it('does not show a loading icon', async () => { + await createComponent(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders the Pipeline Mini Graph', async () => { + await createComponent(); + + expect(findLegacyPipelineMiniGraph().exists()).toBe(true); + }); + + it('fires the queries', async () => { + await createComponent(); + + expect(linkedPipelinesResponse).toHaveBeenCalledWith({ iid, fullPath }); + expect(pipelineStagesResponse).toHaveBeenCalledWith({ iid, fullPath }); + }); + }); + + describe('polling', () => { + it('toggles query polling with visibility check', async () => { + jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility'); + + createComponent(); + + await waitForPromises(); + + expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledTimes(2); + }); + }); + + describe('when pipeline queries are unsuccessful', () => { + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + it.each` + query | handlerName | errorMessage + ${'pipeline stages'} | ${'pipelineStagesHandler'} | ${stagesFetchError} + ${'linked pipelines'} | ${'linkedPipelinesHandler'} | ${linkedPipelinesFetchError} + `('throws an error for the $query query', async ({ errorMessage, handlerName }) => { + await createComponent({ [handlerName]: failedHandler }); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/pipeline_stage_spec.js b/spec/frontend/ci/pipeline_mini_graph/pipeline_stage_spec.js new file mode 100644 index 00000000000..96966bcbb84 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/pipeline_stage_spec.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +import getPipelineStageQuery from '~/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stage.query.graphql'; +import PipelineStage from '~/ci/pipeline_mini_graph/pipeline_stage.vue'; + +Vue.use(VueApollo); + +describe('PipelineStage', () => { + let wrapper; + let pipelineStageResponse; + + const defaultProps = { + pipelineEtag: '/etag', + stageId: '1', + }; + + const createComponent = ({ pipelineStageHandler = pipelineStageResponse } = {}) => { + const handlers = [[getPipelineStageQuery, pipelineStageHandler]]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(PipelineStage, { + propsData: { + ...defaultProps, + }, + apolloProvider: mockApollo, + }); + + return waitForPromises(); + }; + + const findPipelineStage = () => wrapper.findComponent(PipelineStage); + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders job item', () => { + expect(findPipelineStage().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_mini_graph/pipeline_stages_spec.js b/spec/frontend/ci/pipeline_mini_graph/pipeline_stages_spec.js new file mode 100644 index 00000000000..bbd39c6fcd9 --- /dev/null +++ b/spec/frontend/ci/pipeline_mini_graph/pipeline_stages_spec.js @@ -0,0 +1,63 @@ +import { shallowMount } from '@vue/test-utils'; +import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; +import LegacyPipelineStage from '~/ci/pipeline_mini_graph/legacy_pipeline_stage.vue'; +import PipelineStages from '~/ci/pipeline_mini_graph/pipeline_stages.vue'; + +const mockStages = pipelines[0].details.stages; + +describe('Pipeline Stages', () => { + let wrapper; + + const findLegacyPipelineStages = () => wrapper.findAllComponents(LegacyPipelineStage); + const findPipelineStagesAt = (i) => findLegacyPipelineStages().at(i); + + const createComponent = (props = {}) => { + wrapper = shallowMount(PipelineStages, { + propsData: { + stages: mockStages, + ...props, + }, + }); + }; + + it('renders stages', () => { + createComponent(); + + expect(findLegacyPipelineStages()).toHaveLength(mockStages.length); + }); + + it('does not fail when stages are empty', () => { + createComponent({ stages: [] }); + + expect(wrapper.exists()).toBe(true); + expect(findLegacyPipelineStages()).toHaveLength(0); + }); + + it('update dropdown is false by default', () => { + createComponent(); + + expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(false); + expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(false); + }); + + it('update dropdown is set to true', () => { + createComponent({ updateDropdown: true }); + + expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(true); + expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(true); + }); + + it('is merge train is false by default', () => { + createComponent(); + + expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(false); + expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(false); + }); + + it('is merge train is set to true', () => { + createComponent({ isMergeTrain: true }); + + expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(true); + expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(true); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js index 79a0cfa0dc9..33cf24c9ed1 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js @@ -97,6 +97,7 @@ describe('Pipeline schedules form', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); // Variables const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findVariableTypes = () => wrapper.findAllByTestId('pipeline-form-ci-variable-type'); const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); const findHiddenValueInputs = () => @@ -182,6 +183,16 @@ describe('Pipeline schedules form', () => { mock.restore(); }); + it('changes variable type', async () => { + expect(findVariableTypes().at(0).props('selected')).toBe('ENV_VAR'); + + findVariableTypes().at(0).vm.$emit('select', 'FILE'); + + await nextTick(); + + expect(findVariableTypes().at(0).props('selected')).toBe('FILE'); + }); + it('creates blank variable on input change event', async () => { expect(findVariableRows()).toHaveLength(1); diff --git a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js index 5cc3829efbd..70b4c7a5224 100644 --- a/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target_spec.js @@ -1,5 +1,6 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { s__ } from '~/locale'; import PipelineScheduleTarget from '~/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue'; import { mockPipelineScheduleNodes } from '../../../mock_data'; @@ -20,18 +21,35 @@ describe('Pipeline schedule target', () => { const findIcon = () => wrapper.findComponent(GlIcon); const findLink = () => wrapper.findComponent(GlLink); + const findTarget = () => wrapper.findComponent('[data-testid="pipeline-schedule-target"]'); - beforeEach(() => { - createComponent(); - }); + describe('with ref', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays icon', () => { + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe('fork'); + }); - it('displays icon', () => { - expect(findIcon().exists()).toBe(true); - expect(findIcon().props('name')).toBe('fork'); + it('displays ref link', () => { + expect(findLink().attributes('href')).toBe(defaultProps.schedule.refPath); + expect(findLink().text()).toBe(defaultProps.schedule.refForDisplay); + }); }); - it('displays ref link', () => { - expect(findLink().attributes('href')).toBe(defaultProps.schedule.refPath); - expect(findLink().text()).toBe(defaultProps.schedule.refForDisplay); + describe('without refPath', () => { + beforeEach(() => { + createComponent({ + schedule: { ...mockPipelineScheduleNodes[0], refPath: null, refForDisplay: null }, + }); + }); + + it('displays none for the target', () => { + expect(findIcon().exists()).toBe(false); + expect(findLink().exists()).toBe(false); + expect(findTarget().text()).toBe(s__('PipelineSchedules|None')); + }); }); }); diff --git a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js b/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js deleted file mode 100644 index e4ff9a0545b..00000000000 --- a/spec/frontend/ci/pipeline_schedules/components/take_ownership_modal_legacy_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import TakeOwnershipModalLegacy from '~/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue'; - -describe('Take ownership modal', () => { - let wrapper; - const url = `/root/job-log-tester/-/pipeline_schedules/3/take_ownership`; - - const createComponent = (props = {}) => { - wrapper = shallowMountExtended(TakeOwnershipModalLegacy, { - propsData: { - ownershipUrl: url, - ...props, - }, - }); - }; - - const findModal = () => wrapper.findComponent(GlModal); - - beforeEach(() => { - createComponent(); - }); - - it('has a primary action set to a url and a post data-method', () => { - const actionPrimary = findModal().props('actionPrimary'); - - expect(actionPrimary.attributes).toEqual( - expect.objectContaining({ - category: 'primary', - variant: 'confirm', - href: url, - 'data-method': 'post', - }), - ); - }); - - it('shows a take ownership message', () => { - expect(findModal().text()).toBe( - 'Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?', - ); - }); -}); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 8d4e0f1bea6..711b120c61e 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -1,8 +1,8 @@ // Fixture located at spec/frontend/fixtures/pipeline_schedules.rb +import mockGetSinglePipelineScheduleGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.single.json'; import mockGetPipelineSchedulesGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.json'; import mockGetPipelineSchedulesAsGuestGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.as_guest.json'; import mockGetPipelineSchedulesTakeOwnershipGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.take_ownership.json'; -import mockGetSinglePipelineScheduleGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.single.json'; const { data: { diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js new file mode 100644 index 00000000000..980a8be24ea --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js @@ -0,0 +1,107 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const suggestedCiTemplates = [ + { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, + { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, + { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, +]; + +describe('CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(CiTemplates, { + provide: { + pipelineEditorPath, + suggestedCiTemplates, + }, + propsData, + }); + }; + + const findTemplateDescription = () => wrapper.findByTestId('template-description'); + const findTemplateLink = () => wrapper.findByTestId('template-link'); + const findTemplateNames = () => wrapper.findAllByTestId('template-name'); + const findTemplateName = () => wrapper.findByTestId('template-name'); + const findTemplateLogo = () => wrapper.findByTestId('template-logo'); + + describe('renders template list', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders all suggested templates', () => { + expect(findTemplateNames().length).toBe(3); + expect(wrapper.text()).toContain('Android', 'Bash', 'C++'); + }); + + it('has the correct template name', () => { + expect(findTemplateName().text()).toBe('Android'); + }); + + it('links to the correct template', () => { + expect(findTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Android'), + ); + }); + + it('has the link button enabled', () => { + expect(findTemplateLink().props('disabled')).toBe(false); + }); + + it('has the description of the template', () => { + expect(findTemplateDescription().text()).toBe( + 'Continuous integration and deployment template to test and deploy your Android project.', + ); + }); + + it('has the right logo of the template', () => { + expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg'); + }); + }); + + describe('filtering the templates', () => { + beforeEach(() => { + createWrapper({ filterTemplates: ['Bash'] }); + }); + + it('renders only the filtered templates', () => { + expect(findTemplateNames()).toHaveLength(1); + expect(findTemplateName().text()).toBe('Bash'); + }); + }); + + describe('disabling the templates', () => { + beforeEach(() => { + createWrapper({ disabled: true }); + }); + + it('has the link button disabled', () => { + expect(findTemplateLink().props('disabled')).toBe(true); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when template is clicked', () => { + findTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Android', + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js new file mode 100644 index 00000000000..8620d41886e --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js @@ -0,0 +1,133 @@ +import '~/commons'; +import { nextTick } from 'vue'; +import { GlPopover, GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import IosTemplates from '~/ci/pipelines_page/components/empty_state/ios_templates.vue'; +import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const registrationToken = 'SECRET_TOKEN'; +const iOSTemplateName = 'iOS-Fastlane'; + +describe('iOS Templates', () => { + let wrapper; + + const createWrapper = (providedPropsData = {}) => { + return shallowMountExtended(IosTemplates, { + provide: { + pipelineEditorPath, + iosRunnersAvailable: true, + ...providedPropsData, + }, + propsData: { + registrationToken, + }, + stubs: { + GlButton, + }, + }); + }; + + const findIosTemplate = () => wrapper.findComponent(CiTemplates); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + const findRunnerInstructionsPopover = () => wrapper.findComponent(GlPopover); + const findRunnerSetupTodoEmoji = () => wrapper.findByTestId('runner-setup-marked-todo'); + const findRunnerSetupCompletedEmoji = () => wrapper.findByTestId('runner-setup-marked-completed'); + const findSetupRunnerLink = () => wrapper.findByText('Set up a runner'); + const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link'); + + describe('when ios runners are not available', () => { + beforeEach(() => { + wrapper = createWrapper({ iosRunnersAvailable: false }); + }); + + describe('the runner setup section', () => { + it('marks the section as todo', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(true); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(false); + }); + + it('renders the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(true); + }); + + it('renders the runner instructions modal with a popover once clicked', async () => { + findSetupRunnerLink().element.parentElement.click(); + + await nextTick(); + + expect(findRunnerInstructionsModal().exists()).toBe(true); + expect(findRunnerInstructionsModal().props('registrationToken')).toBe(registrationToken); + expect(findRunnerInstructionsModal().props('defaultPlatformName')).toBe('osx'); + + findRunnerInstructionsModal().vm.$emit('shown'); + + await nextTick(); + + expect(findRunnerInstructionsPopover().exists()).toBe(true); + }); + }); + + describe('the configure pipeline section', () => { + it('has a disabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(true); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has a disabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(true); + }); + }); + }); + + describe('when ios runners are available', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + describe('the runner setup section', () => { + it('marks the section as completed', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(false); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(true); + }); + + it('does not render the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(false); + }); + }); + + describe('the configure pipeline section', () => { + it('has an enabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has an enabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js new file mode 100644 index 00000000000..0c42723f753 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js @@ -0,0 +1,87 @@ +import '~/commons'; +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import { stubExperiments } from 'helpers/experimentation_helper'; +import EmptyState from '~/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; +import IosTemplates from '~/ci/pipelines_page/components/empty_state/ios_templates.vue'; + +describe('Pipelines Empty State', () => { + let wrapper; + + const findIllustration = () => wrapper.find('img'); + const findButton = () => wrapper.find('a'); + const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates); + const iosTemplates = () => wrapper.findComponent(IosTemplates); + + const createWrapper = (props = {}) => { + wrapper = shallowMount(EmptyState, { + provide: { + pipelineEditorPath: '', + suggestedCiTemplates: [], + anyRunnersAvailable: true, + ciRunnerSettingsPath: '', + }, + propsData: { + emptyStateSvgPath: 'foo.svg', + canSetCi: true, + ...props, + }, + stubs: { + GlEmptyState, + GitlabExperiment, + }, + }); + }; + + describe('when user can configure CI', () => { + describe('when the ios_specific_templates experiment is active', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'candidate' }); + createWrapper(); + }); + + it('should render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(true); + }); + + it('should not render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(false); + }); + }); + + describe('when the ios_specific_templates experiment is inactive', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'control' }); + createWrapper(); + }); + + it('should render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(true); + }); + + it('should not render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(false); + }); + }); + }); + + describe('when user cannot configure CI', () => { + beforeEach(() => { + createWrapper({ canSetCi: false }); + }); + + it('should render empty state SVG', () => { + expect(findIllustration().attributes('src')).toBe('foo.svg'); + }); + + it('should render empty state header', () => { + expect(wrapper.text()).toBe('This project is not currently set up to run pipelines.'); + }); + + it('should not render a link', () => { + expect(findButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js new file mode 100644 index 00000000000..fbef4aa08eb --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js @@ -0,0 +1,58 @@ +import '~/commons'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; +import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; + +describe('Pipelines CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = (propsData = {}, stubs = {}) => { + return shallowMountExtended(PipelinesCiTemplates, { + provide: { + pipelineEditorPath, + ...propsData, + }, + stubs, + }); + }; + + const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); + const findCiTemplates = () => wrapper.findComponent(CiTemplates); + + describe('templates', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders test template and Ci templates', () => { + expect(findTestTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Getting-Started'), + ); + expect(findCiTemplates().exists()).toBe(true); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + wrapper = createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when Getting-Started template is clicked', () => { + findTestTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Getting-Started', + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js new file mode 100644 index 00000000000..6967a369338 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js @@ -0,0 +1,254 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlIcon, GlLink } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue'; +import RetryMrFailedJobMutation from '~/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql'; +import { BRIDGE_KIND } from '~/ci/pipeline_details/graph/constants'; +import { job } from './mock'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +const createFakeEvent = () => ({ stopPropagation: jest.fn() }); + +describe('FailedJobDetails component', () => { + let wrapper; + let mockRetryResponse; + + const retrySuccessResponse = { + data: { + jobRetry: { + errors: [], + }, + }, + }; + + const defaultProps = { + job, + }; + + const createComponent = ({ props = {} } = {}) => { + const handlers = [[RetryMrFailedJobMutation, mockRetryResponse]]; + + wrapper = shallowMountExtended(FailedJobDetails, { + propsData: { + ...defaultProps, + ...props, + }, + apolloProvider: createMockApollo(handlers), + }); + }; + + const findArrowIcon = () => wrapper.findComponent(GlIcon); + const findJobId = () => wrapper.findComponent(GlLink); + const findJobLog = () => wrapper.findByTestId('job-log'); + const findJobName = () => wrapper.findByText(defaultProps.job.name); + const findRetryButton = () => wrapper.findByLabelText('Retry'); + const findRow = () => wrapper.findByTestId('widget-row'); + const findStageName = () => wrapper.findByText(defaultProps.job.stage.name); + + beforeEach(() => { + mockRetryResponse = jest.fn(); + mockRetryResponse.mockResolvedValue(retrySuccessResponse); + }); + + describe('ui', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the job name', () => { + expect(findJobName().exists()).toBe(true); + }); + + it('renders the stage name', () => { + expect(findStageName().exists()).toBe(true); + }); + + it('renders the job id as a link', () => { + const jobId = getIdFromGraphQLId(defaultProps.job.id); + + expect(findJobId().exists()).toBe(true); + expect(findJobId().text()).toContain(String(jobId)); + }); + + it('does not renders the job lob', () => { + expect(findJobLog().exists()).toBe(false); + }); + }); + + describe('Retry action', () => { + describe('when the job is not retryable', () => { + beforeEach(() => { + createComponent({ props: { job: { ...job, retryable: false } } }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + + describe('when the job is a bridge', () => { + beforeEach(() => { + createComponent({ props: { job: { ...job, kind: BRIDGE_KIND } } }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + + describe('when the job is retryable', () => { + describe('and user has permission to update the build', () => { + beforeEach(() => { + createComponent(); + }); + + it('enables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(false); + }); + + describe('when clicking on the retry button', () => { + it('passes the loading state to the button', async () => { + await findRetryButton().vm.$emit('click', createFakeEvent()); + + expect(findRetryButton().props().loading).toBe(true); + }); + + describe('and it succeeds', () => { + beforeEach(async () => { + findRetryButton().vm.$emit('click', createFakeEvent()); + await waitForPromises(); + }); + + it('is no longer loading', () => { + expect(findRetryButton().props().loading).toBe(false); + }); + + it('calls the retry mutation', () => { + expect(mockRetryResponse).toHaveBeenCalled(); + expect(mockRetryResponse).toHaveBeenCalledWith({ + id: job.id, + }); + }); + + it('emits the `retried-job` event', () => { + expect(wrapper.emitted('job-retried')).toStrictEqual([[job.name]]); + }); + }); + + describe('and it fails', () => { + const customErrorMsg = 'Custom error message from API'; + + beforeEach(async () => { + mockRetryResponse.mockResolvedValue({ + data: { jobRetry: { errors: [customErrorMsg] } }, + }); + findRetryButton().vm.$emit('click', createFakeEvent()); + + await waitForPromises(); + }); + + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: customErrorMsg }); + }); + + it('does not emits the `refetch-jobs` event', () => { + expect(wrapper.emitted('refetch-jobs')).toBeUndefined(); + }); + }); + }); + }); + + describe('and user does not have permission to update the build', () => { + beforeEach(() => { + createComponent({ + props: { job: { ...job, retryable: true, userPermissions: { updateBuild: false } } }, + }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + }); + }); + + describe('Job log', () => { + describe('without permissions', () => { + beforeEach(async () => { + createComponent({ props: { job: { ...job, userPermissions: { readBuild: false } } } }); + await findRow().trigger('click'); + }); + + it('does not renders the received html of the job log', () => { + expect(findJobLog().html()).not.toContain(defaultProps.job.trace.htmlSummary); + }); + + it('shows a permission error message', () => { + expect(findJobLog().text()).toBe("You do not have permission to read this job's log."); + }); + }); + + describe('with permissions', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when clicking on the row', () => { + beforeEach(async () => { + await findRow().trigger('click'); + }); + + describe('while collapsed', () => { + it('expands the job log', () => { + expect(findJobLog().exists()).toBe(true); + }); + + it('renders the down arrow', () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + }); + + it('renders the received html of the job log', () => { + expect(findJobLog().html()).toContain(defaultProps.job.trace.htmlSummary); + }); + }); + + describe('while expanded', () => { + it('collapes the job log', async () => { + expect(findJobLog().exists()).toBe(true); + + await findRow().trigger('click'); + + expect(findJobLog().exists()).toBe(false); + }); + + it('renders the right arrow', async () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + + await findRow().trigger('click'); + + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + + describe('when clicking on a link element within the row', () => { + it('does not expands/collapse the job log', async () => { + expect(findJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + + await findJobId().vm.$emit('click'); + + expect(findJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js new file mode 100644 index 00000000000..af075b02b64 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js @@ -0,0 +1,279 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { GlLoadingIcon, GlToast } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue'; +import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue'; +import * as utils from '~/ci/pipelines_page/components/failure_widget/utils'; +import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { failedJobsMock, failedJobsMock2, failedJobsMockEmpty, activeFailedJobsMock } from './mock'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +jest.mock('~/alert'); + +describe('FailedJobsList component', () => { + let wrapper; + let mockFailedJobsResponse; + const showToast = jest.fn(); + + const defaultProps = { + failedJobsCount: 0, + graphqlResourceEtag: 'api/graphql', + isPipelineActive: false, + pipelineIid: 1, + projectPath: 'namespace/project/', + }; + + const defaultProvide = { + graphqlPath: 'api/graphql', + }; + + const createComponent = ({ props = {}, provide } = {}) => { + const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(FailedJobsList, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + apolloProvider: mockApollo, + mocks: { + $toast: { + show: showToast, + }, + }, + }); + }; + + const findAllHeaders = () => wrapper.findAllByTestId('header'); + const findFailedJobRows = () => wrapper.findAllComponents(FailedJobDetails); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findNoFailedJobsText = () => wrapper.findByText('No failed jobs in this pipeline 🎉'); + + beforeEach(() => { + mockFailedJobsResponse = jest.fn(); + }); + + describe('on mount', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + createComponent(); + }); + + it('fires the graphql query', () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(mockFailedJobsResponse).toHaveBeenCalledWith({ + fullPath: defaultProps.projectPath, + pipelineIid: defaultProps.pipelineIid, + }); + }); + }); + + describe('when loading failed jobs', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + createComponent(); + }); + + it('shows a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('when failed jobs have loaded', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await waitForPromises(); + }); + + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders table column', () => { + expect(findAllHeaders()).toHaveLength(3); + }); + + it('shows the list of failed jobs', () => { + expect(findFailedJobRows()).toHaveLength( + failedJobsMock.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('does not renders the empty state', () => { + expect(findNoFailedJobsText().exists()).toBe(false); + }); + + it('calls sortJobsByStatus', () => { + expect(utils.sortJobsByStatus).toHaveBeenCalledWith( + failedJobsMock.data.project.pipeline.jobs.nodes, + ); + }); + }); + + describe('when there are no failed jobs', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await waitForPromises(); + }); + + it('renders the empty state', () => { + expect(findNoFailedJobsText().exists()).toBe(true); + }); + }); + + describe('polling', () => { + it.each` + isGraphqlActive | text + ${true} | ${'polls'} + ${false} | ${'does not poll'} + `(`$text when isGraphqlActive: $isGraphqlActive`, async ({ isGraphqlActive }) => { + const defaultCount = 2; + const newCount = 1; + + const expectedCount = isGraphqlActive ? newCount : defaultCount; + const expectedCallCount = isGraphqlActive ? 2 : 1; + const mockResponse = isGraphqlActive ? activeFailedJobsMock : failedJobsMock; + + // Second result is to simulate polling with a different response + mockFailedJobsResponse.mockResolvedValueOnce(mockResponse); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + + createComponent(); + await waitForPromises(); + + // Initially, we get the first response which is always the default + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(findFailedJobRows()).toHaveLength(defaultCount); + + jest.advanceTimersByTime(10000); + await waitForPromises(); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(expectedCallCount); + expect(findFailedJobRows()).toHaveLength(expectedCount); + }); + }); + + describe('when a REST action occurs', () => { + beforeEach(() => { + // Second result is to simulate polling with a different response + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + }); + + it.each([true, false])('triggers a refetch of the jobs count', async (isPipelineActive) => { + const defaultCount = 2; + const newCount = 1; + + createComponent({ props: { isPipelineActive } }); + await waitForPromises(); + + // Initially, we get the first response which is always the default + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(findFailedJobRows()).toHaveLength(defaultCount); + + wrapper.setProps({ isPipelineActive: !isPipelineActive }); + await waitForPromises(); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); + expect(findFailedJobRows()).toHaveLength(newCount); + }); + }); + + describe('When the job count changes from REST', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); + + createComponent(); + }); + + describe('and the count is the same', () => { + it('does not re-fetch the query', async () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ failedJobsCount: 0 }); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + }); + }); + + describe('and the count is different', () => { + it('re-fetches the query', async () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ failedJobsCount: 10 }); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('when an error occurs loading jobs', () => { + const errorMessage = "We couldn't fetch jobs for you because you are not qualified"; + + beforeEach(async () => { + mockFailedJobsResponse.mockRejectedValue({ message: errorMessage }); + + createComponent(); + + await waitForPromises(); + }); + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('calls create Alert with the error message and danger variant', () => { + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' }); + }); + }); + + describe('when `refetch-jobs` job is fired from the widget', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + + createComponent(); + + await waitForPromises(); + }); + + it('refetches all failed jobs', async () => { + expect(findFailedJobRows()).not.toHaveLength( + failedJobsMock2.data.project.pipeline.jobs.nodes.length, + ); + + await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); + await waitForPromises(); + + expect(findFailedJobRows()).toHaveLength( + failedJobsMock2.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('shows a toast message', async () => { + await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); + await waitForPromises(); + + expect(showToast).toHaveBeenCalledWith('job-name job is being retried'); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js b/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js new file mode 100644 index 00000000000..318d787a984 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js @@ -0,0 +1,78 @@ +export const job = { + id: 'gid://gitlab/Ci::Build/5241', + allowFailure: false, + detailedStatus: { + id: 'status', + detailsPath: '/jobs/5241', + action: { + id: 'action', + path: '/retry', + icon: 'retry', + }, + group: 'running', + icon: 'status_running_icon', + }, + name: 'job-name', + retried: false, + retryable: true, + kind: 'BUILD', + stage: { + id: '1', + name: 'build', + }, + trace: { + htmlSummary: '

    Hello

    ', + }, + userPermissions: { + readBuild: true, + updateBuild: true, + }, +}; + +export const allowedToFailJob = { + ...job, + id: 'gid://gitlab/Ci::Build/5242', + allowFailure: true, +}; + +export const createFailedJobsMockCount = ({ count = 4, active = false } = {}) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Pipeline/20', + active, + jobs: { + count, + }, + }, + }, + }, + }; +}; + +const createFailedJobsMock = (nodes, active = false) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + active, + id: 'gid://gitlab/Pipeline/20', + jobs: { + count: nodes.length, + nodes, + }, + }, + }, + }, + }; +}; + +export const failedJobsMock = createFailedJobsMock([allowedToFailJob, job]); +export const failedJobsMockEmpty = createFailedJobsMock([]); + +export const activeFailedJobsMock = createFailedJobsMock([allowedToFailJob, job], true); + +export const failedJobsMock2 = createFailedJobsMock([job]); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js new file mode 100644 index 00000000000..e52b62feb23 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js @@ -0,0 +1,139 @@ +import { GlButton, GlCard, GlIcon, GlPopover } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue'; +import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue'; + +jest.mock('~/alert'); + +describe('PipelineFailedJobsWidget component', () => { + let wrapper; + + const defaultProps = { + failedJobsCount: 4, + isPipelineActive: false, + pipelineIid: 1, + pipelinePath: '/pipelines/1', + projectPath: 'namespace/project/', + }; + + const defaultProvide = { + fullPath: 'namespace/project/', + }; + + const createComponent = ({ props = {}, provide = {} } = {}) => { + wrapper = shallowMountExtended(PipelineFailedJobsWidget, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { GlCard }, + }); + }; + + const findFailedJobsCard = () => wrapper.findByTestId('failed-jobs-card'); + const findFailedJobsButton = () => wrapper.findComponent(GlButton); + const findFailedJobsList = () => wrapper.findAllComponents(FailedJobsList); + const findInfoIcon = () => wrapper.findComponent(GlIcon); + const findInfoPopover = () => wrapper.findComponent(GlPopover); + + describe('when there are no failed jobs', () => { + beforeEach(() => { + createComponent({ props: { failedJobsCount: 0 } }); + }); + + it('renders the show failed jobs button with a count of 0', () => { + expect(findFailedJobsButton().exists()).toBe(true); + expect(findFailedJobsButton().text()).toBe('Failed jobs (0)'); + }); + }); + + describe('when there are failed jobs', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the show failed jobs button with correct count', () => { + expect(findFailedJobsButton().exists()).toBe(true); + expect(findFailedJobsButton().text()).toBe(`Failed jobs (${defaultProps.failedJobsCount})`); + }); + + it('renders the info icon', () => { + expect(findInfoIcon().exists()).toBe(true); + }); + + it('renders the info popover', () => { + expect(findInfoPopover().exists()).toBe(true); + }); + + it('does not render the failed jobs widget', () => { + expect(findFailedJobsList().exists()).toBe(false); + }); + }); + + describe('when the job button is clicked', () => { + beforeEach(async () => { + createComponent(); + await findFailedJobsButton().vm.$emit('click'); + }); + + it('renders the failed jobs widget', () => { + expect(findFailedJobsList().exists()).toBe(true); + }); + + it('removes the CSS border classes', () => { + expect(findFailedJobsCard().attributes('class')).not.toContain( + 'gl-border-white gl-hover-border-gray-100', + ); + }); + }); + + describe('when the job details are not expanded', () => { + beforeEach(() => { + createComponent(); + }); + + it('has the CSS border classes', () => { + expect(findFailedJobsCard().attributes('class')).toContain( + 'gl-border-white gl-hover-border-gray-100', + ); + }); + }); + + describe('when the job count changes', () => { + beforeEach(() => { + createComponent(); + }); + + describe('from the prop', () => { + it('updates the job count', async () => { + const newJobCount = 12; + + expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); + + await wrapper.setProps({ failedJobsCount: newJobCount }); + + expect(findFailedJobsButton().text()).toContain(String(newJobCount)); + }); + }); + + describe('from the event', () => { + beforeEach(async () => { + await findFailedJobsButton().vm.$emit('click'); + }); + + it('updates the job count', async () => { + const newJobCount = 12; + + expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); + + await findFailedJobsList().at(0).vm.$emit('failed-jobs-count', newJobCount); + + expect(findFailedJobsButton().text()).toContain(String(newJobCount)); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js new file mode 100644 index 00000000000..5755cd846ac --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js @@ -0,0 +1,55 @@ +import { isFailedJob, sortJobsByStatus } from '~/ci/pipelines_page/components/failure_widget/utils'; + +describe('isFailedJob', () => { + describe('when the job argument is undefined', () => { + it('returns false', () => { + expect(isFailedJob()).toBe(false); + }); + }); + + describe('when the job is of status `failed`', () => { + it('returns false', () => { + expect(isFailedJob({ detailedStatus: { group: 'success' } })).toBe(false); + }); + }); + + describe('when the job status is `failed`', () => { + it('returns true', () => { + expect(isFailedJob({ detailedStatus: { group: 'failed' } })).toBe(true); + }); + }); +}); + +describe('sortJobsByStatus', () => { + describe('when the arg is undefined', () => { + it('returns an empty array', () => { + expect(sortJobsByStatus()).toEqual([]); + }); + }); + + describe('when receiving an empty array', () => { + it('returns an empty array', () => { + expect(sortJobsByStatus([])).toEqual([]); + }); + }); + + describe('when reciving a list of jobs', () => { + const jobArr = [ + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'allowed_to_fail' } }, + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'success' } }, + ]; + + const expectedResult = [ + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'allowed_to_fail' } }, + { detailedStatus: { group: 'success' } }, + ]; + + it('sorts failed jobs first', () => { + expect(sortJobsByStatus(jobArr)).toEqual(expectedResult); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js b/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js new file mode 100644 index 00000000000..f4858ac27ea --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js @@ -0,0 +1,80 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import NavControls from '~/ci/pipelines_page/components/nav_controls.vue'; + +describe('Pipelines Nav Controls', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMountExtended(NavControls, { + propsData: { + ...props, + }, + }); + }; + + const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); + const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); + const findClearCacheButton = () => wrapper.findByTestId('clear-cache-button'); + + it('should render link to create a new pipeline', () => { + const mockData = { + newPipelinePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + + const runPipelineButton = findRunPipelineButton(); + expect(runPipelineButton.text()).toContain('Run pipeline'); + expect(runPipelineButton.attributes('href')).toBe(mockData.newPipelinePath); + }); + + it('should not render link to create pipeline if no path is provided', () => { + const mockData = { + helpPagePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + + expect(findRunPipelineButton().exists()).toBe(false); + }); + + it('should render link for CI lint', () => { + const mockData = { + newPipelinePath: 'foo', + helpPagePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + const ciLintButton = findCiLintButton(); + + expect(ciLintButton.text()).toContain('CI lint'); + expect(ciLintButton.attributes('href')).toBe(mockData.ciLintPath); + }); + + describe('Reset Runners Cache', () => { + beforeEach(() => { + const mockData = { + newPipelinePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + createComponent(mockData); + }); + + it('should render button for resetting runner caches', () => { + expect(findClearCacheButton().text()).toContain('Clear runner caches'); + }); + + it('should emit postAction event when reset runner cache button is clicked', () => { + findClearCacheButton().vm.$emit('click'); + + expect(wrapper.emitted('resetRunnersCache')).toEqual([['foo']]); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js new file mode 100644 index 00000000000..b5c9a3030e0 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js @@ -0,0 +1,164 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { trimText } from 'helpers/text_helper'; +import PipelineLabelsComponent from '~/ci/pipelines_page/components/pipeline_labels.vue'; +import { mockPipeline } from 'jest/ci/pipeline_details/mock_data'; + +const projectPath = 'test/test'; + +describe('Pipeline label component', () => { + let wrapper; + + const findScheduledTag = () => wrapper.findByTestId('pipeline-url-scheduled'); + const findLatestTag = () => wrapper.findByTestId('pipeline-url-latest'); + const findYamlTag = () => wrapper.findByTestId('pipeline-url-yaml'); + const findStuckTag = () => wrapper.findByTestId('pipeline-url-stuck'); + const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops'); + const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link'); + const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached'); + const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure'); + const findForkTag = () => wrapper.findByTestId('pipeline-url-fork'); + const findTrainTag = () => wrapper.findByTestId('pipeline-url-train'); + + const defaultProps = mockPipeline(projectPath); + + const createComponent = (props) => { + wrapper = shallowMountExtended(PipelineLabelsComponent, { + propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: projectPath, + }, + }); + }; + + it('should not render tags when flags are not set', () => { + createComponent(); + + expect(findStuckTag().exists()).toBe(false); + expect(findLatestTag().exists()).toBe(false); + expect(findYamlTag().exists()).toBe(false); + expect(findAutoDevopsTag().exists()).toBe(false); + expect(findFailureTag().exists()).toBe(false); + expect(findScheduledTag().exists()).toBe(false); + expect(findForkTag().exists()).toBe(false); + expect(findTrainTag().exists()).toBe(false); + }); + + it('should render the stuck tag when flag is provided', () => { + const stuckPipeline = defaultProps.pipeline; + stuckPipeline.flags.stuck = true; + + createComponent({ + ...stuckPipeline.pipeline, + }); + + expect(findStuckTag().text()).toContain('stuck'); + }); + + it('should render latest tag when flag is provided', () => { + const latestPipeline = defaultProps.pipeline; + latestPipeline.flags.latest = true; + + createComponent({ + ...latestPipeline, + }); + + expect(findLatestTag().text()).toContain('latest'); + }); + + it('should render a yaml badge when it is invalid', () => { + const yamlPipeline = defaultProps.pipeline; + yamlPipeline.flags.yaml_errors = true; + + createComponent({ + ...yamlPipeline, + }); + + expect(findYamlTag().text()).toContain('yaml invalid'); + }); + + it('should render an autodevops badge when flag is provided', () => { + const autoDevopsPipeline = defaultProps.pipeline; + autoDevopsPipeline.flags.auto_devops = true; + + createComponent({ + ...autoDevopsPipeline, + }); + + expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps'); + + expect(findAutoDevopsTagLink().attributes()).toMatchObject({ + href: '/help/topics/autodevops/index.md', + target: '_blank', + }); + }); + + it('should render a detached badge when flag is provided', () => { + const detachedMRPipeline = defaultProps.pipeline; + detachedMRPipeline.flags.detached_merge_request_pipeline = true; + + createComponent({ + ...detachedMRPipeline, + }); + + expect(findDetachedTag().text()).toBe('merge request'); + }); + + it('should render error badge when pipeline has a failure reason set', () => { + const failedPipeline = defaultProps.pipeline; + failedPipeline.flags.failure_reason = true; + failedPipeline.failure_reason = 'some reason'; + + createComponent({ + ...failedPipeline, + }); + + expect(findFailureTag().text()).toContain('error'); + expect(findFailureTag().attributes('title')).toContain('some reason'); + }); + + it('should render scheduled badge when pipeline was triggered by a schedule', () => { + const scheduledPipeline = defaultProps.pipeline; + scheduledPipeline.source = 'schedule'; + + createComponent({ + ...scheduledPipeline, + }); + + expect(findScheduledTag().exists()).toBe(true); + expect(findScheduledTag().text()).toContain('Scheduled'); + }); + + it('should render the fork badge when the pipeline was run in a fork', () => { + const forkedPipeline = defaultProps.pipeline; + forkedPipeline.project.full_path = '/test/forked'; + + createComponent({ + ...forkedPipeline, + }); + + expect(findForkTag().exists()).toBe(true); + expect(findForkTag().text()).toBe('fork'); + }); + + it('should render the train badge when the pipeline is a merge train pipeline', () => { + const mergeTrainPipeline = defaultProps.pipeline; + mergeTrainPipeline.flags.merge_train_pipeline = true; + + createComponent({ + ...mergeTrainPipeline, + }); + + expect(findTrainTag().text()).toBe('merge train'); + }); + + it('should not render the train badge when the pipeline is not a merge train pipeline', () => { + const mergeTrainPipeline = defaultProps.pipeline; + mergeTrainPipeline.flags.merge_train_pipeline = false; + + createComponent({ + ...mergeTrainPipeline, + }); + + expect(findTrainTag().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js new file mode 100644 index 00000000000..7ae21db8815 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js @@ -0,0 +1,316 @@ +import { nextTick } from 'vue'; +import { + GlAlert, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlSprintf, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import PipelineMultiActions, { + i18n, +} from '~/ci/pipelines_page/components/pipeline_multi_actions.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; + +describe('Pipeline Multi Actions Dropdown', () => { + let wrapper; + let mockAxios; + + const artifacts = [ + { + name: 'job my-artifact', + path: '/download/path', + }, + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]; + const newArtifacts = [ + { + name: 'job-3 my-new-artifact', + path: '/new/download/path', + }, + { + name: 'job-4 my-new-artifact-2', + path: '/new/download/path-two', + }, + { + name: 'job-5 my-new-artifact-3', + path: '/new/download/path-three', + }, + ]; + const artifactItemTestId = 'artifact-item'; + const artifactsEndpointPlaceholder = ':pipeline_artifacts_id'; + const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`; + const pipelineId = 108; + + const createComponent = () => { + wrapper = extendedWrapper( + shallowMount(PipelineMultiActions, { + provide: { + artifactsEndpoint, + artifactsEndpointPlaceholder, + }, + propsData: { + pipelineId, + }, + stubs: { + GlAlert, + GlSprintf, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlSearchBoxByType: stubComponent(GlSearchBoxByType), + }, + }), + ); + }; + + const findAlert = () => wrapper.findByTestId('artifacts-fetch-error'); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); + const findAllArtifactItemsData = () => + findDropdown() + .props('items') + .map(({ text, href }) => ({ + name: text, + path: href, + })); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message'); + const findWarning = () => wrapper.findByTestId('artifacts-fetch-warning'); + const changePipelineId = (newId) => wrapper.setProps({ pipelineId: newId }); + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('should render the dropdown', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(true); + }); + + describe('Artifacts', () => { + const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); + + describe('while loading artifacts', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); + }); + + it('should render a loading spinner and no empty message', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findEmptyMessage().exists()).toBe(false); + }); + }); + + describe('artifacts loaded successfully', () => { + describe('artifacts exist', () => { + beforeEach(async () => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); + + createComponent(); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('should fetch artifacts and show search box on dropdown click', () => { + expect(mockAxios.history.get).toHaveLength(1); + expect(findSearchBox().exists()).toBe(true); + }); + + it('should focus the search box when opened with artifacts', () => { + findDropdown().vm.$emit('shown'); + + expect(findSearchBox().attributes('autofocus')).not.toBe(undefined); + }); + + it('should clear searchQuery when dropdown is closed', async () => { + findDropdown().vm.$emit('shown'); + findSearchBox().vm.$emit('input', 'job-2'); + await waitForPromises(); + + expect(findSearchBox().vm.value).toBe('job-2'); + + findDropdown().vm.$emit('hidden'); + await waitForPromises(); + + expect(findSearchBox().vm.value).toBe(''); + }); + + it('should render all the provided artifacts when search query is empty', async () => { + findSearchBox().vm.$emit('input', ''); + await waitForPromises(); + + expect(findAllArtifactItemsData()).toEqual( + artifacts.map(({ name, path }) => ({ name, path })), + ); + expect(findEmptyMessage().exists()).toBe(false); + }); + + it('should render filtered artifacts when search query is not empty', async () => { + findSearchBox().vm.$emit('input', 'job-2'); + await waitForPromises(); + + expect(findAllArtifactItemsData()).toEqual([ + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]); + expect(findEmptyMessage().exists()).toBe(false); + }); + + it('should render the correct artifact name and path', () => { + expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); + expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); + }); + + describe('when opened again with new artifacts', () => { + describe('with a successful refetch', () => { + beforeEach(async () => { + mockAxios.resetHistory(); + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: newArtifacts }); + + findDropdown().vm.$emit('shown'); + await nextTick(); + }); + + it('should hide list and render a loading spinner on dropdown click', () => { + expect(findAllArtifactItemsData()).toHaveLength(0); + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('should not render warning or empty message while loading', () => { + expect(findEmptyMessage().exists()).toBe(false); + expect(findWarning().exists()).toBe(false); + }); + + it('should render the correct new list', async () => { + await waitForPromises(); + + expect(findAllArtifactItemsData()).toEqual(newArtifacts); + }); + }); + + describe('with a failing refetch', () => { + beforeEach(async () => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('should render warning', () => { + expect(findWarning().text()).toBe(i18n.artifactsFetchWarningMessage); + }); + + it('should render old list', () => { + expect(findAllArtifactItemsData()).toEqual(artifacts); + }); + }); + }); + + describe('pipeline id has changed', () => { + const newEndpoint = artifactsEndpoint.replace( + artifactsEndpointPlaceholder, + pipelineId + 1, + ); + + beforeEach(() => { + changePipelineId(pipelineId + 1); + }); + + describe('followed by a failing request', () => { + beforeEach(async () => { + mockAxios.onGet(newEndpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('should render error message and no warning', () => { + expect(findWarning().exists()).toBe(false); + expect(findAlert().text()).toBe(i18n.artifactsFetchErrorMessage); + }); + + it('should clear list', () => { + expect(findAllArtifactItemsData()).toHaveLength(0); + }); + }); + }); + }); + + describe('artifacts list is empty', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: [] }); + }); + + it('should render empty message and no search box when no artifacts are found', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + + expect(findEmptyMessage().exists()).toBe(true); + expect(findSearchBox().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + }); + + describe('with a failing request', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + }); + + it('should render an error message', async () => { + createComponent(); + findDropdown().vm.$emit('shown'); + await waitForPromises(); + + const error = findAlert(); + expect(error.exists()).toBe(true); + expect(error.text()).toBe(i18n.artifactsFetchErrorMessage); + }); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks artifacts dropdown click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(); + + findDropdown().vm.$emit('shown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_artifacts_dropdown', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js new file mode 100644 index 00000000000..d2eab64b317 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js @@ -0,0 +1,77 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue'; +import PipelineMultiActions from '~/ci/pipelines_page/components/pipeline_multi_actions.vue'; +import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue'; +import eventHub from '~/ci/event_hub'; + +describe('Pipeline operations', () => { + let wrapper; + + const defaultProps = { + pipeline: { + id: 329, + iid: 234, + details: { + has_manual_actions: true, + has_scheduled_actions: false, + }, + flags: { + retryable: true, + cancelable: true, + }, + cancel_path: '/root/ci-project/-/pipelines/329/cancel', + retry_path: '/root/ci-project/-/pipelines/329/retry', + }, + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineOperations, { + propsData: { + ...props, + }, + }); + }; + + const findManualActions = () => wrapper.findComponent(PipelinesManualActions); + const findMultiActions = () => wrapper.findComponent(PipelineMultiActions); + const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); + const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + + it('should display pipeline manual actions', () => { + createComponent(); + + expect(findManualActions().exists()).toBe(true); + }); + + it('should display pipeline multi actions', () => { + createComponent(); + + expect(findMultiActions().exists()).toBe(true); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('should emit retryPipeline event', () => { + findRetryBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'retryPipeline', + defaultProps.pipeline.retry_path, + ); + }); + + it('should emit openConfirmationModal event', () => { + findCancelBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', { + pipeline: defaultProps.pipeline, + endpoint: defaultProps.pipeline.cancel_path, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js new file mode 100644 index 00000000000..4d78a923542 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import { mockPipelineHeader } from 'jest/ci/pipeline_details/mock_data'; +import PipelineStopModal from '~/ci/pipelines_page/components/pipeline_stop_modal.vue'; + +describe('PipelineStopModal', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineStopModal, { + propsData: { + pipeline: mockPipelineHeader, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should render "stop pipeline" warning', () => { + expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js new file mode 100644 index 00000000000..cb04171f031 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js @@ -0,0 +1,76 @@ +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import pipelineTriggerer from '~/ci/pipelines_page/components/pipeline_triggerer.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('Pipelines Triggerer', () => { + let wrapper; + + const mockData = { + pipeline: { + user: { + name: 'foo', + avatar_url: '/avatar', + path: '/path', + }, + }, + }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(pipelineTriggerer, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findTriggerer = () => wrapper.findByText('API'); + + describe('when user was a triggerer', () => { + beforeEach(() => { + createComponent(mockData); + }); + + it('should render pipeline triggerer table cell', () => { + expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); + }); + + it('should render only user avatar', () => { + expect(findAvatarLink().exists()).toBe(true); + expect(findTriggerer().exists()).toBe(false); + }); + + it('should set correct props on avatar link component', () => { + expect(findAvatarLink().attributes()).toMatchObject({ + title: mockData.pipeline.user.name, + href: mockData.pipeline.user.path, + }); + }); + + it('should add tooltip to avatar link', () => { + const tooltip = getBinding(findAvatarLink().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + }); + + it('should set correct props on avatar component', () => { + expect(findAvatar().attributes().src).toBe(mockData.pipeline.user.avatar_url); + }); + }); + + describe('when API was a triggerer', () => { + beforeEach(() => { + createComponent({ pipeline: {} }); + }); + + it('should render label only', () => { + expect(findAvatarLink().exists()).toBe(false); + expect(findTriggerer().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js new file mode 100644 index 00000000000..0ee22dda826 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js @@ -0,0 +1,188 @@ +import { merge } from 'lodash'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineUrlComponent from '~/ci/pipelines_page/components/pipeline_url.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { + mockPipeline, + mockPipelineBranch, + mockPipelineTag, +} from 'jest/ci/pipeline_details/mock_data'; + +const projectPath = 'test/test'; + +describe('Pipeline Url Component', () => { + let wrapper; + let trackingSpy; + + const findTableCell = () => wrapper.findByTestId('pipeline-url-table-cell'); + const findPipelineUrlLink = () => wrapper.findByTestId('pipeline-url-link'); + const findRefName = () => wrapper.findByTestId('merge-request-ref'); + const findCommitShortSha = () => wrapper.findByTestId('commit-short-sha'); + const findCommitIcon = () => wrapper.findByTestId('commit-icon'); + const findCommitIconType = () => wrapper.findByTestId('commit-icon-type'); + const findCommitRefName = () => wrapper.findByTestId('commit-ref-name'); + + const findCommitTitleContainer = () => wrapper.findByTestId('commit-title-container'); + const findPipelineNameContainer = () => wrapper.findByTestId('pipeline-name-container'); + const findCommitTitle = (commitWrapper) => commitWrapper.find('[data-testid="commit-title"]'); + + const defaultProps = { ...mockPipeline(projectPath), refClass: 'gl-text-black' }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(PipelineUrlComponent, { + propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: projectPath, + }, + }); + }; + + it('should render pipeline url table cell', () => { + createComponent(); + + expect(findTableCell().exists()).toBe(true); + }); + + it('should render a link the provided path and id', () => { + createComponent(); + + expect(findPipelineUrlLink().attributes('href')).toBe('foo'); + + expect(findPipelineUrlLink().text()).toBe('#1'); + }); + + it('should render the pipeline name instead of commit title', () => { + createComponent(merge(mockPipeline(projectPath), { pipeline: { name: 'Build pipeline' } })); + + expect(findCommitTitleContainer().exists()).toBe(false); + expect(findPipelineNameContainer().exists()).toBe(true); + expect(findRefName().exists()).toBe(true); + expect(findCommitShortSha().exists()).toBe(true); + }); + + it('should render the commit title when pipeline has no name', () => { + createComponent(); + + const commitWrapper = findCommitTitleContainer(); + + expect(findCommitTitle(commitWrapper).exists()).toBe(true); + expect(findRefName().exists()).toBe(true); + expect(findCommitShortSha().exists()).toBe(true); + expect(findPipelineNameContainer().exists()).toBe(false); + }); + + it('should pass the refClass prop to merge request link', () => { + createComponent(); + + expect(findRefName().classes()).toContain(defaultProps.refClass); + }); + + it('should pass the refClass prop to the commit ref name link', () => { + createComponent(mockPipelineBranch()); + + expect(findCommitRefName().classes()).toContain(defaultProps.refClass); + }); + + describe('commit user avatar', () => { + it('renders when commit author exists', () => { + const pipelineBranch = mockPipelineBranch(); + const { avatar_url: imgSrc, name, path } = pipelineBranch.pipeline.commit.author; + createComponent(pipelineBranch); + + const component = wrapper.findComponent(UserAvatarLink); + expect(component.exists()).toBe(true); + expect(component.props()).toMatchObject({ + imgSize: 16, + imgSrc, + imgAlt: name, + linkHref: path, + tooltipText: name, + }); + }); + + it('does not render when commit author does not exist', () => { + createComponent(); + + expect(wrapper.findComponent(UserAvatarLink).exists()).toBe(false); + }); + }); + + it('should render commit icon tooltip', () => { + createComponent(); + + expect(findCommitIcon().attributes('title')).toBe('Commit'); + }); + + it.each` + pipeline | expectedTitle + ${mockPipelineTag()} | ${'Tag'} + ${mockPipelineBranch()} | ${'Branch'} + ${mockPipeline()} | ${'Merge Request'} + `('should render tooltip $expectedTitle for commit icon type', ({ pipeline, expectedTitle }) => { + createComponent(pipeline); + + expect(findCommitIconType().attributes('title')).toBe(expectedTitle); + }); + + describe('tracking', () => { + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks pipeline id click', () => { + createComponent(); + + findPipelineUrlLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_pipeline_id', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks merge request ref click', () => { + createComponent(); + + findRefName().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_mr_ref', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit ref name click', () => { + createComponent(mockPipelineBranch()); + + findCommitRefName().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_name', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit title click', () => { + createComponent(merge(mockPipelineBranch(), { pipeline: { name: null } })); + + findCommitTitle(findCommitTitleContainer()).vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_title', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit short sha click', () => { + createComponent(mockPipelineBranch()); + + findCommitShortSha().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_sha', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js new file mode 100644 index 00000000000..557403b3de9 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js @@ -0,0 +1,64 @@ +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlSprintf, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue'; + +describe('Pipelines Artifacts dropdown', () => { + let wrapper; + + const artifacts = [ + { + name: 'job my-artifact', + path: '/download/path', + }, + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]; + const pipelineId = 108; + + const createComponent = ({ mockArtifacts = artifacts } = {}) => { + wrapper = shallowMount(PipelineArtifacts, { + propsData: { + pipelineId, + artifacts: mockArtifacts, + }, + stubs: { + GlSprintf, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + }, + }); + }; + + const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findFirstGlDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + + it('should render a dropdown with all the provided artifacts', () => { + createComponent(); + + const [{ items }] = findGlDropdown().props('items'); + expect(items).toHaveLength(artifacts.length); + }); + + it('should render a link with the provided path', () => { + createComponent(); + + expect(findFirstGlDropdownItem().props('item').href).toBe(artifacts[0].path); + expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name); + }); + + describe('with no artifacts', () => { + it('should not render the dropdown', () => { + createComponent({ mockArtifacts: [] }); + + expect(findGlDropdown().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js new file mode 100644 index 00000000000..4cd85b86e31 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js @@ -0,0 +1,199 @@ +import { GlFilteredSearch } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import PipelinesFilteredSearch from '~/ci/pipelines_page/components/pipelines_filtered_search.vue'; +import { + FILTERED_SEARCH_TERM, + OPERATORS_IS, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { users, mockSearch, branches, tags } from 'jest/ci/pipeline_details/mock_data'; + +describe('Pipelines filtered search', () => { + let wrapper; + let mock; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const getSearchToken = (type) => + findFilteredSearch() + .props('availableTokens') + .find((token) => token.type === type); + const findBranchToken = () => getSearchToken('ref'); + const findTagToken = () => getSearchToken('tag'); + const findUserToken = () => getSearchToken('username'); + const findStatusToken = () => getSearchToken('status'); + const findSourceToken = () => getSearchToken('source'); + + const createComponent = (params = {}) => { + wrapper = mount(PipelinesFilteredSearch, { + propsData: { + projectId: '21', + defaultBranchName: 'main', + params, + }, + attachTo: document.body, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); + + createComponent(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('displays UI elements', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('displays search tokens', () => { + expect(findUserToken()).toMatchObject({ + type: 'username', + icon: 'user', + title: 'Trigger author', + unique: true, + projectId: '21', + operators: OPERATORS_IS, + }); + + expect(findBranchToken()).toMatchObject({ + type: 'ref', + icon: 'branch', + title: 'Branch name', + unique: true, + projectId: '21', + defaultBranchName: 'main', + operators: OPERATORS_IS, + }); + + expect(findSourceToken()).toMatchObject({ + type: 'source', + icon: 'trigger-source', + title: 'Source', + unique: true, + operators: OPERATORS_IS, + }); + + expect(findStatusToken()).toMatchObject({ + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + operators: OPERATORS_IS, + }); + + expect(findTagToken()).toMatchObject({ + type: 'tag', + icon: 'tag', + title: 'Tag name', + unique: true, + operators: OPERATORS_IS, + }); + }); + + it('emits filterPipelines on submit with correct filter', () => { + findFilteredSearch().vm.$emit('submit', mockSearch); + + expect(wrapper.emitted('filterPipelines')).toHaveLength(1); + expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]); + }); + + it('disables tag name token when branch name token is active', async () => { + findFilteredSearch().vm.$emit('input', [ + { type: 'ref', value: { data: 'branch-1', operator: '=' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(true); + }); + + it('disables branch name token when tag name token is active', async () => { + findFilteredSearch().vm.$emit('input', [ + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(true); + expect(findTagToken().disabled).toBe(false); + }); + + it('resets tokens disabled state on clear', async () => { + findFilteredSearch().vm.$emit('clearInput'); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(false); + }); + + it('resets tokens disabled state when clearing tokens by backspace', async () => { + findFilteredSearch().vm.$emit('input', [{ type: FILTERED_SEARCH_TERM, value: { data: '' } }]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(false); + }); + + describe('Url query params', () => { + const params = { + username: 'deja.green', + ref: 'main', + }; + + beforeEach(() => { + createComponent(params); + }); + + it('sets default value if url query params', () => { + const expectedValueProp = [ + { + type: 'username', + value: { + data: params.username, + operator: '=', + }, + }, + { + type: 'ref', + value: { + data: params.ref, + operator: '=', + }, + }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]; + + expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp); + expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks filtered search click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findFilteredSearch().vm.$emit('submit', mockSearch); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filtered_search', { + label: TRACKING_CATEGORIES.search, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js new file mode 100644 index 00000000000..a24e136f1ff --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js @@ -0,0 +1,216 @@ +import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineActionsQueryResponse from 'test_fixtures/graphql/pipelines/get_pipeline_actions.query.graphql.json'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue'; +import getPipelineActionsQuery from '~/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +describe('Pipeline manual actions', () => { + let wrapper; + let mock; + + const queryHandler = jest.fn().mockResolvedValue(mockPipelineActionsQueryResponse); + const { + data: { + project: { + pipeline: { + jobs: { nodes }, + }, + }, + }, + } = mockPipelineActionsQueryResponse; + + const mockPath = nodes[2].playPath; + + const createComponent = (limit = 50) => { + wrapper = shallowMountExtended(PipelinesManualActions, { + provide: { + fullPath: 'root/ci-project', + manualActionsLimit: limit, + }, + propsData: { + iid: 100, + }, + stubs: { + GlDropdown, + }, + apolloProvider: createMockApollo([[getPipelineActionsQuery, queryHandler]]), + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLimitMessage = () => wrapper.findByTestId('limit-reached-msg'); + + it('skips calling query on mount', () => { + createComponent(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + + describe('loading', () => { + beforeEach(() => { + createComponent(); + + findDropdown().vm.$emit('shown'); + }); + + it('display loading state while actions are being fetched', () => { + expect(findAllDropdownItems().at(0).text()).toBe('Loading...'); + expect(findLoadingIcon().exists()).toBe(true); + expect(findAllDropdownItems()).toHaveLength(1); + }); + }); + + describe('loaded', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + }); + + afterEach(() => { + mock.restore(); + confirmAction.mockReset(); + }); + + it('displays dropdown with the provided actions', () => { + expect(findAllDropdownItems()).toHaveLength(3); + }); + + it("displays a disabled action when it's not playable", () => { + expect(findAllDropdownItems().at(0).attributes('disabled')).toBeDefined(); + }); + + describe('on action click', () => { + it('makes a request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + }); + + it('makes a failed request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks manual actions click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('shown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); + + describe('scheduled jobs', () => { + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + }); + + it('makes post request after confirming', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(true); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(1); + }); + + it('does not make post request if confirmation is cancelled', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(false); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(0); + }); + + it('displays the remaining time in the dropdown', () => { + expect(findAllCountdowns().at(0).props('endDateString')).toBe(nodes[2].scheduledAt); + }); + }); + }); + + describe('limit message', () => { + it('limit message does not show', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(false); + }); + + it('limit message does show', async () => { + createComponent(3); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/time_ago_spec.js b/spec/frontend/ci/pipelines_page/components/time_ago_spec.js new file mode 100644 index 00000000000..f7203f8d1b4 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/time_ago_spec.js @@ -0,0 +1,85 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TimeAgo from '~/ci/pipelines_page/components/time_ago.vue'; + +describe('Timeago component', () => { + let wrapper; + + const defaultProps = { duration: 0, finished_at: '' }; + + const createComponent = (props = defaultProps, extraProps) => { + wrapper = extendedWrapper( + shallowMount(TimeAgo, { + propsData: { + pipeline: { + details: { + ...props, + }, + }, + ...extraProps, + }, + data() { + return { + iconTimerSvg: ``, + }; + }, + }), + ); + }; + + const duration = () => wrapper.find('.duration'); + const finishedAt = () => wrapper.find('.finished-at'); + const findCalendarIcon = () => wrapper.findByTestId('calendar-icon'); + + describe('with duration', () => { + beforeEach(() => { + createComponent({ duration: 10, finished_at: '' }); + }); + + it('should render duration and timer svg', () => { + const icon = duration().findComponent(GlIcon); + + expect(duration().exists()).toBe(true); + expect(icon.props('name')).toBe('timer'); + }); + }); + + describe('without duration', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not render duration and timer svg', () => { + expect(duration().exists()).toBe(false); + }); + }); + + describe('with finishedTime', () => { + it('should render time', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); + + const time = finishedAt().find('time'); + + expect(finishedAt().exists()).toBe(true); + expect(time.exists()).toBe(true); + }); + + it('should display calendar icon', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); + + expect(findCalendarIcon().exists()).toBe(true); + }); + }); + + describe('without finishedTime', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not render time and calendar icon', () => { + expect(finishedAt().exists()).toBe(false); + expect(findCalendarIcon().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/pipelines_spec.js b/spec/frontend/ci/pipelines_page/pipelines_spec.js new file mode 100644 index 00000000000..5d1f431e57c --- /dev/null +++ b/spec/frontend/ci/pipelines_page/pipelines_spec.js @@ -0,0 +1,851 @@ +import '~/commons'; +import { + GlButton, + GlEmptyState, + GlFilteredSearch, + GlLoadingIcon, + GlPagination, + GlCollapsibleListbox, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { chunk } from 'lodash'; +import { nextTick } from 'vue'; +import mockPipelinesResponse from 'test_fixtures/pipelines/pipelines.json'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import { mockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import Api from '~/api'; +import { createAlert, VARIANT_WARNING } from '~/alert'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import NavigationControls from '~/ci/pipelines_page/components/nav_controls.vue'; +import PipelinesComponent from '~/ci/pipelines_page/pipelines.vue'; +import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; +import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; +import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/constants'; +import Store from '~/ci/pipeline_details/stores/pipelines_store'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { + setIdTypePreferenceMutationResponse, + setIdTypePreferenceMutationResponseWithErrors, +} from 'jest/issues/list/mock_data'; + +import { stageReply } from 'jest/ci/pipeline_mini_graph/mock_data'; +import { users, mockSearch, branches } from '../pipeline_details/mock_data'; + +jest.mock('@sentry/browser'); +jest.mock('~/alert'); + +const mockProjectPath = 'twitter/flight'; +const mockProjectId = '21'; +const mockDefaultBranchName = 'main'; +const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`; +const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id); +const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( + (p) => p.details.stages && p.details.stages.length, +); + +describe('Pipelines', () => { + let wrapper; + let mockApollo; + let mock; + let trackingSpy; + + const paths = { + emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', + noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + ciLintPath: '/ci/lint', + resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, + newPipelinePath: `${mockProjectPath}/pipelines/new`, + + ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`, + }; + + const noPermissions = { + emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', + noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + }; + + const defaultProps = { + hasGitlabCi: true, + canCreatePipeline: true, + ...paths, + }; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findNavigationTabs = () => wrapper.findComponent(NavigationTabs); + const findNavigationControls = () => wrapper.findComponent(NavigationControls); + const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); + const findTablePagination = () => wrapper.findComponent(TablePagination); + const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox); + + const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); + const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box'); + const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); + const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); + const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); + const findStagesDropdownToggle = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'); + const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); + + const createComponent = (props = defaultProps) => { + const { mutationMock, ...restProps } = props; + mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]); + + wrapper = extendedWrapper( + mount(PipelinesComponent, { + provide: { + pipelineEditorPath: '', + suggestedCiTemplates: [], + ciRunnerSettingsPath: paths.ciRunnerSettingsPath, + anyRunnersAvailable: true, + }, + propsData: { + store: new Store(), + projectId: mockProjectId, + defaultBranchName: mockDefaultBranchName, + endpoint: mockPipelinesEndpoint, + params: {}, + ...restProps, + }, + apolloProvider: mockApollo, + }), + ); + }; + + beforeEach(() => { + setWindowLocation(TEST_HOST); + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(window.history, 'pushState'); + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + }); + + afterEach(() => { + mock.reset(); + mockApollo = null; + window.history.pushState.mockReset(); + }); + + describe('when pipelines are not yet loaded', () => { + beforeEach(async () => { + createComponent(); + await nextTick(); + }); + + it('shows loading state when the app is loading', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('does not display tabs when the first request has not yet been made', () => { + expect(findNavigationTabs().exists()).toBe(false); + }); + + it('does not display buttons', () => { + expect(findNavigationControls().exists()).toBe(false); + }); + }); + + describe('when there are pipelines in the project', () => { + beforeEach(() => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, mockPipelinesResponse); + }); + + describe('when user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + await waitForPromises(); + }); + + it('renders "All" tab with count different from "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 3'); + }); + + it('does not render buttons', () => { + expect(findNavigationControls().exists()).toBe(false); + + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('renders pipelines in a table', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + }); + + describe('when user has permissions', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should set up navigation tabs', () => { + expect(findNavigationTabs().props('tabs')).toEqual([ + { name: 'All', scope: 'all', count: '3', isActive: true }, + { name: 'Finished', scope: 'finished', count: undefined, isActive: false }, + { name: 'Branches', scope: 'branches', isActive: false }, + { name: 'Tags', scope: 'tags', isActive: false }, + ]); + }); + + it('renders "All" tab with count different from "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 3'); + }); + + it('should render other navigation tabs', () => { + expect(findTab('finished').text()).toBe('Finished'); + expect(findTab('branches').text()).toBe('Branches'); + expect(findTab('tags').text()).toBe('Tags'); + }); + + it('shows navigation controls', () => { + expect(findNavigationControls().exists()).toBe(true); + }); + + it('renders Run pipeline link', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + }); + + it('renders CI lint link', () => { + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + }); + + it('renders Clear runner cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('renders pipelines in a table', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + + describe('when user goes to a tab', () => { + const goToTab = (tab) => { + findNavigationTabs().vm.$emit('onChangeTab', tab); + }; + + describe('when the scope in the tab has pipelines', () => { + const mockFinishedPipeline = mockPipelinesResponse.pipelines[0]; + + beforeEach(async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [mockFinishedPipeline], + count: mockPipelinesResponse.count, + }); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + goToTab('finished'); + + await waitForPromises(); + }); + + it('should filter pipelines', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(1); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFinishedPipeline.id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?scope=finished&page=1`, + ); + }); + + it.each(['all', 'finished', 'branches', 'tags'])('tracks %p tab click', async (scope) => { + goToTab(scope); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filter_tabs', { + label: TRACKING_CATEGORIES.tabs, + property: scope, + }); + }); + }); + + describe('when the scope in the tab is empty', () => { + beforeEach(async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'branches', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: mockPipelinesResponse.count, + }); + + goToTab('branches'); + + await waitForPromises(); + }); + + it('should filter pipelines', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?scope=branches&page=1`, + ); + }); + }); + }); + + describe('when user triggers a filtered search', () => { + const mockFilteredPipeline = mockPipelinesResponse.pipelines[1]; + + let expectedParams; + + beforeEach(async () => { + expectedParams = { + page: '1', + scope: 'all', + username: 'root', + ref: 'main', + status: 'pending', + }; + + mock + .onGet(mockPipelinesEndpoint, { + params: expectedParams, + }) + .replyOnce(HTTP_STATUS_OK, { + pipelines: [mockFilteredPipeline], + count: mockPipelinesResponse.count, + }); + + findFilteredSearch().vm.$emit('submit', mockSearch); + + await waitForPromises(); + }); + + it('requests data with query params on filter submit', () => { + expect(mock.history.get[1].params).toEqual(expectedParams); + }); + + it('renders filtered pipelines', () => { + expect(findPipelineUrlLinks()).toHaveLength(1); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all&username=root&ref=main&status=pending`, + ); + }); + }); + + describe('when user changes Show Pipeline ID to Show Pipeline IID', () => { + const mockFilteredPipeline = mockPipelinesResponse.pipelines[0]; + + beforeEach(() => { + gon.current_user_id = 1; + }); + + it('should change the text to Show Pipeline IID', async () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + + await waitForPromises(); + + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.iid}`); + }); + + it('calls mutation to save idType preference', () => { + const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); + createComponent({ ...defaultProps, mutationMock }); + + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + + expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } }); + }); + + it('captures error when mutation response has errors', async () => { + const mutationMock = jest + .fn() + .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); + createComponent({ ...defaultProps, mutationMock }); + + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); + }); + }); + + describe('when user triggers a filtered search with raw text', () => { + beforeEach(async () => { + findFilteredSearch().vm.$emit('submit', ['rawText']); + + await waitForPromises(); + }); + + it('requests data with query params on filter submit', () => { + expect(mock.history.get[1].params).toEqual({ page: '1', scope: 'all' }); + }); + + it('displays a warning message if raw text search is used', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: RAW_TEXT_WARNING, + variant: VARIANT_WARNING, + }); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all`, + ); + }); + }); + }); + }); + + describe('when there are multiple pages of pipelines', () => { + const mockPageSize = 2; + const mockPageHeaders = ({ page = 1 } = {}) => { + return { + 'X-PER-PAGE': `${mockPageSize}`, + 'X-PREV-PAGE': `${page - 1}`, + 'X-PAGE': `${page}`, + 'X-NEXT-PAGE': `${page + 1}`, + }; + }; + const [firstPage, secondPage] = chunk(mockPipelinesResponse.pipelines, mockPageSize); + + const goToPage = (page) => { + findTablePagination().findComponent(GlPagination).vm.$emit('input', page); + }; + + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }).reply( + HTTP_STATUS_OK, + { + pipelines: firstPage, + count: mockPipelinesResponse.count, + }, + mockPageHeaders({ page: 1 }), + ); + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '2' } }).reply( + HTTP_STATUS_OK, + { + pipelines: secondPage, + count: mockPipelinesResponse.count, + }, + mockPageHeaders({ page: 2 }), + ); + + createComponent(); + + await waitForPromises(); + }); + + it('shows the first page of pipelines', () => { + expect(findPipelineUrlLinks()).toHaveLength(firstPage.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${firstPage[0].id}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${firstPage[1].id}`); + }); + + it('should not update browser bar', () => { + expect(window.history.pushState).not.toHaveBeenCalled(); + }); + + describe('when user goes to next page', () => { + beforeEach(async () => { + goToPage(2); + await waitForPromises(); + }); + + it('should update page and keep scope the same scope', () => { + expect(findPipelineUrlLinks()).toHaveLength(secondPage.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${secondPage[0].id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=2&scope=all`, + ); + }); + + it('should reset page to 1 when filtering pipelines', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=2&scope=all`, + ); + + findFilteredSearch().vm.$emit('submit', [ + { type: 'status', value: { data: 'success', operator: '=' } }, + ]); + + expect(window.history.pushState).toHaveBeenCalledTimes(2); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all&status=success`, + ); + }); + }); + }); + + describe('when pipelines can be polled', () => { + beforeEach(() => { + const emptyResponse = { + pipelines: [], + count: { all: '0' }, + }; + + // Mock no pipelines in the first attempt + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .replyOnce(HTTP_STATUS_OK, emptyResponse, { + 'POLL-INTERVAL': 100, + }); + // Mock pipelines in the next attempt + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, mockPipelinesResponse, { + 'POLL-INTERVAL': 100, + }); + }); + + describe('data is loaded for the first time', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('shows tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + }); + + it('should update page and keep scope the same scope', () => { + expect(findPipelineUrlLinks()).toHaveLength(0); + }); + + describe('data is loaded for a second time', () => { + beforeEach(async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('shows tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + }); + + it('is loading after a time', () => { + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + }); + }); + }); + + describe('when no pipelines exist', () => { + beforeEach(() => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: { all: '0' }, + }); + }); + + describe('when CI is enabled and user has permissions', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('renders tab with count of "0"', () => { + expect(findNavigationTabs().exists()).toBe(true); + expect(findTab('all').text()).toMatchInterpolatedText('All 0'); + }); + + it('renders Run pipeline link', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + }); + + it('renders CI lint link', () => { + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + }); + + it('renders Clear runner cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('renders empty state', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + + it('renders filtered search', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('renders the pipeline key collapsible box', () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); + }); + + it('renders tab empty state finished scope', async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: { all: '0' }, + }); + + findNavigationTabs().vm.$emit('onChangeTab', 'finished'); + + await waitForPromises(); + + expect(findEmptyState().text()).toBe('There are currently no finished pipelines.'); + }); + }); + + describe('when CI is not enabled and user has permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + await waitForPromises(); + }); + + it('renders the CI/CD templates', () => { + expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true); + }); + + it('does not render filtered search', () => { + expect(findFilteredSearch().exists()).toBe(false); + }); + + it('does not render the pipeline key dropdown', () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(false); + }); + + it('does not render tabs nor buttons', () => { + expect(findNavigationTabs().exists()).toBe(false); + expect(findTab('all').exists()).toBe(false); + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + }); + + describe('when CI is not enabled and user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + await waitForPromises(); + }); + + it('renders empty state without button to set CI', () => { + expect(findEmptyState().text()).toBe( + 'This project is not currently set up to run pipelines.', + ); + + expect(findEmptyState().findComponent(GlButton).exists()).toBe(false); + }); + + it('does not render tabs or buttons', () => { + expect(findTab('all').exists()).toBe(false); + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + }); + + describe('when CI is enabled and user has no permissions', () => { + beforeEach(() => { + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + + return waitForPromises(); + }); + + it('renders tab with count of "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 0'); + }); + + it('does not render buttons', () => { + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('renders empty state', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + }); + }); + + describe('when a pipeline with stages exists', () => { + describe('updates results when a staged is clicked', () => { + let stopMock; + let restartMock; + let cancelMock; + + beforeEach(() => { + mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply( + HTTP_STATUS_OK, + { + pipelines: [mockPipelineWithStages], + count: { all: '1' }, + }, + { + 'POLL-INTERVAL': 100, + }, + ); + + mock + .onGet(mockPipelineWithStages.details.stages[0].dropdown_path) + .reply(HTTP_STATUS_OK, stageReply); + + createComponent(); + + stopMock = jest.spyOn(window, 'clearTimeout'); + restartMock = jest.spyOn(axios, 'get'); + }); + + describe('when a request is being made', () => { + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_OK, mockPipelinesResponse); + + await waitForPromises(); + }); + + it('stops polling, cancels the request, & restarts polling', async () => { + // Mock init a polling cycle + wrapper.vm.poll.options.notificationCallback(true); + + await findStagesDropdownToggle().trigger('click'); + jest.runOnlyPendingTimers(); + + // cancelMock is getting overwritten in pipelines_service.js#L29 + // so we have to spy on it again here + cancelMock = jest.spyOn(axios.CancelToken, 'source'); + + await waitForPromises(); + + expect(cancelMock).toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalledWith( + `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`, + ); + }); + + it('stops polling & restarts polling', async () => { + await findStagesDropdownToggle().trigger('click'); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(cancelMock).not.toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalledWith( + `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`, + ); + }); + }); + }); + }); + + describe('when pipelines cannot be loaded', () => { + beforeEach(() => { + mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, {}); + }); + + describe('when user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); + + await waitForPromises(); + }); + + it('renders tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + expect(findTab('all').text()).toBe('All'); + }); + + it('does not render buttons', () => { + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('shows error state', () => { + expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.'); + expect(findEmptyState().props('description')).toBe( + 'Try again in a few moments or contact your support team.', + ); + }); + }); + + describe('when user has permissions', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('renders tabs', () => { + expect(findTab('all').text()).toBe('All'); + }); + + it('renders buttons', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('shows error state', () => { + expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.'); + expect(findEmptyState().props('description')).toBe( + 'Try again in a few moments or contact your support team.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js new file mode 100644 index 00000000000..ea615d85c4b --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js @@ -0,0 +1,142 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import PipelineBranchNameToken from '~/ci/pipelines_page/tokens/pipeline_branch_name_token.vue'; +import { branches, mockBranchesAfterMap } from 'jest/ci/pipeline_details/mock_data'; + +describe('Pipeline Branch Name Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const getBranchSuggestions = () => + findAllFilteredSearchSuggestions().wrappers.map((w) => w.text()); + + const stubs = { + GlFilteredSearchToken: { + template: `
    `, + }, + }; + + const defaultProps = { + config: { + type: 'ref', + icon: 'branch', + title: 'Branch name', + unique: true, + projectId: '21', + defaultBranchName: null, + disabled: false, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const optionsWithDefaultBranchName = (options) => { + return { + propsData: { + ...defaultProps, + config: { + ...defaultProps.config, + defaultBranchName: 'main', + }, + }, + ...options, + }; + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineBranchNameToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project branches', () => { + expect(Api.branches).toHaveBeenCalled(); + + expect(wrapper.vm.branches).toEqual(mockBranchesAfterMap); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ stubs }, { loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ stubs }, { loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows branches correctly', () => { + it('renders all branches', () => { + createComponent({ stubs }, { branches, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length); + }); + + it('renders only the branch searched for', () => { + const mockBranches = ['main']; + createComponent({ stubs }, { branches: mockBranches, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length); + }); + + it('shows the default branch first if no branch was searched for', async () => { + const mockBranches = [{ name: 'branch-1' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false }); + await nextTick(); + expect(getBranchSuggestions()).toEqual(['main', 'branch-1']); + }); + + it('does not show the default branch if a search term was provided', async () => { + const mockBranches = [{ name: 'branch-1' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName(), { loading: false }); + + findFilteredSearchToken().vm.$emit('input', { data: 'branch-1' }); + await waitForPromises(); + expect(getBranchSuggestions()).toEqual(['branch-1']); + }); + + it('shows the default branch only once if it appears in the results', async () => { + const mockBranches = [{ name: 'main' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false }); + await nextTick(); + expect(getBranchSuggestions()).toEqual(['main']); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js new file mode 100644 index 00000000000..0ea2b641b33 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js @@ -0,0 +1,53 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipelines_page/tokens/constants'; +import { stubComponent } from 'helpers/stub_component'; +import PipelineSourceToken from '~/ci/pipelines_page/tokens/pipeline_source_token.vue'; + +describe('Pipeline Source Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + + const defaultProps = { + config: { + type: 'source', + icon: 'trigger-source', + title: 'Source', + unique: true, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = () => { + wrapper = shallowMount(PipelineSourceToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `
    `, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('shows sources correctly', () => { + it('renders all pipeline sources available', () => { + expect(findAllFilteredSearchSuggestions()).toHaveLength(PIPELINE_SOURCES.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js new file mode 100644 index 00000000000..b8f98666438 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js @@ -0,0 +1,58 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import PipelineStatusToken from '~/ci/pipelines_page/tokens/pipeline_status_token.vue'; +import { + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +describe('Pipeline Status Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findAllGlIcons = () => wrapper.findAllComponents(GlIcon); + + const defaultProps = { + config: { + type: TOKEN_TYPE_STATUS, + icon: 'status', + title: TOKEN_TITLE_STATUS, + unique: true, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = () => { + wrapper = shallowMount(PipelineStatusToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `
    `, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('shows statuses correctly', () => { + it('renders all pipeline statuses available', () => { + expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.statuses.length); + expect(findAllGlIcons()).toHaveLength(wrapper.vm.statuses.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js new file mode 100644 index 00000000000..d23d9f07df3 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js @@ -0,0 +1,95 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Api from '~/api'; +import PipelineTagNameToken from '~/ci/pipelines_page/tokens/pipeline_tag_name_token.vue'; +import { tags, mockTagsAfterMap } from 'jest/ci/pipeline_details/mock_data'; + +describe('Pipeline Branch Name Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const stubs = { + GlFilteredSearchToken: { + template: `
    `, + }, + }; + + const defaultProps = { + config: { + type: 'tag', + icon: 'tag', + title: 'Tag name', + unique: true, + projectId: '21', + disabled: false, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineTagNameToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project tags', () => { + expect(Api.tags).toHaveBeenCalled(); + + expect(wrapper.vm.tags).toEqual(mockTagsAfterMap); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ stubs }, { loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ stubs }, { loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows tags correctly', () => { + it('renders all tags', () => { + createComponent({ stubs }, { tags, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(tags.length); + }); + + it('renders only the tag searched for', () => { + const mockTags = ['main-tag']; + createComponent({ stubs }, { tags: mockTags, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js new file mode 100644 index 00000000000..eccb90b0c94 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js @@ -0,0 +1,99 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import Api from '~/api'; +import PipelineTriggerAuthorToken from '~/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue'; +import { users } from 'jest/ci/pipeline_details/mock_data'; + +describe('Pipeline Trigger Author Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const defaultProps = { + config: { + type: 'username', + icon: 'user', + title: 'Trigger author', + dataType: 'username', + unique: true, + triggerAuthors: users, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = (data) => { + wrapper = shallowMount(PipelineTriggerAuthorToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `
    `, + }), + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project users', () => { + expect(Api.projectUsers).toHaveBeenCalled(); + + expect(wrapper.vm.users).toEqual(users); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows trigger authors correctly', () => { + beforeEach(() => {}); + + it('renders all trigger authors', () => { + createComponent({ users, loading: false }); + + // should have length of all users plus the static 'Any' option + expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1); + }); + + it('renders only the trigger author searched for', () => { + createComponent({ + users: [{ name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }], + loading: false, + }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(2); + }); + }); +}); diff --git a/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap b/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap index b5a4cb42463..2de634a6209 100644 --- a/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap +++ b/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap @@ -2,10 +2,9 @@ exports[`IssueStatusIcon renders "failed" state correctly 1`] = `
    @@ -14,10 +13,9 @@ exports[`IssueStatusIcon renders "failed" state correctly 1`] = ` exports[`IssueStatusIcon renders "neutral" state correctly 1`] = `
    @@ -29,7 +27,6 @@ exports[`IssueStatusIcon renders "success" state correctly 1`] = ` class="report-block-list-icon success" > diff --git a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js index ad20d7682ed..bc77b7b89dd 100644 --- a/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js @@ -13,7 +13,6 @@ import { INSTANCE_TYPE, I18N_INSTANCE_TYPE, PROJECT_TYPE, - I18N_NO_DESCRIPTION, I18N_CREATED_AT_LABEL, I18N_CREATED_AT_BY_LABEL, } from '~/ci/runner/constants'; @@ -102,15 +101,6 @@ describe('RunnerTypeCell', () => { it('Displays the runner description', () => { expect(wrapper.text()).toContain(mockRunner.description); - expect(wrapper.findByText(I18N_NO_DESCRIPTION).exists()).toBe(false); - }); - - it('Displays "No description" for missing runner description', () => { - createComponent({ - runner: { description: null }, - }); - - expect(wrapper.findByText(I18N_NO_DESCRIPTION).classes()).toContain('gl-text-secondary'); }); it('Displays last contact', () => { diff --git a/spec/frontend/ci/runner/components/runner_create_form_spec.js b/spec/frontend/ci/runner/components/runner_create_form_spec.js index c452e32b0e4..3c5f8c4d6a9 100644 --- a/spec/frontend/ci/runner/components/runner_create_form_spec.js +++ b/spec/frontend/ci/runner/components/runner_create_form_spec.js @@ -61,6 +61,7 @@ describe('RunnerCreateForm', () => { createComponent(); expect(findRunnerFormFields().props('value')).toEqual(defaultRunnerModel); + expect(findRunnerFormFields().props('runnerType')).toEqual(INSTANCE_TYPE); }); it('shows a submit button', () => { diff --git a/spec/frontend/ci/runner/components/runner_form_fields_spec.js b/spec/frontend/ci/runner/components/runner_form_fields_spec.js index 93be4d9d35e..7e39a6b72f9 100644 --- a/spec/frontend/ci/runner/components/runner_form_fields_spec.js +++ b/spec/frontend/ci/runner/components/runner_form_fields_spec.js @@ -132,8 +132,8 @@ describe('RunnerFormFields', () => { it('when runner is of project type, locked checkbox can be checked', async () => { createComponent({ + runnerType: PROJECT_TYPE, value: { - runnerType: PROJECT_TYPE, locked: false, }, }); @@ -144,7 +144,6 @@ describe('RunnerFormFields', () => { expect(wrapper.emitted('input').at(-1)).toEqual([ { - runnerType: PROJECT_TYPE, locked: true, }, ]); diff --git a/spec/frontend/ci/runner/components/runner_managers_table_spec.js b/spec/frontend/ci/runner/components/runner_managers_table_spec.js index cde6ee6eea0..d5782e21a2f 100644 --- a/spec/frontend/ci/runner/components/runner_managers_table_spec.js +++ b/spec/frontend/ci/runner/components/runner_managers_table_spec.js @@ -60,8 +60,8 @@ describe('RunnerJobs', () => { it('shows status', () => { createComponent(); - expect(findCellText({ field: 'status', i: 0 })).toBe(s__('Runners|Online')); - expect(findCellText({ field: 'status', i: 1 })).toBe(s__('Runners|Online')); + expect(findCellText({ field: 'status', i: 0 })).toContain(s__('Runners|Online')); + expect(findCellText({ field: 'status', i: 0 })).toContain(s__('Runners|Idle')); }); it('shows version', () => { diff --git a/spec/frontend/ci/runner/components/runner_update_form_spec.js b/spec/frontend/ci/runner/components/runner_update_form_spec.js index 5851078a8d3..2ba1c31fe52 100644 --- a/spec/frontend/ci/runner/components/runner_update_form_spec.js +++ b/spec/frontend/ci/runner/components/runner_update_form_spec.js @@ -15,6 +15,7 @@ import RunnerUpdateForm from '~/ci/runner/components/runner_update_form.vue'; import runnerUpdateMutation from '~/ci/runner/graphql/edit/runner_update.mutation.graphql'; import { captureException } from '~/ci/runner/sentry_utils'; import { saveAlertToLocalStorage } from '~/ci/runner/local_storage_alert/save_alert_to_local_storage'; +import { INSTANCE_TYPE } from '~/ci/runner/constants'; import { runnerFormData } from '../mock_data'; jest.mock('~/ci/runner/local_storage_alert/save_alert_to_local_storage'); @@ -119,6 +120,7 @@ describe('RunnerUpdateForm', () => { it('shows runner fields', () => { expect(findRunnerFormFields().props('value')).toEqual(runnerToModel(mockRunner)); + expect(findRunnerFormFields().props('runnerType')).toEqual(INSTANCE_TYPE); }); it('form has not been submitted', () => { diff --git a/spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap b/spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap index 79194c20ff5..d0c1987829f 100644 --- a/spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap +++ b/spec/frontend/ci_secure_files/components/metadata/__snapshots__/modal_spec.js.snap @@ -13,22 +13,16 @@ exports[`Secure File Metadata Modal when a .cer file is supplied matches cer the
    - - - - - - -
    -
    @@ -72,21 +63,16 @@ exports[`Secure File Metadata Modal when a .cer file is supplied matches cer the - - Apple Distribution: Team Name (ABC123XYZ) - + Apple Distribution: Team Name (ABC123XYZ)
    @@ -95,21 +81,16 @@ exports[`Secure File Metadata Modal when a .cer file is supplied matches cer the - - 33669367788748363528491290218354043267 - + 33669367788748363528491290218354043267
    @@ -118,21 +99,16 @@ exports[`Secure File Metadata Modal when a .cer file is supplied matches cer the - - Team Name (ABC123XYZ) - + Team Name (ABC123XYZ)
    @@ -141,21 +117,16 @@ exports[`Secure File Metadata Modal when a .cer file is supplied matches cer the - - Apple Worldwide Developer Relations Certification Authority - G3 - + Apple Worldwide Developer Relations Certification Authority - G3
    @@ -164,18 +135,12 @@ exports[`Secure File Metadata Modal when a .cer file is supplied matches cer the - - April 26, 2023 at 7:20:39 PM GMT - + April 26, 2023 at 7:20:39 PM GMT
    @@ -194,22 +159,16 @@ exports[`Secure File Metadata Modal when a .mobileprovision file is supplied mat
    - - - - - - -
    -
    @@ -253,21 +209,16 @@ exports[`Secure File Metadata Modal when a .mobileprovision file is supplied mat - - 6b9fcce1-b9a9-4b37-b2ce-ec4da2044abf - + 6b9fcce1-b9a9-4b37-b2ce-ec4da2044abf
    @@ -276,21 +227,16 @@ exports[`Secure File Metadata Modal when a .mobileprovision file is supplied mat - - iOS - + iOS
    @@ -299,21 +245,16 @@ exports[`Secure File Metadata Modal when a .mobileprovision file is supplied mat - - Team Name (ABC123XYZ) - + Team Name (ABC123XYZ)
    @@ -322,21 +263,16 @@ exports[`Secure File Metadata Modal when a .mobileprovision file is supplied mat - - iOS Demo - match Development com.gitlab.ios-demo - + iOS Demo - match Development com.gitlab.ios-demo
    @@ -345,21 +281,16 @@ exports[`Secure File Metadata Modal when a .mobileprovision file is supplied mat - - 33669367788748363528491290218354043267 - + 33669367788748363528491290218354043267
    @@ -368,18 +299,12 @@ exports[`Secure File Metadata Modal when a .mobileprovision file is supplied mat - - August 1, 2023 at 11:15:13 PM GMT - + August 1, 2023 at 11:15:13 PM GMT
    diff --git a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap index 21ffda8578a..f90acb5cb22 100644 --- a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap @@ -1,9 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`NewCluster renders the cluster component correctly 1`] = ` -"
    -

    Enter your Kubernetes cluster certificate details

    -

    Enter details about your cluster. How do I use a certificate to connect to my cluster? +

    +

    + Enter your Kubernetes cluster certificate details +

    +

    + Enter details about your cluster. + + How do I use a certificate to connect to my cluster? +

    -
    " +
    `; diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index 67b0ecdf7eb..b5fc3247165 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -5,42 +5,27 @@ exports[`Remove cluster confirmation modal renders buttons with modal included 1 class="gl-display-flex" > - - -
    `; @@ -49,63 +34,44 @@ exports[`Remove cluster confirmation modal two buttons open modal with "cleanup" class="gl-display-flex" > - -

    You are about to remove your cluster integration and all GitLab-created resources associated with this cluster.

    -
    - This will permanently delete the following resources: -
    • Any project namespaces
    • -
    • clusterroles
    • -
    • clusterrolebindings @@ -113,15 +79,13 @@ exports[`Remove cluster confirmation modal two buttons open modal with "cleanup"
    - - To remove your integration and resources, type + To remove your integration and resources, type my-test-cluster - to confirm: + to confirm: -
    - - -
    - If you do not wish to delete all associated GitLab resources, you can simply remove the integration. @@ -165,58 +125,40 @@ exports[`Remove cluster confirmation modal two buttons open modal without "clean class="gl-display-flex" > - -

    You are about to remove your cluster integration.

    - - - - To remove your integration, type + To remove your integration, type my-test-cluster - to confirm: + to confirm: -
    - - -
    - -
    `; diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap index 36d2c2cabc5..1c2bdd2f8bc 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -2,14 +2,13 @@ exports[`Code navigation popover component renders popover 1`] = `
    -
    -
    +
                 
                   
    -                 main() {
    +                main() {
                   
    -               
                   
    } -
    -
    - Go to definition -
    - -

    - No references found -

    diff --git a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap index 60c87aa10eb..24b2677f497 100644 --- a/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap +++ b/spec/frontend/comment_templates/components/__snapshots__/list_item_spec.js.snap @@ -13,23 +13,20 @@ exports[`Comment templates list item component renders list item 1`] = ` > test -
    -
    -
    • - - Edit - + Edit
    • @@ -106,36 +94,25 @@ exports[`Comment templates list item component renders list item 1`] = ` - - Delete - + Delete
    -
    -
    - Comment template actions -
    -
    - /assign_reviewer -
    - - `; diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js index 3b3e5098857..891cd0a6b83 100644 --- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -7,11 +7,11 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue'; -import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; import { COMMIT_BOX_POLL_INTERVAL } from '~/projects/commit_box/info/constants'; -import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; -import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql'; import * as sharedGraphQlUtils from '~/graphql_shared/utils'; import { mockDownstreamQueryResponse, diff --git a/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js b/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js new file mode 100644 index 00000000000..4af292e3588 --- /dev/null +++ b/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js @@ -0,0 +1,362 @@ +import { GlLoadingIcon, GlModal, GlTableLite } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import LegacyPipelinesTableWraper from '~/commit/pipelines/legacy_pipelines_table_wrapper.vue'; +import { + HTTP_STATUS_BAD_REQUEST, + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_OK, + HTTP_STATUS_UNAUTHORIZED, +} from '~/lib/utils/http_status'; +import { createAlert } from '~/alert'; +import { TOAST_MESSAGE } from '~/ci/pipeline_details/constants'; +import axios from '~/lib/utils/axios_utils'; + +const $toast = { + show: jest.fn(), +}; + +jest.mock('~/alert'); + +describe('Pipelines table in Commits and Merge requests', () => { + let wrapper; + let pipeline; + let mock; + const showMock = jest.fn(); + + const findRunPipelineBtn = () => wrapper.findByTestId('run_pipeline_button'); + const findRunPipelineBtnMobile = () => wrapper.findByTestId('run_pipeline_button_mobile'); + const findLoadingState = () => wrapper.findComponent(GlLoadingIcon); + const findErrorEmptyState = () => wrapper.findByTestId('pipeline-error-empty-state'); + const findEmptyState = () => wrapper.findByTestId('pipeline-empty-state'); + const findTable = () => wrapper.findComponent(GlTableLite); + const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); + const findModal = () => wrapper.findComponent(GlModal); + const findMrPipelinesDocsLink = () => wrapper.findByTestId('mr-pipelines-docs-link'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = extendedWrapper( + mount(LegacyPipelinesTableWraper, { + propsData: { + endpoint: 'endpoint.json', + emptyStateSvgPath: 'foo', + errorStateSvgPath: 'foo', + ...props, + }, + mocks: { + $toast, + }, + stubs: { + GlModal: stubComponent(GlModal, { + template: '
    ', + methods: { show: showMock }, + }), + }, + }), + ); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + const { pipelines } = fixture; + + pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); + }); + + describe('successful request', () => { + describe('without pipelines', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, []); + + createComponent(); + + await waitForPromises(); + }); + + it('should render the empty state', () => { + expect(findTableRows()).toHaveLength(0); + expect(findLoadingState().exists()).toBe(false); + expect(findErrorEmptyState().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + }); + + it('should render correct empty state content', () => { + expect(findRunPipelineBtn().exists()).toBe(true); + expect(findMrPipelinesDocsLink().attributes('href')).toBe( + '/help/ci/pipelines/merge_request_pipelines.md#prerequisites', + ); + expect(findEmptyState().text()).toContain( + 'To run a merge request pipeline, the jobs in the CI/CD configuration file must be configured to run in merge request pipelines.', + ); + }); + }); + + describe('with pagination', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipeline], { + 'X-TOTAL': 10, + 'X-PER-PAGE': 2, + 'X-PAGE': 1, + 'X-TOTAL-PAGES': 5, + 'X-NEXT-PAGE': 2, + 'X-PREV-PAGE': 2, + }); + + createComponent(); + + await waitForPromises(); + }); + + it('should make an API request when using pagination', async () => { + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0].params.page).toBe('1'); + + wrapper.find('.next-page-item').trigger('click'); + + await waitForPromises(); + + expect(mock.history.get).toHaveLength(2); + expect(mock.history.get[1].params.page).toBe('2'); + }); + }); + + describe('with pipelines', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipeline], { 'x-total': 10 }); + + createComponent(); + + await waitForPromises(); + }); + + it('should render a table with the received pipelines', () => { + expect(findTable().exists()).toBe(true); + expect(findTableRows()).toHaveLength(1); + expect(findLoadingState().exists()).toBe(false); + expect(findErrorEmptyState().exists()).toBe(false); + }); + + describe('pipeline badge counts', () => { + it('should receive update-pipelines-count event', () => { + const element = document.createElement('div'); + document.body.appendChild(element); + + return new Promise((resolve) => { + element.addEventListener('update-pipelines-count', (event) => { + expect(event.detail.pipelineCount).toEqual(10); + resolve(); + }); + + createComponent(); + + element.appendChild(wrapper.vm.$el); + }); + }); + }); + }); + }); + + describe('run pipeline button', () => { + let pipelineCopy; + + beforeEach(() => { + pipelineCopy = { ...pipeline }; + }); + + describe('when latest pipeline has detached flag', () => { + it('renders the run pipeline button', async () => { + pipelineCopy.flags.detached_merge_request_pipeline = true; + pipelineCopy.flags.merge_request_pipeline = true; + + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]); + + createComponent(); + + await waitForPromises(); + + expect(findRunPipelineBtn().exists()).toBe(true); + expect(findRunPipelineBtnMobile().exists()).toBe(true); + }); + }); + + describe('when latest pipeline does not have detached flag', () => { + it('does not render the run pipeline button', async () => { + pipelineCopy.flags.detached_merge_request_pipeline = false; + pipelineCopy.flags.merge_request_pipeline = false; + + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]); + + createComponent(); + + await waitForPromises(); + + expect(findRunPipelineBtn().exists()).toBe(false); + expect(findRunPipelineBtnMobile().exists()).toBe(false); + }); + }); + + describe('on click', () => { + beforeEach(async () => { + pipelineCopy.flags.detached_merge_request_pipeline = true; + + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]); + + createComponent({ + props: { + canRunPipeline: true, + projectId: '5', + mergeRequestId: 3, + }, + }); + + await waitForPromises(); + }); + describe('success', () => { + beforeEach(() => { + jest.spyOn(Api, 'postMergeRequestPipeline').mockResolvedValue(); + }); + it('displays a toast message during pipeline creation', async () => { + await findRunPipelineBtn().trigger('click'); + + expect($toast.show).toHaveBeenCalledWith(TOAST_MESSAGE); + }); + + it('on desktop, shows a loading button', async () => { + await findRunPipelineBtn().trigger('click'); + + expect(findRunPipelineBtn().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findRunPipelineBtn().props('loading')).toBe(false); + }); + + it('on mobile, shows a loading button', async () => { + await findRunPipelineBtnMobile().trigger('click'); + + expect(findRunPipelineBtn().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findRunPipelineBtn().props('disabled')).toBe(false); + expect(findRunPipelineBtn().props('loading')).toBe(false); + }); + }); + + describe('failure', () => { + const permissionsMsg = 'You do not have permission to run a pipeline on this branch.'; + const defaultMsg = + 'An error occurred while trying to run a new pipeline for this merge request.'; + + it.each` + status | message + ${HTTP_STATUS_BAD_REQUEST} | ${defaultMsg} + ${HTTP_STATUS_UNAUTHORIZED} | ${permissionsMsg} + ${HTTP_STATUS_INTERNAL_SERVER_ERROR} | ${defaultMsg} + `('displays permissions error message', async ({ status, message }) => { + const response = { response: { status } }; + + jest.spyOn(Api, 'postMergeRequestPipeline').mockRejectedValue(response); + + await findRunPipelineBtn().trigger('click'); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message, + primaryButton: { + text: 'Learn more', + link: '/help/ci/pipelines/merge_request_pipelines.md', + }, + }); + }); + }); + }); + + describe('on click for fork merge request', () => { + beforeEach(async () => { + pipelineCopy.flags.detached_merge_request_pipeline = true; + + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]); + + createComponent({ + props: { + projectId: '5', + mergeRequestId: 3, + canCreatePipelineInTargetProject: true, + sourceProjectFullPath: 'test/parent-project', + targetProjectFullPath: 'test/fork-project', + }, + }); + + jest.spyOn(Api, 'postMergeRequestPipeline').mockResolvedValue(); + + await waitForPromises(); + }); + + it('on desktop, shows a security warning modal', async () => { + await findRunPipelineBtn().trigger('click'); + + await nextTick(); + + expect(findModal()).not.toBeNull(); + }); + + it('on mobile, shows a security warning modal', async () => { + await findRunPipelineBtnMobile().trigger('click'); + + expect(findModal()).not.toBeNull(); + }); + }); + + describe('when no pipelines were created on a forked merge request', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, []); + + createComponent({ + props: { + projectId: '5', + mergeRequestId: 3, + canCreatePipelineInTargetProject: true, + sourceProjectFullPath: 'test/parent-project', + targetProjectFullPath: 'test/fork-project', + }, + }); + + await waitForPromises(); + }); + + it('should show security modal from empty state run pipeline button', () => { + expect(findEmptyState().exists()).toBe(true); + expect(findModal().exists()).toBe(true); + + findRunPipelineBtn().trigger('click'); + + expect(showMock).toHaveBeenCalled(); + }); + }); + }); + + describe('unsuccessfull request', () => { + beforeEach(async () => { + mock.onGet('endpoint.json').reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, []); + + createComponent(); + + await waitForPromises(); + }); + + it('should render error state', () => { + expect(findErrorEmptyState().text()).toBe( + 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', + ); + }); + }); +}); diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js deleted file mode 100644 index 009ec68ddcf..00000000000 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ /dev/null @@ -1,362 +0,0 @@ -import { GlLoadingIcon, GlModal, GlTableLite } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import fixture from 'test_fixtures/pipelines/pipelines.json'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { stubComponent } from 'helpers/stub_component'; -import waitForPromises from 'helpers/wait_for_promises'; -import Api from '~/api'; -import PipelinesTable from '~/commit/pipelines/pipelines_table.vue'; -import { - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_OK, - HTTP_STATUS_UNAUTHORIZED, -} from '~/lib/utils/http_status'; -import { createAlert } from '~/alert'; -import { TOAST_MESSAGE } from '~/pipelines/constants'; -import axios from '~/lib/utils/axios_utils'; - -const $toast = { - show: jest.fn(), -}; - -jest.mock('~/alert'); - -describe('Pipelines table in Commits and Merge requests', () => { - let wrapper; - let pipeline; - let mock; - const showMock = jest.fn(); - - const findRunPipelineBtn = () => wrapper.findByTestId('run_pipeline_button'); - const findRunPipelineBtnMobile = () => wrapper.findByTestId('run_pipeline_button_mobile'); - const findLoadingState = () => wrapper.findComponent(GlLoadingIcon); - const findErrorEmptyState = () => wrapper.findByTestId('pipeline-error-empty-state'); - const findEmptyState = () => wrapper.findByTestId('pipeline-empty-state'); - const findTable = () => wrapper.findComponent(GlTableLite); - const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); - const findModal = () => wrapper.findComponent(GlModal); - const findMrPipelinesDocsLink = () => wrapper.findByTestId('mr-pipelines-docs-link'); - - const createComponent = ({ props = {} } = {}) => { - wrapper = extendedWrapper( - mount(PipelinesTable, { - propsData: { - endpoint: 'endpoint.json', - emptyStateSvgPath: 'foo', - errorStateSvgPath: 'foo', - ...props, - }, - mocks: { - $toast, - }, - stubs: { - GlModal: stubComponent(GlModal, { - template: '
    ', - methods: { show: showMock }, - }), - }, - }), - ); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - - const { pipelines } = fixture; - - pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); - }); - - describe('successful request', () => { - describe('without pipelines', () => { - beforeEach(async () => { - mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, []); - - createComponent(); - - await waitForPromises(); - }); - - it('should render the empty state', () => { - expect(findTableRows()).toHaveLength(0); - expect(findLoadingState().exists()).toBe(false); - expect(findErrorEmptyState().exists()).toBe(false); - expect(findEmptyState().exists()).toBe(true); - }); - - it('should render correct empty state content', () => { - expect(findRunPipelineBtn().exists()).toBe(true); - expect(findMrPipelinesDocsLink().attributes('href')).toBe( - '/help/ci/pipelines/merge_request_pipelines.md#prerequisites', - ); - expect(findEmptyState().text()).toContain( - 'To run a merge request pipeline, the jobs in the CI/CD configuration file must be configured to run in merge request pipelines.', - ); - }); - }); - - describe('with pagination', () => { - beforeEach(async () => { - mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipeline], { - 'X-TOTAL': 10, - 'X-PER-PAGE': 2, - 'X-PAGE': 1, - 'X-TOTAL-PAGES': 5, - 'X-NEXT-PAGE': 2, - 'X-PREV-PAGE': 2, - }); - - createComponent(); - - await waitForPromises(); - }); - - it('should make an API request when using pagination', async () => { - expect(mock.history.get).toHaveLength(1); - expect(mock.history.get[0].params.page).toBe('1'); - - wrapper.find('.next-page-item').trigger('click'); - - await waitForPromises(); - - expect(mock.history.get).toHaveLength(2); - expect(mock.history.get[1].params.page).toBe('2'); - }); - }); - - describe('with pipelines', () => { - beforeEach(async () => { - mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipeline], { 'x-total': 10 }); - - createComponent(); - - await waitForPromises(); - }); - - it('should render a table with the received pipelines', () => { - expect(findTable().exists()).toBe(true); - expect(findTableRows()).toHaveLength(1); - expect(findLoadingState().exists()).toBe(false); - expect(findErrorEmptyState().exists()).toBe(false); - }); - - describe('pipeline badge counts', () => { - it('should receive update-pipelines-count event', () => { - const element = document.createElement('div'); - document.body.appendChild(element); - - return new Promise((resolve) => { - element.addEventListener('update-pipelines-count', (event) => { - expect(event.detail.pipelineCount).toEqual(10); - resolve(); - }); - - createComponent(); - - element.appendChild(wrapper.vm.$el); - }); - }); - }); - }); - }); - - describe('run pipeline button', () => { - let pipelineCopy; - - beforeEach(() => { - pipelineCopy = { ...pipeline }; - }); - - describe('when latest pipeline has detached flag', () => { - it('renders the run pipeline button', async () => { - pipelineCopy.flags.detached_merge_request_pipeline = true; - pipelineCopy.flags.merge_request_pipeline = true; - - mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]); - - createComponent(); - - await waitForPromises(); - - expect(findRunPipelineBtn().exists()).toBe(true); - expect(findRunPipelineBtnMobile().exists()).toBe(true); - }); - }); - - describe('when latest pipeline does not have detached flag', () => { - it('does not render the run pipeline button', async () => { - pipelineCopy.flags.detached_merge_request_pipeline = false; - pipelineCopy.flags.merge_request_pipeline = false; - - mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]); - - createComponent(); - - await waitForPromises(); - - expect(findRunPipelineBtn().exists()).toBe(false); - expect(findRunPipelineBtnMobile().exists()).toBe(false); - }); - }); - - describe('on click', () => { - beforeEach(async () => { - pipelineCopy.flags.detached_merge_request_pipeline = true; - - mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]); - - createComponent({ - props: { - canRunPipeline: true, - projectId: '5', - mergeRequestId: 3, - }, - }); - - await waitForPromises(); - }); - describe('success', () => { - beforeEach(() => { - jest.spyOn(Api, 'postMergeRequestPipeline').mockResolvedValue(); - }); - it('displays a toast message during pipeline creation', async () => { - await findRunPipelineBtn().trigger('click'); - - expect($toast.show).toHaveBeenCalledWith(TOAST_MESSAGE); - }); - - it('on desktop, shows a loading button', async () => { - await findRunPipelineBtn().trigger('click'); - - expect(findRunPipelineBtn().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findRunPipelineBtn().props('loading')).toBe(false); - }); - - it('on mobile, shows a loading button', async () => { - await findRunPipelineBtnMobile().trigger('click'); - - expect(findRunPipelineBtn().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findRunPipelineBtn().props('disabled')).toBe(false); - expect(findRunPipelineBtn().props('loading')).toBe(false); - }); - }); - - describe('failure', () => { - const permissionsMsg = 'You do not have permission to run a pipeline on this branch.'; - const defaultMsg = - 'An error occurred while trying to run a new pipeline for this merge request.'; - - it.each` - status | message - ${HTTP_STATUS_BAD_REQUEST} | ${defaultMsg} - ${HTTP_STATUS_UNAUTHORIZED} | ${permissionsMsg} - ${HTTP_STATUS_INTERNAL_SERVER_ERROR} | ${defaultMsg} - `('displays permissions error message', async ({ status, message }) => { - const response = { response: { status } }; - - jest.spyOn(Api, 'postMergeRequestPipeline').mockRejectedValue(response); - - await findRunPipelineBtn().trigger('click'); - - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message, - primaryButton: { - text: 'Learn more', - link: '/help/ci/pipelines/merge_request_pipelines.md', - }, - }); - }); - }); - }); - - describe('on click for fork merge request', () => { - beforeEach(async () => { - pipelineCopy.flags.detached_merge_request_pipeline = true; - - mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [pipelineCopy]); - - createComponent({ - props: { - projectId: '5', - mergeRequestId: 3, - canCreatePipelineInTargetProject: true, - sourceProjectFullPath: 'test/parent-project', - targetProjectFullPath: 'test/fork-project', - }, - }); - - jest.spyOn(Api, 'postMergeRequestPipeline').mockResolvedValue(); - - await waitForPromises(); - }); - - it('on desktop, shows a security warning modal', async () => { - await findRunPipelineBtn().trigger('click'); - - await nextTick(); - - expect(findModal()).not.toBeNull(); - }); - - it('on mobile, shows a security warning modal', async () => { - await findRunPipelineBtnMobile().trigger('click'); - - expect(findModal()).not.toBeNull(); - }); - }); - - describe('when no pipelines were created on a forked merge request', () => { - beforeEach(async () => { - mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, []); - - createComponent({ - props: { - projectId: '5', - mergeRequestId: 3, - canCreatePipelineInTargetProject: true, - sourceProjectFullPath: 'test/parent-project', - targetProjectFullPath: 'test/fork-project', - }, - }); - - await waitForPromises(); - }); - - it('should show security modal from empty state run pipeline button', () => { - expect(findEmptyState().exists()).toBe(true); - expect(findModal().exists()).toBe(true); - - findRunPipelineBtn().trigger('click'); - - expect(showMock).toHaveBeenCalled(); - }); - }); - }); - - describe('unsuccessfull request', () => { - beforeEach(async () => { - mock.onGet('endpoint.json').reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, []); - - createComponent(); - - await waitForPromises(); - }); - - it('should render error state', () => { - expect(findErrorEmptyState().text()).toBe( - 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', - ); - }); - }); -}); diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap index d9f161b47b1..b17987dad89 100644 --- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap +++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap @@ -7,17 +7,13 @@ exports[`Confidential merge request project form group component renders empty s -
    - -

    - - No forks are available to you. + No forks are available to you.
    - To protect this issue's confidentiality, + To protect this issue's confidentiality, fork this project - and set the fork's visibility to private. + and set the fork's visibility to private. @@ -36,7 +32,6 @@ exports[`Confidential merge request project form group component renders empty s > Read more - Project -

    -

    - - To protect this issue's confidentiality, a private fork of this project was selected. - + To protect this issue's confidentiality, a private fork of this project was selected. @@ -77,7 +68,6 @@ exports[`Confidential merge request project form group component renders fork dr > Read more - - - - -" + + + `; diff --git a/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap b/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap index a9d42769789..e058f05fec4 100644 --- a/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap +++ b/spec/frontend/content_editor/components/wrappers/__snapshots__/table_of_contents_spec.js.snap @@ -2,23 +2,18 @@ exports[`content/components/wrappers/table_of_contents collects all headings and renders a nested list of headings 1`] = `

    - Table of contents -
  • - - Heading 1 - + Heading 1 -
  • `; diff --git a/spec/frontend/content_editor/components/wrappers/code_block_spec.js b/spec/frontend/content_editor/components/wrappers/code_block_spec.js index e802681dfc6..0093393eceb 100644 --- a/spec/frontend/content_editor/components/wrappers/code_block_spec.js +++ b/spec/frontend/content_editor/components/wrappers/code_block_spec.js @@ -11,6 +11,9 @@ import CodeBlockWrapper from '~/content_editor/components/wrappers/code_block.vu import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; import { emitEditorEvent, createTestEditor, mockChainedCommands } from '../../test_utils'; +// Disabled due to eslint reporting errors for inline snapshots +/* eslint-disable no-irregular-whitespace */ + const SAMPLE_README_CONTENT = `# Sample README This is a sample README. @@ -212,12 +215,20 @@ describe('content/components/wrappers/code_block', () => { it('shows a code suggestion block', () => { expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 5'); - expect(findCodeDeleted()).toMatchInlineSnapshot( - `"## Usage\u200b"`, - ); - expect(findCodeAdded()).toMatchInlineSnapshot( - `"\u200b"`, - ); + expect(findCodeDeleted()).toMatchInlineSnapshot(` + + ## Usage​ + + `); + expect(findCodeAdded()).toMatchInlineSnapshot(` + + ​ + + `); }); describe('decrement line start button', () => { @@ -232,9 +243,11 @@ describe('content/components/wrappers/code_block', () => { expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 4 to 5'); expect(findCodeDeleted()).toMatchInlineSnapshot(` - "\u200b + + ​ - ## Usage\u200b" `); }); @@ -248,15 +261,11 @@ describe('content/components/wrappers/code_block', () => { expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 1 to 5'); expect(findCodeDeleted()).toMatchInlineSnapshot(` - "# Sample README\u200b - - \u200b - - This is a sample README.\u200b - - \u200b + + # Sample README​ - ## Usage\u200b" `); expect(button.attributes('disabled')).toBe('disabled'); @@ -291,9 +300,11 @@ describe('content/components/wrappers/code_block', () => { expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 4 to 5'); expect(findCodeDeleted()).toMatchInlineSnapshot(` - "\u200b + + ​ - ## Usage\u200b" `); }); }); @@ -326,9 +337,11 @@ describe('content/components/wrappers/code_block', () => { expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 6'); expect(findCodeDeleted()).toMatchInlineSnapshot(` - "## Usage\u200b + + ## Usage​ - \u200b" `); }); }); @@ -345,9 +358,11 @@ describe('content/components/wrappers/code_block', () => { expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 6'); expect(findCodeDeleted()).toMatchInlineSnapshot(` - "## Usage\u200b + + ## Usage​ - \u200b" `); }); @@ -361,15 +376,11 @@ describe('content/components/wrappers/code_block', () => { expect(findCodeSuggestionBoxText()).toContain('Suggested change From line 5 to 9'); expect(findCodeDeleted()).toMatchInlineSnapshot(` - "## Usage\u200b - - \u200b - - \`\`\`yaml\u200b - - foo: bar\u200b + + ## Usage​ - \`\`\`\u200b" `); expect(button.attributes('disabled')).toBe('disabled'); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 7be8114902a..3eb00f69345 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -175,7 +175,7 @@ describe('markdownSerializer', () => { inlineDiff({ type: 'deletion' }, '-10 lines'), ), ), - ).toBe('{+\\+30 lines+}{-\\-10 lines-}'); + ).toBe('{++30 lines+}{--10 lines-}'); }); it('correctly serializes highlight', () => { diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_created_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_created_spec.js index 4be4aa50dfc..50b12244a55 100644 --- a/spec/frontend/contribution_events/components/contribution_event/contribution_event_created_spec.js +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_created_spec.js @@ -23,15 +23,15 @@ describe('ContributionEventCreated', () => { }; describe.each` - event | expectedMessage | expectedIconName | expectedIconClass - ${eventProjectCreated()} | ${'Created project %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} - ${eventMilestoneCreated()} | ${'Opened milestone %{targetLink} in %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} - ${eventIssueCreated()} | ${'Opened issue %{targetLink} in %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} - ${eventMergeRequestCreated()} | ${'Opened merge request %{targetLink} in %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} - ${eventWikiPageCreated()} | ${'Created wiki page %{targetLink} in %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} - ${eventDesignCreated()} | ${'Added design %{targetLink} in %{resourceParentLink}.'} | ${'upload'} | ${null} - ${{ resource_parent: { type: 'unsupported type' } }} | ${'Created resource.'} | ${'status_open'} | ${'gl-text-green-500'} - ${{ target: { type: 'unsupported type' } }} | ${'Created resource.'} | ${'status_open'} | ${'gl-text-green-500'} + event | expectedMessage | expectedIconName | expectedIconClass + ${eventProjectCreated()} | ${'Created project %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} + ${eventMilestoneCreated()} | ${'Opened milestone %{targetLink} in %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} + ${eventIssueCreated()} | ${'Opened issue %{targetLink} in %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} + ${eventMergeRequestCreated()} | ${'Opened merge request %{targetLink} in %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} + ${eventWikiPageCreated()} | ${'Created wiki page %{targetLink} in %{resourceParentLink}.'} | ${'status_open'} | ${'gl-text-green-500'} + ${eventDesignCreated()} | ${'Added design %{targetLink} in %{resourceParentLink}.'} | ${'upload'} | ${null} + ${{ resource_parent: { type: 'unsupported type' }, target: { type: null } }} | ${'Created resource.'} | ${'status_open'} | ${'gl-text-green-500'} + ${{ target: { type: 'unsupported type' } }} | ${'Created resource.'} | ${'status_open'} | ${'gl-text-green-500'} `( 'when event target type is $event.target.type', ({ event, expectedMessage, expectedIconName, expectedIconClass }) => { diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_destroyed_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_destroyed_spec.js new file mode 100644 index 00000000000..b296b75ce0a --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_destroyed_spec.js @@ -0,0 +1,32 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventDestroyed from '~/contribution_events/components/contribution_event/contribution_event_destroyed.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import { eventDesignDestroyed, eventWikiPageDestroyed, eventMilestoneDestroyed } from '../../utils'; + +describe('ContributionEventDestroyed', () => { + let wrapper; + + const createComponent = ({ propsData }) => { + wrapper = shallowMountExtended(ContributionEventDestroyed, { + propsData, + }); + }; + + describe.each` + event | expectedMessage | iconName + ${eventDesignDestroyed()} | ${'Archived design in %{resourceParentLink}.'} | ${'archive'} + ${eventWikiPageDestroyed()} | ${'Deleted wiki page in %{resourceParentLink}.'} | ${'remove'} + ${eventMilestoneDestroyed()} | ${'Deleted milestone in %{resourceParentLink}.'} | ${'remove'} + ${{ target: { type: 'unsupported type' } }} | ${'Deleted resource.'} | ${'remove'} + `('when event target type is $event.target.type', ({ event, expectedMessage, iconName }) => { + it('renders `ContributionEventBase` with correct props', () => { + createComponent({ propsData: { event } }); + + expect(wrapper.findComponent(ContributionEventBase).props()).toMatchObject({ + event, + message: expectedMessage, + iconName, + }); + }); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_event/contribution_event_updated_spec.js b/spec/frontend/contribution_events/components/contribution_event/contribution_event_updated_spec.js new file mode 100644 index 00000000000..e8e25b24dc9 --- /dev/null +++ b/spec/frontend/contribution_events/components/contribution_event/contribution_event_updated_spec.js @@ -0,0 +1,31 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ContributionEventUpdated from '~/contribution_events/components/contribution_event/contribution_event_updated.vue'; +import ContributionEventBase from '~/contribution_events/components/contribution_event/contribution_event_base.vue'; +import { eventDesignUpdated, eventWikiPageUpdated } from '../../utils'; + +describe('ContributionEventUpdated', () => { + let wrapper; + + const createComponent = ({ propsData }) => { + wrapper = shallowMountExtended(ContributionEventUpdated, { + propsData, + }); + }; + + describe.each` + event | expectedMessage + ${eventDesignUpdated()} | ${'Updated design %{targetLink} in %{resourceParentLink}.'} + ${eventWikiPageUpdated()} | ${'Updated wiki page %{targetLink} in %{resourceParentLink}.'} + ${{ target: { type: 'unsupported type' } }} | ${'Updated resource.'} + `('when event target type is $event.target.type', ({ event, expectedMessage }) => { + it('renders `ContributionEventBase` with correct props', () => { + createComponent({ propsData: { event } }); + + expect(wrapper.findComponent(ContributionEventBase).props()).toMatchObject({ + event, + message: expectedMessage, + iconName: 'pencil', + }); + }); + }); +}); diff --git a/spec/frontend/contribution_events/components/contribution_events_spec.js b/spec/frontend/contribution_events/components/contribution_events_spec.js index 7493d248e2b..dc460a698bd 100644 --- a/spec/frontend/contribution_events/components/contribution_events_spec.js +++ b/spec/frontend/contribution_events/components/contribution_events_spec.js @@ -11,6 +11,8 @@ import ContributionEventCreated from '~/contribution_events/components/contribut import ContributionEventClosed from '~/contribution_events/components/contribution_event/contribution_event_closed.vue'; import ContributionEventReopened from '~/contribution_events/components/contribution_event/contribution_event_reopened.vue'; import ContributionEventCommented from '~/contribution_events/components/contribution_event/contribution_event_commented.vue'; +import ContributionEventUpdated from '~/contribution_events/components/contribution_event/contribution_event_updated.vue'; +import ContributionEventDestroyed from '~/contribution_events/components/contribution_event/contribution_event_destroyed.vue'; import { eventApproved, eventExpired, @@ -23,6 +25,8 @@ import { eventClosed, eventReopened, eventCommented, + eventUpdated, + eventDestroyed, } from '../utils'; describe('ContributionEvents', () => { @@ -43,6 +47,8 @@ describe('ContributionEvents', () => { eventClosed(), eventReopened(), eventCommented(), + eventUpdated(), + eventDestroyed(), ], }, }); @@ -61,6 +67,8 @@ describe('ContributionEvents', () => { ${ContributionEventClosed} | ${eventClosed()} ${ContributionEventReopened} | ${eventReopened()} ${ContributionEventCommented} | ${eventCommented()} + ${ContributionEventUpdated} | ${eventUpdated()} + ${ContributionEventDestroyed} | ${eventDestroyed()} `( 'renders `$expectedComponent.name` component and passes expected event', ({ expectedComponent, expectedEvent }) => { diff --git a/spec/frontend/contribution_events/components/target_link_spec.js b/spec/frontend/contribution_events/components/target_link_spec.js index 40650b3585c..968a9d3bd3d 100644 --- a/spec/frontend/contribution_events/components/target_link_spec.js +++ b/spec/frontend/contribution_events/components/target_link_spec.js @@ -49,7 +49,7 @@ describe('TargetLink', () => { }); }); - describe('when target is not defined', () => { + describe('when target type is not defined', () => { beforeEach(() => { createComponent({ propsData: { event: eventJoined() } }); }); diff --git a/spec/frontend/contribution_events/utils.js b/spec/frontend/contribution_events/utils.js index 8b34506c6ac..f91a4dd800b 100644 --- a/spec/frontend/contribution_events/utils.js +++ b/spec/frontend/contribution_events/utils.js @@ -10,6 +10,8 @@ import { EVENT_TYPE_CLOSED, EVENT_TYPE_REOPENED, EVENT_TYPE_COMMENTED, + EVENT_TYPE_UPDATED, + EVENT_TYPE_DESTROYED, PUSH_EVENT_REF_TYPE_BRANCH, PUSH_EVENT_REF_TYPE_TAG, EVENT_TYPE_CREATED, @@ -32,22 +34,12 @@ import { COMMIT_NOTEABLE_TYPE, } from '~/notes/constants'; +// Private finders const findEventByAction = (action) => () => events.find((event) => event.action === action); const findEventByActionAndTargetType = (action, targetType) => () => events.find((event) => event.action === action && event.target?.type === targetType); const findEventByActionAndIssueType = (action, issueType) => () => events.find((event) => event.action === action && event.target.issue_type === issueType); - -export const eventApproved = findEventByAction(EVENT_TYPE_APPROVED); - -export const eventExpired = findEventByAction(EVENT_TYPE_EXPIRED); - -export const eventJoined = findEventByAction(EVENT_TYPE_JOINED); - -export const eventLeft = findEventByAction(EVENT_TYPE_LEFT); - -export const eventMerged = findEventByAction(EVENT_TYPE_MERGED); - const findPushEvent = ({ isNew = false, isRemoved = false, @@ -62,6 +54,45 @@ const findPushEvent = ({ ref.type === refType && commit.count === commitCount, ); +const findEventByActionAndNoteableType = (action, noteableType) => () => + events.find((event) => event.action === action && event.noteable?.type === noteableType); +const findCommentedSnippet = (resourceParentType) => () => + events.find( + (event) => + event.action === EVENT_TYPE_COMMENTED && + event.noteable?.type === SNIPPET_NOTEABLE_TYPE && + event.resource_parent?.type === resourceParentType, + ); +const findUpdatedEvent = (targetType) => + findEventByActionAndTargetType(EVENT_TYPE_UPDATED, targetType); +const findDestroyedEvent = (targetType) => + findEventByActionAndTargetType(EVENT_TYPE_DESTROYED, targetType); + +// Finders that are used by EE +export const findCreatedEvent = (targetType) => + findEventByActionAndTargetType(EVENT_TYPE_CREATED, targetType); +export const findWorkItemCreatedEvent = (issueType) => + findEventByActionAndIssueType(EVENT_TYPE_CREATED, issueType); +export const findClosedEvent = (targetType) => + findEventByActionAndTargetType(EVENT_TYPE_CREATED, targetType); +export const findWorkItemClosedEvent = (issueType) => + findEventByActionAndIssueType(EVENT_TYPE_CLOSED, issueType); +export const findReopenedEvent = (targetType) => + findEventByActionAndTargetType(EVENT_TYPE_REOPENED, targetType); +export const findWorkItemReopenedEvent = (issueType) => + findEventByActionAndIssueType(EVENT_TYPE_REOPENED, issueType); +export const findCommentedEvent = (noteableType) => + findEventByActionAndNoteableType(EVENT_TYPE_COMMENTED, noteableType); + +export const eventApproved = findEventByAction(EVENT_TYPE_APPROVED); + +export const eventExpired = findEventByAction(EVENT_TYPE_EXPIRED); + +export const eventJoined = findEventByAction(EVENT_TYPE_JOINED); + +export const eventLeft = findEventByAction(EVENT_TYPE_LEFT); + +export const eventMerged = findEventByAction(EVENT_TYPE_MERGED); export const eventPushedNewBranch = findPushEvent({ isNew: true }); export const eventPushedNewTag = findPushEvent({ isNew: true, refType: PUSH_EVENT_REF_TYPE_TAG }); @@ -77,13 +108,7 @@ export const eventBulkPushedBranch = findPushEvent({ commitCount: 5 }); export const eventPrivate = () => ({ ...events[0], action: EVENT_TYPE_PRIVATE }); export const eventCreated = findEventByAction(EVENT_TYPE_CREATED); - -export const findCreatedEvent = (targetType) => - findEventByActionAndTargetType(EVENT_TYPE_CREATED, targetType); -export const findWorkItemCreatedEvent = (issueType) => - findEventByActionAndIssueType(EVENT_TYPE_CREATED, issueType); - -export const eventProjectCreated = findCreatedEvent(undefined); +export const eventProjectCreated = findCreatedEvent(null); export const eventMilestoneCreated = findCreatedEvent(TARGET_TYPE_MILESTONE); export const eventIssueCreated = findCreatedEvent(TARGET_TYPE_ISSUE); export const eventMergeRequestCreated = findCreatedEvent(TARGET_TYPE_MERGE_REQUEST); @@ -93,12 +118,6 @@ export const eventTaskCreated = findWorkItemCreatedEvent(WORK_ITEM_ISSUE_TYPE_TA export const eventIncidentCreated = findWorkItemCreatedEvent(WORK_ITEM_ISSUE_TYPE_INCIDENT); export const eventClosed = findEventByAction(EVENT_TYPE_CLOSED); - -export const findClosedEvent = (targetType) => - findEventByActionAndTargetType(EVENT_TYPE_CREATED, targetType); -export const findWorkItemClosedEvent = (issueType) => - findEventByActionAndIssueType(EVENT_TYPE_CLOSED, issueType); - export const eventMilestoneClosed = findClosedEvent(TARGET_TYPE_MILESTONE); export const eventIssueClosed = findClosedEvent(TARGET_TYPE_ISSUE); export const eventMergeRequestClosed = findClosedEvent(TARGET_TYPE_MERGE_REQUEST); @@ -108,12 +127,6 @@ export const eventTaskClosed = findWorkItemClosedEvent(WORK_ITEM_ISSUE_TYPE_TASK export const eventIncidentClosed = findWorkItemClosedEvent(WORK_ITEM_ISSUE_TYPE_INCIDENT); export const eventReopened = findEventByAction(EVENT_TYPE_REOPENED); - -export const findReopenedEvent = (targetType) => - findEventByActionAndTargetType(EVENT_TYPE_REOPENED, targetType); -export const findWorkItemReopenedEvent = (issueType) => - findEventByActionAndIssueType(EVENT_TYPE_REOPENED, issueType); - export const eventMilestoneReopened = findReopenedEvent(TARGET_TYPE_MILESTONE); export const eventMergeRequestReopened = findReopenedEvent(TARGET_TYPE_MERGE_REQUEST); export const eventWikiPageReopened = findReopenedEvent(TARGET_TYPE_WIKI); @@ -123,19 +136,6 @@ export const eventTaskReopened = findWorkItemReopenedEvent(WORK_ITEM_ISSUE_TYPE_ export const eventIncidentReopened = findWorkItemReopenedEvent(WORK_ITEM_ISSUE_TYPE_INCIDENT); export const eventCommented = findEventByAction(EVENT_TYPE_COMMENTED); - -const findEventByActionAndNoteableType = (action, noteableType) => () => - events.find((event) => event.action === action && event.noteable?.type === noteableType); -export const findCommentedEvent = (noteableType) => - findEventByActionAndNoteableType(EVENT_TYPE_COMMENTED, noteableType); -export const findCommentedSnippet = (resourceParentType) => () => - events.find( - (event) => - event.action === EVENT_TYPE_COMMENTED && - event.noteable?.type === SNIPPET_NOTEABLE_TYPE && - event.resource_parent?.type === resourceParentType, - ); - export const eventCommentedIssue = findCommentedEvent(ISSUE_NOTEABLE_TYPE); export const eventCommentedMergeRequest = findCommentedEvent(MERGE_REQUEST_NOTEABLE_TYPE); export const eventCommentedSnippet = findCommentedEvent(SNIPPET_NOTEABLE_TYPE); @@ -153,3 +153,12 @@ export const eventCommentedCommit = () => ({ first_line_in_markdown: '\u003cp\u003eMy title 9\u003c/p\u003e', }, }); + +export const eventUpdated = findEventByAction(EVENT_TYPE_UPDATED); +export const eventDesignUpdated = findUpdatedEvent(TARGET_TYPE_DESIGN); +export const eventWikiPageUpdated = findUpdatedEvent(TARGET_TYPE_WIKI); + +export const eventDestroyed = findEventByAction(EVENT_TYPE_DESTROYED); +export const eventDesignDestroyed = findDestroyedEvent(TARGET_TYPE_DESIGN); +export const eventWikiPageDestroyed = findDestroyedEvent(TARGET_TYPE_WIKI); +export const eventMilestoneDestroyed = findDestroyedEvent(TARGET_TYPE_MILESTONE); diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap index 5cfb4702be7..8b76a627c1e 100644 --- a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -3,7 +3,7 @@ exports[`Contributors charts should render charts and a RefSelector when loading completed and there is chart data 1`] = ` -
    @@ -48,11 +41,9 @@ exports[`Contributors charts should render charts and a RefSelector when loading > Commits to main - Excluding merge commits. Limited to 6,000 commits. - -

    John

    -

    - - 2 commits (jawnnypoo@gmail.com) - + 2 commits (jawnnypoo@gmail.com)

    -
    - -
    +
    - - - - - -
    -
    - - created-at - + created-at
    - -
    -
    diff --git a/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap index 560533891c9..8560b80ac9c 100644 --- a/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap +++ b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap @@ -2,17 +2,16 @@ exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = `
    -
    -
    -
    - - - -
    + class="gl-align-items-center gl-display-flex gl-h-full gl-relative gl-w-full" + />
    `; exports[`Design management design presentation component renders image and overlay when image provided 1`] = `
    - - - test
    @@ -18,11 +16,9 @@ exports[`Design management large image component renders image 1`] = `
    - - test
    @@ -33,12 +29,10 @@ exports[`Design management large image component renders loading state 1`] = ` class="gl-mx-auto gl-my-auto js-design-image" isloading="true" > - -
    `; @@ -55,8 +49,6 @@ exports[`Design management large image component sets correct classes and styles
    - - test - - test - Please + Please register - or + or sign in - to reply. + to reply.
    `; @@ -24,18 +24,18 @@ exports[`DesignNoteSignedOut renders message containing register and sign-in lin
    - Please + Please register - or + or sign in - to start a new discussion. + to start a new discussion.
    `; diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap index 4dc8eaea174..206187c3530 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap @@ -1,17 +1,33 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` -"" + `; exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` -"" + `; diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap index 0bbb44bb517..53359b02b4c 100644 --- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap +++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap @@ -9,36 +9,27 @@ exports[`Design management list item component when item appears in view after i `; exports[`Design management list item component with notes renders item with multiple comments 1`] = ` -
    - - - - test
    - -
    + `; exports[`Design management list item component with notes renders item with single comment 1`] = ` -
    - - - - test
    - -
    + `; diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js index 4a0ad5a045b..14e8a5579ba 100644 --- a/spec/frontend/design_management/components/list/item_spec.js +++ b/spec/frontend/design_management/components/list/item_spec.js @@ -1,5 +1,5 @@ import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueRouter from 'vue-router'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -49,7 +49,7 @@ describe('Design management list item component', () => { imageLoading, }; }, - stubs: ['router-link'], + stubs: { RouterLink: RouterLinkStub }, }), ); } diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap index 3c4aa0f4d3c..cf8aac22f67 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap @@ -2,14 +2,14 @@ exports[`Design management toolbar component renders design and updated data 1`] = `
    -

    test.jpg

    - @@ -34,12 +32,10 @@ exports[`Design management toolbar component renders design and updated data 1`]
    - - - - Upload designs - - @@ -37,15 +34,12 @@ exports[`Design management upload button component renders upload design button title="Adding a design with the same filename replaces the file in a new version." variant="confirm" > - Upload designs - - 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 f0615f61059..224e35e9f5e 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 @@ -2,86 +2,70 @@ exports[`Design management design index page renders design index 1`] = `
    - - - -
    -
    To Do -
    -

    - - My precious issue - + My precious issue

    - ull-issue-path - - - - - - - -
    `; exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = `
    -
    @@ -144,82 +125,64 @@ exports[`Design management design index page with error GlAlert is rendered in c title="" variant="danger" > - woops -
    - -
    -
    To Do -
    -

    - - My precious issue - + My precious issue

    - ull-issue-path - - - - - - - -
    `; diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index c1f0966f9c6..e10aad6214c 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -11,7 +11,7 @@ import CommitWidget from '~/diffs/components/commit_widget.vue'; import CompareVersions from '~/diffs/components/compare_versions.vue'; import DiffFile from '~/diffs/components/diff_file.vue'; import NoChanges from '~/diffs/components/no_changes.vue'; -import findingsDrawer from '~/diffs/components/shared/findings_drawer.vue'; +import FindingsDrawer from '~/diffs/components/shared/findings_drawer.vue'; import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue'; import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; @@ -251,6 +251,11 @@ describe('diffs/components/app', () => { await nextTick(); expect(store.state.diffs.currentDiffFileId).toBe('ABC'); }); + + it('renders findings-drawer', () => { + createComponent(); + expect(wrapper.findComponent(FindingsDrawer).exists()).toBe(true); + }); }); it('marks current diff file based on currently highlighted row', async () => { @@ -755,20 +760,4 @@ describe('diffs/components/app', () => { ); }); }); - - describe('findings-drawer', () => { - it('does not render findings-drawer when codeQualityInlineDrawer flag is off', () => { - createComponent(); - expect(wrapper.findComponent(findingsDrawer).exists()).toBe(false); - }); - - it('does render findings-drawer when codeQualityInlineDrawer flag is on', () => { - createComponent({}, () => {}, { - glFeatures: { - codeQualityInlineDrawer: true, - }, - }); - expect(wrapper.findComponent(findingsDrawer).exists()).toBe(true); - }); - }); }); diff --git a/spec/frontend/diffs/components/diff_inline_findings_item_spec.js b/spec/frontend/diffs/components/diff_inline_findings_item_spec.js index 72d96d3435f..cda3273d51e 100644 --- a/spec/frontend/diffs/components/diff_inline_findings_item_spec.js +++ b/spec/frontend/diffs/components/diff_inline_findings_item_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DiffInlineFindingsItem from '~/diffs/components/diff_inline_findings_item.vue'; import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; @@ -8,19 +8,13 @@ let wrapper; const [codeQualityFinding] = multipleFindingsArrCodeQualityScale; const findIcon = () => wrapper.findComponent(GlIcon); -const findButton = () => wrapper.findComponent(GlLink); const findDescriptionPlainText = () => wrapper.findByTestId('description-plain-text'); -const findDescriptionLinkSection = () => wrapper.findByTestId('description-button-section'); describe('DiffCodeQuality', () => { - const createWrapper = ({ glFeatures = {}, link = true } = {}) => { + const createWrapper = () => { return shallowMountExtended(DiffInlineFindingsItem, { propsData: { finding: codeQualityFinding, - link, - }, - provide: { - glFeatures, }, }); }; @@ -36,42 +30,9 @@ describe('DiffCodeQuality', () => { }); }); - describe('with codeQualityInlineDrawer flag false', () => { - it('should render severity + description in plain text', () => { - wrapper = createWrapper({ - glFeatures: { - codeQualityInlineDrawer: false, - }, - }); - expect(findDescriptionPlainText().text()).toContain(codeQualityFinding.severity); - expect(findDescriptionPlainText().text()).toContain(codeQualityFinding.description); - }); - }); - - describe('with codeQualityInlineDrawer flag true', () => { - const [{ description, severity }] = multipleFindingsArrCodeQualityScale; - const renderedText = `${severity} - ${description}`; - it('when link prop is true, should render gl-link', () => { - wrapper = createWrapper({ - glFeatures: { - codeQualityInlineDrawer: true, - }, - }); - - expect(findButton().exists()).toBe(true); - expect(findButton().text()).toBe(renderedText); - }); - - it('when link prop is false, should not render gl-link', () => { - wrapper = createWrapper({ - glFeatures: { - codeQualityInlineDrawer: true, - }, - link: false, - }); - - expect(findButton().exists()).toBe(false); - expect(findDescriptionLinkSection().text()).toBe(renderedText); - }); + it('should render severity + description in plain text', () => { + wrapper = createWrapper(); + expect(findDescriptionPlainText().text()).toContain(codeQualityFinding.severity); + expect(findDescriptionPlainText().text()).toContain(codeQualityFinding.description); }); }); diff --git a/spec/frontend/diffs/components/diff_inline_findings_spec.js b/spec/frontend/diffs/components/diff_inline_findings_spec.js index 65b2abe7dd5..f654a2e2d4f 100644 --- a/spec/frontend/diffs/components/diff_inline_findings_spec.js +++ b/spec/frontend/diffs/components/diff_inline_findings_spec.js @@ -2,7 +2,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DiffInlineFindings from '~/diffs/components/diff_inline_findings.vue'; import DiffInlineFindingsItem from '~/diffs/components/diff_inline_findings_item.vue'; import { NEW_CODE_QUALITY_FINDINGS } from '~/diffs/i18n'; -import { multipleCodeQualityNoSast } from '../mock_data/inline_findings'; +import { multipleFindingsArrCodeQualityScale } from '../mock_data/inline_findings'; let wrapper; const heading = () => wrapper.findByTestId('diff-inline-findings-heading'); @@ -13,7 +13,7 @@ describe('DiffInlineFindings', () => { return shallowMountExtended(DiffInlineFindings, { propsData: { title: NEW_CODE_QUALITY_FINDINGS, - findings: multipleCodeQualityNoSast.codeQuality, + findings: multipleFindingsArrCodeQualityScale, }, }); }; @@ -25,7 +25,7 @@ describe('DiffInlineFindings', () => { it('renders the correct number of DiffInlineFindingsItem components with correct props', () => { wrapper = createWrapper(); - expect(diffInlineFindingsItems()).toHaveLength(multipleCodeQualityNoSast.codeQuality.length); + expect(diffInlineFindingsItems()).toHaveLength(multipleFindingsArrCodeQualityScale.length); expect(diffInlineFindingsItems().wrappers[0].props('finding')).toEqual( wrapper.props('findings')[0], ); diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index 8a67d7b152c..30510958704 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -71,7 +71,8 @@ describe('DiffRow', () => { const hits = coverageFileData[file]?.[line]; if (hits) { return { text: `Test coverage: ${hits} hits`, class: 'coverage' }; - } else if (hits === 0) { + } + if (hits === 0) { return { text: 'No test coverage', class: 'no-coverage' }; } diff --git a/spec/frontend/diffs/components/inline_findings_spec.js b/spec/frontend/diffs/components/inline_findings_spec.js index 71cc6ae49fd..102287a23b6 100644 --- a/spec/frontend/diffs/components/inline_findings_spec.js +++ b/spec/frontend/diffs/components/inline_findings_spec.js @@ -2,7 +2,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import InlineFindings from '~/diffs/components/inline_findings.vue'; import DiffInlineFindings from '~/diffs/components/diff_inline_findings.vue'; import { NEW_CODE_QUALITY_FINDINGS } from '~/diffs/i18n'; -import { threeCodeQualityFindingsRaw } from '../mock_data/inline_findings'; +import { threeCodeQualityFindings } from '../mock_data/inline_findings'; let wrapper; @@ -12,7 +12,7 @@ describe('InlineFindings', () => { const createWrapper = () => { return mountExtended(InlineFindings, { propsData: { - codeQuality: threeCodeQualityFindingsRaw, + codeQuality: threeCodeQualityFindings, }, }); }; @@ -28,6 +28,6 @@ describe('InlineFindings', () => { it('renders diff inline findings component with correct props for codequality array', () => { wrapper = createWrapper(); expect(diffInlineFindings().props('title')).toBe(NEW_CODE_QUALITY_FINDINGS); - expect(diffInlineFindings().props('findings')).toBe(threeCodeQualityFindingsRaw); + expect(diffInlineFindings().props('findings')).toBe(threeCodeQualityFindings); }); }); diff --git a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap index 51bd8f380ee..afa2a7d9678 100644 --- a/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap +++ b/spec/frontend/diffs/components/shared/__snapshots__/findings_drawer_spec.js.snap @@ -9,15 +9,13 @@ exports[`FindingsDrawer matches the snapshot 1`] = ` zindex="252" >

    - - Unused method argument - \`c\`. If it's necessary, use \`_\` or \`_c\` as an argument name to indicate that it won't be used. - + Unused method argument - \`c\`. If it's necessary, use \`_\` or \`_c\` as an argument name to indicate that it won't be used.

    • Severity: - - - - minor - + minor
    • -
    • Engine: - - testengine name - + testengine name
    • -
    • Category: - - testcategory 1 - + testcategory 1
    • -
    • Other locations: -
        @@ -115,7 +101,6 @@ exports[`FindingsDrawer matches the snapshot 1`] = `
    - { let editorFacade; let drawioIFrameReceivedMessages; const diagramURL = `${window.location.origin}/uploads/diagram.drawio.svg`; - const testSvg = ''; - const testEncodedSvg = `data:image/svg+xml;base64,${btoa(testSvg)}`; + const testSvg = '😀'; + const testEncodedSvg = `data:image/svg+xml;base64,${base64EncodeUnicode(testSvg)}`; const filename = 'diagram.drawio.svg'; const findDrawioIframe = () => document.getElementById(DRAWIO_FRAME_ID); diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml index 909911debf1..3076105ffde 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml @@ -33,6 +33,17 @@ include: rules: - exists: - file.md + - local: builds.yml + rules: + - if: $INCLUDE_BUILDS == "true" + changes: + - 'test.yml' + - local: builds.yml + rules: + - changes: + paths: + - 'test.yml' + compare_to: 'master' # valid trigger:include trigger:include accepts project and file properties: diff --git a/spec/frontend/emoji/index_spec.js b/spec/frontend/emoji/index_spec.js index 1b948cce73a..1a12bd303f1 100644 --- a/spec/frontend/emoji/index_spec.js +++ b/spec/frontend/emoji/index_spec.js @@ -134,9 +134,11 @@ describe('emoji', () => { const emojiKey = 'bomb'; const markup = glEmojiTag(emojiKey); - expect(trimText(markup)).toMatchInlineSnapshot( - `""`, - ); + expect(trimText(markup)).toMatchInlineSnapshot(` + + `); }); it('bomb emoji with sprite fallback readiness', () => { @@ -144,9 +146,12 @@ describe('emoji', () => { const markup = glEmojiTag(emojiKey, { sprite: true, }); - expect(trimText(markup)).toMatchInlineSnapshot( - `""`, - ); + expect(trimText(markup)).toMatchInlineSnapshot(` + + `); }); }); diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js index b55bbb34c65..9989c946800 100644 --- a/spec/frontend/environments/edit_environment_spec.js +++ b/spec/frontend/environments/edit_environment_spec.js @@ -7,7 +7,6 @@ import EditEnvironment from '~/environments/components/edit_environment.vue'; import { createAlert } from '~/alert'; import { visitUrl } from '~/lib/utils/url_utility'; import getEnvironment from '~/environments/graphql/queries/environment.query.graphql'; -import getEnvironmentWithFluxResource from '~/environments/graphql/queries/environment_with_flux_resource.query.graphql'; import updateEnvironment from '~/environments/graphql/mutations/update_environment.mutation.graphql'; import { __ } from '~/locale'; import createMockApollo from '../__helpers__/mock_apollo_helper'; @@ -44,9 +43,6 @@ describe('~/environments/components/edit.vue', () => { let wrapper; const getEnvironmentQuery = jest.fn().mockResolvedValue({ data: resolvedEnvironment }); - const getEnvironmentWithFluxResourceQuery = jest - .fn() - .mockResolvedValue({ data: resolvedEnvironment }); const updateEnvironmentSuccess = jest .fn() @@ -60,24 +56,17 @@ describe('~/environments/components/edit.vue', () => { const mocks = [ [getEnvironment, getEnvironmentQuery], - [getEnvironmentWithFluxResource, getEnvironmentWithFluxResourceQuery], [updateEnvironment, mutationHandler], ]; return createMockApollo(mocks); }; - const createWrapperWithApollo = async ({ - mutationHandler = updateEnvironmentSuccess, - fluxResourceForEnvironment = false, - } = {}) => { + const createWrapperWithApollo = async ({ mutationHandler = updateEnvironmentSuccess } = {}) => { wrapper = mountExtended(EditEnvironment, { propsData: { environment: {} }, provide: { ...provide, - glFeatures: { - fluxResourceForEnvironment, - }, }, apolloProvider: createMockApolloProvider(mutationHandler), }); @@ -170,11 +159,4 @@ describe('~/environments/components/edit.vue', () => { }); }); }); - - describe('when `fluxResourceForEnvironment` is enabled', () => { - it('calls the `getEnvironmentWithFluxResource` query', () => { - createWrapperWithApollo({ fluxResourceForEnvironment: true }); - expect(getEnvironmentWithFluxResourceQuery).toHaveBeenCalled(); - }); - }); }); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index 1b80b596db7..22dd7437d82 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -53,11 +53,7 @@ describe('~/environments/components/form.vue', () => { }, }); - const createWrapperWithApollo = ({ - propsData = {}, - fluxResourceForEnvironment = false, - queryResult = null, - } = {}) => { + const createWrapperWithApollo = ({ propsData = {}, queryResult = null } = {}) => { Vue.use(VueApollo); const requestHandlers = [ @@ -83,9 +79,6 @@ describe('~/environments/components/form.vue', () => { return mountExtended(EnvironmentForm, { provide: { ...PROVIDE, - glFeatures: { - fluxResourceForEnvironment, - }, }, propsData: { ...DEFAULT_PROPS, @@ -422,39 +415,30 @@ describe('~/environments/components/form.vue', () => { }); describe('flux resource selector', () => { - it("doesn't render if `fluxResourceForEnvironment` feature flag is disabled", () => { + beforeEach(() => { wrapper = createWrapperWithApollo(); + }); + + it("doesn't render flux resource selector by default", () => { expect(findFluxResourceSelector().exists()).toBe(false); }); - describe('when `fluxResourceForEnvironment` feature flag is enabled', () => { - beforeEach(() => { - wrapper = createWrapperWithApollo({ - fluxResourceForEnvironment: true, - }); + describe('when the agent was selected', () => { + beforeEach(async () => { + await selectAgent(); }); - it("doesn't render flux resource selector by default", () => { + it("doesn't render flux resource selector", () => { expect(findFluxResourceSelector().exists()).toBe(false); }); - describe('when the agent was selected', () => { - beforeEach(async () => { - await selectAgent(); - }); - - it("doesn't render flux resource selector", () => { - expect(findFluxResourceSelector().exists()).toBe(false); - }); - - it('renders the flux resource selector when the namespace is selected', async () => { - await findNamespaceSelector().vm.$emit('select', 'agent'); + it('renders the flux resource selector when the namespace is selected', async () => { + await findNamespaceSelector().vm.$emit('select', 'agent'); - expect(findFluxResourceSelector().props()).toEqual({ - namespace: 'agent', - fluxResourcePath: '', - configuration, - }); + expect(findFluxResourceSelector().props()).toEqual({ + namespace: 'agent', + fluxResourcePath: '', + configuration, }); }); }); @@ -522,7 +506,6 @@ describe('~/environments/components/form.vue', () => { beforeEach(() => { wrapper = createWrapperWithApollo({ propsData: { environment: environmentWithAgentAndNamespace }, - fluxResourceForEnvironment: true, }); }); diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index bfcc4f4ebb6..7ee31bf2c62 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -13,7 +13,6 @@ import Deployment from '~/environments/components/deployment.vue'; import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue'; import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; import getEnvironmentClusterAgent from '~/environments/graphql/queries/environment_cluster_agent.query.graphql'; -import getEnvironmentClusterAgentWithFluxResource from '~/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql'; import { resolvedEnvironment, rolloutStatus, @@ -27,7 +26,6 @@ Vue.use(VueApollo); describe('~/environments/components/new_environment_item.vue', () => { let wrapper; let queryResponseHandler; - let queryWithFluxResourceResponseHandler; const projectPath = '/1'; @@ -39,27 +37,14 @@ describe('~/environments/components/new_environment_item.vue', () => { environment: { id: '1', kubernetesNamespace: 'default', + fluxResourcePath: fluxResourcePathMock, clusterAgent, }, }, }, }; queryResponseHandler = jest.fn().mockResolvedValue(response); - queryWithFluxResourceResponseHandler = jest.fn().mockResolvedValue({ - data: { - project: { - id: response.data.project.id, - environment: { - ...response.data.project.environment, - fluxResourcePath: fluxResourcePathMock, - }, - }, - }, - }); - return createMockApollo([ - [getEnvironmentClusterAgent, queryResponseHandler], - [getEnvironmentClusterAgentWithFluxResource, queryWithFluxResourceResponseHandler], - ]); + return createMockApollo([[getEnvironmentClusterAgent, queryResponseHandler]]); }; const createWrapper = ({ propsData = {}, provideData = {}, apolloProvider } = {}) => @@ -554,25 +539,6 @@ describe('~/environments/components/new_environment_item.vue', () => { }); }); - it('should request agent data with Flux resource when `fluxResourceForEnvironment` feature flag is enabled', async () => { - wrapper = createWrapper({ - propsData: { environment: resolvedEnvironment }, - provideData: { - glFeatures: { - fluxResourceForEnvironment: true, - }, - }, - apolloProvider: createApolloProvider(agent), - }); - - await expandCollapsedSection(); - - expect(queryWithFluxResourceResponseHandler).toHaveBeenCalledWith({ - environmentName: resolvedEnvironment.name, - projectFullPath: projectPath, - }); - }); - it('should render if the environment has an agent associated', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, @@ -588,14 +554,9 @@ describe('~/environments/components/new_environment_item.vue', () => { }); }); - it('should render with the namespace if `fluxResourceForEnvironment` feature flag is enabled and the environment has an agent associated', async () => { + it('should render with the namespace if the environment has an agent associated', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, - provideData: { - glFeatures: { - fluxResourceForEnvironment: true, - }, - }, apolloProvider: createApolloProvider(agent), }); diff --git a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js index 6156addd63f..b503a6f829e 100644 --- a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js +++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js @@ -1,7 +1,6 @@ -import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -13,87 +12,78 @@ describe('New Environments Dropdown', () => { let wrapper; let axiosMock; - beforeEach(() => { + const createWrapper = (axiosResult = []) => { axiosMock = new MockAdapter(axios); - wrapper = shallowMount(NewEnvironmentsDropdown, { + axiosMock.onGet(TEST_HOST).reply(HTTP_STATUS_OK, axiosResult); + + wrapper = shallowMountExtended(NewEnvironmentsDropdown, { provide: { environmentsEndpoint: TEST_HOST }, + stubs: { + GlCollapsibleListbox, + }, }); - }); + }; + + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findCreateEnvironmentButton = () => wrapper.findByTestId('add-environment-button'); afterEach(() => { axiosMock.restore(); }); describe('before results', () => { + beforeEach(() => { + createWrapper(); + }); + it('should show a loading icon', () => { - axiosMock.onGet(TEST_HOST).reply(() => { - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - wrapper.findComponent(GlSearchBoxByType).vm.$emit('focus'); - return axios.waitForAll(); + expect(findListbox().props('searching')).toBe(true); }); it('should not show any dropdown items', () => { - axiosMock.onGet(TEST_HOST).reply(() => { - expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(0); - }); - wrapper.findComponent(GlSearchBoxByType).vm.$emit('focus'); - return axios.waitForAll(); + expect(findListbox().props('items')).toEqual([]); }); }); describe('with empty results', () => { - let item; beforeEach(async () => { - axiosMock.onGet(TEST_HOST).reply(HTTP_STATUS_OK, []); - wrapper.findComponent(GlSearchBoxByType).vm.$emit('focus'); - wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH); + createWrapper(); + findListbox().vm.$emit('search', TEST_SEARCH); await axios.waitForAll(); - await nextTick(); - item = wrapper.findComponent(GlDropdownItem); }); it('should display a Create item label', () => { - expect(item.text()).toBe('Create production'); - }); - - it('should display that no matching items are found', () => { - expect(wrapper.findComponent({ ref: 'noResults' }).exists()).toBe(true); + expect(findCreateEnvironmentButton().text()).toBe(`Create ${TEST_SEARCH}`); }); it('should emit a new scope when selected', () => { - item.vm.$emit('click'); + findCreateEnvironmentButton().vm.$emit('click'); expect(wrapper.emitted('add')).toEqual([[TEST_SEARCH]]); }); }); describe('with results', () => { - let items; - beforeEach(() => { - axiosMock.onGet(TEST_HOST).reply(HTTP_STATUS_OK, ['prod', 'production']); - wrapper.findComponent(GlSearchBoxByType).vm.$emit('focus'); - wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'prod'); - return axios.waitForAll().then(() => { - items = wrapper.findAllComponents(GlDropdownItem); - }); + beforeEach(async () => { + createWrapper(['prod', 'production']); + findListbox().vm.$emit('search', TEST_SEARCH); + await axios.waitForAll(); }); - it('should display one item per result', () => { - expect(items).toHaveLength(2); + it('should populate results properly', () => { + expect(findListbox().props().items).toHaveLength(2); }); - it('should emit an add if an item is clicked', () => { - items.at(0).vm.$emit('click'); + it('should emit an add on selection', () => { + findListbox().vm.$emit('select', ['prod']); expect(wrapper.emitted('add')).toEqual([['prod']]); }); - it('should not display a create label', () => { - items = items.filter((i) => i.text().startsWith('Create')); - expect(items).toHaveLength(0); - }); - it('should not display a message about no results', () => { expect(wrapper.findComponent({ ref: 'noResults' }).exists()).toBe(false); }); + + it('should not display a footer with the create button', () => { + expect(findCreateEnvironmentButton().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js index ca6e338ac6c..90021829212 100644 --- a/spec/frontend/feature_flags/components/strategy_spec.js +++ b/spec/frontend/feature_flags/components/strategy_spec.js @@ -1,11 +1,14 @@ import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import { last } from 'lodash'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import Strategy from '~/feature_flags/components/strategy.vue'; import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; import { @@ -22,16 +25,18 @@ import { userList } from '../mock_data'; jest.mock('~/api'); +const TEST_HOST = '/test'; const provide = { strategyTypeDocsPagePath: 'link-to-strategy-docs', environmentsScopeDocsPath: 'link-scope-docs', - environmentsEndpoint: '', + environmentsEndpoint: TEST_HOST, }; Vue.use(Vuex); describe('Feature flags strategy', () => { let wrapper; + let axiosMock; const findStrategyParameters = () => wrapper.findComponent(StrategyParameters); const findDocsLinks = () => wrapper.findAllComponents(GlLink); @@ -45,6 +50,8 @@ describe('Feature flags strategy', () => { provide, }, ) => { + axiosMock = new MockAdapter(axios); + axiosMock.onGet(TEST_HOST).reply(HTTP_STATUS_OK, []); wrapper = mount(Strategy, { store: createStore({ projectId: '1' }), ...opts }); }; @@ -52,6 +59,10 @@ describe('Feature flags strategy', () => { Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] }); }); + afterEach(() => { + axiosMock.restore(); + }); + describe('helper links', () => { const propsData = { strategy: {}, index: 0, userLists: [userList] }; factory({ propsData, provide }); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 8c16ff100eb..c55099d89d9 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -24,7 +24,7 @@ describe('Filtered Search Manager', () => { let manager; let tokensContainer; const page = 'issues'; - const placeholder = 'Search or filter results...'; + const placeholder = 'Search or filter results…'; function dispatchBackspaceEvent(element, eventType) { const event = new Event(eventType); diff --git a/spec/frontend/fixtures/abuse_reports.rb b/spec/frontend/fixtures/abuse_reports.rb deleted file mode 100644 index ad0fb9be8dc..00000000000 --- a/spec/frontend/fixtures/abuse_reports.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Admin::AbuseReportsController, '(JavaScript fixtures)', type: :controller do - include JavaScriptFixturesHelpers - include AdminModeHelper - - let(:admin) { create(:admin) } - let!(:abuse_report) { create(:abuse_report) } - let!(:abuse_report_with_short_message) { create(:abuse_report, message: 'SHORT MESSAGE') } - let!(:abuse_report_with_long_message) { create(:abuse_report, message: "LONG MESSAGE\n" * 50) } - - render_views - - before do - stub_feature_flags(abuse_reports_list: false) - - sign_in(admin) - enable_admin_mode!(admin) - end - - it 'abuse_reports/abuse_reports_list.html' do - get :index - - expect(response).to be_successful - end -end diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index 73594ddf686..9e6fcea2d17 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', :with_license, type: :controller do include JavaScriptFixturesHelpers - let(:user) { create(:user, feed_token: 'feedtoken:coldfeed') } + let(:user) { create(:user, :no_super_sidebar, feed_token: 'feedtoken:coldfeed') } let(:namespace) { create(:namespace, name: 'frontend-fixtures') } let(:project) { create(:project_empty_repo, namespace: namespace, path: 'issues-project') } diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb index 6c0b87c5a68..1502999ac9c 100644 --- a/spec/frontend/fixtures/jobs.rb +++ b/spec/frontend/fixtures/jobs.rb @@ -89,28 +89,28 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do end end - it_behaves_like 'graphql queries', 'jobs/components/table/graphql/queries', 'get_jobs.query.graphql' do + it_behaves_like 'graphql queries', 'ci/jobs_page/graphql/queries', 'get_jobs.query.graphql' do let(:variables) { { fullPath: 'frontend-fixtures/builds-project' } } let(:success_path) { %w[project jobs] } end - it_behaves_like 'graphql queries', 'jobs/components/table/graphql/queries', 'get_jobs_count.query.graphql', true do + it_behaves_like 'graphql queries', 'ci/jobs_page/graphql/queries', 'get_jobs_count.query.graphql', true do let(:variables) { { fullPath: 'frontend-fixtures/builds-project' } } let(:success_path) { %w[project jobs] } end - it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_all_jobs.query.graphql' do + it_behaves_like 'graphql queries', 'ci/admin/jobs_table/graphql/queries', 'get_all_jobs.query.graphql' do let(:user) { create(:admin) } let(:success_path) { 'jobs' } end - it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_cancelable_jobs_count.query.graphql', true do + it_behaves_like 'graphql queries', 'ci/admin/jobs_table/graphql/queries', 'get_cancelable_jobs_count.query.graphql', true do let(:variables) { { statuses: %w[PENDING RUNNING] } } let(:user) { create(:admin) } let(:success_path) { %w[cancelable count] } end - it_behaves_like 'graphql queries', 'pages/admin/jobs/components/table/graphql/queries', 'get_all_jobs_count.query.graphql', true do + it_behaves_like 'graphql queries', 'ci/admin/jobs_table/graphql/queries', 'get_all_jobs_count.query.graphql', true do let(:user) { create(:admin) } let(:success_path) { 'jobs' } end diff --git a/spec/frontend/fixtures/pipeline_header.rb b/spec/frontend/fixtures/pipeline_header.rb index 3fdc45b1194..744df18a403 100644 --- a/spec/frontend/fixtures/pipeline_header.rb +++ b/spec/frontend/fixtures/pipeline_header.rb @@ -12,7 +12,7 @@ RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :reques let_it_be(:user) { project.first_owner } let_it_be(:commit) { create(:commit, project: project) } - let(:query_path) { 'pipelines/graphql/queries/get_pipeline_header_data.query.graphql' } + let(:query_path) { 'ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql' } context 'with successful pipeline' do let_it_be(:pipeline) do diff --git a/spec/frontend/fixtures/pipeline_schedules.rb b/spec/frontend/fixtures/pipeline_schedules.rb index 7bba7910b87..4c95e7ecd20 100644 --- a/spec/frontend/fixtures/pipeline_schedules.rb +++ b/spec/frontend/fixtures/pipeline_schedules.rb @@ -16,35 +16,6 @@ RSpec.describe 'Pipeline schedules (JavaScript fixtures)' do let!(:pipeline_schedule_variable1) { create(:ci_pipeline_schedule_variable, key: 'foo', value: 'foovalue', pipeline_schedule: pipeline_schedule_populated) } let!(:pipeline_schedule_variable2) { create(:ci_pipeline_schedule_variable, key: 'bar', value: 'barvalue', pipeline_schedule: pipeline_schedule_populated) } - describe Projects::PipelineSchedulesController, type: :controller do - render_views - - before do - sign_in(user) - stub_feature_flags(pipeline_schedules_vue: false) - end - - it 'pipeline_schedules/edit.html' do - get :edit, params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: pipeline_schedule.id - } - - expect(response).to be_successful - end - - it 'pipeline_schedules/edit_with_variables.html' do - get :edit, params: { - namespace_id: project.namespace.to_param, - project_id: project, - id: pipeline_schedule_populated.id - } - - expect(response).to be_successful - end - end - describe GraphQL::Query, type: :request do before do pipeline_schedule.pipelines << build(:ci_pipeline, project: project) diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index 24a6f6f7de6..151d4a763c0 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -71,7 +71,7 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co end let_it_be(:query) do - get_graphql_query_as_string("pipelines/graphql/queries/#{get_pipeline_actions_query}") + get_graphql_query_as_string("ci/pipelines_page/graphql/queries/#{get_pipeline_actions_query}") end it "#{fixtures_path}#{get_pipeline_actions_query}.json" do diff --git a/spec/frontend/fixtures/snippet.rb b/spec/frontend/fixtures/snippet.rb index 0510746a944..23df89a244c 100644 --- a/spec/frontend/fixtures/snippet.rb +++ b/spec/frontend/fixtures/snippet.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe SnippetsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers - let(:namespace) { create(:namespace, name: 'frontend-fixtures') } + let(:user) { create(:user, :no_super_sidebar) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures', owner: user) } let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } - let(:user) { project.first_owner } let(:snippet) { create(:personal_snippet, :public, title: 'snippet.md', content: '# snippet', file_name: 'snippet.md', author: user) } render_views diff --git a/spec/frontend/groups/components/empty_states/groups_dashboard_empty_state_spec.js b/spec/frontend/groups/components/empty_states/groups_dashboard_empty_state_spec.js new file mode 100644 index 00000000000..d2afbad802c --- /dev/null +++ b/spec/frontend/groups/components/empty_states/groups_dashboard_empty_state_spec.js @@ -0,0 +1,29 @@ +import { GlEmptyState } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GroupsDashboardEmptyState from '~/groups/components/empty_states/groups_dashboard_empty_state.vue'; + +let wrapper; + +const defaultProvide = { + groupsEmptyStateIllustration: '/assets/illustrations/empty-state/empty-groups-md.svg', +}; + +const createComponent = () => { + wrapper = shallowMountExtended(GroupsDashboardEmptyState, { + provide: defaultProvide, + }); +}; + +describe('GroupsDashboardEmptyState', () => { + it('renders empty state', () => { + createComponent(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: 'A group is a collection of several projects', + description: + "If you organize your projects under a group, it works like a folder. You can manage your group member's permissions and access to each project in the group.", + svgPath: defaultProvide.groupsEmptyStateIllustration, + }); + }); +}); diff --git a/spec/frontend/groups/components/empty_states/groups_explore_empty_state_spec.js b/spec/frontend/groups/components/empty_states/groups_explore_empty_state_spec.js new file mode 100644 index 00000000000..f4c425902f5 --- /dev/null +++ b/spec/frontend/groups/components/empty_states/groups_explore_empty_state_spec.js @@ -0,0 +1,27 @@ +import { GlEmptyState } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GroupsExploreEmptyState from '~/groups/components/empty_states/groups_explore_empty_state.vue'; + +let wrapper; + +const defaultProvide = { + groupsEmptyStateIllustration: '/assets/illustrations/empty-state/empty-groups-md.svg', +}; + +const createComponent = () => { + wrapper = shallowMountExtended(GroupsExploreEmptyState, { + provide: defaultProvide, + }); +}; + +describe('GroupsExploreEmptyState', () => { + it('renders empty state', () => { + createComponent(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: 'No public groups', + svgPath: defaultProvide.groupsEmptyStateIllustration, + }); + }); +}); diff --git a/spec/frontend/ide/components/file_templates/dropdown_spec.js b/spec/frontend/ide/components/file_templates/dropdown_spec.js deleted file mode 100644 index 9ccdaf8b916..00000000000 --- a/spec/frontend/ide/components/file_templates/dropdown_spec.js +++ /dev/null @@ -1,168 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import $ from 'jquery'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import Dropdown from '~/ide/components/file_templates/dropdown.vue'; - -Vue.use(Vuex); - -describe('IDE file templates dropdown component', () => { - let wrapper; - let element; - let fetchTemplateTypesMock; - - const defaultProps = { - label: 'label', - }; - - const findItemButtons = () => wrapper.findAll('button'); - const findSearch = () => wrapper.find('input[type="search"]'); - const triggerDropdown = () => $(element).trigger('show.bs.dropdown'); - - const createComponent = ({ props, state } = {}) => { - fetchTemplateTypesMock = jest.fn(); - const fakeStore = new Vuex.Store({ - modules: { - fileTemplates: { - namespaced: true, - state: { - templates: [], - isLoading: false, - ...state, - }, - actions: { - fetchTemplateTypes: fetchTemplateTypesMock, - }, - }, - }, - }); - - wrapper = shallowMount(Dropdown, { - propsData: { - ...defaultProps, - ...props, - }, - store: fakeStore, - }); - - ({ element } = wrapper); - }; - - it('calls clickItem on click', async () => { - const itemData = { name: 'test.yml ' }; - createComponent({ props: { data: [itemData] } }); - const item = findItemButtons().at(0); - item.trigger('click'); - - await nextTick(); - expect(wrapper.emitted().click[0][0]).toBe(itemData); - }); - - it('renders dropdown title', () => { - const title = 'Test title'; - createComponent({ props: { title } }); - - expect(wrapper.find('.dropdown-title').text()).toContain(title); - }); - - describe('in async mode', () => { - const defaultAsyncProps = { ...defaultProps, isAsyncData: true }; - - it('calls `fetchTemplateTypes` on dropdown event', () => { - createComponent({ props: defaultAsyncProps }); - - triggerDropdown(); - - expect(fetchTemplateTypesMock).toHaveBeenCalled(); - }); - - it('does not call `fetchTemplateTypes` on dropdown event if destroyed', () => { - createComponent({ props: defaultAsyncProps }); - wrapper.destroy(); - - triggerDropdown(); - - expect(fetchTemplateTypesMock).not.toHaveBeenCalled(); - }); - - it('shows loader when isLoading is true', () => { - createComponent({ props: defaultAsyncProps, state: { isLoading: true } }); - - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - - it('renders templates', () => { - const templates = [{ name: 'file-1' }, { name: 'file-2' }]; - createComponent({ - props: { ...defaultAsyncProps, data: [{ name: 'should-never-appear ' }] }, - state: { - templates, - }, - }); - const items = findItemButtons(); - - expect(items.wrappers.map((x) => x.text())).toEqual(templates.map((x) => x.name)); - }); - - it('searches template data', async () => { - const templates = [{ name: 'match 1' }, { name: 'other' }, { name: 'match 2' }]; - const matches = ['match 1', 'match 2']; - createComponent({ - props: { ...defaultAsyncProps, data: matches, searchable: true }, - state: { templates }, - }); - findSearch().setValue('match'); - await nextTick(); - const items = findItemButtons(); - - expect(items.length).toBe(matches.length); - expect(items.wrappers.map((x) => x.text())).toEqual(matches); - }); - - it('does not render input when `searchable` is true & `showLoading` is true', () => { - createComponent({ - props: { ...defaultAsyncProps, searchable: true }, - state: { isLoading: true }, - }); - - expect(findSearch().exists()).toBe(false); - }); - }); - - describe('in sync mode', () => { - it('renders props data', () => { - const data = [{ name: 'file-1' }, { name: 'file-2' }]; - createComponent({ - props: { data }, - state: { - templates: [{ name: 'should-never-appear ' }], - }, - }); - - const items = findItemButtons(); - - expect(items.length).toBe(data.length); - expect(items.wrappers.map((x) => x.text())).toEqual(data.map((x) => x.name)); - }); - - it('renders input when `searchable` is true', () => { - createComponent({ props: { searchable: true } }); - - expect(findSearch().exists()).toBe(true); - }); - - it('searches data', async () => { - const data = [{ name: 'match 1' }, { name: 'other' }, { name: 'match 2' }]; - const matches = ['match 1', 'match 2']; - createComponent({ props: { searchable: true, data } }); - findSearch().setValue('match'); - await nextTick(); - const items = findItemButtons(); - - expect(items.length).toBe(matches.length); - expect(items.wrappers.map((x) => x.text())).toEqual(matches); - }); - }); -}); diff --git a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap index 069b6927bac..f7b690fb3a4 100644 --- a/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap +++ b/spec/frontend/ide/components/pipelines/__snapshots__/list_spec.js.snap @@ -4,10 +4,8 @@ exports[`IDE pipelines list when loaded renders empty state when no latestPipeli
    - -
    diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index f8af8459025..efbbd6c7514 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -18,6 +18,7 @@ jest.mock('~/lib/utils/csrf', () => ({ const ROOT_ELEMENT_ID = 'ide'; const TEST_NONCE = 'test123nonce'; +const TEST_USERNAME = 'lipsum'; const TEST_PROJECT_PATH = 'group1/project1'; const TEST_BRANCH_NAME = '12345-foo-patch'; const TEST_USER_PREFERENCES_PATH = '/user/preferences'; @@ -69,6 +70,7 @@ describe('ide/init_gitlab_web_ide', () => { }; beforeEach(() => { + gon.current_username = TEST_USERNAME; process.env.GITLAB_WEB_IDE_PUBLIC_PATH = TEST_GITLAB_WEB_IDE_PUBLIC_PATH; confirmAction.mockImplementation( @@ -100,6 +102,7 @@ describe('ide/init_gitlab_web_ide', () => { mrId: TEST_MR_ID, mrTargetProject: '', forkInfo: null, + username: gon.current_username, gitlabUrl: TEST_HOST, nonce: TEST_NONCE, httpHeaders: { diff --git a/spec/frontend/ide/lib/gitlab_web_ide/setup_root_element_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/setup_root_element_spec.js index 35cf41b31f5..011f2564cec 100644 --- a/spec/frontend/ide/lib/gitlab_web_ide/setup_root_element_spec.js +++ b/spec/frontend/ide/lib/gitlab_web_ide/setup_root_element_spec.js @@ -24,8 +24,8 @@ describe('~/ide/lib/gitlab_web_ide/setup_root_element', () => { expect(result).toBe(findIDERoot()); expect(result).toMatchInlineSnapshot(`
    `); }); 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 dae5671777c..03d0920994c 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 @@ -317,7 +317,7 @@ describe('import table', () => { }); it('updates page size when selected in Dropdown', async () => { - const otherOption = findPaginationDropdown().findAll('li p').at(1); + const otherOption = findPaginationDropdown().findAll('.gl-new-dropdown-item-content').at(1); expect(otherOption.text()).toMatchInterpolatedText('50 items per page'); bulkImportSourceGroupsQueryMock.mockResolvedValue({ diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index a0710ddb06c..470d63e7c2a 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -46,7 +46,7 @@ describe('Incidents List', () => { const findLoader = () => wrapper.findComponent(GlLoadingIcon); const findTimeAgo = () => wrapper.findAllComponents(TimeAgoTooltip); const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]'); - const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); + const findCreateIncidentBtn = () => wrapper.find('[data-testid="create-incident-button"]'); const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']"); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findSeverity = () => wrapper.findAllComponents(SeverityToken); diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index b5f8f0023f9..f8a7c47e634 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -2,14 +2,11 @@ exports[`Alert integration settings form should match the default snapshot 1`] = `
    - -

    -
    - - - Reset webhook URL - - - Resetting the webhook URL for this project will require updating this integration's settings in PagerDuty. -
    diff --git a/spec/frontend/integrations/index/mock_data.js b/spec/frontend/integrations/index/mock_data.js index c07b320c0d3..65c1e5643e9 100644 --- a/spec/frontend/integrations/index/mock_data.js +++ b/spec/frontend/integrations/index/mock_data.js @@ -1,6 +1,7 @@ export const mockActiveIntegrations = [ { active: true, + configured: true, title: 'Asana', description: 'Asana - Teamwork without email', updated_at: '2021-03-18T00:27:09.634Z', @@ -10,6 +11,7 @@ export const mockActiveIntegrations = [ }, { active: true, + configured: true, title: 'Jira', description: 'Jira issue tracker', updated_at: '2021-01-29T06:41:25.806Z', @@ -22,6 +24,7 @@ export const mockActiveIntegrations = [ export const mockInactiveIntegrations = [ { active: false, + configured: false, title: 'Webex Teams', description: 'Receive event notifications in Webex Teams', updated_at: null, @@ -31,6 +34,7 @@ export const mockInactiveIntegrations = [ }, { active: false, + configured: false, title: 'YouTrack', description: 'YouTrack issue tracker', updated_at: null, @@ -40,6 +44,7 @@ export const mockInactiveIntegrations = [ }, { active: false, + configured: false, title: 'Atlassian Bamboo CI', description: 'A continuous integration and build server', updated_at: null, @@ -49,6 +54,7 @@ export const mockInactiveIntegrations = [ }, { active: false, + configured: false, title: 'Prometheus', description: 'A monitoring tool for Kubernetes', updated_at: null, 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 1a9b0fae52a..526487f6460 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlModal, GlSprintf, GlFormGroup, GlCollapse, GlIcon } from '@gitlab/ui'; +import { GlModal, GlSprintf, GlFormGroup, GlCollapse, GlIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import { stubComponent } from 'helpers/stub_component'; @@ -12,7 +12,6 @@ import ModalConfetti from '~/invite_members/components/confetti.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue'; import { - INVITE_MEMBERS_FOR_TASK, MEMBERS_MODAL_CELEBRATE_INTRO, MEMBERS_MODAL_CELEBRATE_TITLE, MEMBERS_PLACEHOLDER, @@ -31,7 +30,6 @@ import { HTTP_STATUS_CREATED, HTTP_STATUS_INTERNAL_SERVER_ERROR, } from '~/lib/utils/http_status'; -import { getParameterValues } from '~/lib/utils/url_utility'; import { displaySuccessfulInvitationAlert, reloadOnInvitationSuccess, @@ -54,10 +52,6 @@ import { jest.mock('~/invite_members/utils/trigger_successful_invite_alert'); jest.mock('~/experimentation/experiment_tracking'); -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - getParameterValues: jest.fn(() => []), -})); describe('InviteMembersModal', () => { let wrapper; @@ -129,7 +123,6 @@ describe('InviteMembersModal', () => { }); const findModal = () => wrapper.findComponent(GlModal); - const findBase = () => wrapper.findComponent(InviteModalBase); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); const findEmptyInvitesAlert = () => wrapper.findByTestId('empty-invites-alert'); const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error'); @@ -155,10 +148,6 @@ describe('InviteMembersModal', () => { findMembersFormGroup().attributes('invalid-feedback'); const membersFormGroupDescription = () => findMembersFormGroup().attributes('description'); const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); - const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done'); - const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks'); - const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select'); - const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert'); const findCelebrationEmoji = () => wrapper.findComponent(GlEmoji); const triggerOpenModal = async ({ mode = 'default', source } = {}) => { eventHub.$emit('openModal', { mode, source }); @@ -168,131 +157,11 @@ describe('InviteMembersModal', () => { findMembersSelect().vm.$emit('input', val); await nextTick(); }; - const triggerTasks = async (val) => { - findTasks().vm.$emit('input', val); - await nextTick(); - }; - const triggerAccessLevel = async (val) => { - 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']) => { - getParameterValues.mockImplementation(() => urlParameter); - createComponent(props); - - await triggerAccessLevel(30); - }; - - const setupComponentWithTasks = async (...args) => { - await setupComponent(...args); - await triggerTasks(['ci', 'code']); - }; - - afterAll(() => { - getParameterValues.mockImplementation(() => []); - }); - - it('renders the tasks to be done', async () => { - await setupComponent(); - - expect(findTasksToBeDone().exists()).toBe(true); - }); - - describe('when the selected access level is lower than 30', () => { - it('does not render the tasks to be done', async () => { - await setupComponent(); - await triggerAccessLevel(20); - - expect(findTasksToBeDone().exists()).toBe(false); - }); - }); - - describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => { - it('does not render the tasks to be done', async () => { - await setupComponent({}, []); - - expect(findTasksToBeDone().exists()).toBe(false); - }); - }); - - describe('rendering the tasks', () => { - it('renders the tasks', async () => { - await setupComponent(); - - expect(findTasks().exists()).toBe(true); - }); - - it('does not render an alert', async () => { - await setupComponent(); - - expect(findNoProjectsAlert().exists()).toBe(false); - }); - - describe('when there are no projects passed in the data', () => { - it('does not render the tasks', async () => { - await setupComponent({ projects: [] }); - - expect(findTasks().exists()).toBe(false); - }); - - it('renders an alert with a link to the new projects path', async () => { - await setupComponent({ projects: [] }); - - expect(findNoProjectsAlert().exists()).toBe(true); - expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe( - newProjectPath, - ); - }); - }); - }); - - describe('rendering the project dropdown', () => { - it('renders the project select', async () => { - await setupComponentWithTasks(); - - expect(findProjectSelect().exists()).toBe(true); - }); - - describe('when the modal is shown for a project', () => { - it('does not render the project select', async () => { - await setupComponentWithTasks({ isProject: true }); - - expect(findProjectSelect().exists()).toBe(false); - }); - }); - - describe('when no tasks are selected', () => { - it('does not render the project select', async () => { - await setupComponent(); - - expect(findProjectSelect().exists()).toBe(false); - }); - }); - }); - - describe('tracking events', () => { - it('tracks the submit for invite_members_for_task', async () => { - await setupComponentWithTasks(); - - await triggerMembersTokenSelect([user1]); - - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - clickInviteButton(); - - expectTracking(INVITE_MEMBERS_FOR_TASK.submit, 'selected_tasks_to_be_done', 'ci,code'); - - unmockTracking(); - }); - }); - }); - describe('rendering with tracking considerations', () => { describe('when inviting to a project', () => { describe('when inviting members', () => { @@ -624,6 +493,18 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe(''); expect(findMembersSelect().props('exceptionState')).not.toBe(false); }); + + it('displays invite limit error message', async () => { + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.INVITE_LIMIT); + + clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe( + invitationsApiResponse.INVITE_LIMIT.message, + ); + }); }); }); diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js index e3e2426fcfc..4f773009f37 100644 --- a/spec/frontend/invite_members/mock_data/api_responses.js +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -47,6 +47,11 @@ const EMAIL_TAKEN = { status: 'error', }; +const INVITE_LIMIT = { + message: 'Invite limit of 5 per day exceeded.', + status: 'error', +}; + export const GROUPS_INVITATIONS_PATH = '/api/v4/groups/1/invitations'; export const invitationsApiResponse = { @@ -56,6 +61,7 @@ export const invitationsApiResponse = { MULTIPLE_RESTRICTED, EMAIL_TAKEN, EXPANDED_RESTRICTED, + INVITE_LIMIT, }; export const IMPORT_PROJECT_MEMBERS_PATH = '/api/v4/projects/1/import_project_members/2'; diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js index 67fb1dcbfbd..8cde13bf69c 100644 --- a/spec/frontend/invite_members/mock_data/member_modal.js +++ b/spec/frontend/invite_members/mock_data/member_modal.js @@ -6,14 +6,6 @@ export const propsData = { accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, defaultAccessLevel: 30, helpLink: 'https://example.com', - tasksToBeDoneOptions: [ - { text: 'First task', value: 'first' }, - { text: 'Second task', value: 'second' }, - ], - projects: [ - { text: 'First project', value: '1' }, - { text: 'Second project', value: '2' }, - ], }; export const inviteSource = 'unknown'; @@ -51,8 +43,6 @@ export const postData = { expires_at: undefined, invite_source: inviteSource, format: 'json', - tasks_to_be_done: [], - tasks_project_id: '', }; export const emailPostData = { @@ -60,8 +50,6 @@ export const emailPostData = { expires_at: undefined, email: `${user3.name}`, invite_source: inviteSource, - tasks_to_be_done: [], - tasks_project_id: '', format: 'json', }; @@ -71,8 +59,6 @@ export const singleUserPostData = { user_id: `${user1.id}`, email: `${user3.name}`, invite_source: inviteSource, - tasks_to_be_done: [], - tasks_project_id: '', format: 'json', }; diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js index b6fc70038bb..abae43c3dbb 100644 --- a/spec/frontend/invite_members/utils/member_utils_spec.js +++ b/spec/frontend/invite_members/utils/member_utils_spec.js @@ -1,10 +1,4 @@ -import { - memberName, - triggerExternalAlert, - qualifiesForTasksToBeDone, -} from '~/invite_members/utils/member_utils'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import { getParameterValues } from '~/lib/utils/url_utility'; +import { memberName, triggerExternalAlert } from '~/invite_members/utils/member_utils'; jest.mock('~/lib/utils/url_utility'); @@ -24,17 +18,3 @@ describe('Trigger External Alert', () => { expect(triggerExternalAlert()).toBe(false); }); }); - -describe('Qualifies For Tasks To Be Done', () => { - it.each([ - ['invite_members_for_task', true], - ['blah', false], - ])(`returns name from supplied member token: %j`, (value, result) => { - setWindowLocation(`blah/blah?open_modal=${value}`); - getParameterValues.mockImplementation(() => { - return [value]; - }); - - expect(qualifiesForTasksToBeDone()).toBe(result); - }); -}); diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index ccd53e64c4d..118ba9ab378 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -53,7 +53,7 @@ describe('CsvExportModal', () => { href: 'export/csv/path', variant: 'confirm', 'data-method': 'post', - 'data-qa-selector': `export_issues_button`, + 'data-testid': 'export-issues-button', 'data-track-action': 'click_button', 'data-track-label': dataTrackLabel, }, diff --git a/spec/frontend/issuable/components/issuable_header_warnings_spec.js b/spec/frontend/issuable/components/issuable_header_warnings_spec.js deleted file mode 100644 index 34f36bdf6cb..00000000000 --- a/spec/frontend/issuable/components/issuable_header_warnings_spec.js +++ /dev/null @@ -1,105 +0,0 @@ -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import mrStore from '~/mr_notes/stores'; -import createIssueStore from '~/notes/stores'; -import IssuableHeaderWarnings from '~/issuable/components/issuable_header_warnings.vue'; - -const ISSUABLE_TYPE_ISSUE = 'issue'; -const ISSUABLE_TYPE_MR = 'merge_request'; - -jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); - -describe('IssuableHeaderWarnings', () => { - let wrapper; - - const findConfidentialIcon = () => wrapper.findByTestId('confidential'); - const findLockedIcon = () => wrapper.findByTestId('locked'); - const findHiddenIcon = () => wrapper.findByTestId('hidden'); - - const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render'); - - const createComponent = ({ store, provide }) => { - wrapper = shallowMountExtended(IssuableHeaderWarnings, { - mocks: { - $store: store, - }, - provide, - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, - }); - }; - - describe.each` - issuableType - ${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR} - `(`when issuableType=$issuableType`, ({ issuableType }) => { - describe.each` - lockStatus | confidentialStatus | hiddenStatus - ${true} | ${true} | ${false} - ${true} | ${false} | ${false} - ${false} | ${true} | ${false} - ${false} | ${false} | ${false} - ${true} | ${true} | ${true} - ${true} | ${false} | ${true} - ${false} | ${true} | ${true} - ${false} | ${false} | ${true} - `( - `when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`, - ({ lockStatus, confidentialStatus, hiddenStatus }) => { - const store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : mrStore; - - beforeEach(() => { - // TODO: simplify to single assignment after issue store is mock - if (store === mrStore) { - store.getters.getNoteableData = {}; - } - - store.getters.getNoteableData.confidential = confidentialStatus; - store.getters.getNoteableData.discussion_locked = lockStatus; - store.getters.getNoteableData.targetType = issuableType; - - createComponent({ store, provide: { hidden: hiddenStatus } }); - }); - - it(`${renderTestMessage(lockStatus)} the locked icon`, () => { - const lockedIcon = findLockedIcon(); - - expect(lockedIcon.exists()).toBe(lockStatus); - - if (lockStatus) { - expect(lockedIcon.attributes('title')).toBe( - `This ${issuableType.replace('_', ' ')} is locked. Only project members can comment.`, - ); - expect(getBinding(lockedIcon.element, 'gl-tooltip')).not.toBeUndefined(); - } - }); - - it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => { - const confidentialEl = findConfidentialIcon(); - expect(confidentialEl.exists()).toBe(confidentialStatus); - - if (confidentialStatus && !hiddenStatus) { - expect(confidentialEl.props()).toMatchObject({ - workspaceType: 'project', - issuableType: 'issue', - }); - } - }); - - it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => { - const hiddenIcon = findHiddenIcon(); - - expect(hiddenIcon.exists()).toBe(hiddenStatus); - - if (hiddenStatus) { - expect(hiddenIcon.attributes('title')).toBe( - `This ${issuableType.replace('_', ' ')} is hidden because its author has been banned`, - ); - expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined(); - } - }); - }, - ); - }); -}); diff --git a/spec/frontend/issuable/components/status_badge_spec.js b/spec/frontend/issuable/components/status_badge_spec.js new file mode 100644 index 00000000000..cdc848626c7 --- /dev/null +++ b/spec/frontend/issuable/components/status_badge_spec.js @@ -0,0 +1,43 @@ +import { GlBadge, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StatusBadge from '~/issuable/components/status_badge.vue'; + +describe('StatusBadge component', () => { + let wrapper; + + const mountComponent = (propsData) => { + wrapper = shallowMount(StatusBadge, { propsData }); + }; + + const findBadge = () => wrapper.findComponent(GlBadge); + + describe.each` + issuableType | badgeText | state | badgeVariant | badgeIcon + ${'merge_request'} | ${'Open'} | ${'opened'} | ${'success'} | ${'merge-request-open'} + ${'merge_request'} | ${'Closed'} | ${'closed'} | ${'danger'} | ${'merge-request-close'} + ${'merge_request'} | ${'Merged'} | ${'merged'} | ${'info'} | ${'merge'} + ${'issue'} | ${'Open'} | ${'opened'} | ${'success'} | ${'issues'} + ${'issue'} | ${'Closed'} | ${'closed'} | ${'info'} | ${'issue-closed'} + ${'epic'} | ${'Open'} | ${'opened'} | ${'success'} | ${'epic'} + ${'epic'} | ${'Closed'} | ${'closed'} | ${'info'} | ${'epic-closed'} + `( + 'when issuableType=$issuableType and state=$state', + ({ issuableType, badgeText, state, badgeVariant, badgeIcon }) => { + beforeEach(() => { + mountComponent({ state, issuableType }); + }); + + it(`renders badge with text '${badgeText}'`, () => { + expect(findBadge().text()).toBe(badgeText); + }); + + it(`sets badge variant as '${badgeVariant}`, () => { + expect(findBadge().props('variant')).toBe(badgeVariant); + }); + + it(`sets badge icon as '${badgeIcon}'`, () => { + expect(findBadge().findComponent(GlIcon).props('name')).toBe(badgeIcon); + }); + }, + ); +}); diff --git a/spec/frontend/issuable/components/status_box_spec.js b/spec/frontend/issuable/components/status_box_spec.js deleted file mode 100644 index 0d47595c9e6..00000000000 --- a/spec/frontend/issuable/components/status_box_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import { GlBadge, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import StatusBox from '~/issuable/components/status_box.vue'; - -let wrapper; - -function factory(propsData) { - wrapper = shallowMount(StatusBox, { propsData, stubs: { GlBadge } }); -} - -describe('Merge request status box component', () => { - const findBadge = () => wrapper.findComponent(GlBadge); - - describe.each` - issuableType | badgeText | initialState | badgeClass | badgeVariant | badgeIcon - ${'merge_request'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'merge-request-open'} - ${'merge_request'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'danger'} | ${'merge-request-close'} - ${'merge_request'} | ${'Merged'} | ${'merged'} | ${'issuable-status-badge-merged'} | ${'info'} | ${'merge'} - ${'issue'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'issues'} - ${'issue'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'info'} | ${'issue-closed'} - ${'epic'} | ${'Open'} | ${'opened'} | ${'issuable-status-badge-open'} | ${'success'} | ${'epic'} - ${'epic'} | ${'Closed'} | ${'closed'} | ${'issuable-status-badge-closed'} | ${'info'} | ${'epic-closed'} - `( - 'with issuableType set to "$issuableType" and state set to "$initialState"', - ({ issuableType, badgeText, initialState, badgeClass, badgeVariant, badgeIcon }) => { - beforeEach(() => { - factory({ - initialState, - issuableType, - }); - }); - - it(`renders badge with text '${badgeText}'`, () => { - expect(findBadge().text()).toBe(badgeText); - }); - - it(`sets badge css class as '${badgeClass}'`, () => { - expect(findBadge().classes()).toContain(badgeClass); - }); - - it(`sets badge variant as '${badgeVariant}`, () => { - expect(findBadge().props('variant')).toBe(badgeVariant); - }); - - it(`sets badge icon as '${badgeIcon}'`, () => { - expect(findBadge().findComponent(GlIcon).props('name')).toBe(badgeIcon); - }); - }, - ); -}); diff --git a/spec/frontend/issuable/popover/components/issue_popover_spec.js b/spec/frontend/issuable/popover/components/issue_popover_spec.js index 0596433ce9a..2db3a83572c 100644 --- a/spec/frontend/issuable/popover/components/issue_popover_spec.js +++ b/spec/frontend/issuable/popover/components/issue_popover_spec.js @@ -8,7 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import IssueMilestone from '~/issuable/components/issue_milestone.vue'; -import StatusBox from '~/issuable/components/status_box.vue'; +import StatusBadge from '~/issuable/components/status_badge.vue'; import IssuePopover from '~/issuable/popover/components/issue_popover.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -52,9 +52,9 @@ describe('Issue Popover', () => { }); it('shows status badge', () => { - expect(wrapper.findComponent(StatusBox).props()).toEqual({ + expect(wrapper.findComponent(StatusBadge).props()).toEqual({ issuableType: 'issue', - initialState: issueQueryResponse.data.project.issue.state, + state: issueQueryResponse.data.project.issue.state, }); }); diff --git a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js index 4686a4fe0c4..f6c9fab76d1 100644 --- a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js +++ b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js @@ -137,7 +137,6 @@ describe('IssuesDashboardApp component', () => { issuablesLoading: false, namespace: 'dashboard', recentSearchesStorageKey: 'issues', - searchInputPlaceholder: i18n.searchPlaceholder, showPaginationControls: true, sortOptions: getSortOptions({ hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature, diff --git a/spec/frontend/issues/dashboard/mock_data.js b/spec/frontend/issues/dashboard/mock_data.js index adcd4268449..1e3abd5a018 100644 --- a/spec/frontend/issues/dashboard/mock_data.js +++ b/spec/frontend/issues/dashboard/mock_data.js @@ -19,6 +19,7 @@ export const issuesQueryResponse = { reference: 'group/project#123456', state: 'opened', title: 'Issue title', + titleHtml: 'Issue title', type: 'issue', updatedAt: '2021-05-22T04:08:01Z', upvotes: 3, diff --git a/spec/frontend/issues/list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js index e80ffea0591..8286f84b98a 100644 --- a/spec/frontend/issues/list/components/issue_card_time_info_spec.js +++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js @@ -3,13 +3,14 @@ import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants'; import IssueCardTimeInfo from '~/issues/list/components/issue_card_time_info.vue'; +import { WIDGET_TYPE_MILESTONE, WIDGET_TYPE_START_AND_DUE_DATE } from '~/work_items/constants'; describe('CE IssueCardTimeInfo component', () => { useFakeDate(2020, 11, 11); // 2020 Dec 11 let wrapper; - const issue = { + const issueObject = { milestone: { dueDate: '2020-12-17', startDate: '2020-12-10', @@ -20,22 +21,41 @@ describe('CE IssueCardTimeInfo component', () => { humanTimeEstimate: '1w', }; + const workItemObject = { + widgets: [ + { + type: WIDGET_TYPE_MILESTONE, + milestone: { + dueDate: '2020-12-17', + startDate: '2020-12-10', + title: 'My milestone', + webPath: '/milestone/webPath', + }, + }, + { + type: WIDGET_TYPE_START_AND_DUE_DATE, + dueDate: '2020-12-12', + }, + ], + }; + const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]'); const findMilestoneTitle = () => findMilestone().findComponent(GlLink).attributes('title'); const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]'); const mountComponent = ({ + issue = issueObject, state = STATUS_OPEN, - dueDate = issue.dueDate, - milestoneDueDate = issue.milestone.dueDate, - milestoneStartDate = issue.milestone.startDate, + dueDate = issueObject.dueDate, + milestoneDueDate = issueObject.milestone.dueDate, + milestoneStartDate = issueObject.milestone.startDate, } = {}) => shallowMount(IssueCardTimeInfo, { propsData: { issue: { ...issue, milestone: { - ...issue.milestone, + ...issueObject.milestone, dueDate: milestoneDueDate, startDate: milestoneStartDate, }, @@ -45,63 +65,70 @@ describe('CE IssueCardTimeInfo component', () => { }, }); - describe('milestone', () => { - it('renders', () => { - wrapper = mountComponent(); + describe.each` + type | obj + ${'issue'} | ${issueObject} + ${'work item'} | ${workItemObject} + `('with $type object', ({ obj }) => { + describe('milestone', () => { + it('renders', () => { + wrapper = mountComponent({ issue: obj }); - const milestone = findMilestone(); + const milestone = findMilestone(); - expect(milestone.text()).toBe(issue.milestone.title); - expect(milestone.findComponent(GlIcon).props('name')).toBe('clock'); - expect(milestone.findComponent(GlLink).attributes('href')).toBe(issue.milestone.webPath); - }); + expect(milestone.text()).toBe('My milestone'); + expect(milestone.findComponent(GlIcon).props('name')).toBe('clock'); + expect(milestone.findComponent(GlLink).attributes('href')).toBe('/milestone/webPath'); + }); - describe.each` - time | text | milestoneDueDate | milestoneStartDate | expected - ${'due date is in past'} | ${'Past due'} | ${'2020-09-09'} | ${null} | ${'Sep 9, 2020 (Past due)'} - ${'due date is today'} | ${'Today'} | ${'2020-12-11'} | ${null} | ${'Dec 11, 2020 (Today)'} - ${'start date is in future'} | ${'Upcoming'} | ${'2021-03-01'} | ${'2021-02-01'} | ${'Mar 1, 2021 (Upcoming)'} - ${'due date is in future'} | ${'2 weeks remaining'} | ${'2020-12-25'} | ${null} | ${'Dec 25, 2020 (2 weeks remaining)'} - `('when $description', ({ text, milestoneDueDate, milestoneStartDate, expected }) => { - it(`renders with "${text}"`, () => { - wrapper = mountComponent({ milestoneDueDate, milestoneStartDate }); - - expect(findMilestoneTitle()).toBe(expected); + describe.each` + time | text | milestoneDueDate | milestoneStartDate | expected + ${'due date is in past'} | ${'Past due'} | ${'2020-09-09'} | ${null} | ${'Sep 9, 2020 (Past due)'} + ${'due date is today'} | ${'Today'} | ${'2020-12-11'} | ${null} | ${'Dec 11, 2020 (Today)'} + ${'start date is in future'} | ${'Upcoming'} | ${'2021-03-01'} | ${'2021-02-01'} | ${'Mar 1, 2021 (Upcoming)'} + ${'due date is in future'} | ${'2 weeks remaining'} | ${'2020-12-25'} | ${null} | ${'Dec 25, 2020 (2 weeks remaining)'} + `('when $description', ({ text, milestoneDueDate, milestoneStartDate, expected }) => { + it(`renders with "${text}"`, () => { + wrapper = mountComponent({ issue: obj, milestoneDueDate, milestoneStartDate }); + + expect(findMilestoneTitle()).toBe(expected); + }); }); }); - }); - describe('due date', () => { - describe('when upcoming', () => { - it('renders', () => { - wrapper = mountComponent(); + describe('due date', () => { + describe('when upcoming', () => { + it('renders', () => { + wrapper = mountComponent({ issue: obj }); - const dueDate = findDueDate(); + const dueDate = findDueDate(); - expect(dueDate.text()).toBe('Dec 12, 2020'); - expect(dueDate.attributes('title')).toBe('Due date'); - expect(dueDate.findComponent(GlIcon).props('name')).toBe('calendar'); - expect(dueDate.classes()).not.toContain('gl-text-red-500'); + expect(dueDate.text()).toBe('Dec 12, 2020'); + expect(dueDate.attributes('title')).toBe('Due date'); + expect(dueDate.findComponent(GlIcon).props('name')).toBe('calendar'); + expect(dueDate.classes()).not.toContain('gl-text-red-500'); + }); }); - }); - describe('when in the past', () => { - describe('when issue is open', () => { - it('renders in red', () => { - wrapper = mountComponent({ dueDate: '2020-10-10' }); + describe('when in the past', () => { + describe('when issue is open', () => { + it('renders in red', () => { + wrapper = mountComponent({ issue: obj, dueDate: '2020-10-10' }); - expect(findDueDate().classes()).toContain('gl-text-red-500'); + expect(findDueDate().classes()).toContain('gl-text-red-500'); + }); }); - }); - describe('when issue is closed', () => { - it('does not render in red', () => { - wrapper = mountComponent({ - dueDate: '2020-10-10', - state: STATUS_CLOSED, - }); + describe('when issue is closed', () => { + it('does not render in red', () => { + wrapper = mountComponent({ + issue: obj, + dueDate: '2020-10-10', + state: STATUS_CLOSED, + }); - expect(findDueDate().classes()).not.toContain('gl-text-red-500'); + expect(findDueDate().classes()).not.toContain('gl-text-red-500'); + }); }); }); }); @@ -112,7 +139,7 @@ describe('CE IssueCardTimeInfo component', () => { const timeEstimate = wrapper.find('[data-testid="time-estimate"]'); - expect(timeEstimate.text()).toBe(issue.humanTimeEstimate); + expect(timeEstimate.text()).toBe(issueObject.humanTimeEstimate); expect(timeEstimate.attributes('title')).toBe('Estimate'); expect(timeEstimate.findComponent(GlIcon).props('name')).toBe('timer'); }); 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 de027a21c8f..f830168ce5d 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -237,7 +237,6 @@ describe('CE IssuesListApp component', () => { expect(findIssuableList().props()).toMatchObject({ namespace: defaultProvide.fullPath, recentSearchesStorageKey: 'issues', - searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder, sortOptions: getSortOptions({ hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature, hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature, diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index b9a8bc171db..73fda11f38c 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -49,6 +49,7 @@ export const getIssuesQueryResponse = { moved: false, state: 'opened', title: 'Issue title', + titleHtml: 'Issue title', updatedAt: '2021-05-22T04:08:01Z', closedAt: null, upvotes: 3, 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 1a199ed2ee9..a4bd9608e34 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 @@ -3,15 +3,14 @@ exports[`Issue type info popover renders 1`] = ` -
    • Issue
    - For general work -
  • Incident
    - For investigating IT service disruptions or outages diff --git a/spec/frontend/issues/service_desk/components/empty_state_with_any_issues_spec.js b/spec/frontend/issues/service_desk/components/empty_state_with_any_issues_spec.js new file mode 100644 index 00000000000..90f0847f37b --- /dev/null +++ b/spec/frontend/issues/service_desk/components/empty_state_with_any_issues_spec.js @@ -0,0 +1,74 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EmptyStateWithAnyIssues from '~/issues/service_desk/components/empty_state_with_any_issues.vue'; +import { + noSearchResultsTitle, + noSearchResultsDescription, + infoBannerUserNote, + noOpenIssuesTitle, + noClosedIssuesTitle, +} from '~/issues/service_desk/constants'; + +describe('EmptyStateWithAnyIssues component', () => { + let wrapper; + + const defaultProvide = { + emptyStateSvgPath: 'empty/state/svg/path', + newIssuePath: 'new/issue/path', + showNewIssueLink: false, + }; + + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + + const mountComponent = (props = {}) => { + wrapper = shallowMount(EmptyStateWithAnyIssues, { + propsData: { + hasSearch: true, + isOpenTab: true, + ...props, + }, + provide: defaultProvide, + }); + }; + + describe('when there is a search (with no results)', () => { + beforeEach(() => { + mountComponent(); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: noSearchResultsDescription, + title: noSearchResultsTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Open" tab is active', () => { + beforeEach(() => { + mountComponent({ hasSearch: false }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: infoBannerUserNote, + title: noOpenIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Closed" tab is active', () => { + beforeEach(() => { + mountComponent({ hasSearch: false, isClosedTab: true, isOpenTab: false }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + title: noClosedIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); +}); diff --git a/spec/frontend/issues/service_desk/components/empty_state_without_any_issues_spec.js b/spec/frontend/issues/service_desk/components/empty_state_without_any_issues_spec.js new file mode 100644 index 00000000000..7f281d6fbfe --- /dev/null +++ b/spec/frontend/issues/service_desk/components/empty_state_without_any_issues_spec.js @@ -0,0 +1,90 @@ +import { GlEmptyState, GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import EmptyStateWithoutAnyIssues from '~/issues/service_desk/components/empty_state_without_any_issues.vue'; +import { + infoBannerTitle, + noIssuesSignedOutButtonText, + learnMore, +} from '~/issues/service_desk/constants'; + +describe('EmptyStateWithoutAnyIssues component', () => { + let wrapper; + + const defaultProvide = { + emptyStateSvgPath: 'empty/state/svg/path', + isSignedIn: true, + signInPath: 'sign/in/path', + canAdminIssues: true, + isServiceDeskEnabled: true, + serviceDeskEmailAddress: 'email@address.com', + serviceDeskHelpPath: 'service/desk/help/path', + }; + + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + const findGlLink = () => wrapper.findComponent(GlLink); + const findIssuesHelpPageLink = () => wrapper.findByRole('link', { name: learnMore }); + + const mountComponent = ({ provide = {} } = {}) => { + wrapper = mountExtended(EmptyStateWithoutAnyIssues, { + provide: { + ...defaultProvide, + ...provide, + }, + }); + }; + + describe('when signed in', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + title: infoBannerTitle, + svgPath: defaultProvide.emptyStateSvgPath, + contentClass: 'gl-max-w-80!', + }); + }); + + it('renders description with service desk docs link', () => { + expect(findIssuesHelpPageLink().attributes('href')).toBe(defaultProvide.serviceDeskHelpPath); + }); + + it('renders email address, when user can admin issues and service desk is enabled', () => { + expect(wrapper.text()).toContain(wrapper.vm.serviceDeskEmailAddress); + }); + + it('does not render email address, when user can not admin issues', () => { + mountComponent({ provide: { canAdminIssues: false } }); + + expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); + }); + + it('does not render email address, when service desk is not setup', () => { + mountComponent({ provide: { isServiceDeskEnabled: false } }); + + expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); + }); + }); + + describe('when signed out', () => { + beforeEach(() => { + mountComponent({ provide: { isSignedIn: false } }); + }); + + it('renders empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + title: infoBannerTitle, + svgPath: defaultProvide.emptyStateSvgPath, + primaryButtonText: noIssuesSignedOutButtonText, + primaryButtonLink: defaultProvide.signInPath, + contentClass: 'gl-max-w-80!', + }); + }); + + it('renders service desk docs link', () => { + expect(findGlLink().attributes('href')).toBe(defaultProvide.serviceDeskHelpPath); + expect(findGlLink().text()).toBe(learnMore); + }); + }); +}); diff --git a/spec/frontend/issues/service_desk/components/info_banner_spec.js b/spec/frontend/issues/service_desk/components/info_banner_spec.js new file mode 100644 index 00000000000..593455f5deb --- /dev/null +++ b/spec/frontend/issues/service_desk/components/info_banner_spec.js @@ -0,0 +1,81 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlButton } from '@gitlab/ui'; +import InfoBanner from '~/issues/service_desk/components/info_banner.vue'; +import { infoBannerAdminNote, enableServiceDesk } from '~/issues/service_desk/constants'; + +describe('InfoBanner', () => { + let wrapper; + + const defaultProvide = { + serviceDeskCalloutSvgPath: 'callout.svg', + serviceDeskEmailAddress: 'sd@gmail.com', + canAdminIssues: true, + canEditProjectSettings: true, + serviceDeskSettingsPath: 'path/to/project/settings', + serviceDeskHelpPath: 'path/to/documentation', + isServiceDeskEnabled: true, + }; + + const findEnableSDButton = () => wrapper.findComponent(GlButton); + + const mountComponent = (provide) => { + return shallowMount(InfoBanner, { + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + GlLink, + GlButton, + }, + }); + }; + + beforeEach(() => { + wrapper = mountComponent(); + }); + + describe('Service Desk email address', () => { + it('renders when user can admin issues and service desk is enabled', () => { + expect(wrapper.text()).toContain(infoBannerAdminNote); + expect(wrapper.text()).toContain(wrapper.vm.serviceDeskEmailAddress); + }); + + it('does not render, when user can not admin issues', () => { + wrapper = mountComponent({ canAdminIssues: false }); + + expect(wrapper.text()).not.toContain(infoBannerAdminNote); + expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); + }); + + it('does not render, when service desk is not setup', () => { + wrapper = mountComponent({ isServiceDeskEnabled: false }); + + expect(wrapper.text()).not.toContain(infoBannerAdminNote); + expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); + }); + }); + + describe('Link to Service Desk settings', () => { + it('renders when user can edit settings and service desk is not enabled', () => { + wrapper = mountComponent({ isServiceDeskEnabled: false }); + + expect(wrapper.text()).toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(true); + }); + + it('does not render when service desk is enabled', () => { + wrapper = mountComponent(); + + expect(wrapper.text()).not.toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(false); + }); + + it('does not render when user cannot edit settings', () => { + wrapper = mountComponent({ canEditProjectSettings: false }); + + expect(wrapper.text()).not.toContain(enableServiceDesk); + expect(findEnableSDButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/issues/service_desk/components/service_desk_list_app_spec.js b/spec/frontend/issues/service_desk/components/service_desk_list_app_spec.js new file mode 100644 index 00000000000..d28b4f2fe76 --- /dev/null +++ b/spec/frontend/issues/service_desk/components/service_desk_list_app_spec.js @@ -0,0 +1,717 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { cloneDeep } from 'lodash'; +import VueRouter from 'vue-router'; +import * as Sentry from '@sentry/browser'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; +import waitForPromises from 'helpers/wait_for_promises'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; +import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; +import { TYPENAME_USER } from '~/graphql_shared/constants'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { getSortKey, getSortOptions } from '~/issues/list/utils'; +import { STATUS_CLOSED, STATUS_OPEN, STATUS_ALL } from '~/issues/service_desk/constants'; +import getServiceDeskIssuesQuery from 'ee_else_ce/issues/service_desk/queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCountsQuery from 'ee_else_ce/issues/service_desk/queries/get_service_desk_issues_counts.query.graphql'; +import setSortingPreferenceMutation from '~/issues/service_desk/queries/set_sorting_preference.mutation.graphql'; +import ServiceDeskListApp from '~/issues/service_desk/components/service_desk_list_app.vue'; +import InfoBanner from '~/issues/service_desk/components/info_banner.vue'; +import EmptyStateWithAnyIssues from '~/issues/service_desk/components/empty_state_with_any_issues.vue'; +import EmptyStateWithoutAnyIssues from '~/issues/service_desk/components/empty_state_without_any_issues.vue'; +import { createAlert, VARIANT_INFO } from '~/alert'; +import { + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_SEARCH_WITHIN, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { + CREATED_DESC, + UPDATED_DESC, + RELATIVE_POSITION_ASC, + RELATIVE_POSITION, + urlSortParams, +} from '~/issues/list/constants'; +import { + getServiceDeskIssuesQueryResponse, + getServiceDeskIssuesQueryEmptyResponse, + getServiceDeskIssuesCountsQueryResponse, + setSortPreferenceMutationResponse, + setSortPreferenceMutationResponseWithErrors, + filteredTokens, + urlParams, + locationSearch, +} from '../mock_data'; + +jest.mock('@sentry/browser'); +jest.mock('~/alert'); +jest.mock('~/lib/utils/scroll_utils', () => ({ scrollUp: jest.fn() })); + +describe('CE ServiceDeskListApp', () => { + let wrapper; + let router; + let axiosMock; + + Vue.use(VueApollo); + Vue.use(VueRouter); + + const defaultProvide = { + releasesPath: 'releases/path', + autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', + hasBlockedIssuesFeature: false, + hasIterationsFeature: true, + hasIssueWeightsFeature: true, + hasIssuableHealthStatusFeature: true, + groupPath: 'group/path', + emptyStateSvgPath: 'empty-state.svg', + isProject: true, + isSignedIn: true, + fullPath: 'path/to/project', + isServiceDeskSupported: true, + hasAnyIssues: true, + initialSort: CREATED_DESC, + isIssueRepositioningDisabled: false, + issuablesLoading: false, + showPaginationControls: true, + useKeysetPagination: true, + hasPreviousPage: getServiceDeskIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage, + hasNextPage: getServiceDeskIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage, + }; + + let defaultQueryResponse = getServiceDeskIssuesQueryResponse; + if (IS_EE) { + defaultQueryResponse = cloneDeep(getServiceDeskIssuesQueryResponse); + defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null; + defaultQueryResponse.data.project.issues.nodes[0].weight = 5; + } + + const mockServiceDeskIssuesQueryResponseHandler = jest + .fn() + .mockResolvedValue(defaultQueryResponse); + const mockServiceDeskIssuesQueryEmptyResponseHandler = jest + .fn() + .mockResolvedValue(getServiceDeskIssuesQueryEmptyResponse); + const mockServiceDeskIssuesCountsQueryResponseHandler = jest + .fn() + .mockResolvedValue(getServiceDeskIssuesCountsQueryResponse); + + const findIssuableList = () => wrapper.findComponent(IssuableList); + const findInfoBanner = () => wrapper.findComponent(InfoBanner); + const findLabelsToken = () => + findIssuableList() + .props('searchTokens') + .find((token) => token.type === TOKEN_TYPE_LABEL); + + const createComponent = ({ + provide = {}, + serviceDeskIssuesQueryResponseHandler = mockServiceDeskIssuesQueryResponseHandler, + serviceDeskIssuesCountsQueryResponseHandler = mockServiceDeskIssuesCountsQueryResponseHandler, + sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse), + } = {}) => { + const requestHandlers = [ + [getServiceDeskIssuesQuery, serviceDeskIssuesQueryResponseHandler], + [getServiceDeskIssuesCountsQuery, serviceDeskIssuesCountsQueryResponseHandler], + [setSortingPreferenceMutation, sortPreferenceMutationResponse], + ]; + + router = new VueRouter({ mode: 'history' }); + + return shallowMount(ServiceDeskListApp, { + apolloProvider: createMockApollo( + requestHandlers, + {}, + { + typePolicies: { + Query: { + fields: { + project: { + merge: true, + }, + }, + }, + }, + }, + ), + router, + provide: { + ...defaultProvide, + ...provide, + }, + }); + }; + + beforeEach(() => { + setWindowLocation(TEST_HOST); + axiosMock = new AxiosMockAdapter(axios); + wrapper = createComponent(); + return waitForPromises(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + it('renders the issuable list with skeletons while fetching service desk issues', async () => { + wrapper = createComponent(); + await nextTick(); + + expect(findIssuableList().props('issuablesLoading')).toBe(true); + + await waitForPromises(); + + expect(findIssuableList().props('issuablesLoading')).toBe(false); + }); + + it('fetches service desk issues and renders them in the issuable list', () => { + expect(findIssuableList().props()).toMatchObject({ + namespace: 'service-desk', + recentSearchesStorageKey: 'service-desk-issues', + issuables: defaultQueryResponse.data.project.issues.nodes, + tabs: issuableListTabs, + currentTab: STATUS_OPEN, + tabCounts: { + opened: 1, + closed: 1, + all: 1, + }, + sortOptions: getSortOptions({ + hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature, + }), + initialSortBy: CREATED_DESC, + isManualOrdering: false, + }); + }); + + describe('InfoBanner', () => { + it('renders when Service Desk is supported and has any number of issues', () => { + expect(findInfoBanner().exists()).toBe(true); + }); + + it('does not render when Service Desk is not supported and has any number of issues', () => { + wrapper = createComponent({ provide: { isServiceDeskSupported: false } }); + + expect(findInfoBanner().exists()).toBe(false); + }); + + it('does not render, when there are no issues', () => { + wrapper = createComponent({ + serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler, + }); + + expect(findInfoBanner().exists()).toBe(false); + }); + }); + + describe('Empty states', () => { + describe('when there are issues', () => { + it('shows EmptyStateWithAnyIssues component', () => { + setWindowLocation(locationSearch); + wrapper = createComponent({ + serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler, + }); + + expect(wrapper.findComponent(EmptyStateWithAnyIssues).props()).toEqual({ + hasSearch: true, + isOpenTab: true, + }); + }); + }); + + describe('when there are no issues', () => { + it('shows EmptyStateWithoutAnyIssues component', () => { + wrapper = createComponent({ + provide: { hasAnyIssues: false }, + serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler, + }); + + expect(wrapper.findComponent(EmptyStateWithoutAnyIssues).exists()).toBe(true); + }); + }); + }); + + describe('Initial url params', () => { + describe('search', () => { + it('is set from the url params', () => { + setWindowLocation(locationSearch); + wrapper = createComponent(); + + expect(router.history.current.query).toMatchObject({ search: 'find issues' }); + }); + }); + + describe('sort', () => { + describe('when initial sort value uses old enum values', () => { + const oldEnumSortValues = Object.values(urlSortParams); + + it.each(oldEnumSortValues)('initial sort is set with value %s', async (sort) => { + wrapper = createComponent({ provide: { initialSort: sort } }); + await waitForPromises(); + + expect(findIssuableList().props('initialSortBy')).toBe(getSortKey(sort)); + }); + }); + + describe('when initial sort value uses new GraphQL enum values', () => { + const graphQLEnumSortValues = Object.keys(urlSortParams); + + it.each(graphQLEnumSortValues)('initial sort is set with value %s', async (sort) => { + wrapper = createComponent({ provide: { initialSort: sort.toLowerCase() } }); + await waitForPromises(); + + expect(findIssuableList().props('initialSortBy')).toBe(sort); + }); + }); + + describe('when initial sort value is invalid', () => { + it.each(['', 'asdf', null, undefined])( + 'initial sort is set to value CREATED_DESC', + async (sort) => { + wrapper = createComponent({ provide: { initialSort: sort } }); + await waitForPromises(); + + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); + }, + ); + }); + + describe('when sort is manual and issue repositioning is disabled', () => { + beforeEach(async () => { + wrapper = createComponent({ + provide: { initialSort: RELATIVE_POSITION, isIssueRepositioningDisabled: true }, + }); + await waitForPromises(); + }); + + it('changes the sort to the default of created descending', () => { + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); + }); + + it('shows an alert to tell the user that manual reordering is disabled', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: ServiceDeskListApp.i18n.issueRepositioningMessage, + variant: VARIANT_INFO, + }); + }); + }); + }); + + describe('state', () => { + it('is set from the url params', async () => { + const initialState = STATUS_ALL; + setWindowLocation(`?state=${initialState}`); + wrapper = createComponent(); + await waitForPromises(); + + expect(findIssuableList().props('currentTab')).toBe(initialState); + }); + }); + + describe('filter tokens', () => { + it('are set from the url params', () => { + setWindowLocation(locationSearch); + wrapper = createComponent(); + + expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); + }); + }); + }); + + describe('Tokens', () => { + const mockCurrentUser = { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'avatar/url', + }; + + describe('when user is signed out', () => { + beforeEach(() => { + wrapper = createComponent({ provide: { isSignedIn: false } }); + return waitForPromises(); + }); + + it('does not render My-Reaction or Confidential tokens', () => { + expect(findIssuableList().props('searchTokens')).not.toMatchObject([ + { type: TOKEN_TYPE_AUTHOR, preloadedUsers: [mockCurrentUser] }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers: [mockCurrentUser] }, + { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_CONFIDENTIAL }, + ]); + }); + }); + + describe('when all tokens are available', () => { + beforeEach(() => { + window.gon = { + current_user_id: mockCurrentUser.id, + current_user_fullname: mockCurrentUser.name, + current_username: mockCurrentUser.username, + current_user_avatar_url: mockCurrentUser.avatar_url, + }; + + wrapper = createComponent(); + return waitForPromises(); + }); + + it('renders all tokens alphabetically', () => { + const preloadedUsers = [ + { ...mockCurrentUser, id: convertToGraphQLId(TYPENAME_USER, mockCurrentUser.id) }, + ]; + + expect(findIssuableList().props('searchTokens')).toMatchObject([ + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, + { type: TOKEN_TYPE_CONFIDENTIAL }, + { type: TOKEN_TYPE_LABEL }, + { type: TOKEN_TYPE_MILESTONE }, + { type: TOKEN_TYPE_MY_REACTION }, + { type: TOKEN_TYPE_RELEASE }, + { type: TOKEN_TYPE_SEARCH_WITHIN }, + ]); + }); + }); + }); + + describe('Events', () => { + describe('when "click-tab" event is emitted by IssuableList', () => { + beforeEach(async () => { + wrapper = createComponent(); + router.push = jest.fn(); + await waitForPromises(); + + findIssuableList().vm.$emit('click-tab', STATUS_CLOSED); + }); + + it('updates ui to the new tab', () => { + expect(findIssuableList().props('currentTab')).toBe(STATUS_CLOSED); + }); + + it('updates url to the new tab', () => { + expect(router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ state: STATUS_CLOSED }), + }); + }); + }); + + describe('when "reorder" event is emitted by IssuableList', () => { + const issueOne = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/1', + iid: '101', + reference: 'group/project#1', + webPath: '/group/project/-/issues/1', + }; + const issueTwo = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/2', + iid: '102', + reference: 'group/project#2', + webPath: '/group/project/-/issues/2', + }; + const issueThree = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/3', + iid: '103', + reference: 'group/project#3', + webPath: '/group/project/-/issues/3', + }; + const issueFour = { + ...defaultQueryResponse.data.project.issues.nodes[0], + id: 'gid://gitlab/Issue/4', + iid: '104', + reference: 'group/project#4', + webPath: '/group/project/-/issues/4', + }; + const response = () => ({ + data: { + project: { + id: '1', + issues: { + ...defaultQueryResponse.data.project.issues, + nodes: [issueOne, issueTwo, issueThree, issueFour], + }, + }, + }, + }); + + describe('when successful', () => { + describe.each` + description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId + ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id} + ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id} + ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id} + ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null} + `( + 'when moving issue $description', + ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { + beforeEach(() => { + wrapper = createComponent({ + serviceDeskIssuesQueryResponseHandler: jest.fn().mockResolvedValue(response()), + }); + return waitForPromises(); + }); + + it('makes API call to reorder the issue', async () => { + findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); + await waitForPromises(); + + expect(axiosMock.history.put[0]).toMatchObject({ + url: joinPaths(issueToMove.webPath, 'reorder'), + data: JSON.stringify({ + move_before_id: getIdFromGraphQLId(moveBeforeId), + move_after_id: getIdFromGraphQLId(moveAfterId), + }), + }); + }); + }, + ); + }); + + describe('when unsuccessful', () => { + beforeEach(() => { + wrapper = createComponent({ + serviceDeskIssuesQueryResponseHandler: jest.fn().mockResolvedValue(response()), + }); + return waitForPromises(); + }); + + it('displays an error message', async () => { + axiosMock + .onPut(joinPaths(issueOne.webPath, 'reorder')) + .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); + await waitForPromises(); + + expect(findIssuableList().props('error')).toBe(ServiceDeskListApp.i18n.reorderError); + expect(Sentry.captureException).toHaveBeenCalledWith( + new Error('Request failed with status code 500'), + ); + }); + }); + }); + + describe('when "sort" event is emitted by IssuableList', () => { + it.each(Object.keys(urlSortParams))( + 'updates to the new sort when payload is `%s`', + async (sortKey) => { + // Ensure initial sort key is different so we can trigger an update when emitting a sort key + wrapper = + sortKey === CREATED_DESC + ? createComponent({ provide: { initialSort: UPDATED_DESC } }) + : createComponent(); + router.push = jest.fn(); + await waitForPromises(); + + findIssuableList().vm.$emit('sort', sortKey); + + expect(router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ sort: urlSortParams[sortKey] }), + }); + }, + ); + + describe('when issue repositioning is disabled', () => { + const initialSort = CREATED_DESC; + + beforeEach(async () => { + wrapper = createComponent({ + provide: { initialSort, isIssueRepositioningDisabled: true }, + }); + router.push = jest.fn(); + await waitForPromises(); + + findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC); + }); + + it('does not update the sort to manual', () => { + expect(router.push).not.toHaveBeenCalled(); + }); + + it('shows an alert to tell the user that manual reordering is disabled', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: ServiceDeskListApp.i18n.issueRepositioningMessage, + variant: VARIANT_INFO, + }); + }); + }); + + describe('when user is signed in', () => { + it('calls mutation to save sort preference', async () => { + const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse); + wrapper = createComponent({ sortPreferenceMutationResponse: mutationMock }); + await waitForPromises(); + + findIssuableList().vm.$emit('sort', UPDATED_DESC); + + expect(mutationMock).toHaveBeenCalledWith({ input: { issuesSort: UPDATED_DESC } }); + }); + + it('captures error when mutation response has errors', async () => { + const mutationMock = jest + .fn() + .mockResolvedValue(setSortPreferenceMutationResponseWithErrors); + wrapper = createComponent({ sortPreferenceMutationResponse: mutationMock }); + await waitForPromises(); + + findIssuableList().vm.$emit('sort', UPDATED_DESC); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); + }); + }); + + describe('when user is signed out', () => { + it('does not call mutation to save sort preference', async () => { + const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse); + wrapper = createComponent({ + provide: { isSignedIn: false }, + sortPreferenceMutationResponse: mutationMock, + }); + await waitForPromises(); + + findIssuableList().vm.$emit('sort', CREATED_DESC); + + expect(mutationMock).not.toHaveBeenCalled(); + }); + }); + }); + + 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 }} + `('when "$event" event is emitted by IssuableList', ({ event, params }) => { + beforeEach(async () => { + wrapper = createComponent({ + data: { + pageInfo: { + endCursor: 'endCursor', + startCursor: 'startCursor', + }, + }, + }); + await waitForPromises(); + router.push = jest.fn(); + + findIssuableList().vm.$emit(event); + }); + + it('scrolls to the top', () => { + expect(scrollUp).toHaveBeenCalled(); + }); + + it('updates url', () => { + expect(router.push).toHaveBeenCalledWith({ + query: expect.objectContaining(params), + }); + }); + }); + + describe('when "filter" event is emitted by IssuableList', () => { + it('updates IssuableList with url params', async () => { + wrapper = createComponent(); + router.push = jest.fn(); + await waitForPromises(); + + findIssuableList().vm.$emit('filter', filteredTokens); + await nextTick(); + + expect(router.push).toHaveBeenCalledWith({ + query: expect.objectContaining(urlParams), + }); + }); + }); + + describe('when "page-size-change" event is emitted by IssuableList', () => { + it('updates url params with new page size', async () => { + wrapper = createComponent(); + router.push = jest.fn(); + await waitForPromises(); + + 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 }), + }); + }); + }); + }); + + describe('Errors', () => { + describe.each` + error | responseHandler | message + ${'fetching issues'} | ${'serviceDeskIssuesQueryResponseHandler'} | ${'An error occurred while loading issues'} + ${'fetching issue counts'} | ${'serviceDeskIssuesCountsQueryResponseHandler'} | ${'An error occurred while getting issue counts'} + `('when there is an error $error', ({ responseHandler, message }) => { + beforeEach(() => { + wrapper = createComponent({ + [responseHandler]: jest.fn().mockRejectedValue(new Error('ERROR')), + }); + return waitForPromises(); + }); + + it('shows an error message', () => { + expect(findIssuableList().props('error')).toBe(message); + }); + + it('is captured with Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR')); + }); + }); + + it('clears error message when "dismiss-alert" event is emitted from IssuableList', async () => { + wrapper = createComponent({ + serviceDeskIssuesQueryResponseHandler: jest.fn().mockRejectedValue(new Error()), + }); + await waitForPromises(); + findIssuableList().vm.$emit('dismiss-alert'); + await nextTick(); + + expect(findIssuableList().props('error')).toBe(''); + }); + }); + + describe('When providing token for labels', () => { + it('passes function to fetchLatestLabels property if frontend caching is enabled', async () => { + wrapper = createComponent({ + provide: { + glFeatures: { + frontendCaching: true, + }, + }, + }); + await waitForPromises(); + + expect(typeof findLabelsToken().fetchLatestLabels).toBe('function'); + }); + + it('passes null to fetchLatestLabels property if frontend caching is disabled', async () => { + wrapper = createComponent({ + provide: { + glFeatures: { + frontendCaching: false, + }, + }, + }); + await waitForPromises(); + + expect(findLabelsToken().fetchLatestLabels).toBe(null); + }); + }); +}); diff --git a/spec/frontend/issues/service_desk/mock_data.js b/spec/frontend/issues/service_desk/mock_data.js new file mode 100644 index 00000000000..1e2f209d732 --- /dev/null +++ b/spec/frontend/issues/service_desk/mock_data.js @@ -0,0 +1,253 @@ +import { + FILTERED_SEARCH_TERM, + OPERATOR_IS, + OPERATOR_NOT, + OPERATOR_OR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_EPIC, + TOKEN_TYPE_ITERATION, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_WEIGHT, + TOKEN_TYPE_HEALTH, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +export const getServiceDeskIssuesQueryResponse = { + data: { + project: { + id: '1', + __typename: 'Project', + issues: { + __persist: true, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'startcursor', + endCursor: 'endcursor', + }, + nodes: [ + { + __persist: true, + __typename: 'Issue', + id: 'gid://gitlab/Issue/123456', + iid: '789', + confidential: false, + createdAt: '2021-05-22T04:08:01Z', + downvotes: 2, + dueDate: '2021-05-29', + hidden: false, + humanTimeEstimate: null, + mergeRequestsCount: false, + moved: false, + state: 'opened', + title: 'Issue title', + updatedAt: '2021-05-22T04:08:01Z', + closedAt: null, + upvotes: 3, + userDiscussionsCount: 4, + webPath: 'project/-/issues/789', + webUrl: 'project/-/issues/789', + type: 'issue', + assignees: { + nodes: [ + { + __persist: true, + __typename: 'UserCore', + id: 'gid://gitlab/User/234', + avatarUrl: 'avatar/url', + name: 'Marge Simpson', + username: 'msimpson', + webUrl: 'url/msimpson', + }, + ], + }, + author: { + __persist: true, + __typename: 'UserCore', + id: 'gid://gitlab/User/456', + avatarUrl: 'avatar/url', + name: 'GitLab Support Bot', + username: 'support-bot', + webUrl: 'url/hsimpson', + }, + externalAuthor: 'client@client.com', + labels: { + nodes: [ + { + __persist: true, + id: 'gid://gitlab/ProjectLabel/456', + color: '#333', + title: 'Label title', + description: 'Label description', + }, + ], + }, + milestone: null, + taskCompletionStatus: { + completedCount: 1, + count: 2, + }, + }, + ], + }, + }, + }, +}; + +export const getServiceDeskIssuesQueryEmptyResponse = { + data: { + project: { + id: '1', + __typename: 'Project', + issues: { + __persist: true, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'startcursor', + endCursor: 'endcursor', + }, + nodes: [], + }, + }, + }, +}; + +export const getServiceDeskIssuesCountsQueryResponse = { + data: { + project: { + id: '1', + openedIssues: { + count: 1, + }, + closedIssues: { + count: 1, + }, + allIssues: { + count: 1, + }, + }, + }, +}; + +export const setSortPreferenceMutationResponse = { + data: { + userPreferencesUpdate: { + errors: [], + }, + }, +}; + +export const setSortPreferenceMutationResponseWithErrors = { + data: { + userPreferencesUpdate: { + errors: ['oh no!'], + }, + }, +}; + +export const filteredTokens = [ + { type: FILTERED_SEARCH_TERM, value: { data: 'find issues', operator: 'undefined' } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lisa', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: '5', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } }, + { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 3', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 4', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 30', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'cartoon', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'tv', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'live action', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'drama', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'comedy', operator: OPERATOR_OR } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'sitcom', operator: OPERATOR_OR } }, + { type: TOKEN_TYPE_RELEASE, value: { data: 'v3', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_RELEASE, value: { data: 'v4', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_RELEASE, value: { data: 'v20', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_RELEASE, value: { data: 'v30', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsup', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsdown', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_CONFIDENTIAL, value: { data: 'yes', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_ITERATION, value: { data: '4', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_ITERATION, value: { data: '12', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_ITERATION, value: { data: '20', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_ITERATION, value: { data: '42', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_EPIC, value: { data: '12', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_EPIC, value: { data: '34', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_WEIGHT, value: { data: '1', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_WEIGHT, value: { data: '3', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_HEALTH, value: { data: 'atRisk', operator: OPERATOR_IS } }, + { type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: OPERATOR_NOT } }, +]; + +export const urlParams = { + search: 'find issues', + 'assignee_username[]': ['bart', 'lisa', '5'], + 'not[assignee_username][]': ['patty', 'selma'], + 'or[assignee_username][]': ['carl', 'lenny'], + milestone_title: ['season 3', 'season 4'], + 'not[milestone_title]': ['season 20', 'season 30'], + 'label_name[]': ['cartoon', 'tv'], + 'not[label_name][]': ['live action', 'drama'], + 'or[label_name][]': ['comedy', 'sitcom'], + release_tag: ['v3', 'v4'], + 'not[release_tag]': ['v20', 'v30'], + my_reaction_emoji: 'thumbsup', + 'not[my_reaction_emoji]': 'thumbsdown', + confidential: 'yes', + iteration_id: ['4', '12'], + 'not[iteration_id]': ['20', '42'], + epic_id: '12', + 'not[epic_id]': '34', + weight: '1', + 'not[weight]': '3', + health_status: 'atRisk', + 'not[health_status]': 'onTrack', +}; + +export const locationSearch = [ + '?search=find+issues', + 'assignee_username[]=bart', + 'assignee_username[]=lisa', + 'assignee_username[]=5', + 'not[assignee_username][]=patty', + 'not[assignee_username][]=selma', + 'or[assignee_username][]=carl', + 'or[assignee_username][]=lenny', + 'milestone_title=season+3', + 'milestone_title=season+4', + 'not[milestone_title]=season+20', + 'not[milestone_title]=season+30', + 'label_name[]=cartoon', + 'label_name[]=tv', + 'not[label_name][]=live action', + 'not[label_name][]=drama', + 'or[label_name][]=comedy', + 'or[label_name][]=sitcom', + 'release_tag=v3', + 'release_tag=v4', + 'not[release_tag]=v20', + 'not[release_tag]=v30', + 'my_reaction_emoji=thumbsup', + 'not[my_reaction_emoji]=thumbsdown', + 'confidential=yes', + 'iteration_id=4', + 'iteration_id=12', + 'not[iteration_id]=20', + 'not[iteration_id]=42', + 'epic_id=12', + 'not[epic_id]=34', + 'weight=1', + 'not[weight]=3', + 'health_status=atRisk', + 'not[health_status]=onTrack', +].join('&'); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index de183f94277..8999952c54c 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -1,23 +1,14 @@ -import { GlIcon, GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; -import { - issuableStatusText, - STATUS_CLOSED, - STATUS_OPEN, - STATUS_REOPENED, - TYPE_EPIC, - TYPE_INCIDENT, - TYPE_ISSUE, -} from '~/issues/constants'; +import { TYPE_EPIC, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; import IssuableApp from '~/issues/show/components/app.vue'; import DescriptionComponent from '~/issues/show/components/description.vue'; import EditedComponent from '~/issues/show/components/edited.vue'; import FormComponent from '~/issues/show/components/form.vue'; +import StickyHeader from '~/issues/show/components/sticky_header.vue'; import TitleComponent from '~/issues/show/components/title.vue'; import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; import PinnedLinks from '~/issues/show/components/pinned_links.vue'; @@ -44,22 +35,15 @@ describe('Issuable output', () => { let axiosMock; let wrapper; - const findStickyHeader = () => wrapper.findByTestId('issue-sticky-header'); - const findLockedBadge = () => wrapper.findByTestId('locked'); - const findConfidentialBadge = () => wrapper.findByTestId('confidential'); - const findHiddenBadge = () => wrapper.findByTestId('hidden'); - + const findStickyHeader = () => wrapper.findComponent(StickyHeader); const findTitle = () => wrapper.findComponent(TitleComponent); const findDescription = () => wrapper.findComponent(DescriptionComponent); const findEdited = () => wrapper.findComponent(EditedComponent); const findForm = () => wrapper.findComponent(FormComponent); const findPinnedLinks = () => wrapper.findComponent(PinnedLinks); - const createComponent = ({ props = {}, options = {}, data = {} } = {}) => { - wrapper = shallowMountExtended(IssuableApp, { - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, + const createComponent = ({ props = {}, options = {} } = {}) => { + wrapper = shallowMount(IssuableApp, { propsData: { ...appProps, ...props }, provide: { fullPath: 'gitlab-org/incidents', @@ -69,11 +53,6 @@ describe('Issuable output', () => { HighlightBar: true, IncidentTabs: true, }, - data() { - return { - ...data, - }; - }, ...options, }); @@ -81,13 +60,6 @@ describe('Issuable output', () => { return waitForPromises(); }; - const createComponentAndScroll = async (props) => { - await createComponent({ props }); - global.pageYOffset = 100; - wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); - await nextTick(); - }; - const emitHubEvent = (event) => { eventHub.$emit(event); return waitForPromises(); @@ -332,104 +304,36 @@ describe('Issuable output', () => { describe('when title is in view', () => { it('is not shown', async () => { await createComponent(); - wrapper.findComponent(GlIntersectionObserver).vm.$emit('disappear'); - expect(findStickyHeader().exists()).toBe(false); + wrapper.findComponent(StickyHeader).vm.$emit('show'); + + expect(findStickyHeader().props('show')).toBe(false); }); }); - describe('when title is not in view', () => { - it.each([TYPE_INCIDENT, TYPE_ISSUE, TYPE_EPIC])( - 'shows with title when issuableType="%s"', - async (issuableType) => { - await createComponentAndScroll({ issuableType }); - - expect(findStickyHeader().text()).toContain('this is a title'); - }, - ); - - it.each` - issuableType | issuableStatus | statusIcon - ${TYPE_INCIDENT} | ${STATUS_OPEN} | ${'issues'} - ${TYPE_INCIDENT} | ${STATUS_CLOSED} | ${'issue-closed'} - ${TYPE_ISSUE} | ${STATUS_OPEN} | ${'issues'} - ${TYPE_ISSUE} | ${STATUS_CLOSED} | ${'issue-closed'} - ${TYPE_EPIC} | ${STATUS_OPEN} | ${'epic'} - ${TYPE_EPIC} | ${STATUS_CLOSED} | ${'epic-closed'} - `( - 'shows with state icon "$statusIcon" for $issuableType when status is $issuableStatus', - async ({ issuableType, issuableStatus, statusIcon }) => { - await createComponentAndScroll({ issuableType, issuableStatus }); - - expect(findStickyHeader().findComponent(GlIcon).props('name')).toBe(statusIcon); - }, - ); - - it.each` - title | issuableStatus - ${'shows with Open when status is opened'} | ${STATUS_OPEN} - ${'shows with Closed when status is closed'} | ${STATUS_CLOSED} - ${'shows with Open when status is reopened'} | ${STATUS_REOPENED} - `('$title', async ({ issuableStatus }) => { - await createComponentAndScroll({ issuableStatus }); - - expect(findStickyHeader().text()).toContain(issuableStatusText[issuableStatus]); - }); + describe.each([TYPE_INCIDENT, TYPE_ISSUE, TYPE_EPIC])( + 'when title is not in view', + (issuableType) => { + beforeEach(async () => { + await createComponent({ props: { issuableType } }); - it.each` - title | isConfidential - ${'does not show confidential badge when issue is not confidential'} | ${false} - ${'shows confidential badge when issue is confidential'} | ${true} - `('$title', async ({ isConfidential }) => { - await createComponentAndScroll({ isConfidential }); - const confidentialEl = findConfidentialBadge(); - - expect(confidentialEl.exists()).toBe(isConfidential); - - if (isConfidential) { - expect(confidentialEl.props()).toMatchObject({ - workspaceType: 'project', - issuableType: 'issue', - }); - } - }); + global.pageYOffset = 100; + wrapper.findComponent(StickyHeader).vm.$emit('show'); + await nextTick(); + }); - it.each` - title | isLocked - ${'does not show locked badge when issue is not locked'} | ${false} - ${'shows locked badge when issue is locked'} | ${true} - `('$title', async ({ isLocked }) => { - await createComponentAndScroll({ isLocked }); - const lockedBadge = findLockedBadge(); - - expect(lockedBadge.exists()).toBe(isLocked); - - if (isLocked) { - expect(lockedBadge.attributes('title')).toBe( - 'This issue is locked. Only project members can comment.', - ); - expect(getBinding(lockedBadge.element, 'gl-tooltip')).not.toBeUndefined(); - } - }); + it(`shows when issuableType=${issuableType}`, () => { + expect(findStickyHeader().props('show')).toBe(true); + }); - it.each` - title | isHidden - ${'does not show hidden badge when issue is not hidden'} | ${false} - ${'shows hidden badge when issue is hidden'} | ${true} - `('$title', async ({ isHidden }) => { - await createComponentAndScroll({ isHidden }); - const hiddenBadge = findHiddenBadge(); - - expect(hiddenBadge.exists()).toBe(isHidden); - - if (isHidden) { - expect(hiddenBadge.attributes('title')).toBe( - 'This issue is hidden because its author has been banned', - ); - expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined(); - } - }); - }); + it('hides again when title is back in view', async () => { + wrapper.findComponent(StickyHeader).vm.$emit('hide'); + await nextTick(); + + expect(findStickyHeader().props('show')).toBe(false); + }); + }, + ); }); describe('Composable description component', () => { diff --git a/spec/frontend/issues/show/components/sticky_header_spec.js b/spec/frontend/issues/show/components/sticky_header_spec.js new file mode 100644 index 00000000000..0c54ae45e70 --- /dev/null +++ b/spec/frontend/issues/show/components/sticky_header_spec.js @@ -0,0 +1,135 @@ +import { GlIcon } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + issuableStatusText, + STATUS_CLOSED, + STATUS_OPEN, + STATUS_REOPENED, + TYPE_EPIC, + TYPE_INCIDENT, + TYPE_ISSUE, +} from '~/issues/constants'; +import StickyHeader from '~/issues/show/components/sticky_header.vue'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; + +describe('StickyHeader component', () => { + let wrapper; + + const findConfidentialBadge = () => wrapper.findComponent(ConfidentialityBadge); + const findHiddenBadge = () => wrapper.findByTestId('hidden'); + const findLockedBadge = () => wrapper.findByTestId('locked'); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(StickyHeader, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + issuableStatus: STATUS_OPEN, + issuableType: TYPE_ISSUE, + show: true, + title: 'A sticky issue', + titleHtml: '', + ...props, + }, + }); + }; + + it.each` + issuableType | issuableStatus | statusIcon + ${TYPE_INCIDENT} | ${STATUS_OPEN} | ${'issues'} + ${TYPE_INCIDENT} | ${STATUS_CLOSED} | ${'issue-closed'} + ${TYPE_ISSUE} | ${STATUS_OPEN} | ${'issues'} + ${TYPE_ISSUE} | ${STATUS_CLOSED} | ${'issue-closed'} + ${TYPE_EPIC} | ${STATUS_OPEN} | ${'epic'} + ${TYPE_EPIC} | ${STATUS_CLOSED} | ${'epic-closed'} + `( + 'shows with state icon "$statusIcon" for $issuableType when status is $issuableStatus', + ({ issuableType, issuableStatus, statusIcon }) => { + createComponent({ issuableType, issuableStatus }); + + expect(wrapper.findComponent(GlIcon).props('name')).toBe(statusIcon); + }, + ); + + it.each` + title | issuableStatus + ${'shows with Open when status is opened'} | ${STATUS_OPEN} + ${'shows with Closed when status is closed'} | ${STATUS_CLOSED} + ${'shows with Open when status is reopened'} | ${STATUS_REOPENED} + `('$title', ({ issuableStatus }) => { + createComponent({ issuableStatus }); + + expect(wrapper.text()).toContain(issuableStatusText[issuableStatus]); + }); + + it.each` + title | isConfidential + ${'does not show confidential badge when issue is not confidential'} | ${false} + ${'shows confidential badge when issue is confidential'} | ${true} + `('$title', ({ isConfidential }) => { + createComponent({ isConfidential }); + const confidentialBadge = findConfidentialBadge(); + + expect(confidentialBadge.exists()).toBe(isConfidential); + + if (isConfidential) { + expect(confidentialBadge.props()).toMatchObject({ + workspaceType: 'project', + issuableType: 'issue', + }); + } + }); + + it.each` + title | isLocked + ${'does not show locked badge when issue is not locked'} | ${false} + ${'shows locked badge when issue is locked'} | ${true} + `('$title', ({ isLocked }) => { + createComponent({ isLocked }); + const lockedBadge = findLockedBadge(); + + expect(lockedBadge.exists()).toBe(isLocked); + + if (isLocked) { + expect(lockedBadge.attributes('title')).toBe( + 'This issue is locked. Only project members can comment.', + ); + expect(getBinding(lockedBadge.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); + + it.each` + title | isHidden + ${'does not show hidden badge when issue is not hidden'} | ${false} + ${'shows hidden badge when issue is hidden'} | ${true} + `('$title', ({ isHidden }) => { + createComponent({ isHidden }); + const hiddenBadge = findHiddenBadge(); + + expect(hiddenBadge.exists()).toBe(isHidden); + + if (isHidden) { + expect(hiddenBadge.attributes('title')).toBe( + 'This issue is hidden because its author has been banned', + ); + expect(getBinding(hiddenBadge.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); + + it('shows with title', () => { + createComponent(); + const title = wrapper.find('a'); + + expect(title.text()).toContain('A sticky issue'); + expect(title.attributes('href')).toBe('#top'); + }); + + it('shows title containing markup', () => { + const titleHtml = 'A sticky issue'; + createComponent({ titleHtml }); + + expect(wrapper.find('a').html()).toContain(titleHtml); + }); +}); diff --git a/spec/frontend/issues/show/components/task_list_item_actions_spec.js b/spec/frontend/issues/show/components/task_list_item_actions_spec.js index 93cb7b5ae16..b2e57bf49d0 100644 --- a/spec/frontend/issues/show/components/task_list_item_actions_spec.js +++ b/spec/frontend/issues/show/components/task_list_item_actions_spec.js @@ -1,5 +1,6 @@ -import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { TYPE_EPIC, TYPE_INCIDENT, TYPE_ISSUE } from '~/issues/constants'; import TaskListItemActions from '~/issues/show/components/task_list_item_actions.vue'; import eventHub from '~/issues/show/event_hub'; @@ -9,26 +10,24 @@ describe('TaskListItemActions component', () => { let wrapper; const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findConvertToTaskItem = () => wrapper.findAllComponents(GlDisclosureDropdownItem).at(0); - const findDeleteItem = () => wrapper.findAllComponents(GlDisclosureDropdownItem).at(1); + const findConvertToTaskItem = () => wrapper.findByTestId('convert'); + const findDeleteItem = () => wrapper.findByTestId('delete'); - const mountComponent = () => { + const mountComponent = ({ issuableType = TYPE_ISSUE } = {}) => { const li = document.createElement('li'); li.dataset.sourcepos = '3:1-3:10'; li.appendChild(document.createElement('div')); document.body.appendChild(li); - wrapper = shallowMount(TaskListItemActions, { - provide: { canUpdate: true }, + wrapper = shallowMountExtended(TaskListItemActions, { + provide: { canUpdate: true, issuableType }, attachTo: document.querySelector('div'), }); }; - beforeEach(() => { + it('renders dropdown', () => { mountComponent(); - }); - it('renders dropdown', () => { expect(findGlDisclosureDropdown().props()).toMatchObject({ category: 'tertiary', icon: 'ellipsis_v', @@ -38,15 +37,36 @@ describe('TaskListItemActions component', () => { }); }); - it('emits event when `Convert to task` dropdown item is clicked', () => { - findConvertToTaskItem().vm.$emit('action'); + describe('"Convert to task" dropdown item', () => { + describe.each` + issuableType | exists + ${TYPE_EPIC} | ${false} + ${TYPE_INCIDENT} | ${true} + ${TYPE_ISSUE} | ${true} + `(`when $issuableType`, ({ issuableType, exists }) => { + it(`${exists ? 'renders' : 'does not render'}`, () => { + mountComponent({ issuableType }); - expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', '3:1-3:10'); + expect(findConvertToTaskItem().exists()).toBe(exists); + }); + }); }); - it('emits event when `Delete` dropdown item is clicked', () => { - findDeleteItem().vm.$emit('action'); + describe('events', () => { + beforeEach(() => { + mountComponent(); + }); + + it('emits event when `Convert to task` dropdown item is clicked', () => { + findConvertToTaskItem().vm.$emit('action'); + + expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', '3:1-3:10'); + }); - expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', '3:1-3:10'); + it('emits event when `Delete` dropdown item is clicked', () => { + findDeleteItem().vm.$emit('action'); + + expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', '3:1-3:10'); + }); }); }); diff --git a/spec/frontend/issues/show/issue_spec.js b/spec/frontend/issues/show/issue_spec.js deleted file mode 100644 index 561035242eb..00000000000 --- a/spec/frontend/issues/show/issue_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import { initIssueApp } from '~/issues/show'; -import * as parseData from '~/issues/show/utils/parse_data'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import createStore from '~/notes/stores'; -import { appProps } from './mock_data/mock_data'; - -const mock = new MockAdapter(axios); -mock.onGet().reply(HTTP_STATUS_OK); - -jest.mock('~/lib/utils/poll'); - -const setupHTML = (initialData) => { - document.body.innerHTML = `
    `; - document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(initialData); -}; - -describe('Issue show index', () => { - describe('initIssueApp', () => { - // quarantine: https://gitlab.com/gitlab-org/gitlab/-/issues/390368 - // eslint-disable-next-line jest/no-disabled-tests - it.skip('should initialize app with no potential XSS attack', async () => { - const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); - const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData'); - - setupHTML({ - ...appProps, - initialDescriptionHtml: '', - }); - - const initialDataEl = document.getElementById('js-issuable-app'); - const issuableData = parseData.parseIssuableData(initialDataEl); - initIssueApp(issuableData, createStore()); - - await waitForPromises(); - - expect(parseDataSpy).toHaveBeenCalled(); - expect(alertSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js index ed969a08ac5..37aa18ced8d 100644 --- a/spec/frontend/issues/show/mock_data/mock_data.js +++ b/spec/frontend/issues/show/mock_data/mock_data.js @@ -1,8 +1,9 @@ import { TEST_HOST } from 'helpers/test_constants'; export const initialRequest = { - title: '

    this is a title

    ', + title: 'this is a title', title_text: 'this is a title', + title_html: 'this is a title', description: '

    this is a description!

    ', description_text: 'this is a description', task_completion_status: { completed_count: 2, count: 4 }, diff --git a/spec/frontend/issues/show/store_spec.js b/spec/frontend/issues/show/store_spec.js deleted file mode 100644 index 20d3a6cdaae..00000000000 --- a/spec/frontend/issues/show/store_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import Store from '~/issues/show/stores'; -import updateDescription from '~/issues/show/utils/update_description'; - -jest.mock('~/issues/show/utils/update_description'); - -describe('Store', () => { - let store; - - beforeEach(() => { - store = new Store({ - descriptionHtml: '

    This is a description

    ', - }); - }); - - describe('updateState', () => { - beforeEach(() => { - document.body.innerHTML = ` -
    -
    - One -
    -
    - Two -
    -
    - `; - }); - - afterEach(() => { - document.getElementsByTagName('html')[0].innerHTML = ''; - }); - - it('calls updateDetailsState', () => { - store.updateState({ description: '' }); - - expect(updateDescription).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js index cf2dacb50d8..95658f66d09 100644 --- a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js +++ b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js @@ -1,58 +1,45 @@ -import { GlCollapsibleListbox } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; + import SourceBranchDropdown from '~/jira_connect/branches/components/source_branch_dropdown.vue'; import { BRANCHES_PER_PAGE } from '~/jira_connect/branches/constants'; import getProjectQuery from '~/jira_connect/branches/graphql/queries/get_project.query.graphql'; -import { mockProjects } from '../mock_data'; - -const mockProject = { - id: 'test', - repository: { - branchNames: ['main', 'f-test', 'release'], - rootRef: 'main', - }, -}; -const mockSelectedProject = mockProjects[0]; - -const mockProjectQueryResponse = { - data: { - project: mockProject, - }, -}; -const mockGetProjectQuery = jest.fn().mockResolvedValue(mockProjectQueryResponse); -const mockQueryLoading = jest.fn().mockReturnValue(new Promise(() => {})); +import { + mockBranchNames, + mockBranchNames2, + mockProjects, + mockProjectQueryResponse, +} from '../mock_data'; + +Vue.use(VueApollo); describe('SourceBranchDropdown', () => { let wrapper; + const mockSelectedProject = mockProjects[0]; + const querySuccessHandler = jest.fn().mockResolvedValue(mockProjectQueryResponse()); + const queryLoadingHandler = jest.fn().mockReturnValue(new Promise(() => {})); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); - const assertListboxItems = () => { + const assertListboxItems = (branchNames = mockBranchNames) => { const listboxItems = findListbox().props('items'); - expect(listboxItems).toHaveLength(mockProject.repository.branchNames.length); - expect(listboxItems.map((item) => item.text)).toEqual(mockProject.repository.branchNames); + expect(listboxItems).toHaveLength(branchNames.length); + expect(listboxItems.map((item) => item.text)).toEqual(branchNames); }; - function createMockApolloProvider({ getProjectQueryLoading = false } = {}) { - Vue.use(VueApollo); - - const mockApollo = createMockApollo([ - [getProjectQuery, getProjectQueryLoading ? mockQueryLoading : mockGetProjectQuery], - ]); + const createComponent = ({ props, handler = querySuccessHandler } = {}) => { + const mockApollo = createMockApollo([[getProjectQuery, handler]]); - return mockApollo; - } - - function createComponent({ mockApollo, props, mountFn = shallowMount } = {}) { - wrapper = mountFn(SourceBranchDropdown, { - apolloProvider: mockApollo || createMockApolloProvider(), + wrapper = shallowMount(SourceBranchDropdown, { + apolloProvider: mockApollo, propsData: props, }); - } + }; describe('when `selectedProject` prop is not specified', () => { beforeEach(() => { @@ -78,6 +65,7 @@ describe('SourceBranchDropdown', () => { loading: false, searchable: true, searching: false, + selected: null, toggleText: 'Select a branch', }); }); @@ -92,23 +80,26 @@ describe('SourceBranchDropdown', () => { describe('when branches are loading', () => { it('sets loading prop to true', () => { createComponent({ - mockApollo: createMockApolloProvider({ getProjectQueryLoading: true }), props: { selectedProject: mockSelectedProject }, + handler: queryLoadingHandler, }); - expect(findListbox().props('loading')).toEqual(true); + expect(findListbox().props('loading')).toBe(true); }); }); describe('when branches have loaded', () => { describe('when searching branches', () => { it('triggers a refetch', async () => { - createComponent({ mountFn: mount, props: { selectedProject: mockSelectedProject } }); + createComponent({ props: { selectedProject: mockSelectedProject } }); await waitForPromises(); const mockSearchTerm = 'mai'; + expect(querySuccessHandler).toHaveBeenCalledTimes(1); + await findListbox().vm.$emit('search', mockSearchTerm); - expect(mockGetProjectQuery).toHaveBeenCalledWith({ + expect(querySuccessHandler).toHaveBeenCalledTimes(2); + expect(querySuccessHandler).toHaveBeenLastCalledWith({ branchNamesLimit: BRANCHES_PER_PAGE, branchNamesOffset: 0, branchNamesSearchPattern: `*${mockSearchTerm}*`, @@ -129,10 +120,15 @@ describe('SourceBranchDropdown', () => { loading: false, searchable: true, searching: false, + selected: null, toggleText: 'Select a branch', }); }); + it('disables infinite scroll', () => { + expect(findListbox().props('infiniteScroll')).toBe(false); + }); + it('omits monospace styling from listbox', () => { expect(findListbox().classes()).not.toContain('gl-font-monospace'); }); @@ -142,19 +138,19 @@ describe('SourceBranchDropdown', () => { }); it("emits `change` event with the repository's `rootRef` by default", () => { - expect(wrapper.emitted('change')[0]).toEqual([mockProject.repository.rootRef]); + expect(wrapper.emitted('change')[0]).toEqual([mockBranchNames[0]]); }); describe('when selecting a listbox item', () => { it('emits `change` event with the selected branch name', () => { - const mockBranchName = mockProject.repository.branchNames[1]; + const mockBranchName = mockBranchNames[1]; findListbox().vm.$emit('select', mockBranchName); expect(wrapper.emitted('change')[1]).toEqual([mockBranchName]); }); }); describe('when `selectedBranchName` prop is specified', () => { - const mockBranchName = mockProject.repository.branchNames[2]; + const mockBranchName = mockBranchNames[2]; beforeEach(() => { wrapper.setProps({ @@ -162,6 +158,10 @@ describe('SourceBranchDropdown', () => { }); }); + it('sets listbox selected to `selectedBranchName`', () => { + expect(findListbox().props('selected')).toBe(mockBranchName); + }); + it('sets listbox text to `selectedBranchName` value', () => { expect(findListbox().props('toggleText')).toBe(mockBranchName); }); @@ -170,6 +170,66 @@ describe('SourceBranchDropdown', () => { expect(findListbox().classes()).toContain('gl-font-monospace'); }); }); + + describe('when full page of branches returns', () => { + const fullPageBranchNames = Array(BRANCHES_PER_PAGE) + .fill(1) + .map((_, i) => mockBranchNames[i % mockBranchNames.length]); + + beforeEach(async () => { + createComponent({ + props: { selectedProject: mockSelectedProject }, + handler: () => Promise.resolve(mockProjectQueryResponse(fullPageBranchNames)), + }); + await waitForPromises(); + }); + + it('enables infinite scroll', () => { + expect(findListbox().props('infiniteScroll')).toBe(true); + }); + }); + }); + + describe('when loading more branches from infinite scroll', () => { + const queryLoadMoreHandler = jest.fn(); + + beforeEach(async () => { + queryLoadMoreHandler.mockResolvedValueOnce(mockProjectQueryResponse()); + queryLoadMoreHandler.mockResolvedValueOnce(mockProjectQueryResponse(mockBranchNames2)); + createComponent({ + props: { selectedProject: mockSelectedProject }, + handler: queryLoadMoreHandler, + }); + + await waitForPromises(); + + await findListbox().vm.$emit('bottom-reached'); + }); + + it('sets loading more prop to true', () => { + expect(findListbox().props('infiniteScrollLoading')).toBe(true); + }); + + it('triggers load more query', () => { + expect(queryLoadMoreHandler).toHaveBeenLastCalledWith({ + branchNamesLimit: BRANCHES_PER_PAGE, + branchNamesOffset: 3, + branchNamesSearchPattern: '*', + projectPath: 'test-path', + }); + }); + + it('renders available source branches as listbox items', async () => { + await waitForPromises(); + + assertListboxItems([...mockBranchNames, ...mockBranchNames2]); + }); + + it('sets loading more prop to false once done', async () => { + await waitForPromises(); + + expect(findListbox().props('infiniteScrollLoading')).toBe(false); + }); }); }); }); diff --git a/spec/frontend/jira_connect/branches/mock_data.js b/spec/frontend/jira_connect/branches/mock_data.js index 742ab5392c8..1720e0118c8 100644 --- a/spec/frontend/jira_connect/branches/mock_data.js +++ b/spec/frontend/jira_connect/branches/mock_data.js @@ -1,3 +1,6 @@ +export const mockBranchNames = ['main', 'f-test', 'release']; +export const mockBranchNames2 = ['dev', 'dev-1', 'dev-2']; + export const mockProjects = [ { id: 'test', @@ -28,3 +31,15 @@ export const mockProjects = [ }, }, ]; + +export const mockProjectQueryResponse = (branchNames = mockBranchNames) => ({ + data: { + project: { + id: 'gid://gitlab/Project/27', + repository: { + branchNames, + rootRef: 'main', + }, + }, + }, +}); diff --git a/spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap b/spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap index 21c903f064d..af9f827117f 100644 --- a/spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap +++ b/spec/frontend/jira_connect/subscriptions/components/__snapshots__/group_item_name_spec.js.snap @@ -2,16 +2,15 @@ exports[`GroupItemName template matches the snapshot 1`] = `
    -
    -
    - Gitlab Org - -

    Open source software to collaborate on code

    diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index abd849b387e..263deb3b616 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -4,23 +4,17 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = - - - - - - -
    @@ -31,7 +25,6 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = @@ -39,7 +32,6 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = @@ -52,21 +44,17 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
    Jane Doe
    Fred Chopin
    `; diff --git a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js deleted file mode 100644 index 5ecddc7efd6..00000000000 --- a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js +++ /dev/null @@ -1,71 +0,0 @@ -import { GlFilteredSearch } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { - OPERATORS_IS, - TOKEN_TITLE_STATUS, - TOKEN_TYPE_STATUS, -} from '~/vue_shared/components/filtered_search_bar/constants'; -import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; -import { mockFailedSearchToken } from '../../mock_data'; - -describe('Jobs filtered search', () => { - let wrapper; - - const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); - const getSearchToken = (type) => - findFilteredSearch() - .props('availableTokens') - .find((token) => token.type === type); - - const findStatusToken = () => getSearchToken('status'); - - const createComponent = (props) => { - wrapper = shallowMount(JobsFilteredSearch, { - propsData: { - ...props, - }, - }); - }; - - it('displays filtered search', () => { - createComponent(); - - expect(findFilteredSearch().exists()).toBe(true); - }); - - it('displays status token', () => { - createComponent(); - - expect(findStatusToken()).toMatchObject({ - type: TOKEN_TYPE_STATUS, - icon: 'status', - title: TOKEN_TITLE_STATUS, - unique: true, - operators: OPERATORS_IS, - }); - }); - - it('emits filter token to parent component', () => { - createComponent(); - - findFilteredSearch().vm.$emit('submit', mockFailedSearchToken); - - expect(wrapper.emitted('filterJobsBySearch')).toEqual([[mockFailedSearchToken]]); - }); - - it('filtered search value is empty array when no query string is passed', () => { - createComponent(); - - expect(findFilteredSearch().props('value')).toEqual([]); - }); - - it('filtered search returns correct data shape when passed query string', () => { - const value = 'SUCCESS'; - - createComponent({ queryString: { statuses: value } }); - - expect(findFilteredSearch().props('value')).toEqual([ - { type: TOKEN_TYPE_STATUS, value: { data: value, operator: '=' } }, - ]); - }); -}); diff --git a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js deleted file mode 100644 index 6755b854f01..00000000000 --- a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { stubComponent } from 'helpers/stub_component'; -import JobStatusToken from '~/jobs/components/filtered_search/tokens/job_status_token.vue'; -import { - TOKEN_TITLE_STATUS, - TOKEN_TYPE_STATUS, -} from '~/vue_shared/components/filtered_search_bar/constants'; - -describe('Job Status Token', () => { - let wrapper; - - const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); - const findAllFilteredSearchSuggestions = () => - wrapper.findAllComponents(GlFilteredSearchSuggestion); - const findAllGlIcons = () => wrapper.findAllComponents(GlIcon); - - const defaultProps = { - config: { - type: TOKEN_TYPE_STATUS, - icon: 'status', - title: TOKEN_TITLE_STATUS, - unique: true, - }, - value: { - data: '', - }, - cursorPosition: 'start', - }; - - const createComponent = () => { - wrapper = shallowMount(JobStatusToken, { - propsData: { - ...defaultProps, - }, - stubs: { - GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { - template: `
    `, - }), - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('passes config correctly', () => { - expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); - }); - - it('renders all job statuses available', () => { - const expectedLength = 11; - - expect(findAllFilteredSearchSuggestions()).toHaveLength(expectedLength); - expect(findAllGlIcons()).toHaveLength(expectedLength); - }); -}); diff --git a/spec/frontend/jobs/components/filtered_search/utils_spec.js b/spec/frontend/jobs/components/filtered_search/utils_spec.js deleted file mode 100644 index 8440ab42b86..00000000000 --- a/spec/frontend/jobs/components/filtered_search/utils_spec.js +++ /dev/null @@ -1,19 +0,0 @@ -import { validateQueryString } from '~/jobs/components/filtered_search/utils'; - -describe('Filtered search utils', () => { - describe('validateQueryString', () => { - it.each` - queryStringObject | expected - ${{ statuses: 'SUCCESS' }} | ${{ statuses: 'SUCCESS' }} - ${{ statuses: 'failed' }} | ${{ statuses: 'FAILED' }} - ${{ wrong: 'SUCCESS' }} | ${null} - ${{ statuses: 'wrong' }} | ${null} - ${{ wrong: 'wrong' }} | ${null} - `( - 'when provided $queryStringObject, the expected result is $expected', - ({ queryStringObject, expected }) => { - expect(validateQueryString(queryStringObject)).toEqual(expected); - }, - ); - }); -}); diff --git a/spec/frontend/jobs/components/job/artifacts_block_spec.js b/spec/frontend/jobs/components/job/artifacts_block_spec.js deleted file mode 100644 index f9e52a5ae43..00000000000 --- a/spec/frontend/jobs/components/job/artifacts_block_spec.js +++ /dev/null @@ -1,193 +0,0 @@ -import { GlPopover } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { trimText } from 'helpers/text_helper'; -import ArtifactsBlock from '~/jobs/components/job/sidebar/artifacts_block.vue'; -import { getTimeago } from '~/lib/utils/datetime_utility'; - -describe('Artifacts block', () => { - let wrapper; - - const createWrapper = (propsData) => - mountExtended(ArtifactsBlock, { - propsData: { - helpUrl: 'help-url', - ...propsData, - }, - }); - - const findArtifactRemoveElt = () => wrapper.findByTestId('artifacts-remove-timeline'); - const findJobLockedElt = () => wrapper.findByTestId('job-locked-message'); - const findKeepBtn = () => wrapper.findByTestId('keep-artifacts'); - const findDownloadBtn = () => wrapper.findByTestId('download-artifacts'); - const findBrowseBtn = () => wrapper.findByTestId('browse-artifacts'); - const findArtifactsHelpLink = () => wrapper.findByTestId('artifacts-help-link'); - const findPopover = () => wrapper.findComponent(GlPopover); - - const expireAt = '2018-08-14T09:38:49.157Z'; - const timeago = getTimeago(); - const formattedDate = timeago.format(expireAt); - const lockedText = - 'These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.'; - - const expiredArtifact = { - expire_at: expireAt, - expired: true, - locked: false, - }; - - const nonExpiredArtifact = { - download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', - browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', - keep_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/keep', - expire_at: expireAt, - expired: false, - locked: false, - }; - - const lockedExpiredArtifact = { - ...expiredArtifact, - download_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/download', - browse_path: '/gitlab-org/gitlab-foss/-/jobs/98314558/artifacts/browse', - expired: true, - locked: true, - }; - - const lockedNonExpiredArtifact = { - ...nonExpiredArtifact, - keep_path: undefined, - locked: true, - }; - - describe('with expired artifacts that are not locked', () => { - beforeEach(() => { - wrapper = createWrapper({ - artifact: expiredArtifact, - }); - }); - - it('renders expired artifact date and info', () => { - expect(trimText(findArtifactRemoveElt().text())).toBe( - `The artifacts were removed ${formattedDate}`, - ); - - expect( - findArtifactRemoveElt() - .find('[data-testid="artifact-expired-help-link"]') - .attributes('href'), - ).toBe('help-url'); - }); - - it('does not show the keep button', () => { - expect(findKeepBtn().exists()).toBe(false); - }); - - it('does not show the download button', () => { - expect(findDownloadBtn().exists()).toBe(false); - }); - - it('does not show the browse button', () => { - expect(findBrowseBtn().exists()).toBe(false); - }); - }); - - describe('with artifacts that will expire', () => { - beforeEach(() => { - wrapper = createWrapper({ - artifact: nonExpiredArtifact, - }); - }); - - it('renders will expire artifact date and info', () => { - expect(trimText(findArtifactRemoveElt().text())).toBe( - `The artifacts will be removed ${formattedDate}`, - ); - - expect( - findArtifactRemoveElt() - .find('[data-testid="artifact-expired-help-link"]') - .attributes('href'), - ).toBe('help-url'); - }); - - it('renders the keep button', () => { - expect(findKeepBtn().exists()).toBe(true); - }); - - it('renders the download button', () => { - expect(findDownloadBtn().exists()).toBe(true); - }); - - it('renders the browse button', () => { - expect(findBrowseBtn().exists()).toBe(true); - }); - }); - - describe('with expired locked artifacts', () => { - beforeEach(() => { - wrapper = createWrapper({ - artifact: lockedExpiredArtifact, - }); - }); - - it('renders the information that the artefacts are locked', () => { - expect(findArtifactRemoveElt().exists()).toBe(false); - expect(trimText(findJobLockedElt().text())).toBe(lockedText); - }); - - it('does not render the keep button', () => { - expect(findKeepBtn().exists()).toBe(false); - }); - - it('renders the download button', () => { - expect(findDownloadBtn().exists()).toBe(true); - }); - - it('renders the browse button', () => { - expect(findBrowseBtn().exists()).toBe(true); - }); - }); - - describe('with non expired locked artifacts', () => { - beforeEach(() => { - wrapper = createWrapper({ - artifact: lockedNonExpiredArtifact, - }); - }); - - it('renders the information that the artefacts are locked', () => { - expect(findArtifactRemoveElt().exists()).toBe(false); - expect(trimText(findJobLockedElt().text())).toBe(lockedText); - }); - - it('does not render the keep button', () => { - expect(findKeepBtn().exists()).toBe(false); - }); - - it('renders the download button', () => { - expect(findDownloadBtn().exists()).toBe(true); - }); - - it('renders the browse button', () => { - expect(findBrowseBtn().exists()).toBe(true); - }); - }); - - describe('artifacts help text', () => { - beforeEach(() => { - wrapper = createWrapper({ - artifact: lockedNonExpiredArtifact, - }); - }); - - it('displays help text', () => { - const expectedHelpText = - 'Job artifacts are files that are configured to be uploaded when a job finishes execution. Artifacts could be compiled files, unit tests or scanning reports, or any other files generated by a job.'; - - expect(findPopover().text()).toBe(expectedHelpText); - }); - - it('links to artifacts help page', () => { - expect(findArtifactsHelpLink().attributes('href')).toBe('/help/ci/jobs/job_artifacts'); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/commit_block_spec.js b/spec/frontend/jobs/components/job/commit_block_spec.js deleted file mode 100644 index 1c28b5079d7..00000000000 --- a/spec/frontend/jobs/components/job/commit_block_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import CommitBlock from '~/jobs/components/job/sidebar/commit_block.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - -describe('Commit block', () => { - let wrapper; - - const commit = { - short_id: '1f0fb84f', - id: '1f0fb84fb6770d74d97eee58118fd3909cd4f48c', - commit_path: 'commit/1f0fb84fb6770d74d97eee58118fd3909cd4f48c', - title: 'Update README.md', - }; - - const mergeRequest = { - iid: '!21244', - path: 'merge_requests/21244', - }; - - const findCommitSha = () => wrapper.findByTestId('commit-sha'); - const findLinkSha = () => wrapper.findByTestId('link-commit'); - - const mountComponent = (props) => { - wrapper = extendedWrapper( - shallowMount(CommitBlock, { - propsData: { - commit, - ...props, - }, - }), - ); - }; - - describe('without merge request', () => { - beforeEach(() => { - mountComponent(); - }); - - it('renders pipeline short sha link', () => { - expect(findCommitSha().attributes('href')).toBe(commit.commit_path); - expect(findCommitSha().text()).toBe(commit.short_id); - }); - - it('renders clipboard button', () => { - expect(wrapper.findComponent(ClipboardButton).attributes('text')).toBe(commit.id); - }); - - it('renders git commit title', () => { - expect(wrapper.text()).toContain(commit.title); - }); - - it('does not render merge request', () => { - expect(findLinkSha().exists()).toBe(false); - }); - }); - - describe('with merge request', () => { - it('renders merge request link and reference', () => { - mountComponent({ mergeRequest }); - - expect(findLinkSha().attributes('href')).toBe(mergeRequest.path); - expect(findLinkSha().text()).toBe(`!${mergeRequest.iid}`); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/empty_state_spec.js b/spec/frontend/jobs/components/job/empty_state_spec.js deleted file mode 100644 index 970c2591795..00000000000 --- a/spec/frontend/jobs/components/job/empty_state_spec.js +++ /dev/null @@ -1,140 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import EmptyState from '~/jobs/components/job/empty_state.vue'; -import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; -import { mockFullPath, mockId } from './mock_data'; - -describe('Empty State', () => { - let wrapper; - - const defaultProps = { - illustrationPath: 'illustrations/pending_job_empty.svg', - illustrationSizeClass: 'svg-430', - jobId: mockId, - title: 'This job has not started yet', - playable: false, - isRetryable: true, - }; - - const createWrapper = (props) => { - wrapper = shallowMountExtended(EmptyState, { - propsData: { - ...defaultProps, - ...props, - }, - provide: { - projectPath: mockFullPath, - }, - }); - }; - - const content = 'This job is in pending state and is waiting to be picked by a runner'; - - const findEmptyStateImage = () => wrapper.find('img'); - const findTitle = () => wrapper.findByTestId('job-empty-state-title'); - const findContent = () => wrapper.findByTestId('job-empty-state-content'); - const findAction = () => wrapper.findByTestId('job-empty-state-action'); - const findManualVarsForm = () => wrapper.findComponent(ManualVariablesForm); - - describe('renders image and title', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders empty state image', () => { - expect(findEmptyStateImage().exists()).toBe(true); - }); - - it('renders provided title', () => { - expect(findTitle().text().trim()).toBe(defaultProps.title); - }); - }); - - describe('with content', () => { - beforeEach(() => { - createWrapper({ content }); - }); - - it('renders content', () => { - expect(findContent().text().trim()).toBe(content); - }); - }); - - describe('without content', () => { - beforeEach(() => { - createWrapper(); - }); - - it('does not render content', () => { - expect(findContent().exists()).toBe(false); - }); - }); - - describe('with action', () => { - beforeEach(() => { - createWrapper({ - action: { - path: 'runner', - button_title: 'Check runner', - method: 'post', - }, - }); - }); - - it('renders action', () => { - expect(findAction().attributes('href')).toBe('runner'); - }); - }); - - describe('without action', () => { - beforeEach(() => { - createWrapper({ - action: null, - }); - }); - - it('does not render action', () => { - expect(findAction().exists()).toBe(false); - }); - - it('does not render manual variables form', () => { - expect(findManualVarsForm().exists()).toBe(false); - }); - }); - - describe('with playable action and not scheduled job', () => { - beforeEach(() => { - createWrapper({ - content, - playable: true, - scheduled: false, - action: { - path: 'runner', - button_title: 'Check runner', - method: 'post', - }, - }); - }); - - it('renders manual variables form', () => { - expect(findManualVarsForm().exists()).toBe(true); - }); - - it('does not render the empty state action', () => { - expect(findAction().exists()).toBe(false); - }); - }); - - describe('with playable action and scheduled job', () => { - beforeEach(() => { - createWrapper({ - playable: true, - scheduled: true, - content, - }); - }); - - it('does not render manual variables form', () => { - expect(findManualVarsForm().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/environments_block_spec.js b/spec/frontend/jobs/components/job/environments_block_spec.js deleted file mode 100644 index ab36f79ea5e..00000000000 --- a/spec/frontend/jobs/components/job/environments_block_spec.js +++ /dev/null @@ -1,260 +0,0 @@ -import { mount } from '@vue/test-utils'; -import EnvironmentsBlock from '~/jobs/components/job/environments_block.vue'; - -const TEST_CLUSTER_NAME = 'test_cluster'; -const TEST_CLUSTER_PATH = 'path/to/test_cluster'; -const TEST_KUBERNETES_NAMESPACE = 'this-is-a-kubernetes-namespace'; - -describe('Environments block', () => { - let wrapper; - - const status = { - group: 'success', - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - }; - - const environment = { - environment_path: '/environment', - name: 'environment', - }; - - const lastDeployment = { iid: 'deployment', deployable: { build_path: 'bar' } }; - - const createEnvironmentWithLastDeployment = () => ({ - ...environment, - last_deployment: { ...lastDeployment }, - }); - - const createDeploymentWithCluster = () => ({ name: TEST_CLUSTER_NAME, path: TEST_CLUSTER_PATH }); - - const createDeploymentWithClusterAndKubernetesNamespace = () => ({ - name: TEST_CLUSTER_NAME, - path: TEST_CLUSTER_PATH, - kubernetes_namespace: TEST_KUBERNETES_NAMESPACE, - }); - - const createComponent = (deploymentStatus = {}, deploymentCluster = {}) => { - wrapper = mount(EnvironmentsBlock, { - propsData: { - deploymentStatus, - deploymentCluster, - iconStatus: status, - }, - }); - }; - - const findText = () => wrapper.findComponent(EnvironmentsBlock).text(); - const findJobDeploymentLink = () => wrapper.find('[data-testid="job-deployment-link"]'); - const findEnvironmentLink = () => wrapper.find('[data-testid="job-environment-link"]'); - const findClusterLink = () => wrapper.find('[data-testid="job-cluster-link"]'); - - describe('with last deployment', () => { - it('renders info for most recent deployment', () => { - createComponent({ - status: 'last', - environment, - }); - - expect(findText()).toBe('This job is deployed to environment.'); - }); - - describe('when there is a cluster', () => { - it('renders info with cluster', () => { - createComponent( - { - status: 'last', - environment: createEnvironmentWithLastDeployment(), - }, - createDeploymentWithCluster(), - ); - - expect(findText()).toBe( - `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME}.`, - ); - }); - - describe('when there is a kubernetes namespace', () => { - it('renders info with cluster', () => { - createComponent( - { - status: 'last', - environment: createEnvironmentWithLastDeployment(), - }, - createDeploymentWithClusterAndKubernetesNamespace(), - ); - - expect(findText()).toBe( - `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME} and namespace ${TEST_KUBERNETES_NAMESPACE}.`, - ); - }); - }); - }); - }); - - describe('with out of date deployment', () => { - describe('with last deployment', () => { - it('renders info for out date and most recent', () => { - createComponent({ - status: 'out_of_date', - environment: createEnvironmentWithLastDeployment(), - }); - - expect(findText()).toBe( - 'This job is an out-of-date deployment to environment. View the most recent deployment.', - ); - - expect(findJobDeploymentLink().attributes('href')).toBe('bar'); - }); - - describe('when there is a cluster', () => { - it('renders info with cluster', () => { - createComponent( - { - status: 'out_of_date', - environment: createEnvironmentWithLastDeployment(), - }, - createDeploymentWithCluster(), - ); - - expect(findText()).toBe( - `This job is an out-of-date deployment to environment using cluster ${TEST_CLUSTER_NAME}. View the most recent deployment.`, - ); - }); - - describe('when there is a kubernetes namespace', () => { - it('renders info with cluster', () => { - createComponent( - { - status: 'out_of_date', - environment: createEnvironmentWithLastDeployment(), - }, - createDeploymentWithClusterAndKubernetesNamespace(), - ); - - expect(findText()).toBe( - `This job is an out-of-date deployment to environment using cluster ${TEST_CLUSTER_NAME} and namespace ${TEST_KUBERNETES_NAMESPACE}. View the most recent deployment.`, - ); - }); - }); - }); - }); - - describe('without last deployment', () => { - it('renders info about out of date deployment', () => { - createComponent({ - status: 'out_of_date', - environment, - }); - - expect(findText()).toBe('This job is an out-of-date deployment to environment.'); - }); - }); - }); - - describe('with failed deployment', () => { - it('renders info about failed deployment', () => { - createComponent({ - status: 'failed', - environment, - }); - - expect(findText()).toBe('The deployment of this job to environment did not succeed.'); - }); - }); - - describe('creating deployment', () => { - describe('with last deployment', () => { - it('renders info about creating deployment and overriding latest deployment', () => { - createComponent({ - status: 'creating', - environment: createEnvironmentWithLastDeployment(), - }); - - expect(findText()).toBe( - 'This job is creating a deployment to environment. This will overwrite the latest deployment.', - ); - - expect(findEnvironmentLink().attributes('href')).toBe(environment.environment_path); - - expect(findJobDeploymentLink().attributes('href')).toBe('bar'); - - expect(findClusterLink().exists()).toBe(false); - }); - }); - - describe('without last deployment', () => { - it('renders info about deployment being created', () => { - createComponent({ - status: 'creating', - environment, - }); - - expect(findText()).toBe('This job is creating a deployment to environment.'); - }); - - describe('when there is a cluster', () => { - it('inclues information about the cluster', () => { - createComponent( - { - status: 'creating', - environment, - }, - createDeploymentWithCluster(), - ); - - expect(findText()).toBe( - `This job is creating a deployment to environment using cluster ${TEST_CLUSTER_NAME}.`, - ); - }); - }); - }); - - describe('without environment', () => { - it('does not render environment link', () => { - createComponent({ - status: 'creating', - environment: null, - }); - - expect(findEnvironmentLink().exists()).toBe(false); - }); - }); - }); - - describe('with a cluster', () => { - it('renders the cluster link', () => { - createComponent( - { - status: 'last', - environment: createEnvironmentWithLastDeployment(), - }, - createDeploymentWithCluster(), - ); - - expect(findText()).toBe( - `This job is deployed to environment using cluster ${TEST_CLUSTER_NAME}.`, - ); - - expect(findClusterLink().attributes('href')).toBe(TEST_CLUSTER_PATH); - }); - - describe('when the cluster is missing the path', () => { - it('renders the name without a link', () => { - createComponent( - { - status: 'last', - environment: createEnvironmentWithLastDeployment(), - }, - { name: 'the-cluster' }, - ); - - expect(findText()).toContain('using cluster the-cluster.'); - - expect(findClusterLink().exists()).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/erased_block_spec.js b/spec/frontend/jobs/components/job/erased_block_spec.js deleted file mode 100644 index aeab676fc7e..00000000000 --- a/spec/frontend/jobs/components/job/erased_block_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import ErasedBlock from '~/jobs/components/job/erased_block.vue'; -import { getTimeago } from '~/lib/utils/datetime_utility'; - -describe('Erased block', () => { - let wrapper; - - const erasedAt = '2016-11-07T11:11:16.525Z'; - const timeago = getTimeago(); - const formattedDate = timeago.format(erasedAt); - - const findLink = () => wrapper.findComponent(GlLink); - - const createComponent = (props) => { - wrapper = mount(ErasedBlock, { - propsData: props, - }); - }; - - describe('with job erased by user', () => { - beforeEach(() => { - createComponent({ - user: { - username: 'root', - web_url: 'gitlab.com/root', - }, - erasedAt, - }); - }); - - it('renders username and link', () => { - expect(findLink().attributes('href')).toEqual('gitlab.com/root'); - - expect(wrapper.text().trim()).toContain('Job has been erased by'); - expect(wrapper.text().trim()).toContain('root'); - }); - - it('renders erasedAt', () => { - expect(wrapper.text().trim()).toContain(formattedDate); - }); - }); - - describe('with erased job', () => { - beforeEach(() => { - createComponent({ - erasedAt, - }); - }); - - it('renders username and link', () => { - expect(wrapper.text().trim()).toContain('Job has been erased'); - }); - - it('renders erasedAt', () => { - expect(wrapper.text().trim()).toContain(formattedDate); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/job_app_spec.js b/spec/frontend/jobs/components/job/job_app_spec.js deleted file mode 100644 index 8f5700ee22d..00000000000 --- a/spec/frontend/jobs/components/job/job_app_spec.js +++ /dev/null @@ -1,343 +0,0 @@ -import Vue, { nextTick } from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { TEST_HOST } from 'helpers/test_constants'; -import EmptyState from '~/jobs/components/job/empty_state.vue'; -import EnvironmentsBlock from '~/jobs/components/job/environments_block.vue'; -import ErasedBlock from '~/jobs/components/job/erased_block.vue'; -import JobApp from '~/jobs/components/job/job_app.vue'; -import JobLog from '~/jobs/components/log/log.vue'; -import JobLogTopBar from 'ee_else_ce/jobs/components/job/job_log_controllers.vue'; -import Sidebar from '~/jobs/components/job/sidebar/sidebar.vue'; -import StuckBlock from '~/jobs/components/job/stuck_block.vue'; -import UnmetPrerequisitesBlock from '~/jobs/components/job/unmet_prerequisites_block.vue'; -import createStore from '~/jobs/store'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { MANUAL_STATUS } from '~/jobs/constants'; -import job from '../../mock_data'; -import { mockPendingJobData } from './mock_data'; - -describe('Job App', () => { - Vue.use(Vuex); - - let store; - let wrapper; - let mock; - - const initSettings = { - endpoint: `${TEST_HOST}jobs/123.json`, - pagePath: `${TEST_HOST}jobs/123`, - logState: - 'eyJvZmZzZXQiOjE3NDUxLCJuX29wZW5fdGFncyI6MCwiZmdfY29sb3IiOm51bGwsImJnX2NvbG9yIjpudWxsLCJzdHlsZV9tYXNrIjowfQ%3D%3D', - }; - - const props = { - artifactHelpUrl: 'help/artifact', - deploymentHelpUrl: 'help/deployment', - runnerSettingsUrl: 'settings/ci-cd/runners', - terminalPath: 'jobs/123/terminal', - projectPath: 'user-name/project-name', - subscriptionsMoreMinutesUrl: 'https://customers.gitlab.com/buy_pipeline_minutes', - }; - - const createComponent = () => { - wrapper = shallowMountExtended(JobApp, { - propsData: { ...props }, - store, - }); - }; - - const setupAndMount = async ({ jobData = {}, jobLogData = {} } = {}) => { - mock.onGet(initSettings.endpoint).replyOnce(HTTP_STATUS_OK, { ...job, ...jobData }); - mock.onGet(`${initSettings.pagePath}/trace.json`).reply(HTTP_STATUS_OK, jobLogData); - - const asyncInit = store.dispatch('init', initSettings); - - createComponent(); - - await asyncInit; - jest.runOnlyPendingTimers(); - await axios.waitForAll(); - await nextTick(); - }; - - const findLoadingComponent = () => wrapper.findComponent(GlLoadingIcon); - const findSidebar = () => wrapper.findComponent(Sidebar); - const findStuckBlockComponent = () => wrapper.findComponent(StuckBlock); - const findFailedJobComponent = () => wrapper.findComponent(UnmetPrerequisitesBlock); - const findEnvironmentsBlockComponent = () => wrapper.findComponent(EnvironmentsBlock); - const findErasedBlock = () => wrapper.findComponent(ErasedBlock); - const findEmptyState = () => wrapper.findComponent(EmptyState); - const findJobLog = () => wrapper.findComponent(JobLog); - const findJobLogTopBar = () => wrapper.findComponent(JobLogTopBar); - - const findJobContent = () => wrapper.findByTestId('job-content'); - const findArchivedJob = () => wrapper.findByTestId('archived-job'); - - beforeEach(() => { - mock = new MockAdapter(axios); - store = createStore(); - }); - - afterEach(() => { - mock.restore(); - // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy - wrapper.destroy(); - }); - - describe('while loading', () => { - beforeEach(() => { - store.state.isLoading = true; - createComponent(); - }); - - it('renders loading icon', () => { - expect(findLoadingComponent().exists()).toBe(true); - expect(findSidebar().exists()).toBe(false); - expect(findJobContent().exists()).toBe(false); - }); - }); - - describe('with successful request', () => { - describe('Header section', () => { - describe('job callout message', () => { - it('should not render the reason when reason is absent', () => - setupAndMount().then(() => { - expect(wrapper.vm.shouldRenderCalloutMessage).toBe(false); - })); - - it('should render the reason when reason is present', () => - setupAndMount({ - jobData: { - callout_message: 'There is an unkown failure, please try again', - }, - }).then(() => { - expect(wrapper.vm.shouldRenderCalloutMessage).toBe(true); - })); - }); - }); - - describe('stuck block', () => { - describe('without active runners available', () => { - it('renders stuck block when there are no runners', () => - setupAndMount({ - jobData: { - status: { - group: 'pending', - icon: 'status_pending', - label: 'pending', - text: 'pending', - details_path: 'path', - }, - stuck: true, - runners: { - available: false, - online: false, - }, - tags: [], - }, - }).then(() => { - expect(findStuckBlockComponent().exists()).toBe(true); - })); - }); - - it('does not render stuck block when there are runners', () => - setupAndMount({ - jobData: { - runners: { available: true }, - }, - }).then(() => { - expect(findStuckBlockComponent().exists()).toBe(false); - })); - }); - - describe('unmet prerequisites block', () => { - it('renders unmet prerequisites block when there is an unmet prerequisites failure', () => - setupAndMount({ - jobData: { - status: { - group: 'failed', - icon: 'status_failed', - label: 'failed', - text: 'failed', - details_path: 'path', - illustration: { - content: 'Retry this job in order to create the necessary resources.', - image: 'path', - size: 'svg-430', - title: 'Failed to create resources', - }, - }, - failure_reason: 'unmet_prerequisites', - has_trace: false, - runners: { - available: true, - }, - tags: [], - }, - }).then(() => { - expect(findFailedJobComponent().exists()).toBe(true); - })); - }); - - describe('environments block', () => { - it('renders environment block when job has environment', () => - setupAndMount({ - jobData: { - deployment_status: { - environment: { - environment_path: '/path', - name: 'foo', - }, - }, - }, - }).then(() => { - expect(findEnvironmentsBlockComponent().exists()).toBe(true); - })); - - it('does not render environment block when job has environment', () => - setupAndMount().then(() => { - expect(findEnvironmentsBlockComponent().exists()).toBe(false); - })); - }); - - describe('erased block', () => { - it('renders erased block when `erased` is true', () => - setupAndMount({ - jobData: { - erased_by: { - username: 'root', - web_url: 'gitlab.com/root', - }, - erased_at: '2016-11-07T11:11:16.525Z', - }, - }).then(() => { - expect(findErasedBlock().exists()).toBe(true); - })); - - it('does not render erased block when `erased` is false', () => - setupAndMount({ - jobData: { - erased_at: null, - }, - }).then(() => { - expect(findErasedBlock().exists()).toBe(false); - })); - }); - - describe('empty states block', () => { - it('renders empty state when job does not have log and is not running', () => - setupAndMount({ - jobData: { - has_trace: false, - status: { - group: 'pending', - icon: 'status_pending', - label: 'pending', - text: 'pending', - details_path: 'path', - illustration: { - image: 'path', - size: '340', - title: 'Empty State', - content: 'This is an empty state', - }, - action: { - button_title: 'Retry job', - method: 'post', - path: '/path', - }, - }, - }, - }).then(() => { - expect(findEmptyState().exists()).toBe(true); - })); - - it('does not render empty state when job does not have log but it is running', () => - setupAndMount({ - jobData: { - has_trace: false, - status: { - group: 'running', - icon: 'status_running', - label: 'running', - text: 'running', - details_path: 'path', - }, - }, - }).then(() => { - expect(findEmptyState().exists()).toBe(false); - })); - - it('does not render empty state when job has log but it is not running', () => - setupAndMount({ jobData: { has_trace: true } }).then(() => { - expect(findEmptyState().exists()).toBe(false); - })); - }); - - describe('sidebar', () => { - it('renders sidebar', async () => { - await setupAndMount(); - - expect(findSidebar().exists()).toBe(true); - }); - }); - }); - - describe('archived job', () => { - beforeEach(() => setupAndMount({ jobData: { archived: true } })); - - it('renders warning about job being archived', () => { - expect(findArchivedJob().exists()).toBe(true); - }); - }); - - describe('non-archived job', () => { - beforeEach(() => setupAndMount()); - - it('does not warning about job being archived', () => { - expect(findArchivedJob().exists()).toBe(false); - }); - }); - - describe('job log', () => { - beforeEach(() => setupAndMount()); - - it('should render job log header', () => { - expect(findJobLogTopBar().exists()).toBe(true); - }); - - it('should render job log', () => { - expect(findJobLog().exists()).toBe(true); - }); - }); - - describe('job log polling', () => { - beforeEach(() => { - jest.spyOn(store, 'dispatch'); - }); - - it('should poll job log by default', async () => { - await setupAndMount({ - jobData: mockPendingJobData, - }); - - expect(store.dispatch).toHaveBeenCalledWith('fetchJobLog'); - }); - - it('should NOT poll job log for manual variables form empty state', async () => { - const manualPendingJobData = mockPendingJobData; - manualPendingJobData.status.group = MANUAL_STATUS; - - await setupAndMount({ - jobData: manualPendingJobData, - }); - - expect(store.dispatch).not.toHaveBeenCalledWith('fetchJobLog'); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/job_container_item_spec.js b/spec/frontend/jobs/components/job/job_container_item_spec.js deleted file mode 100644 index 39782130d38..00000000000 --- a/spec/frontend/jobs/components/job/job_container_item_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { GlIcon, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; -import JobContainerItem from '~/jobs/components/job/sidebar/job_container_item.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import job from '../../mock_data'; - -describe('JobContainerItem', () => { - let wrapper; - - const findCiIcon = () => wrapper.findComponent(CiIcon); - const findGlIcon = () => wrapper.findComponent(GlIcon); - - function createComponent(jobData = {}, props = { isActive: false, retried: false }) { - wrapper = shallowMount(JobContainerItem, { - propsData: { - job: { - ...jobData, - retried: props.retried, - }, - isActive: props.isActive, - }, - }); - } - - describe('when a job is not active and not retried', () => { - beforeEach(() => { - createComponent(job); - }); - - it('displays a status icon', () => { - expect(findCiIcon().props('status')).toBe(job.status); - }); - - it('displays the job name', () => { - expect(wrapper.text()).toContain(job.name); - }); - - it('displays a link to the job', () => { - const link = wrapper.findComponent(GlLink); - - expect(link.attributes('href')).toBe(job.status.details_path); - }); - }); - - describe('when a job is active', () => { - beforeEach(() => { - createComponent(job, { isActive: true }); - }); - - it('displays an arrow sprite icon', () => { - expect(findGlIcon().props('name')).toBe('arrow-right'); - }); - }); - - describe('when a job is retried', () => { - beforeEach(() => { - createComponent(job, { isActive: false, retried: true }); - }); - - it('displays a retry icon', () => { - expect(findGlIcon().props('name')).toBe('retry'); - }); - }); - - describe('for a delayed job', () => { - beforeEach(() => { - const remainingMilliseconds = 1337000; - jest - .spyOn(Date, 'now') - .mockImplementation( - () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingMilliseconds, - ); - - createComponent(delayedJobFixture); - }); - - it('displays remaining time in tooltip', async () => { - await nextTick(); - - const link = wrapper.findComponent(GlLink); - - expect(link.attributes('title')).toMatch('delayed job - delayed manual action (00:22:17)'); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/job_log_controllers_spec.js b/spec/frontend/jobs/components/job/job_log_controllers_spec.js deleted file mode 100644 index 7b6d58f63d1..00000000000 --- a/spec/frontend/jobs/components/job/job_log_controllers_spec.js +++ /dev/null @@ -1,323 +0,0 @@ -import { GlSearchBoxByClick } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import JobLogControllers from '~/jobs/components/job/job_log_controllers.vue'; -import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import { backoffMockImplementation } from 'helpers/backoff_helper'; -import * as commonUtils from '~/lib/utils/common_utils'; -import { mockJobLog } from '../../mock_data'; - -const mockToastShow = jest.fn(); - -describe('Job log controllers', () => { - let wrapper; - - beforeEach(() => { - jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); - }); - - afterEach(() => { - commonUtils.backOff.mockReset(); - }); - - const defaultProps = { - rawPath: '/raw', - size: 511952, - isScrollTopDisabled: false, - isScrollBottomDisabled: false, - isScrollingDown: true, - isJobLogSizeVisible: true, - isComplete: true, - jobLog: mockJobLog, - }; - - const createWrapper = (props, { jobLogJumpToFailures = false } = {}) => { - wrapper = mount(JobLogControllers, { - propsData: { - ...defaultProps, - ...props, - }, - provide: { - glFeatures: { - jobLogJumpToFailures, - }, - }, - data() { - return { - searchTerm: '82', - }; - }, - mocks: { - $toast: { - show: mockToastShow, - }, - }, - }); - }; - - const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]'); - const findRawLink = () => wrapper.find('[data-testid="raw-link"]'); - 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); - const findScrollFailure = () => wrapper.find('[data-testid="job-controller-scroll-to-failure"]'); - - describe('Truncate information', () => { - describe('with isJobLogSizeVisible', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders size information', () => { - expect(findTruncatedInfo().text()).toMatch('499.95 KiB'); - }); - - it('renders link to raw job log', () => { - expect(findRawLink().attributes('href')).toBe(defaultProps.rawPath); - }); - }); - }); - - describe('links section', () => { - describe('with raw job log path', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders raw job log link', () => { - expect(findRawLinkController().attributes('href')).toBe(defaultProps.rawPath); - }); - }); - - describe('without raw job log path', () => { - beforeEach(() => { - createWrapper({ - rawPath: null, - }); - }); - - it('does not render raw job log link', () => { - expect(findRawLinkController().exists()).toBe(false); - }); - }); - }); - - describe('scroll buttons', () => { - describe('scroll top button', () => { - describe('when user can scroll top', () => { - beforeEach(() => { - createWrapper({ - isScrollTopDisabled: false, - }); - }); - - it('emits scrollJobLogTop event on click', async () => { - await findScrollTop().trigger('click'); - - expect(wrapper.emitted().scrollJobLogTop).toHaveLength(1); - }); - }); - - describe('when user can not scroll top', () => { - beforeEach(() => { - createWrapper({ - isScrollTopDisabled: true, - isScrollBottomDisabled: false, - isScrollingDown: false, - }); - }); - - it('renders disabled scroll top button', () => { - expect(findScrollTop().attributes('disabled')).toBeDefined(); - }); - - it('does not emit scrollJobLogTop event on click', async () => { - await findScrollTop().trigger('click'); - - expect(wrapper.emitted().scrollJobLogTop).toBeUndefined(); - }); - }); - }); - - describe('scroll bottom button', () => { - describe('when user can scroll bottom', () => { - beforeEach(() => { - createWrapper(); - }); - - it('emits scrollJobLogBottom event on click', async () => { - await findScrollBottom().trigger('click'); - - expect(wrapper.emitted().scrollJobLogBottom).toHaveLength(1); - }); - }); - - describe('when user can not scroll bottom', () => { - beforeEach(() => { - createWrapper({ - isScrollTopDisabled: false, - isScrollBottomDisabled: true, - isScrollingDown: false, - }); - }); - - it('renders disabled scroll bottom button', () => { - expect(findScrollBottom().attributes('disabled')).toEqual('disabled'); - }); - - it('does not emit scrollJobLogBottom event on click', async () => { - await findScrollBottom().trigger('click'); - - expect(wrapper.emitted().scrollJobLogBottom).toBeUndefined(); - }); - }); - - describe('while isScrollingDown is true', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders animate class for the scroll down button', () => { - expect(findScrollBottom().classes()).toContain('animate'); - }); - }); - - describe('while isScrollingDown is false', () => { - beforeEach(() => { - createWrapper({ - isScrollTopDisabled: true, - isScrollBottomDisabled: false, - isScrollingDown: false, - }); - }); - - it('does not render animate class for the scroll down button', () => { - expect(findScrollBottom().classes()).not.toContain('animate'); - }); - }); - }); - - describe('scroll to failure button', () => { - describe('with feature flag disabled', () => { - it('does not display button', () => { - createWrapper(); - - expect(findScrollFailure().exists()).toBe(false); - }); - }); - - describe('with red text failures on the page', () => { - let firstFailure; - let secondFailure; - - beforeEach(() => { - jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce(['mock-element']); - - createWrapper({}, { jobLogJumpToFailures: true }); - - firstFailure = document.createElement('div'); - firstFailure.className = 'term-fg-l-red'; - document.body.appendChild(firstFailure); - - secondFailure = document.createElement('div'); - secondFailure.className = 'term-fg-l-red'; - document.body.appendChild(secondFailure); - }); - - afterEach(() => { - if (firstFailure) { - firstFailure.remove(); - firstFailure = null; - } - - if (secondFailure) { - secondFailure.remove(); - secondFailure = null; - } - }); - - it('is enabled', () => { - expect(findScrollFailure().props('disabled')).toBe(false); - }); - - it('scrolls to each failure', async () => { - jest.spyOn(firstFailure, 'scrollIntoView'); - - await findScrollFailure().trigger('click'); - - expect(firstFailure.scrollIntoView).toHaveBeenCalled(); - - await findScrollFailure().trigger('click'); - - expect(secondFailure.scrollIntoView).toHaveBeenCalled(); - - await findScrollFailure().trigger('click'); - - expect(firstFailure.scrollIntoView).toHaveBeenCalled(); - }); - }); - - describe('with no red text failures on the page', () => { - beforeEach(() => { - jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce([]); - - createWrapper({}, { jobLogJumpToFailures: true }); - }); - - it('is disabled', () => { - expect(findScrollFailure().props('disabled')).toBe(true); - }); - }); - - describe('when the job log is not complete', () => { - beforeEach(() => { - jest.spyOn(document, 'querySelectorAll').mockReturnValueOnce(['mock-element']); - - createWrapper({ isComplete: false }, { jobLogJumpToFailures: true }); - }); - - it('is enabled', () => { - expect(findScrollFailure().props('disabled')).toBe(false); - }); - }); - - describe('on error', () => { - beforeEach(() => { - jest.spyOn(commonUtils, 'backOff').mockRejectedValueOnce(); - - createWrapper({}, { jobLogJumpToFailures: true }); - }); - - it('stays disabled', () => { - expect(findScrollFailure().props('disabled')).toBe(true); - }); - }); - }); - }); - - describe('Job log search', () => { - beforeEach(() => { - createWrapper(); - }); - - 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/job_retry_forward_deployment_modal_spec.js b/spec/frontend/jobs/components/job/job_retry_forward_deployment_modal_spec.js deleted file mode 100644 index a44a13259aa..00000000000 --- a/spec/frontend/jobs/components/job/job_retry_forward_deployment_modal_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { GlLink, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import JobRetryForwardDeploymentModal from '~/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue'; -import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants'; -import createStore from '~/jobs/store'; -import job from '../../mock_data'; - -describe('Job Retry Forward Deployment Modal', () => { - let store; - let wrapper; - - const retryOutdatedJobDocsUrl = 'url-to-docs'; - const findLink = () => wrapper.findComponent(GlLink); - const findModal = () => wrapper.findComponent(GlModal); - - const createWrapper = ({ props = {}, provide = {}, stubs = {} } = {}) => { - store = createStore(); - wrapper = shallowMount(JobRetryForwardDeploymentModal, { - propsData: { - modalId: 'modal-id', - href: job.retry_path, - ...props, - }, - provide, - store, - stubs, - }); - }; - - beforeEach(createWrapper); - - describe('Modal configuration', () => { - it('should display the correct messages', () => { - const modal = findModal(); - expect(modal.attributes('title')).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.title); - expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.info); - expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.areYouSure); - }); - }); - - describe('Modal docs help link', () => { - it('should not display an info link when none is provided', () => { - createWrapper(); - - expect(findLink().exists()).toBe(false); - }); - - it('should display an info link when one is provided', () => { - createWrapper({ provide: { retryOutdatedJobDocsUrl } }); - - expect(findLink().attributes('href')).toBe(retryOutdatedJobDocsUrl); - expect(findLink().text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.moreInfo); - }); - }); - - describe('Modal actions', () => { - beforeEach(createWrapper); - - it('should correctly configure the primary action', () => { - expect(findModal().props('actionPrimary').attributes).toMatchObject({ - 'data-method': 'post', - href: job.retry_path, - variant: 'danger', - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js deleted file mode 100644 index c1028f3929d..00000000000 --- a/spec/frontend/jobs/components/job/job_sidebar_details_container_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import DetailRow from '~/jobs/components/job/sidebar/sidebar_detail_row.vue'; -import SidebarJobDetailsContainer from '~/jobs/components/job/sidebar/sidebar_job_details_container.vue'; -import createStore from '~/jobs/store'; -import job from '../../mock_data'; - -describe('Job Sidebar Details Container', () => { - let store; - let wrapper; - - const findJobTimeout = () => wrapper.findByTestId('job-timeout'); - const findJobTags = () => wrapper.findByTestId('job-tags'); - const findAllDetailsRow = () => wrapper.findAllComponents(DetailRow); - - const createWrapper = ({ props = {} } = {}) => { - store = createStore(); - wrapper = extendedWrapper( - shallowMount(SidebarJobDetailsContainer, { - propsData: props, - store, - stubs: { - DetailRow, - }, - }), - ); - }; - - describe('when no details are available', () => { - beforeEach(() => { - createWrapper(); - }); - - it('should render an empty container', () => { - expect(wrapper.html()).toBe(''); - }); - - 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 }); - - expect(findAllDetailsRow()).toHaveLength(0); - }, - ); - }); - - describe('when some of the details are available', () => { - beforeEach(createWrapper); - - it.each([ - ['duration', 'Elapsed time: 6 seconds'], - ['erased_at', 'Erased: 3 weeks ago'], - ['finished_at', 'Finished: 3 weeks ago'], - ['queued_duration', 'Queued: 9 seconds'], - ['runner', 'Runner: #1 (ABCDEFGH) local ci runner'], - ['coverage', 'Coverage: 20%'], - ])('uses %s to render job-%s', async (detail, value) => { - await store.dispatch('receiveJobSuccess', { [detail]: job[detail] }); - const detailsRow = findAllDetailsRow(); - - expect(detailsRow).toHaveLength(1); - expect(detailsRow.at(0).text()).toBe(value); - }); - - it('only renders tags', async () => { - const { tags } = job; - await store.dispatch('receiveJobSuccess', { tags }); - const tagsComponent = findJobTags(); - - expect(tagsComponent.text()).toBe('Tags: tag'); - }); - }); - - describe('when all the info are available', () => { - it('renders all the details components', async () => { - createWrapper(); - await store.dispatch('receiveJobSuccess', job); - - expect(findAllDetailsRow()).toHaveLength(7); - }); - - describe('duration row', () => { - it('renders all the details components', async () => { - createWrapper(); - await store.dispatch('receiveJobSuccess', job); - - expect(findAllDetailsRow().at(0).text()).toBe('Duration: 6 seconds'); - }); - }); - }); - - describe('timeout', () => { - const { - metadata: { timeout_human_readable, timeout_source }, - } = job; - - beforeEach(createWrapper); - - it('does not render if metadata is empty', async () => { - const metadata = {}; - await store.dispatch('receiveJobSuccess', { metadata }); - const detailsRow = findAllDetailsRow(); - - expect(wrapper.html()).toBe(''); - expect(detailsRow.exists()).toBe(false); - }); - - it('uses metadata to render timeout', async () => { - const metadata = { timeout_human_readable }; - await store.dispatch('receiveJobSuccess', { metadata }); - const detailsRow = findAllDetailsRow(); - - expect(detailsRow).toHaveLength(1); - expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s'); - }); - - it('uses metadata to render timeout and the source', async () => { - const metadata = { timeout_human_readable, timeout_source }; - await store.dispatch('receiveJobSuccess', { metadata }); - const detailsRow = findAllDetailsRow(); - - expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s (from runner)'); - }); - - it('should not render when no time is provided', async () => { - const metadata = { timeout_source }; - await store.dispatch('receiveJobSuccess', { metadata }); - - expect(findJobTimeout().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js deleted file mode 100644 index 8a63bfdc3d6..00000000000 --- a/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import JobsSidebarRetryButton from '~/jobs/components/job/sidebar/job_sidebar_retry_button.vue'; -import createStore from '~/jobs/store'; -import job from '../../mock_data'; - -describe('Job Sidebar Retry Button', () => { - let store; - let wrapper; - - const forwardDeploymentFailure = 'forward_deployment_failure'; - const findRetryButton = () => wrapper.findByTestId('retry-job-button'); - const findRetryLink = () => wrapper.findByTestId('retry-job-link'); - - const createWrapper = ({ props = {} } = {}) => { - store = createStore(); - wrapper = shallowMountExtended(JobsSidebarRetryButton, { - propsData: { - href: job.retry_path, - isManualJob: false, - modalId: 'modal-id', - ...props, - }, - store, - }); - }; - - beforeEach(createWrapper); - - it.each([ - [null, false, true], - ['unmet_prerequisites', false, true], - [forwardDeploymentFailure, true, false], - ])( - 'when error is: %s, should render button: %s | should render link: %s', - async (failureReason, buttonExists, linkExists) => { - await store.dispatch('receiveJobSuccess', { ...job, failure_reason: failureReason }); - - expect(findRetryButton().exists()).toBe(buttonExists); - expect(findRetryLink().exists()).toBe(linkExists); - }, - ); - - describe('Button', () => { - it('should have the correct configuration', async () => { - await store.dispatch('receiveJobSuccess', { failure_reason: forwardDeploymentFailure }); - - expect(findRetryButton().attributes()).toMatchObject({ - category: 'primary', - variant: 'confirm', - icon: 'retry', - }); - }); - }); - - describe('Link', () => { - it('should have the correct configuration', () => { - expect(findRetryLink().attributes()).toMatchObject({ - 'data-method': 'post', - href: job.retry_path, - icon: 'retry', - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/jobs_container_spec.js b/spec/frontend/jobs/components/job/jobs_container_spec.js deleted file mode 100644 index 05660880751..00000000000 --- a/spec/frontend/jobs/components/job/jobs_container_spec.js +++ /dev/null @@ -1,143 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import JobsContainer from '~/jobs/components/job/sidebar/jobs_container.vue'; - -describe('Jobs List block', () => { - let wrapper; - - const retried = { - status: { - details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', - group: 'success', - has_details: true, - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - }, - id: 233432756, - tooltip: 'build - passed', - retried: true, - }; - - const active = { - name: 'test', - status: { - details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', - group: 'success', - has_details: true, - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - }, - id: 2322756, - tooltip: 'build - passed', - active: true, - }; - - const job = { - name: 'build', - status: { - details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', - group: 'success', - has_details: true, - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - }, - id: 232153, - tooltip: 'build - passed', - }; - - const findAllJobs = () => wrapper.findAllComponents(GlLink); - const findJob = () => findAllJobs().at(0); - - const findArrowIcon = () => wrapper.findByTestId('arrow-right-icon'); - const findRetryIcon = () => wrapper.findByTestId('retry-icon'); - - const createComponent = (props) => { - wrapper = extendedWrapper( - mount(JobsContainer, { - propsData: { - ...props, - }, - }), - ); - }; - - it('renders a list of jobs', () => { - createComponent({ - jobs: [job, retried, active], - jobId: 12313, - }); - - expect(findAllJobs()).toHaveLength(3); - }); - - it('renders the arrow right icon when job id matches `jobId`', () => { - createComponent({ - jobs: [active], - jobId: active.id, - }); - - expect(findArrowIcon().exists()).toBe(true); - }); - - it('does not render the arrow right icon when the job is not active', () => { - createComponent({ - jobs: [job], - jobId: active.id, - }); - - expect(findArrowIcon().exists()).toBe(false); - }); - - it('renders the job name when present', () => { - createComponent({ - jobs: [job], - jobId: active.id, - }); - - expect(findJob().text()).toBe(job.name); - expect(findJob().text()).not.toContain(job.id.toString()); - }); - - it('renders job id when job name is not available', () => { - createComponent({ - jobs: [retried], - jobId: active.id, - }); - - expect(findJob().text()).toBe(retried.id.toString()); - }); - - it('links to the job page', () => { - createComponent({ - jobs: [job], - jobId: active.id, - }); - - expect(findJob().attributes('href')).toBe(job.status.details_path); - }); - - it('renders retry icon when job was retried', () => { - createComponent({ - jobs: [retried], - jobId: active.id, - }); - - expect(findRetryIcon().exists()).toBe(true); - }); - - it('does not render retry icon when job was not retried', () => { - createComponent({ - jobs: [job], - jobId: active.id, - }); - - expect(findRetryIcon().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/jobs/components/job/manual_variables_form_spec.js b/spec/frontend/jobs/components/job/manual_variables_form_spec.js deleted file mode 100644 index 989fe5c11e9..00000000000 --- a/spec/frontend/jobs/components/job/manual_variables_form_spec.js +++ /dev/null @@ -1,364 +0,0 @@ -import { GlSprintf, GlLink } from '@gitlab/ui'; -import { createLocalVue } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import { nextTick } from 'vue'; -import { createAlert } from '~/alert'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { TYPENAME_CI_BUILD } from '~/graphql_shared/constants'; -import { JOB_GRAPHQL_ERRORS } from '~/jobs/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; -import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql'; -import playJobMutation from '~/jobs/components/job/graphql/mutations/job_play_with_variables.mutation.graphql'; -import retryJobMutation from '~/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql'; - -import { - mockFullPath, - mockId, - mockJobResponse, - mockJobWithVariablesResponse, - mockJobPlayMutationData, - mockJobRetryMutationData, -} from './mock_data'; - -const localVue = createLocalVue(); -jest.mock('~/alert'); -localVue.use(VueApollo); - -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - redirectTo: jest.fn(), -})); - -const defaultProvide = { - projectPath: mockFullPath, -}; - -describe('Manual Variables Form', () => { - let wrapper; - let mockApollo; - let requestHandlers; - - const getJobQueryResponseHandlerWithVariables = jest.fn().mockResolvedValue(mockJobResponse); - const playJobMutationHandler = jest.fn().mockResolvedValue({}); - const retryJobMutationHandler = jest.fn().mockResolvedValue({}); - - const defaultHandlers = { - getJobQueryResponseHandlerWithVariables, - playJobMutationHandler, - retryJobMutationHandler, - }; - - const createComponent = ({ props = {}, handlers = defaultHandlers } = {}) => { - requestHandlers = handlers; - - mockApollo = createMockApollo([ - [getJobQuery, handlers.getJobQueryResponseHandlerWithVariables], - [playJobMutation, handlers.playJobMutationHandler], - [retryJobMutation, handlers.retryJobMutationHandler], - ]); - - const options = { - localVue, - apolloProvider: mockApollo, - }; - - wrapper = mountExtended(ManualVariablesForm, { - propsData: { - jobId: mockId, - isRetryable: false, - ...props, - }, - provide: { - ...defaultProvide, - }, - ...options, - }); - - return waitForPromises(); - }; - - const findHelpText = () => wrapper.findComponent(GlSprintf); - const findHelpLink = () => wrapper.findComponent(GlLink); - const findCancelBtn = () => wrapper.findByTestId('cancel-btn'); - const findRunBtn = () => wrapper.findByTestId('run-manual-job-btn'); - const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); - const findAllDeleteVarBtns = () => wrapper.findAllByTestId('delete-variable-btn'); - const findDeleteVarBtnPlaceholder = () => wrapper.findByTestId('delete-variable-btn-placeholder'); - const findCiVariableKey = () => wrapper.findByTestId('ci-variable-key'); - const findAllCiVariableKeys = () => wrapper.findAllByTestId('ci-variable-key'); - const findCiVariableValue = () => wrapper.findByTestId('ci-variable-value'); - const findAllVariables = () => wrapper.findAllByTestId('ci-variable-row'); - - const setCiVariableKey = () => { - findCiVariableKey().setValue('new key'); - findCiVariableKey().vm.$emit('change'); - nextTick(); - }; - - const setCiVariableKeyByPosition = (position, value) => { - findAllCiVariableKeys().at(position).setValue(value); - findAllCiVariableKeys().at(position).vm.$emit('change'); - nextTick(); - }; - - afterEach(() => { - createAlert.mockClear(); - }); - - describe('when page renders', () => { - beforeEach(async () => { - await createComponent(); - }); - - it('renders help text with provided link', () => { - expect(findHelpText().exists()).toBe(true); - expect(findHelpLink().attributes('href')).toBe( - '/help/ci/variables/index#add-a-cicd-variable-to-a-project', - ); - }); - }); - - describe('when query is unsuccessful', () => { - beforeEach(async () => { - await createComponent({ - handlers: { - getJobQueryResponseHandlerWithVariables: jest.fn().mockRejectedValue({}), - }, - }); - }); - - it('shows an alert with error', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: JOB_GRAPHQL_ERRORS.jobQueryErrorText, - }); - }); - }); - - describe('when job has not been retried', () => { - beforeEach(async () => { - await createComponent({ - handlers: { - getJobQueryResponseHandlerWithVariables: jest - .fn() - .mockResolvedValue(mockJobWithVariablesResponse), - }, - }); - }); - - it('does not render the cancel button', () => { - expect(findCancelBtn().exists()).toBe(false); - expect(findRunBtn().exists()).toBe(true); - }); - }); - - describe('when job has variables', () => { - beforeEach(async () => { - await createComponent({ - handlers: { - getJobQueryResponseHandlerWithVariables: jest - .fn() - .mockResolvedValue(mockJobWithVariablesResponse), - }, - }); - }); - - it('sets manual job variables', () => { - const queryKey = mockJobWithVariablesResponse.data.project.job.manualVariables.nodes[0].key; - const queryValue = - mockJobWithVariablesResponse.data.project.job.manualVariables.nodes[0].value; - - expect(findCiVariableKey().element.value).toBe(queryKey); - expect(findCiVariableValue().element.value).toBe(queryValue); - }); - }); - - describe('when play mutation fires', () => { - beforeEach(async () => { - await createComponent({ - handlers: { - playJobMutationHandler: jest.fn().mockResolvedValue(mockJobPlayMutationData), - }, - }); - }); - - it('passes variables in correct format', async () => { - await setCiVariableKey(); - - await findCiVariableValue().setValue('new value'); - - await findRunBtn().vm.$emit('click'); - - expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1); - expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledWith({ - id: convertToGraphQLId(TYPENAME_CI_BUILD, mockId), - variables: [ - { - key: 'new key', - value: 'new value', - }, - ], - }); - }); - - it('redirects to job properly after job is run', async () => { - findRunBtn().vm.$emit('click'); - await waitForPromises(); - - expect(requestHandlers.playJobMutationHandler).toHaveBeenCalledTimes(1); - expect(redirectTo).toHaveBeenCalledWith(mockJobPlayMutationData.data.jobPlay.job.webPath); // eslint-disable-line import/no-deprecated - }); - }); - - describe('when play mutation is unsuccessful', () => { - beforeEach(async () => { - await createComponent({ - handlers: { - playJobMutationHandler: jest.fn().mockRejectedValue({}), - }, - }); - }); - - it('shows an alert with error', async () => { - findRunBtn().vm.$emit('click'); - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message: JOB_GRAPHQL_ERRORS.jobMutationErrorText, - }); - }); - }); - - describe('when job is retryable', () => { - beforeEach(async () => { - await createComponent({ - props: { isRetryable: true }, - handlers: { - retryJobMutationHandler: jest.fn().mockResolvedValue(mockJobRetryMutationData), - }, - }); - }); - - it('renders cancel button', () => { - expect(findCancelBtn().exists()).toBe(true); - }); - - it('redirects to job properly after rerun', async () => { - findRunBtn().vm.$emit('click'); - await waitForPromises(); - - expect(requestHandlers.retryJobMutationHandler).toHaveBeenCalledTimes(1); - expect(redirectTo).toHaveBeenCalledWith(mockJobRetryMutationData.data.jobRetry.job.webPath); // eslint-disable-line import/no-deprecated - }); - }); - - describe('when retry mutation is unsuccessful', () => { - beforeEach(async () => { - await createComponent({ - props: { isRetryable: true }, - handlers: { - retryJobMutationHandler: jest.fn().mockRejectedValue({}), - }, - }); - }); - - it('shows an alert with error', async () => { - findRunBtn().vm.$emit('click'); - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message: JOB_GRAPHQL_ERRORS.jobMutationErrorText, - }); - }); - }); - - describe('updating variables in UI', () => { - beforeEach(async () => { - await createComponent({ - handlers: { - getJobQueryResponseHandlerWithVariables: jest.fn().mockResolvedValue(mockJobResponse), - }, - }); - }); - - it('creates a new variable when user enters a new key value', async () => { - expect(findAllVariables()).toHaveLength(1); - - await setCiVariableKey(); - - expect(findAllVariables()).toHaveLength(2); - }); - - it('does not create extra empty variables', async () => { - expect(findAllVariables()).toHaveLength(1); - - await setCiVariableKey(); - - expect(findAllVariables()).toHaveLength(2); - - await setCiVariableKey(); - - expect(findAllVariables()).toHaveLength(2); - }); - - it('removes the correct variable row', async () => { - const variableKeyNameOne = 'key-one'; - const variableKeyNameThree = 'key-three'; - - await setCiVariableKeyByPosition(0, variableKeyNameOne); - - await setCiVariableKeyByPosition(1, 'key-two'); - - await setCiVariableKeyByPosition(2, variableKeyNameThree); - - expect(findAllVariables()).toHaveLength(4); - - await findAllDeleteVarBtns().at(1).trigger('click'); - - expect(findAllVariables()).toHaveLength(3); - - expect(findAllCiVariableKeys().at(0).element.value).toBe(variableKeyNameOne); - expect(findAllCiVariableKeys().at(1).element.value).toBe(variableKeyNameThree); - expect(findAllCiVariableKeys().at(2).element.value).toBe(''); - }); - - it('delete variable button should only show when there is more than one variable', async () => { - expect(findDeleteVarBtn().exists()).toBe(false); - - await setCiVariableKey(); - - expect(findDeleteVarBtn().exists()).toBe(true); - }); - }); - - describe('variable delete button placeholder', () => { - beforeEach(async () => { - await createComponent({ - handlers: { - getJobQueryResponseHandlerWithVariables: jest.fn().mockResolvedValue(mockJobResponse), - }, - }); - }); - - it('delete variable button placeholder should only exist when a user cannot remove', () => { - expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); - }); - - it('does not show the placeholder button', () => { - expect(findDeleteVarBtnPlaceholder().classes('gl-opacity-0')).toBe(true); - }); - - it('placeholder button will not delete the row on click', async () => { - expect(findAllCiVariableKeys()).toHaveLength(1); - expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); - - await findDeleteVarBtnPlaceholder().trigger('click'); - - expect(findAllCiVariableKeys()).toHaveLength(1); - expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/mock_data.js b/spec/frontend/jobs/components/job/mock_data.js deleted file mode 100644 index fb3a361c9c9..00000000000 --- a/spec/frontend/jobs/components/job/mock_data.js +++ /dev/null @@ -1,123 +0,0 @@ -export const mockFullPath = 'Commit451/lab-coat'; -export const mockId = 401; - -export const mockJobResponse = { - data: { - project: { - id: 'gid://gitlab/Project/4', - job: { - id: 'gid://gitlab/Ci::Build/401', - manualJob: true, - manualVariables: { - nodes: [], - __typename: 'CiManualVariableConnection', - }, - name: 'manual_job', - retryable: true, - status: 'SUCCESS', - __typename: 'CiJob', - }, - __typename: 'Project', - }, - }, -}; - -export const mockJobWithVariablesResponse = { - data: { - project: { - id: 'gid://gitlab/Project/4', - job: { - id: 'gid://gitlab/Ci::Build/401', - manualJob: true, - manualVariables: { - nodes: [ - { - id: 'gid://gitlab/Ci::JobVariable/150', - key: 'new key', - value: 'new value', - __typename: 'CiManualVariable', - }, - ], - __typename: 'CiManualVariableConnection', - }, - name: 'manual_job', - retryable: true, - status: 'SUCCESS', - __typename: 'CiJob', - }, - __typename: 'Project', - }, - }, -}; - -export const mockJobPlayMutationData = { - data: { - jobPlay: { - job: { - id: 'gid://gitlab/Ci::Build/401', - manualVariables: { - nodes: [ - { - id: 'gid://gitlab/Ci::JobVariable/151', - key: 'new key', - value: 'new value', - __typename: 'CiManualVariable', - }, - ], - __typename: 'CiManualVariableConnection', - }, - webPath: '/Commit451/lab-coat/-/jobs/401', - __typename: 'CiJob', - }, - errors: [], - __typename: 'JobPlayPayload', - }, - }, -}; - -export const mockJobRetryMutationData = { - data: { - jobRetry: { - job: { - id: 'gid://gitlab/Ci::Build/401', - manualVariables: { - nodes: [ - { - id: 'gid://gitlab/Ci::JobVariable/151', - key: 'new key', - value: 'new value', - __typename: 'CiManualVariable', - }, - ], - __typename: 'CiManualVariableConnection', - }, - webPath: '/Commit451/lab-coat/-/jobs/401', - __typename: 'CiJob', - }, - errors: [], - __typename: 'JobRetryPayload', - }, - }, -}; - -export const mockPendingJobData = { - has_trace: false, - status: { - group: 'pending', - icon: 'status_pending', - label: 'pending', - text: 'pending', - details_path: 'path', - illustration: { - image: 'path', - size: '340', - title: '', - content: '', - }, - action: { - button_title: 'Retry job', - method: 'post', - path: '/path', - }, - }, -}; diff --git a/spec/frontend/jobs/components/job/sidebar_detail_row_spec.js b/spec/frontend/jobs/components/job/sidebar_detail_row_spec.js deleted file mode 100644 index 546f5392caf..00000000000 --- a/spec/frontend/jobs/components/job/sidebar_detail_row_spec.js +++ /dev/null @@ -1,68 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import SidebarDetailRow from '~/jobs/components/job/sidebar/sidebar_detail_row.vue'; -import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; - -describe('Sidebar detail row', () => { - let wrapper; - - const title = 'this is the title'; - const value = 'this is the value'; - const helpUrl = `${DOCS_URL}/runner/register/index.html`; - const path = 'path/to/value'; - - const findHelpLink = () => wrapper.findByTestId('job-sidebar-help-link'); - const findValueLink = () => wrapper.findByTestId('job-sidebar-value-link'); - - const createComponent = (props) => { - wrapper = shallowMountExtended(SidebarDetailRow, { - propsData: { - ...props, - }, - }); - }; - - describe('with title/value and without helpUrl/path', () => { - beforeEach(() => { - createComponent({ title, value }); - }); - - it('should render the provided title and value', () => { - expect(wrapper.text()).toBe(`${title}: ${value}`); - }); - - it('should not render the help link', () => { - expect(findHelpLink().exists()).toBe(false); - }); - - it('should not render the value link', () => { - expect(findValueLink().exists()).toBe(false); - }); - }); - - describe('when helpUrl provided', () => { - beforeEach(() => { - createComponent({ - helpUrl, - title, - value, - }); - }); - - it('should render the help link', () => { - expect(findHelpLink().exists()).toBe(true); - expect(findHelpLink().attributes('href')).toBe(helpUrl); - }); - }); - - describe('when path is provided', () => { - it('should render link to value', () => { - createComponent({ - path, - title, - value, - }); - - expect(findValueLink().attributes('href')).toBe(path); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/sidebar_header_spec.js b/spec/frontend/jobs/components/job/sidebar_header_spec.js deleted file mode 100644 index cf182330578..00000000000 --- a/spec/frontend/jobs/components/job/sidebar_header_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import SidebarHeader from '~/jobs/components/job/sidebar/sidebar_header.vue'; -import JobRetryButton from '~/jobs/components/job/sidebar/job_sidebar_retry_button.vue'; -import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql'; -import { mockFullPath, mockId, mockJobResponse } from './mock_data'; - -Vue.use(VueApollo); - -const defaultProvide = { - projectPath: mockFullPath, -}; - -describe('Sidebar Header', () => { - let wrapper; - - const createComponent = ({ options = {}, props = {}, restJob = {} } = {}) => { - wrapper = shallowMountExtended(SidebarHeader, { - propsData: { - ...props, - jobId: mockId, - restJob, - }, - provide: { - ...defaultProvide, - }, - ...options, - }); - }; - - const createComponentWithApollo = ({ props = {}, restJob = {} } = {}) => { - const getJobQueryResponse = jest.fn().mockResolvedValue(mockJobResponse); - - const requestHandlers = [[getJobQuery, getJobQueryResponse]]; - - const apolloProvider = createMockApollo(requestHandlers); - - const options = { - apolloProvider, - }; - - createComponent({ - props, - restJob, - options, - }); - - return waitForPromises(); - }; - - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findEraseButton = () => wrapper.findByTestId('job-log-erase-link'); - const findJobName = () => wrapper.findByTestId('job-name'); - const findRetryButton = () => wrapper.findComponent(JobRetryButton); - - describe('when rendering contents', () => { - it('renders the correct job name', async () => { - await createComponentWithApollo(); - expect(findJobName().text()).toBe(mockJobResponse.data.project.job.name); - }); - - it('does not render buttons with no paths', async () => { - await createComponentWithApollo(); - expect(findCancelButton().exists()).toBe(false); - expect(findEraseButton().exists()).toBe(false); - expect(findRetryButton().exists()).toBe(false); - }); - - it('renders a retry button with a path', async () => { - await createComponentWithApollo({ restJob: { retry_path: 'retry/path' } }); - expect(findRetryButton().exists()).toBe(true); - }); - - it('renders a cancel button with a path', async () => { - await createComponentWithApollo({ restJob: { cancel_path: 'cancel/path' } }); - expect(findCancelButton().exists()).toBe(true); - }); - - it('renders an erase button with a path', async () => { - await createComponentWithApollo({ restJob: { erase_path: 'erase/path' } }); - expect(findEraseButton().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/sidebar_spec.js b/spec/frontend/jobs/components/job/sidebar_spec.js deleted file mode 100644 index fbff64b4d78..00000000000 --- a/spec/frontend/jobs/components/job/sidebar_spec.js +++ /dev/null @@ -1,216 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import MockAdapter from 'axios-mock-adapter'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import ArtifactsBlock from '~/jobs/components/job/sidebar/artifacts_block.vue'; -import JobRetryForwardDeploymentModal from '~/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue'; -import JobsContainer from '~/jobs/components/job/sidebar/jobs_container.vue'; -import Sidebar from '~/jobs/components/job/sidebar/sidebar.vue'; -import StagesDropdown from '~/jobs/components/job/sidebar/stages_dropdown.vue'; -import createStore from '~/jobs/store'; -import job, { jobsInStage } from '../../mock_data'; - -describe('Sidebar details block', () => { - let mock; - let store; - let wrapper; - - const forwardDeploymentFailure = 'forward_deployment_failure'; - const findModal = () => wrapper.findComponent(JobRetryForwardDeploymentModal); - const findArtifactsBlock = () => wrapper.findComponent(ArtifactsBlock); - const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); - const findTerminalLink = () => wrapper.findByTestId('terminal-link'); - const findJobStagesDropdown = () => wrapper.findComponent(StagesDropdown); - const findJobsContainer = () => wrapper.findComponent(JobsContainer); - - const createWrapper = (props) => { - store = createStore(); - - store.state.job = job; - - wrapper = extendedWrapper( - shallowMount(Sidebar, { - propsData: { - ...props, - }, - - store, - }), - ); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet().reply(HTTP_STATUS_OK, { - name: job.stage, - }); - }); - - describe('without terminal path', () => { - it('does not render terminal link', async () => { - createWrapper(); - await store.dispatch('receiveJobSuccess', job); - - expect(findTerminalLink().exists()).toBe(false); - }); - }); - - describe('with terminal path', () => { - it('renders terminal link', async () => { - createWrapper(); - await store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' }); - - expect(findTerminalLink().exists()).toBe(true); - }); - }); - - describe('actions', () => { - beforeEach(() => { - createWrapper(); - return store.dispatch('receiveJobSuccess', job); - }); - - it('should render link to new issue', () => { - expect(findNewIssueButton().attributes('href')).toBe(job.new_issue_path); - expect(findNewIssueButton().text()).toBe('New issue'); - }); - }); - - describe('forward deployment failure', () => { - describe('when the relevant data is missing', () => { - it.each` - retryPath | failureReason - ${null} | ${null} - ${''} | ${''} - ${job.retry_path} | ${''} - ${''} | ${forwardDeploymentFailure} - ${job.retry_path} | ${'unmet_prerequisites'} - `( - 'should not render the modal when path and failure are $retryPath, $failureReason', - async ({ retryPath, failureReason }) => { - createWrapper(); - await store.dispatch('receiveJobSuccess', { - ...job, - failure_reason: failureReason, - retry_path: retryPath, - }); - expect(findModal().exists()).toBe(false); - }, - ); - }); - - describe('when there is the relevant error', () => { - beforeEach(() => { - createWrapper(); - return store.dispatch('receiveJobSuccess', { - ...job, - failure_reason: forwardDeploymentFailure, - }); - }); - - it('should render the modal', () => { - expect(findModal().exists()).toBe(true); - }); - }); - }); - - describe('stages dropdown', () => { - beforeEach(() => { - createWrapper(); - return store.dispatch('receiveJobSuccess', job); - }); - - describe('with stages', () => { - it('renders value provided as selectedStage as selected', () => { - expect(findJobStagesDropdown().props('selectedStage')).toBe(job.stage); - }); - }); - - describe('without jobs for stages', () => { - it('does not render jobs container', () => { - expect(findJobsContainer().exists()).toBe(false); - }); - }); - - describe('with jobs for stages', () => { - beforeEach(() => { - return store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses); - }); - - it('renders list of jobs', () => { - expect(findJobsContainer().exists()).toBe(true); - }); - }); - - describe('when job data changes', () => { - const stageArg = job.pipeline.details.stages.find((stage) => stage.name === job.stage); - - beforeEach(() => { - jest.spyOn(store, 'dispatch'); - }); - - describe('and the job stage is currently selected', () => { - describe('when the status changed', () => { - it('refetch the jobs list for the stage', async () => { - await store.dispatch('receiveJobSuccess', { ...job, status: 'new' }); - - expect(store.dispatch).toHaveBeenNthCalledWith(2, 'fetchJobsForStage', { ...stageArg }); - }); - }); - - describe('when the status did not change', () => { - it('does not refetch the jobs list for the stage', async () => { - await store.dispatch('receiveJobSuccess', { ...job }); - - expect(store.dispatch).toHaveBeenCalledTimes(1); - expect(store.dispatch).toHaveBeenNthCalledWith(1, 'receiveJobSuccess', { - ...job, - }); - }); - }); - }); - - describe('and the job stage is not currently selected', () => { - it('does not refetch the jobs list for the stage', async () => { - // Setting stage to `random` on the job means that we are looking - // at `build` stage currently, but the job we are seeing in the logs - // belong to `random`, so we shouldn't have to refetch - await store.dispatch('receiveJobSuccess', { ...job, stage: 'random' }); - - expect(store.dispatch).toHaveBeenCalledTimes(1); - expect(store.dispatch).toHaveBeenNthCalledWith(1, 'receiveJobSuccess', { - ...job, - stage: 'random', - }); - }); - }); - }); - }); - - describe('artifacts', () => { - beforeEach(() => { - createWrapper(); - }); - - it('artifacts are not shown if there are no properties other than locked', () => { - expect(findArtifactsBlock().exists()).toBe(false); - }); - - it('artifacts are shown if present', async () => { - store.state.job.artifact = { - download_path: '/root/ci-project/-/jobs/1960/artifacts/download', - browse_path: '/root/ci-project/-/jobs/1960/artifacts/browse', - keep_path: '/root/ci-project/-/jobs/1960/artifacts/keep', - expire_at: '2021-03-23T17:57:11.211Z', - expired: false, - locked: false, - }; - - await nextTick(); - - expect(findArtifactsBlock().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/stages_dropdown_spec.js b/spec/frontend/jobs/components/job/stages_dropdown_spec.js deleted file mode 100644 index c42edc62183..00000000000 --- a/spec/frontend/jobs/components/job/stages_dropdown_spec.js +++ /dev/null @@ -1,191 +0,0 @@ -import { GlDisclosureDropdown, GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { Mousetrap } from '~/lib/mousetrap'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import StagesDropdown from '~/jobs/components/job/sidebar/stages_dropdown.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import * as copyToClipboard from '~/behaviors/copy_to_clipboard'; -import { - mockPipelineWithoutRef, - mockPipelineWithoutMR, - mockPipelineWithAttachedMR, - mockPipelineDetached, -} from '../../mock_data'; - -describe('Stages Dropdown', () => { - let wrapper; - - const findStatus = () => wrapper.findComponent(CiIcon); - const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findSelectedStageText = () => findDropdown().props('toggleText'); - - const findPipelineInfoText = () => wrapper.findByTestId('pipeline-info').text(); - - const createComponent = (props) => { - wrapper = extendedWrapper( - shallowMount(StagesDropdown, { - propsData: { - stages: [], - selectedStage: 'deploy', - ...props, - }, - stubs: { - GlSprintf, - GlLink, - }, - }), - ); - }; - - describe('without a merge request pipeline', () => { - beforeEach(() => { - createComponent({ - pipeline: mockPipelineWithoutMR, - stages: [{ name: 'build' }, { name: 'test' }], - }); - }); - - it('renders pipeline status', () => { - expect(findStatus().exists()).toBe(true); - }); - - it('renders dropdown with stages', () => { - expect(findDropdown().props('items')).toEqual([ - expect.objectContaining({ text: 'build' }), - expect.objectContaining({ text: 'test' }), - ]); - }); - - it('renders selected stage', () => { - expect(findSelectedStageText()).toBe('deploy'); - }); - }); - - describe('pipelineInfo', () => { - const allElements = [ - 'pipeline-path', - 'mr-link', - 'source-ref-link', - 'copy-source-ref-link', - 'source-branch-link', - 'copy-source-branch-link', - 'target-branch-link', - 'copy-target-branch-link', - ]; - describe.each([ - [ - 'does not have a ref', - { - pipeline: mockPipelineWithoutRef, - text: `Pipeline #${mockPipelineWithoutRef.id}`, - foundElements: [ - { testId: 'pipeline-path', props: [{ href: mockPipelineWithoutRef.path }] }, - ], - }, - ], - [ - 'hasRef but not triggered by MR', - { - pipeline: mockPipelineWithoutMR, - text: `Pipeline #${mockPipelineWithoutMR.id} for ${mockPipelineWithoutMR.ref.name}`, - foundElements: [ - { testId: 'pipeline-path', props: [{ href: mockPipelineWithoutMR.path }] }, - { testId: 'source-ref-link', props: [{ href: mockPipelineWithoutMR.ref.path }] }, - { testId: 'copy-source-ref-link', props: [{ text: mockPipelineWithoutMR.ref.name }] }, - ], - }, - ], - [ - 'hasRef and MR but not MR pipeline', - { - pipeline: mockPipelineDetached, - text: `Pipeline #${mockPipelineDetached.id} for !${mockPipelineDetached.merge_request.iid} with ${mockPipelineDetached.merge_request.source_branch}`, - foundElements: [ - { testId: 'pipeline-path', props: [{ href: mockPipelineDetached.path }] }, - { testId: 'mr-link', props: [{ href: mockPipelineDetached.merge_request.path }] }, - { - testId: 'source-branch-link', - props: [{ href: mockPipelineDetached.merge_request.source_branch_path }], - }, - { - testId: 'copy-source-branch-link', - props: [{ text: mockPipelineDetached.merge_request.source_branch }], - }, - ], - }, - ], - [ - 'hasRef and MR and MR pipeline', - { - pipeline: mockPipelineWithAttachedMR, - text: `Pipeline #${mockPipelineWithAttachedMR.id} for !${mockPipelineWithAttachedMR.merge_request.iid} with ${mockPipelineWithAttachedMR.merge_request.source_branch} into ${mockPipelineWithAttachedMR.merge_request.target_branch}`, - foundElements: [ - { testId: 'pipeline-path', props: [{ href: mockPipelineWithAttachedMR.path }] }, - { testId: 'mr-link', props: [{ href: mockPipelineWithAttachedMR.merge_request.path }] }, - { - testId: 'source-branch-link', - props: [{ href: mockPipelineWithAttachedMR.merge_request.source_branch_path }], - }, - { - testId: 'copy-source-branch-link', - props: [{ text: mockPipelineWithAttachedMR.merge_request.source_branch }], - }, - { - testId: 'target-branch-link', - props: [{ href: mockPipelineWithAttachedMR.merge_request.target_branch_path }], - }, - { - testId: 'copy-target-branch-link', - props: [{ text: mockPipelineWithAttachedMR.merge_request.target_branch }], - }, - ], - }, - ], - ])('%s', (_, { pipeline, text, foundElements }) => { - beforeEach(() => { - createComponent({ - pipeline, - }); - }); - - it('should render the text', () => { - expect(findPipelineInfoText()).toMatchInterpolatedText(text); - }); - - it('should find components with props', () => { - foundElements.forEach((element) => { - element.props.forEach((prop) => { - const key = Object.keys(prop)[0]; - expect(wrapper.findByTestId(element.testId).attributes(key)).toBe(prop[key]); - }); - }); - }); - - it('should not find components', () => { - const foundTestIds = foundElements.map((element) => element.testId); - allElements - .filter((testId) => !foundTestIds.includes(testId)) - .forEach((testId) => { - expect(wrapper.findByTestId(testId).exists()).toBe(false); - }); - }); - }); - }); - - describe('mousetrap', () => { - it.each([ - ['copy-source-ref-link', mockPipelineWithoutMR], - ['copy-source-branch-link', mockPipelineWithAttachedMR], - ])( - 'calls clickCopyToClipboardButton with `%s` button when `b` is pressed', - (button, pipeline) => { - const copyToClipboardMock = jest.spyOn(copyToClipboard, 'clickCopyToClipboardButton'); - createComponent({ pipeline }); - - Mousetrap.trigger('b'); - - expect(copyToClipboardMock).toHaveBeenCalledWith(wrapper.findByTestId(button).element); - }, - ); - }); -}); diff --git a/spec/frontend/jobs/components/job/stuck_block_spec.js b/spec/frontend/jobs/components/job/stuck_block_spec.js deleted file mode 100644 index 0f014a9222b..00000000000 --- a/spec/frontend/jobs/components/job/stuck_block_spec.js +++ /dev/null @@ -1,94 +0,0 @@ -import { GlBadge, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import StuckBlock from '~/jobs/components/job/stuck_block.vue'; - -describe('Stuck Block Job component', () => { - let wrapper; - - const createWrapper = (props) => { - wrapper = shallowMount(StuckBlock, { - propsData: { - ...props, - }, - }); - }; - - const tags = ['docker', 'gitlab-org']; - - const findStuckNoActiveRunners = () => - wrapper.find('[data-testid="job-stuck-no-active-runners"]'); - const findStuckNoRunners = () => wrapper.find('[data-testid="job-stuck-no-runners"]'); - const findStuckWithTags = () => wrapper.find('[data-testid="job-stuck-with-tags"]'); - const findRunnerPathLink = () => wrapper.findComponent(GlLink); - const findAllBadges = () => wrapper.findAllComponents(GlBadge); - - describe('with no runners for project', () => { - beforeEach(() => { - createWrapper({ - hasOfflineRunnersForProject: true, - runnersPath: '/root/project/runners#js-runners-settings', - }); - }); - - it('renders only information about project not having runners', () => { - expect(findStuckNoRunners().exists()).toBe(true); - expect(findStuckWithTags().exists()).toBe(false); - expect(findStuckNoActiveRunners().exists()).toBe(false); - }); - - it('renders link to runners page', () => { - expect(findRunnerPathLink().attributes('href')).toBe( - '/root/project/runners#js-runners-settings', - ); - }); - }); - - describe('with tags', () => { - beforeEach(() => { - createWrapper({ - hasOfflineRunnersForProject: false, - tags, - runnersPath: '/root/project/runners#js-runners-settings', - }); - }); - - it('renders information about the tags not being set', () => { - expect(findStuckWithTags().exists()).toBe(true); - expect(findStuckNoActiveRunners().exists()).toBe(false); - expect(findStuckNoRunners().exists()).toBe(false); - }); - - it('renders tags', () => { - findAllBadges().wrappers.forEach((badgeElt, index) => { - return expect(badgeElt.text()).toBe(tags[index]); - }); - }); - - it('renders link to runners page', () => { - expect(findRunnerPathLink().attributes('href')).toBe( - '/root/project/runners#js-runners-settings', - ); - }); - }); - - describe('without active runners', () => { - beforeEach(() => { - createWrapper({ - hasOfflineRunnersForProject: false, - runnersPath: '/root/project/runners#js-runners-settings', - }); - }); - - it('renders information about project not having runners', () => { - expect(findStuckNoActiveRunners().exists()).toBe(true); - expect(findStuckNoRunners().exists()).toBe(false); - expect(findStuckWithTags().exists()).toBe(false); - }); - - it('renders link to runners page', () => { - expect(findRunnerPathLink().attributes('href')).toBe( - '/root/project/runners#js-runners-settings', - ); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/trigger_block_spec.js b/spec/frontend/jobs/components/job/trigger_block_spec.js deleted file mode 100644 index 8bb2c1f3ad8..00000000000 --- a/spec/frontend/jobs/components/job/trigger_block_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import { GlButton, GlTableLite } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import TriggerBlock from '~/jobs/components/job/sidebar/trigger_block.vue'; - -describe('Trigger block', () => { - let wrapper; - - const findRevealButton = () => wrapper.findComponent(GlButton); - const findVariableTable = () => wrapper.findComponent(GlTableLite); - const findShortToken = () => wrapper.find('[data-testid="trigger-short-token"]'); - const findVariableValue = (index) => - wrapper.findAll('[data-testid="trigger-build-value"]').at(index); - const findVariableKey = (index) => wrapper.findAll('[data-testid="trigger-build-key"]').at(index); - - const createComponent = (props) => { - wrapper = mount(TriggerBlock, { - propsData: { - ...props, - }, - }); - }; - - describe('with short token and no variables', () => { - it('renders short token', () => { - createComponent({ - trigger: { - short_token: '0a666b2', - variables: [], - }, - }); - - expect(findShortToken().text()).toContain('0a666b2'); - }); - }); - - describe('without variables or short token', () => { - beforeEach(() => { - createComponent({ trigger: { variables: [] } }); - }); - - it('does not render short token', () => { - expect(findShortToken().exists()).toBe(false); - }); - - it('does not render variables', () => { - expect(findRevealButton().exists()).toBe(false); - expect(findVariableTable().exists()).toBe(false); - }); - }); - - describe('with variables', () => { - describe('hide/reveal variables', () => { - it('should toggle variables on click', async () => { - const hiddenValue = '••••••'; - const gcsVar = { key: 'UPLOAD_TO_GCS', value: 'false', public: false }; - const s3Var = { key: 'UPLOAD_TO_S3', value: 'true', public: false }; - - createComponent({ - trigger: { - variables: [gcsVar, s3Var], - }, - }); - - expect(findRevealButton().text()).toBe('Reveal values'); - - expect(findVariableValue(0).text()).toBe(hiddenValue); - expect(findVariableValue(1).text()).toBe(hiddenValue); - - expect(findVariableKey(0).text()).toBe(gcsVar.key); - expect(findVariableKey(1).text()).toBe(s3Var.key); - - await findRevealButton().trigger('click'); - - expect(findRevealButton().text()).toBe('Hide values'); - - expect(findVariableValue(0).text()).toBe(gcsVar.value); - expect(findVariableValue(1).text()).toBe(s3Var.value); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js b/spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js deleted file mode 100644 index 1072cdd6781..00000000000 --- a/spec/frontend/jobs/components/job/unmet_prerequisites_block_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { GlAlert, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import UnmetPrerequisitesBlock from '~/jobs/components/job/unmet_prerequisites_block.vue'; - -describe('Unmet Prerequisites Block Job component', () => { - let wrapper; - const helpPath = '/user/project/clusters/index.html#troubleshooting-failed-deployment-jobs'; - - const createComponent = () => { - wrapper = shallowMount(UnmetPrerequisitesBlock, { - propsData: { - helpPath, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('renders an alert with the correct message', () => { - const container = wrapper.findComponent(GlAlert); - const alertMessage = - 'This job failed because the necessary resources were not successfully created.'; - - expect(container).not.toBeNull(); - expect(container.text()).toContain(alertMessage); - }); - - it('renders link to help page', () => { - const helpLink = wrapper.findComponent(GlLink); - - expect(helpLink).not.toBeNull(); - expect(helpLink.text()).toContain('More information'); - expect(helpLink.attributes().href).toEqual(helpPath); - }); -}); diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js deleted file mode 100644 index 5adedea28a5..00000000000 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ /dev/null @@ -1,71 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import CollapsibleSection from '~/jobs/components/log/collapsible_section.vue'; -import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data'; - -describe('Job Log Collapsible Section', () => { - let wrapper; - - const jobLogEndpoint = 'jobs/335'; - - const findCollapsibleLine = () => wrapper.find('.collapsible-line'); - const findCollapsibleLineSvg = () => wrapper.find('.collapsible-line svg'); - - const createComponent = (props = {}) => { - wrapper = mount(CollapsibleSection, { - propsData: { - ...props, - }, - }); - }; - - describe('with closed section', () => { - beforeEach(() => { - createComponent({ - section: collapsibleSectionClosed, - jobLogEndpoint, - }); - }); - - it('renders clickable header line', () => { - expect(findCollapsibleLine().attributes('role')).toBe('button'); - }); - - it('renders an icon with the closed state', () => { - expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-right-icon'); - }); - }); - - describe('with opened section', () => { - beforeEach(() => { - createComponent({ - section: collapsibleSectionOpened, - jobLogEndpoint, - }); - }); - - it('renders clickable header line', () => { - expect(findCollapsibleLine().attributes('role')).toBe('button'); - }); - - it('renders an icon with the open state', () => { - expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('chevron-lg-down-icon'); - }); - - it('renders collapsible lines content', () => { - expect(wrapper.findAll('.js-line').length).toEqual(collapsibleSectionOpened.lines.length); - }); - }); - - it('emits onClickCollapsibleLine on click', async () => { - createComponent({ - section: collapsibleSectionOpened, - jobLogEndpoint, - }); - - findCollapsibleLine().trigger('click'); - - await nextTick(); - expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1); - }); -}); diff --git a/spec/frontend/jobs/components/log/duration_badge_spec.js b/spec/frontend/jobs/components/log/duration_badge_spec.js deleted file mode 100644 index 644d05366a0..00000000000 --- a/spec/frontend/jobs/components/log/duration_badge_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DurationBadge from '~/jobs/components/log/duration_badge.vue'; - -describe('Job Log Duration Badge', () => { - let wrapper; - - const data = { - duration: '00:30:01', - }; - - const createComponent = (props = {}) => { - wrapper = shallowMount(DurationBadge, { - propsData: { - ...props, - }, - }); - }; - - beforeEach(() => { - createComponent(data); - }); - - it('renders provided duration', () => { - expect(wrapper.text()).toBe(data.duration); - }); -}); diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js deleted file mode 100644 index c02d8c22655..00000000000 --- a/spec/frontend/jobs/components/log/line_header_spec.js +++ /dev/null @@ -1,119 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import DurationBadge from '~/jobs/components/log/duration_badge.vue'; -import LineHeader from '~/jobs/components/log/line_header.vue'; -import LineNumber from '~/jobs/components/log/line_number.vue'; - -describe('Job Log Header Line', () => { - let wrapper; - - const data = { - line: { - content: [ - { - text: 'Running with gitlab-runner 12.1.0 (de7731dd)', - style: 'term-fg-l-green', - }, - ], - lineNumber: 76, - }, - isClosed: true, - path: '/jashkenas/underscore/-/jobs/335', - }; - - const createComponent = (props = {}) => { - wrapper = mount(LineHeader, { - propsData: { - ...props, - }, - }); - }; - - describe('line', () => { - beforeEach(() => { - createComponent(data); - }); - - it('renders the line number component', () => { - expect(wrapper.findComponent(LineNumber).exists()).toBe(true); - }); - - it('renders a span the provided text', () => { - expect(wrapper.find('span').text()).toBe(data.line.content[0].text); - }); - - it('renders the provided style as a class attribute', () => { - expect(wrapper.find('span').classes()).toContain(data.line.content[0].style); - }); - }); - - describe('when isCloses is true', () => { - beforeEach(() => { - createComponent({ ...data, isClosed: true }); - }); - - it('sets icon name to be chevron-lg-right', () => { - expect(wrapper.vm.iconName).toEqual('chevron-lg-right'); - }); - }); - - describe('when isCloses is false', () => { - beforeEach(() => { - createComponent({ ...data, isClosed: false }); - }); - - it('sets icon name to be chevron-lg-down', () => { - expect(wrapper.vm.iconName).toEqual('chevron-lg-down'); - }); - }); - - describe('on click', () => { - beforeEach(() => { - createComponent(data); - }); - - it('emits toggleLine event', async () => { - wrapper.trigger('click'); - - await nextTick(); - expect(wrapper.emitted().toggleLine.length).toBe(1); - }); - }); - - describe('with duration', () => { - beforeEach(() => { - createComponent({ ...data, duration: '00:10' }); - }); - - it('renders the duration badge', () => { - expect(wrapper.findComponent(DurationBadge).exists()).toBe(true); - }); - }); - - describe('line highlighting', () => { - describe('with hash', () => { - beforeEach(() => { - setWindowLocation(`http://foo.com/root/ci-project/-/jobs/6353#L77`); - - createComponent(data); - }); - - it('highlights line', () => { - expect(wrapper.classes()).toContain('gl-bg-gray-700'); - }); - }); - - describe('without hash', () => { - beforeEach(() => { - setWindowLocation(`http://foo.com/root/ci-project/-/jobs/6353`); - - createComponent(data); - }); - - it('does not highlight line', () => { - expect(wrapper.classes()).not.toContain('gl-bg-gray-700'); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/log/line_number_spec.js b/spec/frontend/jobs/components/log/line_number_spec.js deleted file mode 100644 index 4130c124a30..00000000000 --- a/spec/frontend/jobs/components/log/line_number_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import LineNumber from '~/jobs/components/log/line_number.vue'; - -describe('Job Log Line Number', () => { - let wrapper; - - const data = { - lineNumber: 0, - path: '/jashkenas/underscore/-/jobs/335', - }; - - const createComponent = (props = {}) => { - wrapper = shallowMount(LineNumber, { - propsData: { - ...props, - }, - }); - }; - - beforeEach(() => { - createComponent(data); - }); - - it('renders incremented lineNunber by 1', () => { - expect(wrapper.text()).toBe('1'); - }); - - it('renders link with lineNumber as an ID', () => { - expect(wrapper.attributes().id).toBe('L1'); - }); - - it('links to the provided path with line number as anchor', () => { - expect(wrapper.attributes().href).toBe(`${data.path}#L1`); - }); -}); diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js deleted file mode 100644 index fad7a03beef..00000000000 --- a/spec/frontend/jobs/components/log/line_spec.js +++ /dev/null @@ -1,267 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Line from '~/jobs/components/log/line.vue'; -import LineNumber from '~/jobs/components/log/line_number.vue'; -import setWindowLocation from 'helpers/set_window_location_helper'; - -const httpUrl = 'http://example.com'; -const httpsUrl = 'https://example.com'; -const queryUrl = 'https://example.com?param=val'; - -const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = {}) => ({ - line: { - content: [ - { - text, - style: 'term-fg-l-green', - }, - ], - lineNumber: 0, - }, - path: '/jashkenas/underscore/-/jobs/335', -}); - -describe('Job Log Line', () => { - let wrapper; - let data; - - const createComponent = (props = {}) => { - wrapper = shallowMount(Line, { - propsData: { - ...props, - }, - }); - }; - - const findLine = () => wrapper.find('span'); - const findLink = () => findLine().find('a'); - const findLinks = () => findLine().findAll('a'); - const findLinkAttributeByIndex = (i) => findLinks().at(i).attributes(); - - beforeEach(() => { - data = mockProps(); - createComponent(data); - }); - - it('renders the line number component', () => { - expect(wrapper.findComponent(LineNumber).exists()).toBe(true); - }); - - it('renders a span the provided text', () => { - expect(findLine().text()).toBe(data.line.content[0].text); - }); - - it('renders the provided style as a class attribute', () => { - expect(findLine().classes()).toContain(data.line.content[0].style); - }); - - describe('job urls as links', () => { - it('renders an http link', () => { - createComponent(mockProps({ text: httpUrl })); - - expect(findLink().text()).toBe(httpUrl); - expect(findLink().attributes().href).toBe(httpUrl); - }); - - it('renders an https link', () => { - createComponent(mockProps({ text: httpsUrl })); - - expect(findLink().text()).toBe(httpsUrl); - expect(findLink().attributes().href).toBe(httpsUrl); - }); - - it('renders a link with rel nofollow and noopener', () => { - createComponent(mockProps({ text: httpsUrl })); - - expect(findLink().attributes().rel).toBe('nofollow noopener noreferrer'); - }); - - it('renders a link with corresponding styles', () => { - createComponent(mockProps({ text: httpsUrl })); - - expect(findLink().classes()).toEqual(['gl-reset-color!', 'gl-text-decoration-underline']); - }); - - it('renders links with queries, surrounded by questions marks', () => { - createComponent(mockProps({ text: `Did you see my url ${queryUrl}??` })); - - expect(findLine().text()).toBe('Did you see my url https://example.com?param=val??'); - expect(findLinkAttributeByIndex(0).href).toBe(queryUrl); - }); - - it('renders links with queries, surrounded by exclamation marks', () => { - createComponent(mockProps({ text: `No! The ${queryUrl}!?` })); - - expect(findLine().text()).toBe('No! The https://example.com?param=val!?'); - expect(findLinkAttributeByIndex(0).href).toBe(queryUrl); - }); - - it('renders links that have brackets `[]` in their parameters', () => { - const url = `${httpUrl}?label_name[]=frontend`; - - createComponent(mockProps({ text: url })); - - expect(findLine().text()).toBe(url); - expect(findLinks().at(0).text()).toBe(url); - expect(findLinks().at(0).attributes('href')).toBe(url); - }); - - it('renders multiple links surrounded by text', () => { - createComponent( - mockProps({ text: `Well, my HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` }), - ); - expect(findLine().text()).toBe( - 'Well, my HTTP url: http://example.com and my HTTPS url: https://example.com', - ); - - expect(findLinks()).toHaveLength(2); - - expect(findLinkAttributeByIndex(0).href).toBe(httpUrl); - expect(findLinkAttributeByIndex(1).href).toBe(httpsUrl); - }); - - it('renders multiple links surrounded by text, with other symbols', () => { - createComponent( - mockProps({ text: `${httpUrl}, ${httpUrl}: ${httpsUrl}; ${httpsUrl}. ${httpsUrl}...` }), - ); - expect(findLine().text()).toBe( - 'http://example.com, http://example.com: https://example.com; https://example.com. https://example.com...', - ); - - expect(findLinks()).toHaveLength(5); - - expect(findLinkAttributeByIndex(0).href).toBe(httpUrl); - expect(findLinkAttributeByIndex(1).href).toBe(httpUrl); - expect(findLinkAttributeByIndex(2).href).toBe(httpsUrl); - expect(findLinkAttributeByIndex(3).href).toBe(httpsUrl); - expect(findLinkAttributeByIndex(4).href).toBe(httpsUrl); - }); - - it('renders multiple links surrounded by brackets', () => { - createComponent(mockProps({ text: `(${httpUrl}) <${httpUrl}> {${httpsUrl}}` })); - expect(findLine().text()).toBe( - '(http://example.com) {https://example.com}', - ); - - const links = findLinks(); - - expect(links).toHaveLength(3); - - expect(links.at(0).text()).toBe(httpUrl); - expect(links.at(0).attributes('href')).toBe(httpUrl); - - expect(links.at(1).text()).toBe(httpUrl); - expect(links.at(1).attributes('href')).toBe(httpUrl); - - expect(links.at(2).text()).toBe(httpsUrl); - expect(links.at(2).attributes('href')).toBe(httpsUrl); - }); - - it('renders text with symbols in it', () => { - const text = 'apt-get update < /dev/null > /dev/null'; - createComponent(mockProps({ text })); - - expect(findLine().text()).toBe(text); - }); - - const jshref = 'javascript:doEvil();'; // eslint-disable-line no-script-url - - it.each` - type | text - ${'html link'} | ${'linked'} - ${'html script'} | ${''} - ${'html strong'} | ${'highlighted'} - ${'js'} | ${jshref} - ${'file'} | ${'file:///a-file'} - ${'ftp'} | ${'ftp://example.com/file'} - ${'email'} | ${'email@example.com'} - ${'no scheme'} | ${'example.com/page'} - `('does not render a $type link', ({ text }) => { - createComponent(mockProps({ text })); - 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-700'); - }); - - 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-700'); - }); - }); - - describe('job log hash highlighting', () => { - describe('with hash', () => { - beforeEach(() => { - setWindowLocation(`http://foo.com/root/ci-project/-/jobs/6353#L77`); - }); - - it('applies highlight class to job log line', () => { - createComponent({ - line: { - offset: 24526, - content: [{ text: 'job log content' }], - section: 'custom-section', - lineNumber: 76, - }, - path: '/root/ci-project/-/jobs/6353', - }); - - expect(wrapper.classes()).toContain('gl-bg-gray-700'); - }); - }); - - describe('without hash', () => { - beforeEach(() => { - setWindowLocation(`http://foo.com/root/ci-project/-/jobs/6353`); - }); - - it('does not apply highlight class to job log line', () => { - createComponent({ - line: { - offset: 24500, - content: [{ text: 'line' }], - section: 'custom-section', - lineNumber: 10, - }, - path: '/root/ci-project/-/jobs/6353', - }); - - expect(wrapper.classes()).not.toContain('gl-bg-gray-700'); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js deleted file mode 100644 index 9407b340950..00000000000 --- a/spec/frontend/jobs/components/log/log_spec.js +++ /dev/null @@ -1,135 +0,0 @@ -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import waitForPromises from 'helpers/wait_for_promises'; -import { scrollToElement } from '~/lib/utils/common_utils'; -import Log from '~/jobs/components/log/log.vue'; -import LogLineHeader from '~/jobs/components/log/line_header.vue'; -import { logLinesParser } from '~/jobs/store/utils'; -import { jobLog } from './mock_data'; - -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - scrollToElement: jest.fn(), -})); - -describe('Job Log', () => { - let wrapper; - let actions; - let state; - let store; - let toggleCollapsibleLineMock; - - Vue.use(Vuex); - - const createComponent = () => { - wrapper = mount(Log, { - store, - }); - }; - - beforeEach(() => { - toggleCollapsibleLineMock = jest.fn(); - actions = { - toggleCollapsibleLine: toggleCollapsibleLineMock, - }; - - state = { - jobLog: logLinesParser(jobLog), - jobLogEndpoint: 'jobs/id', - }; - - store = new Vuex.Store({ - actions, - state, - }); - }); - - const findCollapsibleLine = () => wrapper.findComponent(LogLineHeader); - - describe('line numbers', () => { - beforeEach(() => { - createComponent(); - }); - - 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', () => { - beforeEach(() => { - createComponent(); - }); - - 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', () => { - findCollapsibleLine().trigger('click'); - - expect(toggleCollapsibleLineMock).toHaveBeenCalled(); - }); - }); - }); - - describe('anchor scrolling', () => { - afterEach(() => { - window.location.hash = ''; - }); - - describe('when hash is not present', () => { - it('does not scroll to line number', async () => { - createComponent(); - - await waitForPromises(); - - expect(wrapper.find('#L6').exists()).toBe(false); - expect(scrollToElement).not.toHaveBeenCalled(); - }); - }); - - describe('when hash is present', () => { - beforeEach(() => { - window.location.hash = '#L6'; - }); - - it('scrolls to line number', async () => { - createComponent(); - - state.jobLog = logLinesParser(jobLog, [], '#L6'); - await waitForPromises(); - - expect(scrollToElement).toHaveBeenCalledTimes(1); - - state.jobLog = logLinesParser(jobLog, [], '#L7'); - await waitForPromises(); - - expect(scrollToElement).toHaveBeenCalledTimes(1); - }); - - it('line number within collapsed section is visible', () => { - state.jobLog = logLinesParser(jobLog, [], '#L6'); - - createComponent(); - - expect(wrapper.find('#L6').exists()).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js deleted file mode 100644 index fa51b92a044..00000000000 --- a/spec/frontend/jobs/components/log/mock_data.js +++ /dev/null @@ -1,218 +0,0 @@ -export const jobLog = [ - { - offset: 1000, - content: [{ text: 'Running with gitlab-runner 12.1.0 (de7731dd)' }], - }, - { - offset: 1001, - content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], - }, - { - offset: 1002, - content: [ - { - text: 'Using Docker executor with image dev.gitlab.org3', - }, - ], - section: 'prepare-executor', - section_header: true, - }, - { - offset: 1003, - content: [{ text: 'Starting service postgres:9.6.14 ...', style: 'text-green' }], - section: 'prepare-executor', - }, - { - offset: 1004, - content: [ - { - text: 'Restore cache', - style: 'term-fg-l-cyan term-bold', - }, - ], - section: 'restore-cache', - section_header: true, - section_options: { - collapsed: 'true', - }, - }, - { - offset: 1005, - content: [ - { - text: 'Checking cache for ruby-gems-debian-bullseye-ruby-3.0-16...', - style: 'term-fg-l-green term-bold', - }, - ], - section: 'restore-cache', - }, -]; - -export const utilsMockData = [ - { - offset: 1001, - content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], - }, - { - offset: 1002, - content: [ - { - text: - 'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34', - }, - ], - section: 'prepare-executor', - section_header: true, - }, - { - offset: 1003, - content: [{ text: 'Starting service postgres:9.6.14 ...' }], - section: 'prepare-executor', - }, - { - offset: 1004, - content: [{ text: 'Pulling docker image postgres:9.6.14 ...', style: 'term-fg-l-green' }], - section: 'prepare-executor', - }, - { - offset: 1005, - content: [], - section: 'prepare-executor', - section_duration: '10:00', - }, -]; - -export const originalTrace = [ - { - offset: 1, - content: [ - { - text: 'Downloading', - }, - ], - }, -]; - -export const regularIncremental = [ - { - offset: 2, - content: [ - { - text: 'log line', - }, - ], - }, -]; - -export const regularIncrementalRepeated = [ - { - offset: 1, - content: [ - { - text: 'log line', - }, - ], - }, -]; - -export const headerTrace = [ - { - offset: 1, - section_header: true, - content: [ - { - text: 'log line', - }, - ], - section: 'section', - }, -]; - -export const headerTraceIncremental = [ - { - offset: 1, - section_header: true, - content: [ - { - text: 'updated log line', - }, - ], - section: 'section', - }, -]; - -export const collapsibleTrace = [ - { - offset: 1, - section_header: true, - content: [ - { - text: 'log line', - }, - ], - section: 'section', - }, - { - offset: 2, - content: [ - { - text: 'log line', - }, - ], - section: 'section', - }, -]; - -export const collapsibleTraceIncremental = [ - { - offset: 2, - content: [ - { - text: 'updated log line', - }, - ], - section: 'section', - }, -]; - -export const collapsibleSectionClosed = { - offset: 5, - section_header: true, - isHeader: true, - isClosed: true, - line: { - content: [{ text: 'foo' }], - section: 'prepare-script', - lineNumber: 1, - }, - section_duration: '00:03', - lines: [ - { - offset: 80, - content: [{ text: 'this is a collapsible nested section' }], - section: 'prepare-script', - lineNumber: 3, - }, - ], -}; - -export const collapsibleSectionOpened = { - offset: 5, - section_header: true, - isHeader: true, - isClosed: false, - line: { - content: [{ text: 'foo' }], - section: 'prepare-script', - lineNumber: 1, - }, - section_duration: '00:03', - lines: [ - { - offset: 80, - content: [{ text: 'this is a collapsible nested section' }], - section: 'prepare-script', - lineNumber: 3, - }, - ], -}; diff --git a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js b/spec/frontend/jobs/components/table/cells/actions_cell_spec.js deleted file mode 100644 index f2d249b6014..00000000000 --- a/spec/frontend/jobs/components/table/cells/actions_cell_spec.js +++ /dev/null @@ -1,240 +0,0 @@ -import { GlModal } 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 { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import ActionsCell from '~/jobs/components/table/cells/actions_cell.vue'; -import eventHub from '~/jobs/components/table/event_hub'; -import JobPlayMutation from '~/jobs/components/table/graphql/mutations/job_play.mutation.graphql'; -import JobRetryMutation from '~/jobs/components/table/graphql/mutations/job_retry.mutation.graphql'; -import JobUnscheduleMutation from '~/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql'; -import JobCancelMutation from '~/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql'; -import { - mockJobsNodes, - mockJobsNodesAsGuest, - playMutationResponse, - retryMutationResponse, - unscheduleMutationResponse, - cancelMutationResponse, -} from '../../../mock_data'; - -jest.mock('~/lib/utils/url_utility'); - -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 failedJob = findMockJob('failed'); - 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'); - const findDownloadArtifactsButton = () => wrapper.findByTestId('download-artifacts'); - const findCountdownButton = () => wrapper.findByTestId('countdown'); - const findPlayScheduledJobButton = () => wrapper.findByTestId('play-scheduled'); - const findUnscheduleButton = () => wrapper.findByTestId('unschedule'); - - const findModal = () => wrapper.findComponent(GlModal); - - const playMutationHandler = jest.fn().mockResolvedValue(playMutationResponse); - const retryMutationHandler = jest.fn().mockResolvedValue(retryMutationResponse); - const unscheduleMutationHandler = jest.fn().mockResolvedValue(unscheduleMutationResponse); - const cancelMutationHandler = jest.fn().mockResolvedValue(cancelMutationResponse); - - const $toast = { - show: jest.fn(), - }; - - const createMockApolloProvider = (requestHandlers) => { - return createMockApollo(requestHandlers); - }; - - const createComponent = (job, requestHandlers, props = {}) => { - wrapper = shallowMountExtended(ActionsCell, { - propsData: { - job, - ...props, - }, - apolloProvider: createMockApolloProvider(requestHandlers), - mocks: { - $toast, - }, - }); - }; - - it('displays the artifacts download button with correct link', () => { - createComponent(jobWithArtifact); - - expect(findDownloadArtifactsButton().attributes('href')).toBe( - jobWithArtifact.artifacts.nodes[0].downloadPath, - ); - }); - - it('does not display an artifacts download button', () => { - createComponent(mockJob); - - expect(findDownloadArtifactsButton().exists()).toBe(false); - }); - - it.each` - button | action | jobType - ${findPlayButton} | ${'play'} | ${cannotPlayJob} - ${findRetryButton} | ${'retry'} | ${cannotRetryJob} - ${findPlayScheduledJobButton} | ${'play scheduled'} | ${cannotPlayScheduledJob} - `('does not display the $action button if user cannot update build', ({ button, jobType }) => { - createComponent(jobType); - - expect(button().exists()).toBe(false); - }); - - it.each` - button | action | jobType - ${findPlayButton} | ${'play'} | ${playableJob} - ${findRetryButton} | ${'retry'} | ${retryableJob} - ${findDownloadArtifactsButton} | ${'download artifacts'} | ${jobWithArtifact} - ${findCancelButton} | ${'cancel'} | ${cancelableJob} - `('displays the $action button', ({ button, jobType }) => { - createComponent(jobType); - - expect(button().exists()).toBe(true); - }); - - it.each` - button | action | jobType | mutationFile | handler | jobId - ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${playableJob.id} - ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${retryableJob.id} - ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} | ${cancelableJob.id} - `('performs the $action mutation', ({ button, jobType, mutationFile, handler, jobId }) => { - createComponent(jobType, [[mutationFile, handler]]); - - button().vm.$emit('click'); - - expect(handler).toHaveBeenCalledWith({ id: jobId }); - }); - - it.each` - button | action | jobType | mutationFile | handler - ${findUnscheduleButton} | ${'unschedule'} | ${scheduledJob} | ${JobUnscheduleMutation} | ${unscheduleMutationHandler} - ${findCancelButton} | ${'cancel'} | ${cancelableJob} | ${JobCancelMutation} | ${cancelMutationHandler} - `( - 'the mutation action $action emits the jobActionPerformed event', - async ({ button, jobType, mutationFile, handler }) => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - - createComponent(jobType, [[mutationFile, handler]]); - - button().vm.$emit('click'); - - await waitForPromises(); - - expect(eventHub.$emit).toHaveBeenCalledWith('jobActionPerformed'); - expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated - }, - ); - - it.each` - button | action | jobType | mutationFile | handler | redirectLink - ${findPlayButton} | ${'play'} | ${playableJob} | ${JobPlayMutation} | ${playMutationHandler} | ${'/root/project/-/jobs/1986'} - ${findRetryButton} | ${'retry'} | ${retryableJob} | ${JobRetryMutation} | ${retryMutationHandler} | ${'/root/project/-/jobs/1985'} - `( - 'the mutation action $action redirects to the job', - async ({ button, jobType, mutationFile, handler, redirectLink }) => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - - createComponent(jobType, [[mutationFile, handler]]); - - button().vm.$emit('click'); - - await waitForPromises(); - - expect(redirectTo).toHaveBeenCalledWith(redirectLink); // eslint-disable-line import/no-deprecated - expect(eventHub.$emit).not.toHaveBeenCalled(); - }, - ); - - it.each` - button | action | jobType - ${findPlayButton} | ${'play'} | ${playableJob} - ${findRetryButton} | ${'retry'} | ${retryableJob} - ${findCancelButton} | ${'cancel'} | ${cancelableJob} - ${findUnscheduleButton} | ${'unschedule'} | ${scheduledJob} - `('disables the $action button after first request', async ({ button, jobType }) => { - createComponent(jobType); - - expect(button().props('disabled')).toBe(false); - - button().vm.$emit('click'); - - await waitForPromises(); - - expect(button().props('disabled')).toBe(true); - }); - - describe('Retry button title', () => { - it('displays retry title when job has failed and is retryable', () => { - createComponent(failedJob); - - expect(findRetryButton().attributes('title')).toBe('Retry'); - }); - - it('displays run again title when job has passed and is retryable', () => { - createComponent(retryableJob); - - expect(findRetryButton().attributes('title')).toBe('Run again'); - }); - }); - - describe('Scheduled Jobs', () => { - const today = () => new Date('2021-08-31'); - - beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(today); - }); - - it('displays the countdown, play and unschedule buttons', () => { - createComponent(scheduledJob); - - expect(findCountdownButton().exists()).toBe(true); - expect(findPlayScheduledJobButton().exists()).toBe(true); - expect(findUnscheduleButton().exists()).toBe(true); - }); - - it('unschedules a job', () => { - createComponent(scheduledJob, [[JobUnscheduleMutation, unscheduleMutationHandler]]); - - findUnscheduleButton().vm.$emit('click'); - - expect(unscheduleMutationHandler).toHaveBeenCalledWith({ - id: scheduledJob.id, - }); - }); - - it('shows the play job confirmation modal', async () => { - createComponent(scheduledJob); - - findPlayScheduledJobButton().vm.$emit('click'); - - await nextTick(); - - expect(findModal().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/jobs/components/table/cells/duration_cell_spec.js b/spec/frontend/jobs/components/table/cells/duration_cell_spec.js deleted file mode 100644 index d015edb0e91..00000000000 --- a/spec/frontend/jobs/components/table/cells/duration_cell_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import DurationCell from '~/jobs/components/table/cells/duration_cell.vue'; - -describe('Duration Cell', () => { - let wrapper; - - const findJobDuration = () => wrapper.findByTestId('job-duration'); - const findJobFinishedTime = () => wrapper.findByTestId('job-finished-time'); - const findDurationIcon = () => wrapper.findByTestId('duration-icon'); - const findFinishedTimeIcon = () => wrapper.findByTestId('finished-time-icon'); - - const createComponent = (props) => { - wrapper = extendedWrapper( - shallowMount(DurationCell, { - propsData: { - job: { - ...props, - }, - }, - }), - ); - }; - - it('does not display duration or finished time when no properties are present', () => { - createComponent(); - - expect(findJobDuration().exists()).toBe(false); - expect(findJobFinishedTime().exists()).toBe(false); - }); - - it('displays duration and finished time when both properties are present', () => { - const props = { - duration: 7, - finishedAt: '2021-04-26T13:37:52Z', - }; - - createComponent(props); - - expect(findJobDuration().exists()).toBe(true); - expect(findJobFinishedTime().exists()).toBe(true); - }); - - it('displays only the duration of the job when the duration property is present', () => { - const props = { - duration: 7, - }; - - createComponent(props); - - expect(findJobDuration().exists()).toBe(true); - expect(findJobFinishedTime().exists()).toBe(false); - }); - - it('displays only the finished time of the job when the finshedAt property is present', () => { - const props = { - finishedAt: '2021-04-26T13:37:52Z', - }; - - createComponent(props); - - expect(findJobFinishedTime().exists()).toBe(true); - expect(findJobDuration().exists()).toBe(false); - }); - - it('displays icons for finished time and duration', () => { - const props = { - duration: 7, - finishedAt: '2021-04-26T13:37:52Z', - }; - - createComponent(props); - - expect(findFinishedTimeIcon().props('name')).toBe('calendar'); - expect(findDurationIcon().props('name')).toBe('timer'); - }); -}); diff --git a/spec/frontend/jobs/components/table/cells/job_cell_spec.js b/spec/frontend/jobs/components/table/cells/job_cell_spec.js deleted file mode 100644 index 73e37eed5f1..00000000000 --- a/spec/frontend/jobs/components/table/cells/job_cell_spec.js +++ /dev/null @@ -1,142 +0,0 @@ -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 { 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'); - const findJobSha = () => wrapper.findByTestId('job-sha'); - const findLabelIcon = () => wrapper.findByTestId('label-icon'); - const findForkIcon = () => wrapper.findByTestId('fork-icon'); - const findStuckIcon = () => wrapper.findByTestId('stuck-icon'); - const findAllTagBadges = () => wrapper.findAllByTestId('job-tag-badge'); - - const findBadgeById = (id) => wrapper.findByTestId(id); - - const createComponent = (job = mockJob) => { - wrapper = extendedWrapper( - shallowMount(JobCell, { - propsData: { - job, - }, - }), - ); - }; - - describe('Job Id', () => { - it('displays the job id and links to the job', () => { - createComponent(); - - const expectedJobId = `#${getIdFromGraphQLId(mockJob.id)}`; - - expect(findJobIdLink().text()).toBe(expectedJobId); - expect(findJobIdLink().attributes('href')).toBe(mockJob.detailedStatus.detailsPath); - expect(findJobIdNoLink().exists()).toBe(false); - }); - - it('display the job id with no link', () => { - createComponent(jobAsGuest); - - const expectedJobId = `#${getIdFromGraphQLId(jobAsGuest.id)}`; - - expect(findJobIdNoLink().text()).toBe(expectedJobId); - expect(findJobIdNoLink().exists()).toBe(true); - expect(findJobIdLink().exists()).toBe(false); - }); - }); - - describe('Ref of the job', () => { - it('displays the ref name and links to the ref', () => { - createComponent(); - - expect(findJobRef().text()).toBe(mockJob.refName); - expect(findJobRef().attributes('href')).toBe(mockJob.refPath); - }); - - it('displays fork icon when job is not created by tag', () => { - createComponent(); - - expect(findForkIcon().exists()).toBe(true); - expect(findLabelIcon().exists()).toBe(false); - }); - - it('displays label icon when job is created by a tag', () => { - createComponent(jobCreatedByTag); - - expect(findLabelIcon().exists()).toBe(true); - expect(findForkIcon().exists()).toBe(false); - }); - }); - - describe('Commit of the job', () => { - beforeEach(() => { - createComponent(); - }); - - it('displays the sha and links to the commit', () => { - expect(findJobSha().text()).toBe(mockJob.shortSha); - expect(findJobSha().attributes('href')).toBe(mockJob.commitPath); - }); - }); - - describe('Job badges', () => { - it('displays tags of the job', () => { - const mockJobWithTags = { - tags: ['tag-1', 'tag-2', 'tag-3'], - }; - - createComponent(mockJobWithTags); - - expect(findAllTagBadges()).toHaveLength(mockJobWithTags.tags.length); - }); - - it.each` - testId | text - ${'manual-job-badge'} | ${'manual'} - ${'triggered-job-badge'} | ${'triggered'} - ${'fail-job-badge'} | ${'allowed to fail'} - ${'delayed-job-badge'} | ${'delayed'} - `('displays the static $text badge', ({ testId, text }) => { - createComponent({ - manualJob: true, - triggered: true, - allowFailure: true, - scheduledAt: '2021-03-09T14:58:50+00:00', - }); - - expect(findBadgeById(testId).exists()).toBe(true); - expect(findBadgeById(testId).text()).toBe(text); - }); - }); - - describe('Job icons', () => { - it('stuck icon is not shown if job is not stuck', () => { - createComponent(); - - expect(findStuckIcon().exists()).toBe(false); - }); - - 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/cells/pipeline_cell_spec.js b/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js deleted file mode 100644 index 3d424b20964..00000000000 --- a/spec/frontend/jobs/components/table/cells/pipeline_cell_spec.js +++ /dev/null @@ -1,78 +0,0 @@ -import { GlAvatar } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import PipelineCell from '~/jobs/components/table/cells/pipeline_cell.vue'; - -const mockJobWithoutUser = { - id: 'gid://gitlab/Ci::Build/2264', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/460', - path: '/root/ci-project/-/pipelines/460', - }, -}; - -const mockJobWithUser = { - id: 'gid://gitlab/Ci::Build/2264', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/460', - path: '/root/ci-project/-/pipelines/460', - user: { - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - webPath: '/root', - }, - }, -}; - -describe('Pipeline Cell', () => { - let wrapper; - - const findPipelineId = () => wrapper.findByTestId('pipeline-id'); - const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link'); - const findUserAvatar = () => wrapper.findComponent(GlAvatar); - - const createComponent = (props = mockJobWithUser) => { - wrapper = extendedWrapper( - shallowMount(PipelineCell, { - propsData: { - job: props, - }, - }), - ); - }; - - describe('Pipeline Id', () => { - beforeEach(() => { - createComponent(); - }); - - it('displays the pipeline id and links to the pipeline', () => { - const expectedPipelineId = `#${getIdFromGraphQLId(mockJobWithUser.pipeline.id)}`; - - expect(findPipelineId().text()).toBe(expectedPipelineId); - expect(findPipelineId().attributes('href')).toBe(mockJobWithUser.pipeline.path); - }); - }); - - describe('Pipeline created by', () => { - const apiWrapperText = 'API'; - - it('shows and links to the pipeline user', () => { - createComponent(); - - expect(findPipelineUserLink().exists()).toBe(true); - expect(findPipelineUserLink().attributes('href')).toBe(mockJobWithUser.pipeline.user.webPath); - expect(findUserAvatar().attributes('src')).toBe(mockJobWithUser.pipeline.user.avatarUrl); - expect(wrapper.text()).not.toContain(apiWrapperText); - }); - - it('shows pipeline was created by the API', () => { - createComponent(mockJobWithoutUser); - - expect(findPipelineUserLink().exists()).toBe(false); - expect(findUserAvatar().exists()).toBe(false); - expect(wrapper.text()).toContain(apiWrapperText); - }); - }); -}); diff --git a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js deleted file mode 100644 index e3b1ca1cce3..00000000000 --- a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import cacheConfig from '~/jobs/components/table/graphql/cache_config'; -import { - CIJobConnectionExistingCache, - CIJobConnectionIncomingCache, - CIJobConnectionIncomingCacheRunningStatus, -} from '../../../mock_data'; - -const firstLoadArgs = { first: 3, statuses: 'PENDING' }; -const runningArgs = { first: 3, statuses: 'RUNNING' }; - -describe('jobs/components/table/graphql/cache_config', () => { - describe('when fetching data with the same statuses', () => { - it('should contain cache nodes and a status when merging caches on first load', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { - args: firstLoadArgs, - }); - - expect(res.nodes).toHaveLength(CIJobConnectionIncomingCache.nodes.length); - expect(res.statuses).toBe('PENDING'); - }); - - it('should add to existing caches when merging caches after first load', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge( - CIJobConnectionExistingCache, - CIJobConnectionIncomingCache, - { - args: firstLoadArgs, - }, - ); - - expect(res.nodes).toHaveLength( - CIJobConnectionIncomingCache.nodes.length + CIJobConnectionExistingCache.nodes.length, - ); - }); - - it('should not add to existing cache if the incoming elements are the same', () => { - // simulate that this is the last page - const finalExistingCache = { - ...CIJobConnectionExistingCache, - pageInfo: { - hasNextPage: false, - }, - }; - - const res = cacheConfig.typePolicies.CiJobConnection.merge( - CIJobConnectionExistingCache, - finalExistingCache, - { - args: firstLoadArgs, - }, - ); - - expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length); - }); - - it('should contain the pageInfo key as part of the result', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { - args: firstLoadArgs, - }); - - expect(res.pageInfo).toEqual( - expect.objectContaining({ - __typename: 'PageInfo', - endCursor: 'eyJpZCI6IjIwNTEifQ', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'eyJpZCI6IjIxNzMifQ', - }), - ); - }); - }); - - describe('when fetching data with different statuses', () => { - it('should reset cache when a cache already exists', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge( - CIJobConnectionExistingCache, - CIJobConnectionIncomingCacheRunningStatus, - { - args: runningArgs, - }, - ); - - expect(res.nodes).not.toEqual(CIJobConnectionExistingCache.nodes); - expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length); - }); - }); - - describe('when incoming data has no nodes', () => { - it('should return existing cache', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge( - CIJobConnectionExistingCache, - { __typename: 'CiJobConnection', count: 500 }, - { - args: { statuses: 'SUCCESS' }, - }, - ); - - const expectedResponse = { - ...CIJobConnectionExistingCache, - statuses: 'SUCCESS', - }; - - expect(res).toEqual(expectedResponse); - }); - }); -}); diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js deleted file mode 100644 index 032b83ca22b..00000000000 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ /dev/null @@ -1,338 +0,0 @@ -import { GlAlert, GlEmptyState, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import { s__ } from '~/locale'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { TEST_HOST } from 'spec/test_constants'; -import { createAlert } from '~/alert'; -import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql'; -import getJobsCountQuery from '~/jobs/components/table/graphql/queries/get_jobs_count.query.graphql'; -import JobsTable from '~/jobs/components/table/jobs_table.vue'; -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 JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue'; -import * as urlUtils from '~/lib/utils/url_utility'; -import { - mockJobsResponsePaginated, - mockJobsResponseEmpty, - mockFailedSearchToken, - mockJobsCountResponse, -} from '../../mock_data'; - -const projectPath = 'gitlab-org/gitlab'; -Vue.use(VueApollo); - -jest.mock('~/alert'); - -describe('Job table app', () => { - let wrapper; - - const successHandler = jest.fn().mockResolvedValue(mockJobsResponsePaginated); - const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); - const emptyHandler = jest.fn().mockResolvedValue(mockJobsResponseEmpty); - - const countSuccessHandler = jest.fn().mockResolvedValue(mockJobsCountResponse); - - const findSkeletonLoader = () => wrapper.findComponent(JobsSkeletonLoader); - const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); - const findTable = () => wrapper.findComponent(JobsTable); - const findTabs = () => wrapper.findComponent(JobsTableTabs); - const findAlert = () => wrapper.findComponent(GlAlert); - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch); - - const triggerInfiniteScroll = () => - wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); - - const createMockApolloProvider = (handler, countHandler) => { - const requestHandlers = [ - [getJobsQuery, handler], - [getJobsCountQuery, countHandler], - ]; - - return createMockApollo(requestHandlers); - }; - - const createComponent = ({ - handler = successHandler, - countHandler = countSuccessHandler, - mountFn = shallowMount, - } = {}) => { - wrapper = mountFn(JobsTableApp, { - provide: { - fullPath: projectPath, - }, - apolloProvider: createMockApolloProvider(handler, countHandler), - }); - }; - - describe('loading state', () => { - it('should display skeleton loader when loading', () => { - createComponent(); - - 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); - expect(findLoadingSpinner().exists()).toBe(false); - }); - }); - - describe('loaded state', () => { - beforeEach(async () => { - createComponent(); - - await waitForPromises(); - }); - - it('should display the jobs table with data', () => { - expect(findTable().exists()).toBe(true); - expect(findSkeletonLoader().exists()).toBe(false); - expect(findLoadingSpinner().exists()).toBe(false); - }); - - it('should refetch jobs query on fetchJobsByStatus event', async () => { - expect(successHandler).toHaveBeenCalledTimes(1); - - await findTabs().vm.$emit('fetchJobsByStatus'); - - expect(successHandler).toHaveBeenCalledTimes(2); - }); - - it('avoids refetch jobs query when scope has not changed', async () => { - expect(successHandler).toHaveBeenCalledTimes(1); - - await findTabs().vm.$emit('fetchJobsByStatus', null); - - expect(successHandler).toHaveBeenCalledTimes(1); - }); - - it('should refetch jobs count query when the amount jobs and count do not match', async () => { - expect(countSuccessHandler).toHaveBeenCalledTimes(1); - - // after applying filter a new count is fetched - findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - - expect(countSuccessHandler).toHaveBeenCalledTimes(2); - - // tab is switched to `finished`, no count - await findTabs().vm.$emit('fetchJobsByStatus', ['FAILED', 'SUCCESS', 'CANCELED']); - - // tab is switched back to `all`, the old filter count has to be overwritten with new count - await findTabs().vm.$emit('fetchJobsByStatus', null); - - expect(countSuccessHandler).toHaveBeenCalledTimes(3); - }); - - describe('when infinite scrolling is triggered', () => { - it('does not display a skeleton loader', () => { - triggerInfiniteScroll(); - - 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).toHaveBeenLastCalledWith({ - first: pageSize, - fullPath: projectPath, - after: mockJobsResponsePaginated.data.project.jobs.pageInfo.endCursor, - }); - }); - }); - }); - - describe('error state', () => { - it('should show an alert if there is an error fetching the jobs data', async () => { - createComponent({ handler: failedHandler }); - - await waitForPromises(); - - expect(findAlert().text()).toBe('There was an error fetching the jobs for your project.'); - expect(findTable().exists()).toBe(false); - }); - - it('should show an alert if there is an error fetching the jobs count data', async () => { - createComponent({ handler: successHandler, countHandler: failedHandler }); - - await waitForPromises(); - - expect(findAlert().text()).toBe( - 'There was an error fetching the number of jobs for your project.', - ); - }); - - it('jobs table should still load if count query fails', async () => { - createComponent({ handler: successHandler, countHandler: failedHandler }); - - await waitForPromises(); - - expect(findTable().exists()).toBe(true); - }); - - it('jobs count should be zero if count query fails', async () => { - createComponent({ handler: successHandler, countHandler: failedHandler }); - - await waitForPromises(); - - expect(findTabs().props('allJobsCount')).toBe(0); - }); - }); - - describe('empty state', () => { - it('should display empty state if there are no jobs and tab scope is null', async () => { - createComponent({ handler: emptyHandler, mountFn: mount }); - - await waitForPromises(); - - expect(findEmptyState().exists()).toBe(true); - expect(findTable().exists()).toBe(false); - }); - - it('should not display empty state if there are jobs and tab scope is not null', async () => { - createComponent({ handler: successHandler, mountFn: mount }); - - await waitForPromises(); - - expect(findEmptyState().exists()).toBe(false); - expect(findTable().exists()).toBe(true); - }); - }); - - describe('filtered search', () => { - it('should display filtered search', () => { - createComponent(); - - expect(findFilteredSearch().exists()).toBe(true); - }); - - // this test should be updated once BE supports tab and filtered search filtering - // https://gitlab.com/gitlab-org/gitlab/-/issues/356210 - it.each` - scope | shouldDisplay - ${null} | ${true} - ${['FAILED', 'SUCCESS', 'CANCELED']} | ${false} - `( - 'with tab scope $scope the filtered search displays $shouldDisplay', - async ({ scope, shouldDisplay }) => { - createComponent(); - - await waitForPromises(); - - await findTabs().vm.$emit('fetchJobsByStatus', scope); - - expect(findFilteredSearch().exists()).toBe(shouldDisplay); - }, - ); - - it('refetches jobs query when filtering', async () => { - createComponent(); - - expect(successHandler).toHaveBeenCalledTimes(1); - - await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - - expect(successHandler).toHaveBeenCalledTimes(2); - }); - - it('refetches jobs count query when filtering', async () => { - createComponent(); - - expect(countSuccessHandler).toHaveBeenCalledTimes(1); - - await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - - expect(countSuccessHandler).toHaveBeenCalledTimes(2); - }); - - it('shows raw text warning when user inputs raw text', async () => { - const expectedWarning = { - message: s__( - 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', - ), - type: 'warning', - }; - - createComponent(); - - expect(successHandler).toHaveBeenCalledTimes(1); - expect(countSuccessHandler).toHaveBeenCalledTimes(1); - - await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']); - - expect(createAlert).toHaveBeenCalledWith(expectedWarning); - expect(successHandler).toHaveBeenCalledTimes(1); - expect(countSuccessHandler).toHaveBeenCalledTimes(1); - }); - - it('updates URL query string when filtering jobs by status', async () => { - createComponent(); - - jest.spyOn(urlUtils, 'updateHistory'); - - await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - - expect(urlUtils.updateHistory).toHaveBeenCalledWith({ - url: `${TEST_HOST}/?statuses=FAILED`, - }); - }); - - it('resets query param after clearing tokens', () => { - createComponent(); - - jest.spyOn(urlUtils, 'updateHistory'); - - findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); - - expect(successHandler).toHaveBeenCalledWith({ - first: 30, - fullPath: 'gitlab-org/gitlab', - statuses: 'FAILED', - }); - expect(countSuccessHandler).toHaveBeenCalledWith({ - fullPath: 'gitlab-org/gitlab', - statuses: 'FAILED', - }); - expect(urlUtils.updateHistory).toHaveBeenCalledWith({ - url: `${TEST_HOST}/?statuses=FAILED`, - }); - - findFilteredSearch().vm.$emit('filterJobsBySearch', []); - - expect(urlUtils.updateHistory).toHaveBeenCalledWith({ - url: `${TEST_HOST}/`, - }); - - expect(successHandler).toHaveBeenCalledWith({ - first: 30, - fullPath: 'gitlab-org/gitlab', - statuses: null, - }); - expect(countSuccessHandler).toHaveBeenCalledWith({ - fullPath: 'gitlab-org/gitlab', - statuses: null, - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/table/jobs_table_empty_state_spec.js b/spec/frontend/jobs/components/table/jobs_table_empty_state_spec.js deleted file mode 100644 index 05b066a9edc..00000000000 --- a/spec/frontend/jobs/components/table/jobs_table_empty_state_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import JobsTableEmptyState from '~/jobs/components/table/jobs_table_empty_state.vue'; - -describe('Jobs table empty state', () => { - let wrapper; - - const pipelineEditorPath = '/root/project/-/ci/editor'; - const emptyStateSvgPath = 'assets/jobs-empty-state.svg'; - - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - - const createComponent = () => { - wrapper = shallowMount(JobsTableEmptyState, { - provide: { - pipelineEditorPath, - emptyStateSvgPath, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('displays empty state', () => { - expect(findEmptyState().exists()).toBe(true); - }); - - it('links to the pipeline editor', () => { - expect(findEmptyState().props('primaryButtonLink')).toBe(pipelineEditorPath); - }); - - it('shows an empty state image', () => { - expect(findEmptyState().props('svgPath')).toBe(emptyStateSvgPath); - }); -}); diff --git a/spec/frontend/jobs/components/table/jobs_table_spec.js b/spec/frontend/jobs/components/table/jobs_table_spec.js deleted file mode 100644 index 654b6d1c130..00000000000 --- a/spec/frontend/jobs/components/table/jobs_table_spec.js +++ /dev/null @@ -1,98 +0,0 @@ -import { GlTable } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import JobsTable from '~/jobs/components/table/jobs_table.vue'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; -import { DEFAULT_FIELDS_ADMIN } from '~/pages/admin/jobs/components/constants'; -import ProjectCell from '~/pages/admin/jobs/components/table/cell/project_cell.vue'; -import RunnerCell from '~/pages/admin/jobs/components/table/cells/runner_cell.vue'; -import { mockJobsNodes, mockAllJobsNodes } from '../../mock_data'; - -describe('Jobs Table', () => { - let wrapper; - - const findTable = () => wrapper.findComponent(GlTable); - const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); - const findTableRows = () => wrapper.findAllByTestId('jobs-table-row'); - const findJobStage = () => wrapper.findByTestId('job-stage-name'); - const findJobName = () => wrapper.findByTestId('job-name'); - const findJobProject = () => wrapper.findComponent(ProjectCell); - const findJobRunner = () => wrapper.findComponent(RunnerCell); - const findAllCoverageJobs = () => wrapper.findAllByTestId('job-coverage'); - - const createComponent = (props = {}) => { - wrapper = extendedWrapper( - mount(JobsTable, { - propsData: { - ...props, - }, - }), - ); - }; - - describe('jobs table', () => { - beforeEach(() => { - createComponent({ jobs: mockJobsNodes }); - }); - - it('displays the jobs table', () => { - expect(findTable().exists()).toBe(true); - }); - - it('displays correct number of job rows', () => { - expect(findTableRows()).toHaveLength(mockJobsNodes.length); - }); - - it('displays job status', () => { - expect(findCiBadgeLink().exists()).toBe(true); - }); - - it('displays the job stage and name', () => { - const [firstJob] = mockJobsNodes; - - 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 = mockJobsNodes.filter((job) => job.coverage !== null); - - jobsThatHaveCoverage.forEach((job, index) => { - expect(findAllCoverageJobs().at(index).text()).toBe(`${job.coverage}%`); - }); - expect(findAllCoverageJobs()).toHaveLength(jobsThatHaveCoverage.length); - }); - }); - - describe('regular user', () => { - beforeEach(() => { - createComponent({ jobs: mockJobsNodes }); - }); - - it('hides the job runner', () => { - expect(findJobRunner().exists()).toBe(false); - }); - - it('hides the job project link', () => { - expect(findJobProject().exists()).toBe(false); - }); - }); - - describe('admin mode', () => { - beforeEach(() => { - createComponent({ jobs: mockAllJobsNodes, tableFields: DEFAULT_FIELDS_ADMIN, admin: true }); - }); - - it('displays the runner cell', () => { - expect(findJobRunner().exists()).toBe(true); - }); - - it('displays the project cell', () => { - expect(findJobProject().exists()).toBe(true); - }); - - it('displays correct number of job rows', () => { - expect(findTableRows()).toHaveLength(mockAllJobsNodes.length); - }); - }); -}); diff --git a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js deleted file mode 100644 index d20a732508a..00000000000 --- a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import { GlTab } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { trimText } from 'helpers/text_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; -import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue'; - -describe('Jobs Table Tabs', () => { - let wrapper; - - const defaultProps = { - allJobsCount: 286, - loading: false, - }; - - const adminProps = { - ...defaultProps, - showCancelAllJobsButton: true, - }; - - const statuses = { - success: 'SUCCESS', - failed: 'FAILED', - canceled: 'CANCELED', - }; - - const findAllTab = () => wrapper.findByTestId('jobs-all-tab'); - const findFinishedTab = () => wrapper.findByTestId('jobs-finished-tab'); - const findCancelJobsButton = () => wrapper.findAllComponents(CancelJobs); - - const triggerTabChange = (index) => wrapper.findAllComponents(GlTab).at(index).vm.$emit('click'); - - const createComponent = (props = defaultProps) => { - wrapper = extendedWrapper( - mount(JobsTableTabs, { - provide: { - jobStatuses: { - ...statuses, - }, - }, - propsData: { - ...props, - }, - }), - ); - }; - - beforeEach(() => { - createComponent(); - }); - - it('displays All tab with count', () => { - expect(trimText(findAllTab().text())).toBe(`All ${defaultProps.allJobsCount}`); - }); - - it('displays Finished tab with no count', () => { - expect(findFinishedTab().text()).toBe('Finished'); - }); - - it.each` - tabIndex | expectedScope - ${0} | ${null} - ${1} | ${[statuses.success, statuses.failed, statuses.canceled]} - `('emits fetchJobsByStatus with $expectedScope on tab change', ({ tabIndex, expectedScope }) => { - triggerTabChange(tabIndex); - - expect(wrapper.emitted()).toEqual({ fetchJobsByStatus: [[expectedScope]] }); - }); - - it('does not displays cancel all jobs button', () => { - expect(findCancelJobsButton().exists()).toBe(false); - }); - - describe('admin mode', () => { - it('displays cancel all jobs button', () => { - createComponent(adminProps); - - expect(findCancelJobsButton().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js deleted file mode 100644 index 098a63719fe..00000000000 --- a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js +++ /dev/null @@ -1,119 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; -import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; - -describe('DelayedJobMixin', () => { - let wrapper; - const dummyComponent = { - props: { - job: { - type: Object, - required: true, - }, - }, - mixins: [delayedJobMixin], - template: '
    {{remainingTime}}
    ', - }; - - describe('if job is empty object', () => { - beforeEach(() => { - wrapper = shallowMount(dummyComponent, { - propsData: { - job: {}, - }, - }); - }); - - it('sets remaining time to 00:00:00', () => { - expect(wrapper.text()).toBe('00:00:00'); - }); - - it('does not update remaining time after mounting', async () => { - await nextTick(); - - expect(wrapper.text()).toBe('00:00:00'); - }); - }); - - describe('in REST component', () => { - describe('if job is delayed job', () => { - let remainingTimeInMilliseconds = 42000; - - beforeEach(async () => { - jest - .spyOn(Date, 'now') - .mockImplementation( - () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds, - ); - - wrapper = shallowMount(dummyComponent, { - propsData: { - job: delayedJobFixture, - }, - }); - - await nextTick(); - }); - - it('sets remaining time', () => { - expect(wrapper.text()).toBe('00:00:42'); - }); - - it('updates remaining time', async () => { - remainingTimeInMilliseconds = 41000; - jest.advanceTimersByTime(1000); - - await nextTick(); - expect(wrapper.text()).toBe('00:00:41'); - }); - }); - }); - - describe('in GraphQL component', () => { - const mockGraphQlJob = { - name: 'build_b', - scheduledAt: new Date(delayedJobFixture.scheduled_at), - status: { - icon: 'status_success', - tooltip: 'passed', - hasDetails: true, - detailsPath: '/root/abcd-dag/-/jobs/1515', - group: 'success', - action: null, - }, - }; - - describe('if job is delayed job', () => { - let remainingTimeInMilliseconds = 42000; - - beforeEach(async () => { - jest - .spyOn(Date, 'now') - .mockImplementation( - () => mockGraphQlJob.scheduledAt.getTime() - remainingTimeInMilliseconds, - ); - - wrapper = shallowMount(dummyComponent, { - propsData: { - job: mockGraphQlJob, - }, - }); - - await nextTick(); - }); - - it('sets remaining time', () => { - expect(wrapper.text()).toBe('00:00:42'); - }); - - it('updates remaining time', async () => { - remainingTimeInMilliseconds = 41000; - jest.advanceTimersByTime(1000); - - await nextTick(); - expect(wrapper.text()).toBe('00:00:41'); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js deleted file mode 100644 index 253e669e889..00000000000 --- a/spec/frontend/jobs/mock_data.js +++ /dev/null @@ -1,1628 +0,0 @@ -import mockJobsCount from 'test_fixtures/graphql/jobs/get_jobs_count.query.graphql.json'; -import mockAllJobsCount from 'test_fixtures/graphql/jobs/get_all_jobs_count.query.graphql.json'; -import mockJobsEmpty from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.empty.json'; -import mockAllJobsEmpty from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.empty.json'; -import mockJobsPaginated from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.paginated.json'; -import mockAllJobsPaginated from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.paginated.json'; -import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json'; -import mockAllJobs from 'test_fixtures/graphql/jobs/get_all_jobs.query.graphql.json'; -import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json'; -import mockCancelableJobsCount from 'test_fixtures/graphql/jobs/get_cancelable_jobs_count.query.graphql.json'; -import { TEST_HOST } from 'spec/test_constants'; -import { TOKEN_TYPE_STATUS } from '~/vue_shared/components/filtered_search_bar/constants'; - -const threeWeeksAgo = new Date(); -threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); - -// Fixtures generated at spec/frontend/fixtures/jobs.rb -export const mockJobsResponsePaginated = mockJobsPaginated; -export const mockAllJobsResponsePaginated = mockAllJobsPaginated; -export const mockJobsResponseEmpty = mockJobsEmpty; -export const mockAllJobsResponseEmpty = mockAllJobsEmpty; -export const mockJobsNodes = mockJobs.data.project.jobs.nodes; -export const mockAllJobsNodes = mockAllJobs.data.jobs.nodes; -export const mockJobsNodesAsGuest = mockJobsAsGuest.data.project.jobs.nodes; -export const mockJobsCountResponse = mockJobsCount; -export const mockAllJobsCountResponse = mockAllJobsCount; -export const mockCancelableJobsCountResponse = mockCancelableJobsCount; - -export const stages = [ - { - name: 'build', - title: 'build: running', - groups: [ - { - name: 'build:linux', - size: 1, - status: { - icon: 'status_pending', - text: 'pending', - label: 'pending', - group: 'pending', - tooltip: 'pending', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/1180', - illustration: { - image: 'illustrations/pending_job_empty.svg', - size: 'svg-430', - title: 'This job has not started yet', - content: 'This job is in pending state and is waiting to be picked by a runner', - }, - favicon: - '/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png', - action: { - icon: 'cancel', - title: 'Cancel', - path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', - method: 'post', - }, - }, - jobs: [ - { - id: 1180, - name: 'build:linux', - started: false, - build_path: '/gitlab-org/gitlab-shell/-/jobs/1180', - cancel_path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', - playable: false, - created_at: '2018-09-28T11:09:57.229Z', - updated_at: '2018-09-28T11:09:57.503Z', - status: { - icon: 'status_pending', - text: 'pending', - label: 'pending', - group: 'pending', - tooltip: 'pending', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/1180', - illustration: { - image: 'illustrations/pending_job_empty.svg', - size: 'svg-430', - title: 'This job has not started yet', - content: 'This job is in pending state and is waiting to be picked by a runner', - }, - favicon: - '/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png', - action: { - icon: 'cancel', - title: 'Cancel', - path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', - method: 'post', - }, - }, - }, - ], - }, - { - name: 'build:osx', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/444', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 444, - name: 'build:osx', - started: '2018-05-18T05:32:20.655Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/444', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', - playable: false, - created_at: '2018-05-18T15:32:54.364Z', - updated_at: '2018-05-18T15:32:54.364Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/444', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', - method: 'post', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - tooltip: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/pipelines/27#build', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_running-9c635b2419a8e1ec991c993061b89cc5aefc0743bb238ecd0c381e7741a70e8c.png', - }, - path: '/gitlab-org/gitlab-shell/pipelines/27#build', - dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=build', - }, - { - name: 'test', - title: 'test: passed with warnings', - groups: [ - { - name: 'jenkins', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: null, - group: 'success', - tooltip: null, - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 459, - name: 'jenkins', - started: '2018-05-18T09:32:20.658Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/459', - playable: false, - created_at: '2018-05-18T15:32:55.330Z', - updated_at: '2018-05-18T15:32:55.330Z', - status: { - icon: 'status_success', - text: 'passed', - label: null, - group: 'success', - tooltip: null, - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - }, - ], - }, - { - name: 'rspec:linux', - size: 3, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 445, - name: 'rspec:linux 0 3', - started: '2018-05-18T07:32:20.655Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/445', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/445/retry', - playable: false, - created_at: '2018-05-18T15:32:54.425Z', - updated_at: '2018-05-18T15:32:54.425Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/445', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/445/retry', - method: 'post', - }, - }, - }, - { - id: 446, - name: 'rspec:linux 1 3', - started: '2018-05-18T07:32:20.655Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/446', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/446/retry', - playable: false, - created_at: '2018-05-18T15:32:54.506Z', - updated_at: '2018-05-18T15:32:54.506Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/446', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/446/retry', - method: 'post', - }, - }, - }, - { - id: 447, - name: 'rspec:linux 2 3', - started: '2018-05-18T07:32:20.656Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/447', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/447/retry', - playable: false, - created_at: '2018-05-18T15:32:54.572Z', - updated_at: '2018-05-18T15:32:54.572Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/447', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/447/retry', - method: 'post', - }, - }, - }, - ], - }, - { - name: 'rspec:osx', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/452', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/452/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 452, - name: 'rspec:osx', - started: '2018-05-18T07:32:20.657Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/452', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/452/retry', - playable: false, - created_at: '2018-05-18T15:32:54.920Z', - updated_at: '2018-05-18T15:32:54.920Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/452', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/452/retry', - method: 'post', - }, - }, - }, - ], - }, - { - name: 'rspec:windows', - size: 3, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: false, - details_path: null, - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - jobs: [ - { - id: 448, - name: 'rspec:windows 0 3', - started: '2018-05-18T07:32:20.656Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/448', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/448/retry', - playable: false, - created_at: '2018-05-18T15:32:54.639Z', - updated_at: '2018-05-18T15:32:54.639Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/448', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/448/retry', - method: 'post', - }, - }, - }, - { - id: 449, - name: 'rspec:windows 1 3', - started: '2018-05-18T07:32:20.656Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/449', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/449/retry', - playable: false, - created_at: '2018-05-18T15:32:54.703Z', - updated_at: '2018-05-18T15:32:54.703Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/449', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/449/retry', - method: 'post', - }, - }, - }, - { - id: 451, - name: 'rspec:windows 2 3', - started: '2018-05-18T07:32:20.657Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/451', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/451/retry', - playable: false, - created_at: '2018-05-18T15:32:54.853Z', - updated_at: '2018-05-18T15:32:54.853Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/451', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/451/retry', - method: 'post', - }, - }, - }, - ], - }, - { - name: 'spinach:linux', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/453', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/453/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 453, - name: 'spinach:linux', - started: '2018-05-18T07:32:20.657Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/453', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/453/retry', - playable: false, - created_at: '2018-05-18T15:32:54.993Z', - updated_at: '2018-05-18T15:32:54.993Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/453', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/453/retry', - method: 'post', - }, - }, - }, - ], - }, - { - name: 'spinach:osx', - size: 1, - status: { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (unknown failure) (allowed to fail)', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/454', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/454/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 454, - name: 'spinach:osx', - started: '2018-05-18T07:32:20.657Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/454', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/454/retry', - playable: false, - created_at: '2018-05-18T15:32:55.053Z', - updated_at: '2018-05-18T15:32:55.053Z', - status: { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (unknown failure) (allowed to fail)', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/454', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/454/retry', - method: 'post', - }, - }, - callout_message: 'There is an unknown failure, please try again', - recoverable: true, - }, - ], - }, - ], - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/pipelines/27#test', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/gitlab-org/gitlab-shell/pipelines/27#test', - dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=test', - }, - { - name: 'deploy', - title: 'deploy: running', - groups: [ - { - name: 'production', - size: 1, - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - tooltip: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/457', - illustration: { - image: 'illustrations/job_not_triggered.svg', - size: 'svg-306', - title: 'This job has not been triggered yet', - content: - 'This job depends on upstream jobs that need to succeed in order for this job to be triggered', - }, - favicon: - '/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png', - action: { - icon: 'cancel', - title: 'Cancel', - path: '/gitlab-org/gitlab-shell/-/jobs/457/cancel', - method: 'post', - }, - }, - jobs: [ - { - id: 457, - name: 'production', - started: false, - build_path: '/gitlab-org/gitlab-shell/-/jobs/457', - cancel_path: '/gitlab-org/gitlab-shell/-/jobs/457/cancel', - playable: false, - created_at: '2018-05-18T15:32:55.259Z', - updated_at: '2018-09-28T11:09:57.454Z', - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - tooltip: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/457', - illustration: { - image: 'illustrations/job_not_triggered.svg', - size: 'svg-306', - title: 'This job has not been triggered yet', - content: - 'This job depends on upstream jobs that need to succeed in order for this job to be triggered', - }, - favicon: - '/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png', - action: { - icon: 'cancel', - title: 'Cancel', - path: '/gitlab-org/gitlab-shell/-/jobs/457/cancel', - method: 'post', - }, - }, - }, - ], - }, - { - name: 'staging', - size: 1, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/455', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/455/retry', - method: 'post', - }, - }, - jobs: [ - { - id: 455, - name: 'staging', - started: '2018-05-18T09:32:20.658Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/455', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/455/retry', - playable: false, - created_at: '2018-05-18T15:32:55.119Z', - updated_at: '2018-05-18T15:32:55.119Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/455', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/455/retry', - method: 'post', - }, - }, - }, - ], - }, - { - name: 'stop staging', - size: 1, - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - tooltip: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/456', - illustration: { - image: 'illustrations/job_not_triggered.svg', - size: 'svg-306', - title: 'This job has not been triggered yet', - content: - 'This job depends on upstream jobs that need to succeed in order for this job to be triggered', - }, - favicon: - '/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png', - action: { - icon: 'cancel', - title: 'Cancel', - path: '/gitlab-org/gitlab-shell/-/jobs/456/cancel', - method: 'post', - }, - }, - jobs: [ - { - id: 456, - name: 'stop staging', - started: false, - build_path: '/gitlab-org/gitlab-shell/-/jobs/456', - cancel_path: '/gitlab-org/gitlab-shell/-/jobs/456/cancel', - playable: false, - created_at: '2018-05-18T15:32:55.205Z', - updated_at: '2018-09-28T11:09:57.396Z', - status: { - icon: 'status_created', - text: 'created', - label: 'created', - group: 'created', - tooltip: 'created', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/456', - illustration: { - image: 'illustrations/job_not_triggered.svg', - size: 'svg-306', - title: 'This job has not been triggered yet', - content: - 'This job depends on upstream jobs that need to succeed in order for this job to be triggered', - }, - favicon: - '/assets/ci_favicons/favicon_status_created-4b975aa976d24e5a3ea7cd9a5713e6ce2cd9afd08b910415e96675de35f64955.png', - action: { - icon: 'cancel', - title: 'Cancel', - path: '/gitlab-org/gitlab-shell/-/jobs/456/cancel', - method: 'post', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - tooltip: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/pipelines/27#deploy', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_running-9c635b2419a8e1ec991c993061b89cc5aefc0743bb238ecd0c381e7741a70e8c.png', - }, - path: '/gitlab-org/gitlab-shell/pipelines/27#deploy', - dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=deploy', - }, - { - name: 'notify', - title: 'notify: manual action', - groups: [ - { - name: 'slack', - size: 1, - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/458', - illustration: { - image: 'illustrations/manual_action.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-org/gitlab-shell/-/jobs/458/play', - method: 'post', - }, - }, - jobs: [ - { - id: 458, - name: 'slack', - started: null, - build_path: '/gitlab-org/gitlab-shell/-/jobs/458', - play_path: '/gitlab-org/gitlab-shell/-/jobs/458/play', - playable: true, - created_at: '2018-05-18T15:32:55.303Z', - updated_at: '2018-05-18T15:34:08.535Z', - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual play action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/458', - illustration: { - image: 'illustrations/manual_action.svg', - size: 'svg-394', - title: 'This job requires a manual action', - content: - 'This job depends on a user to trigger its process. Often they are used to deploy code to production environments', - }, - favicon: - '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - action: { - icon: 'play', - title: 'Play', - path: '/gitlab-org/gitlab-shell/-/jobs/458/play', - method: 'post', - }, - }, - }, - ], - }, - ], - status: { - icon: 'status_manual', - text: 'manual', - label: 'manual action', - group: 'manual', - tooltip: 'manual action', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/pipelines/27#notify', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png', - }, - path: '/gitlab-org/gitlab-shell/pipelines/27#notify', - dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=notify', - }, -]; - -export const statuses = { - success: 'SUCCESS', - failed: 'FAILED', - canceled: 'CANCELED', - pending: 'PENDING', - running: 'RUNNING', -}; - -export default { - id: 4757, - artifact: { - locked: false, - }, - name: 'test', - stage: 'build', - build_path: '/root/ci-mock/-/jobs/4757', - retry_path: '/root/ci-mock/-/jobs/4757/retry', - cancel_path: '/root/ci-mock/-/jobs/4757/cancel', - new_issue_path: '/root/ci-mock/issues/new', - playable: false, - complete: true, - created_at: threeWeeksAgo.toISOString(), - updated_at: threeWeeksAgo.toISOString(), - finished_at: threeWeeksAgo.toISOString(), - queued_duration: 9.54, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: `${TEST_HOST}/root/ci-mock/-/jobs/4757`, - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/-/jobs/4757/retry', - method: 'post', - }, - }, - coverage: 20, - erased_at: threeWeeksAgo.toISOString(), - erased: false, - duration: 6.785563, - tags: ['tag'], - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - erase_path: '/root/ci-mock/-/jobs/4757/erase', - artifacts: [null], - runner: { - id: 1, - short_sha: 'ABCDEFGH', - description: 'local ci runner', - edit_path: '/root/ci-mock/runners/1/edit', - }, - pipeline: { - id: 140, - user: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - active: false, - coverage: null, - source: 'unknown', - created_at: '2017-05-24T09:59:58.634Z', - updated_at: '2017-06-01T17:32:00.062Z', - path: '/root/ci-mock/pipelines/140', - flags: { - latest: true, - stuck: false, - yaml_errors: false, - retryable: false, - cancelable: false, - }, - details: { - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - has_details: true, - details_path: '/root/ci-mock/pipelines/140', - favicon: - '/assets/ci_favicons/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.png', - }, - duration: 6, - finished_at: '2017-06-01T17:32:00.042Z', - stages: [ - { - dropdown_path: '/jashkenas/underscore/pipelines/16/stage.json?stage=build', - name: 'build', - path: '/jashkenas/underscore/pipelines/16#build', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - }, - title: 'build: passed', - }, - { - dropdown_path: '/jashkenas/underscore/pipelines/16/stage.json?stage=test', - name: 'test', - path: '/jashkenas/underscore/pipelines/16#test', - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - }, - title: 'test: passed with warnings', - }, - ], - }, - ref: { - name: 'abc', - path: '/root/ci-mock/commits/abc', - tag: false, - branch: true, - }, - commit: { - id: 'c58647773a6b5faf066d4ad6ff2c9fbba5f180f6', - short_id: 'c5864777', - title: 'Add new file', - created_at: '2017-05-24T10:59:52.000+01:00', - parent_ids: ['798e5f902592192afaba73f4668ae30e56eae492'], - message: 'Add new file', - author_name: 'Root', - author_email: 'admin@example.com', - authored_date: '2017-05-24T10:59:52.000+01:00', - committer_name: 'Root', - committer_email: 'admin@example.com', - committed_date: '2017-05-24T10:59:52.000+01:00', - author: { - name: 'Root', - username: 'root', - id: 1, - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://localhost:3000/root', - }, - author_gravatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - commit_url: - 'http://localhost:3000/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6', - commit_path: '/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6', - }, - }, - metadata: { - timeout_human_readable: '1m 40s', - timeout_source: 'runner', - }, - merge_request: { - iid: 2, - path: '/root/ci-mock/merge_requests/2', - }, - raw_path: '/root/ci-mock/builds/4757/raw', - has_trace: true, -}; - -export const failedJobStatus = { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (unknown failure) (allowed to fail)', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/454', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/454/retry', - method: 'post', - }, -}; - -export const jobsInStage = { - name: 'build', - title: 'build: running', - latest_statuses: [ - { - id: 1180, - name: 'build:linux', - started: false, - build_path: '/gitlab-org/gitlab-shell/-/jobs/1180', - cancel_path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', - playable: false, - created_at: '2018-09-28T11:09:57.229Z', - updated_at: '2018-09-28T11:09:57.503Z', - status: { - icon: 'status_pending', - text: 'pending', - label: 'pending', - group: 'pending', - tooltip: 'pending', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/1180', - illustration: { - image: 'illustrations/pending_job_empty.svg', - size: 'svg-430', - title: 'This job has not started yet', - content: 'This job is in pending state and is waiting to be picked by a runner', - }, - favicon: - '/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png', - action: { - icon: 'cancel', - title: 'Cancel', - path: '/gitlab-org/gitlab-shell/-/jobs/1180/cancel', - method: 'post', - }, - }, - }, - { - id: 444, - name: 'build:osx', - started: '2018-05-18T05:32:20.655Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/444', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', - playable: false, - created_at: '2018-05-18T15:32:54.364Z', - updated_at: '2018-05-18T15:32:54.364Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/444', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/444/retry', - method: 'post', - }, - }, - }, - ], - retried: [ - { - id: 443, - name: 'build:linux', - started: '2018-05-18T06:32:20.655Z', - build_path: '/gitlab-org/gitlab-shell/-/jobs/443', - retry_path: '/gitlab-org/gitlab-shell/-/jobs/443/retry', - playable: false, - created_at: '2018-05-18T15:32:54.296Z', - updated_at: '2018-05-18T15:32:54.296Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed (retried)', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/-/jobs/443', - illustration: { - image: 'illustrations/skipped-job_empty.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/gitlab-org/gitlab-shell/-/jobs/443/retry', - method: 'post', - }, - }, - }, - ], - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - tooltip: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-shell/pipelines/27#build', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_running-9c635b2419a8e1ec991c993061b89cc5aefc0743bb238ecd0c381e7741a70e8c.png', - }, - path: '/gitlab-org/gitlab-shell/pipelines/27#build', - dropdown_path: '/gitlab-org/gitlab-shell/pipelines/27/stage.json?stage=build', -}; - -export const mockPipelineWithoutMR = { - id: 28029444, - details: { - status: { - details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', - group: 'success', - has_details: true, - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - }, - }, - path: 'pipeline/28029444', - ref: { - name: 'test-branch', - }, -}; - -export const mockPipelineWithoutRef = { - ...mockPipelineWithoutMR, - ref: null, -}; - -export const mockPipelineWithAttachedMR = { - id: 28029444, - details: { - status: { - details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', - group: 'success', - has_details: true, - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - }, - }, - path: 'pipeline/28029444', - flags: { - merge_request_pipeline: true, - detached_merge_request_pipeline: false, - }, - merge_request: { - iid: 1234, - path: '/root/detached-merge-request-pipelines/-/merge_requests/1', - title: 'Update README.md', - source_branch: 'feature-1234', - source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', - target_branch: 'main', - target_branch_path: '/root/detached-merge-request-pipelines/branches/main', - }, - ref: { - name: 'test-branch', - }, -}; - -export const mockPipelineDetached = { - id: 28029444, - details: { - status: { - details_path: '/gitlab-org/gitlab-foss/pipelines/28029444', - group: 'success', - has_details: true, - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed', - }, - }, - path: 'pipeline/28029444', - flags: { - merge_request_pipeline: false, - detached_merge_request_pipeline: true, - }, - merge_request: { - iid: 1234, - path: '/root/detached-merge-request-pipelines/-/merge_requests/1', - title: 'Update README.md', - source_branch: 'feature-1234', - source_branch_path: '/root/detached-merge-request-pipelines/branches/feature-1234', - target_branch: 'main', - target_branch_path: '/root/detached-merge-request-pipelines/branches/main', - }, - ref: { - name: 'test-branch', - }, -}; - -export const CIJobConnectionIncomingCache = { - __typename: 'CiJobConnection', - pageInfo: { - __typename: 'PageInfo', - endCursor: 'eyJpZCI6IjIwNTEifQ', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'eyJpZCI6IjIxNzMifQ', - }, - nodes: [ - { __ref: 'CiJob:gid://gitlab/Ci::Build/2057' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2056' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2051' }, - ], -}; - -export const CIJobConnectionIncomingCacheRunningStatus = { - __typename: 'CiJobConnection', - pageInfo: { - __typename: 'PageInfo', - endCursor: 'eyJpZCI6IjIwNTEifQ', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'eyJpZCI6IjIxNzMifQ', - }, - nodes: [ - { __ref: 'CiJob:gid://gitlab/Ci::Build/2000' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2001' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2002' }, - ], -}; - -export const CIJobConnectionExistingCache = { - pageInfo: { - __typename: 'PageInfo', - endCursor: 'eyJpZCI6IjIwNTEifQ', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'eyJpZCI6IjIxNzMifQ', - }, - nodes: [ - { __ref: 'CiJob:gid://gitlab/Ci::Build/2100' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2101' }, - { __ref: 'CiJob:gid://gitlab/Ci::Build/2102' }, - ], - statuses: 'PENDING', -}; - -export const mockFailedSearchToken = { - type: TOKEN_TYPE_STATUS, - value: { data: 'FAILED', operator: '=' }, -}; - -export const retryMutationResponse = { - data: { - jobRetry: { - job: { - __typename: 'CiJob', - id: '"gid://gitlab/Ci::Build/1985"', - detailedStatus: { - detailsPath: '/root/project/-/jobs/1985', - id: 'pending-1985-1985', - __typename: 'DetailedStatus', - }, - }, - errors: [], - __typename: 'JobRetryPayload', - }, - }, -}; - -export const playMutationResponse = { - data: { - jobPlay: { - job: { - __typename: 'CiJob', - id: '"gid://gitlab/Ci::Build/1986"', - detailedStatus: { - detailsPath: '/root/project/-/jobs/1986', - id: 'pending-1986-1986', - __typename: 'DetailedStatus', - }, - }, - errors: [], - __typename: 'JobRetryPayload', - }, - }, -}; - -export const cancelMutationResponse = { - data: { - jobCancel: { - job: { - __typename: 'CiJob', - id: '"gid://gitlab/Ci::Build/1987"', - detailedStatus: { - detailsPath: '/root/project/-/jobs/1987', - id: 'pending-1987-1987', - __typename: 'DetailedStatus', - }, - }, - errors: [], - __typename: 'JobRetryPayload', - }, - }, -}; - -export const unscheduleMutationResponse = { - data: { - jobUnschedule: { - job: { - __typename: 'CiJob', - id: '"gid://gitlab/Ci::Build/1988"', - detailedStatus: { - detailsPath: '/root/project/-/jobs/1988', - id: 'pending-1988-1988', - __typename: 'DetailedStatus', - }, - }, - errors: [], - __typename: 'JobRetryPayload', - }, - }, -}; - -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/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js deleted file mode 100644 index 73a158d52d8..00000000000 --- a/spec/frontend/jobs/store/actions_spec.js +++ /dev/null @@ -1,502 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import { TEST_HOST } from 'helpers/test_constants'; -import testAction from 'helpers/vuex_action_helper'; -import { - setJobEndpoint, - setJobLogOptions, - clearEtagPoll, - stopPolling, - requestJob, - fetchJob, - receiveJobSuccess, - receiveJobError, - scrollTop, - scrollBottom, - requestJobLog, - fetchJobLog, - startPollingJobLog, - stopPollingJobLog, - receiveJobLogSuccess, - receiveJobLogError, - toggleCollapsibleLine, - requestJobsForStage, - fetchJobsForStage, - receiveJobsForStageSuccess, - receiveJobsForStageError, - hideSidebar, - showSidebar, - toggleSidebar, -} from '~/jobs/store/actions'; -import * as types from '~/jobs/store/mutation_types'; -import state from '~/jobs/store/state'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; - -describe('Job State actions', () => { - let mockedState; - - beforeEach(() => { - mockedState = state(); - }); - - describe('setJobEndpoint', () => { - it('should commit SET_JOB_ENDPOINT mutation', () => { - return testAction( - setJobEndpoint, - 'job/872324.json', - mockedState, - [{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }], - [], - ); - }); - }); - - describe('setJobLogOptions', () => { - it('should commit SET_JOB_LOG_OPTIONS mutation', () => { - return testAction( - setJobLogOptions, - { pagePath: 'job/872324/trace.json' }, - mockedState, - [{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], - [], - ); - }); - }); - - describe('hideSidebar', () => { - it('should commit HIDE_SIDEBAR mutation', () => { - return testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], []); - }); - }); - - describe('showSidebar', () => { - it('should commit SHOW_SIDEBAR mutation', () => { - return testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], []); - }); - }); - - describe('toggleSidebar', () => { - describe('when isSidebarOpen is true', () => { - it('should dispatch hideSidebar', () => { - return testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }]); - }); - }); - - describe('when isSidebarOpen is false', () => { - it('should dispatch showSidebar', () => { - mockedState.isSidebarOpen = false; - - return testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }]); - }); - }); - }); - - describe('requestJob', () => { - it('should commit REQUEST_JOB mutation', () => { - return testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], []); - }); - }); - - describe('fetchJob', () => { - let mock; - - beforeEach(() => { - mockedState.jobEndpoint = `${TEST_HOST}/endpoint.json`; - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - stopPolling(); - clearEtagPoll(); - }); - - describe('success', () => { - it('dispatches requestJob and receiveJobSuccess', () => { - mock - .onGet(`${TEST_HOST}/endpoint.json`) - .replyOnce(HTTP_STATUS_OK, { id: 121212, name: 'karma' }); - - return testAction( - fetchJob, - null, - mockedState, - [], - [ - { - type: 'requestJob', - }, - { - payload: { id: 121212, name: 'karma' }, - type: 'receiveJobSuccess', - }, - ], - ); - }); - }); - - describe('error', () => { - beforeEach(() => { - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - }); - - it('dispatches requestJob and receiveJobError', () => { - return testAction( - fetchJob, - null, - mockedState, - [], - [ - { - type: 'requestJob', - }, - { - type: 'receiveJobError', - }, - ], - ); - }); - }); - }); - - describe('receiveJobSuccess', () => { - it('should commit RECEIVE_JOB_SUCCESS mutation', () => { - return testAction( - receiveJobSuccess, - { id: 121232132 }, - mockedState, - [{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }], - [], - ); - }); - }); - - describe('receiveJobError', () => { - it('should commit RECEIVE_JOB_ERROR mutation', () => { - return testAction( - receiveJobError, - null, - mockedState, - [{ type: types.RECEIVE_JOB_ERROR }], - [], - ); - }); - }); - - describe('scrollTop', () => { - it('should dispatch toggleScrollButtons action', () => { - return testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }]); - }); - }); - - describe('scrollBottom', () => { - it('should dispatch toggleScrollButtons action', () => { - return testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }]); - }); - }); - - describe('requestJobLog', () => { - it('should commit REQUEST_JOB_LOG mutation', () => { - return testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], []); - }); - }); - - describe('fetchJobLog', () => { - let mock; - - beforeEach(() => { - mockedState.jobLogEndpoint = `${TEST_HOST}/endpoint`; - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - stopPolling(); - clearEtagPoll(); - }); - - describe('success', () => { - it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', () => { - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(HTTP_STATUS_OK, { - html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', - complete: true, - }); - - return testAction( - fetchJobLog, - null, - mockedState, - [], - [ - { - type: 'toggleScrollisInBottom', - payload: true, - }, - { - payload: { - html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', - complete: true, - }, - type: 'receiveJobLogSuccess', - }, - { - type: 'stopPollingJobLog', - }, - ], - ); - }); - - describe('when job is incomplete', () => { - let jobLogPayload; - - beforeEach(() => { - jobLogPayload = { - html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', - complete: false, - }; - - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(HTTP_STATUS_OK, jobLogPayload); - }); - - it('dispatches startPollingJobLog', () => { - return testAction( - fetchJobLog, - null, - mockedState, - [], - [ - { type: 'toggleScrollisInBottom', payload: true }, - { type: 'receiveJobLogSuccess', payload: jobLogPayload }, - { type: 'startPollingJobLog' }, - ], - ); - }); - - it('does not dispatch startPollingJobLog when timeout is non-empty', () => { - mockedState.jobLogTimeout = 1; - - return testAction( - fetchJobLog, - null, - mockedState, - [], - [ - { type: 'toggleScrollisInBottom', payload: true }, - { type: 'receiveJobLogSuccess', payload: jobLogPayload }, - ], - ); - }); - }); - }); - - describe('error', () => { - beforeEach(() => { - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - }); - - it('dispatches requestJobLog and receiveJobLogError', () => { - return testAction( - fetchJobLog, - null, - mockedState, - [], - [ - { - type: 'receiveJobLogError', - }, - ], - ); - }); - }); - }); - - describe('startPollingJobLog', () => { - let dispatch; - let commit; - - beforeEach(() => { - dispatch = jest.fn(); - commit = jest.fn(); - - startPollingJobLog({ dispatch, commit }); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - it('should save the timeout id but not call fetchJobLog', () => { - expect(commit).toHaveBeenCalledWith(types.SET_JOB_LOG_TIMEOUT, expect.any(Number)); - expect(commit.mock.calls[0][1]).toBeGreaterThan(0); - - expect(dispatch).not.toHaveBeenCalledWith('fetchJobLog'); - }); - - describe('after timeout has passed', () => { - beforeEach(() => { - jest.advanceTimersByTime(4000); - }); - - it('should clear the timeout id and fetchJobLog', () => { - expect(commit).toHaveBeenCalledWith(types.SET_JOB_LOG_TIMEOUT, 0); - expect(dispatch).toHaveBeenCalledWith('fetchJobLog'); - }); - }); - }); - - describe('stopPollingJobLog', () => { - let origTimeout; - - beforeEach(() => { - // Can't use spyOn(window, 'clearTimeout') because this caused unrelated specs to timeout - // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23838#note_280277727 - origTimeout = window.clearTimeout; - window.clearTimeout = jest.fn(); - }); - - afterEach(() => { - window.clearTimeout = origTimeout; - }); - - it('should commit STOP_POLLING_JOB_LOG mutation', async () => { - const jobLogTimeout = 7; - - await testAction( - stopPollingJobLog, - null, - { ...mockedState, jobLogTimeout }, - [{ type: types.SET_JOB_LOG_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_JOB_LOG }], - [], - ); - expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout); - }); - }); - - describe('receiveJobLogSuccess', () => { - it('should commit RECEIVE_JOB_LOG_SUCCESS mutation', () => { - return testAction( - receiveJobLogSuccess, - 'hello world', - mockedState, - [{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }], - [], - ); - }); - }); - - describe('receiveJobLogError', () => { - it('should commit stop polling job log', () => { - return testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }]); - }); - }); - - describe('toggleCollapsibleLine', () => { - it('should commit TOGGLE_COLLAPSIBLE_LINE mutation', () => { - return testAction( - toggleCollapsibleLine, - { isClosed: true }, - mockedState, - [{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }], - [], - ); - }); - }); - - describe('requestJobsForStage', () => { - it('should commit REQUEST_JOBS_FOR_STAGE mutation', () => { - return testAction( - requestJobsForStage, - { name: 'deploy' }, - mockedState, - [{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }], - [], - ); - }); - }); - - describe('fetchJobsForStage', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('success', () => { - it('dispatches requestJobsForStage and receiveJobsForStageSuccess', () => { - mock.onGet(`${TEST_HOST}/jobs.json`).replyOnce(HTTP_STATUS_OK, { - latest_statuses: [{ id: 121212, name: 'build' }], - retried: [], - }); - - return testAction( - fetchJobsForStage, - { dropdown_path: `${TEST_HOST}/jobs.json` }, - mockedState, - [], - [ - { - type: 'requestJobsForStage', - payload: { dropdown_path: `${TEST_HOST}/jobs.json` }, - }, - { - payload: [{ id: 121212, name: 'build' }], - type: 'receiveJobsForStageSuccess', - }, - ], - ); - }); - }); - - describe('error', () => { - beforeEach(() => { - mock.onGet(`${TEST_HOST}/jobs.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - }); - - it('dispatches requestJobsForStage and receiveJobsForStageError', () => { - return testAction( - fetchJobsForStage, - { dropdown_path: `${TEST_HOST}/jobs.json` }, - mockedState, - [], - [ - { - type: 'requestJobsForStage', - payload: { dropdown_path: `${TEST_HOST}/jobs.json` }, - }, - { - type: 'receiveJobsForStageError', - }, - ], - ); - }); - }); - }); - - describe('receiveJobsForStageSuccess', () => { - it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation', () => { - return testAction( - receiveJobsForStageSuccess, - [{ id: 121212, name: 'karma' }], - mockedState, - [{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }], - [], - ); - }); - }); - - describe('receiveJobsForStageError', () => { - it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation', () => { - return testAction( - receiveJobsForStageError, - null, - mockedState, - [{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }], - [], - ); - }); - }); -}); diff --git a/spec/frontend/jobs/store/getters_spec.js b/spec/frontend/jobs/store/getters_spec.js deleted file mode 100644 index c13b051c672..00000000000 --- a/spec/frontend/jobs/store/getters_spec.js +++ /dev/null @@ -1,245 +0,0 @@ -import * as getters from '~/jobs/store/getters'; -import state from '~/jobs/store/state'; - -describe('Job Store Getters', () => { - let localState; - - beforeEach(() => { - localState = state(); - }); - - describe('headerTime', () => { - describe('when the job has started key', () => { - it('returns started_at value', () => { - const started = '2018-08-31T16:20:49.023Z'; - const startedAt = '2018-08-31T16:20:49.023Z'; - localState.job.started_at = startedAt; - localState.job.started = started; - - expect(getters.headerTime(localState)).toEqual(startedAt); - }); - }); - - describe('when the job does not have started key', () => { - it('returns created_at value', () => { - const created = '2018-08-31T16:20:49.023Z'; - localState.job.created_at = created; - - expect(getters.headerTime(localState)).toEqual(created); - }); - }); - }); - - describe('shouldRenderCalloutMessage', () => { - describe('with status and callout message', () => { - it('returns true', () => { - localState.job.callout_message = 'Callout message'; - localState.job.status = { icon: 'passed' }; - - expect(getters.shouldRenderCalloutMessage(localState)).toEqual(true); - }); - }); - - describe('without status & with callout message', () => { - it('returns false', () => { - localState.job.callout_message = 'Callout message'; - - expect(getters.shouldRenderCalloutMessage(localState)).toEqual(false); - }); - }); - - describe('with status & without callout message', () => { - it('returns false', () => { - localState.job.status = { icon: 'passed' }; - - expect(getters.shouldRenderCalloutMessage(localState)).toEqual(false); - }); - }); - }); - - describe('shouldRenderTriggeredLabel', () => { - describe('when started equals null', () => { - it('returns false', () => { - localState.job.started_at = null; - - expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(false); - }); - }); - - describe('when started equals string', () => { - it('returns true', () => { - localState.job.started_at = '2018-08-31T16:20:49.023Z'; - - expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(true); - }); - }); - }); - - describe('hasEnvironment', () => { - describe('without `deployment_status`', () => { - it('returns false', () => { - expect(getters.hasEnvironment(localState)).toEqual(false); - }); - }); - - describe('with an empty object for `deployment_status`', () => { - it('returns false', () => { - localState.job.deployment_status = {}; - - expect(getters.hasEnvironment(localState)).toEqual(false); - }); - }); - - describe('when `deployment_status` is defined and not empty', () => { - it('returns true', () => { - localState.job.deployment_status = { - status: 'creating', - environment: { - last_deployment: {}, - }, - }; - - expect(getters.hasEnvironment(localState)).toEqual(true); - }); - }); - }); - - describe('hasJobLog', () => { - describe('when has_trace is true', () => { - it('returns true', () => { - localState.job.has_trace = true; - localState.job.status = {}; - - expect(getters.hasJobLog(localState)).toEqual(true); - }); - }); - - describe('when job is running', () => { - it('returns true', () => { - localState.job.has_trace = false; - localState.job.status = { group: 'running' }; - - expect(getters.hasJobLog(localState)).toEqual(true); - }); - }); - - describe('when has_trace is false and job is not running', () => { - it('returns false', () => { - localState.job.has_trace = false; - localState.job.status = { group: 'pending' }; - - expect(getters.hasJobLog(localState)).toEqual(false); - }); - }); - }); - - describe('emptyStateIllustration', () => { - describe('with defined illustration', () => { - it('returns the state illustration object', () => { - localState.job.status = { - illustration: { - path: 'foo', - }, - }; - - expect(getters.emptyStateIllustration(localState)).toEqual({ path: 'foo' }); - }); - }); - - describe('when illustration is not defined', () => { - it('returns an empty object', () => { - expect(getters.emptyStateIllustration(localState)).toEqual({}); - }); - }); - }); - - describe('shouldRenderSharedRunnerLimitWarning', () => { - describe('without runners information', () => { - it('returns false', () => { - expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(false); - }); - }); - - describe('with runners information', () => { - describe('when used quota is less than limit', () => { - it('returns false', () => { - localState.job.runners = { - quota: { - used: 33, - limit: 2000, - }, - available: true, - online: true, - }; - - expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(false); - }); - }); - - describe('when used quota is equal to limit', () => { - it('returns true', () => { - localState.job.runners = { - quota: { - used: 2000, - limit: 2000, - }, - available: true, - online: true, - }; - - expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(true); - }); - }); - - describe('when used quota is bigger than limit', () => { - it('returns true', () => { - localState.job.runners = { - quota: { - used: 2002, - limit: 2000, - }, - available: true, - online: true, - }; - - expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(true); - }); - }); - }); - }); - - describe('hasOfflineRunnersForProject', () => { - describe('with available and offline runners', () => { - it('returns true', () => { - localState.job.runners = { - available: true, - online: false, - }; - - expect(getters.hasOfflineRunnersForProject(localState)).toEqual(true); - }); - }); - - describe('with non available runners', () => { - it('returns false', () => { - localState.job.runners = { - available: false, - online: false, - }; - - expect(getters.hasOfflineRunnersForProject(localState)).toEqual(false); - }); - }); - - describe('with online runners', () => { - it('returns false', () => { - localState.job.runners = { - available: false, - online: true, - }; - - expect(getters.hasOfflineRunnersForProject(localState)).toEqual(false); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/store/helpers.js b/spec/frontend/jobs/store/helpers.js deleted file mode 100644 index 402ae58971a..00000000000 --- a/spec/frontend/jobs/store/helpers.js +++ /dev/null @@ -1,5 +0,0 @@ -import state from '~/jobs/store/state'; - -export const resetStore = (store) => { - store.replaceState(state()); -}; diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js deleted file mode 100644 index 89cda3b0544..00000000000 --- a/spec/frontend/jobs/store/mutations_spec.js +++ /dev/null @@ -1,269 +0,0 @@ -import * as types from '~/jobs/store/mutation_types'; -import mutations from '~/jobs/store/mutations'; -import state from '~/jobs/store/state'; - -describe('Jobs Store Mutations', () => { - let stateCopy; - - const html = - 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png
    I'; - - beforeEach(() => { - stateCopy = state(); - }); - - describe('SET_JOB_ENDPOINT', () => { - it('should set jobEndpoint', () => { - mutations[types.SET_JOB_ENDPOINT](stateCopy, 'job/21312321.json'); - - expect(stateCopy.jobEndpoint).toEqual('job/21312321.json'); - }); - }); - - describe('HIDE_SIDEBAR', () => { - it('should set isSidebarOpen to false', () => { - mutations[types.HIDE_SIDEBAR](stateCopy); - - expect(stateCopy.isSidebarOpen).toEqual(false); - }); - }); - - describe('SHOW_SIDEBAR', () => { - it('should set isSidebarOpen to true', () => { - mutations[types.SHOW_SIDEBAR](stateCopy); - - expect(stateCopy.isSidebarOpen).toEqual(true); - }); - }); - - describe('RECEIVE_JOB_LOG_SUCCESS', () => { - describe('when job log has state', () => { - it('sets jobLogState', () => { - const stateLog = - 'eyJvZmZzZXQiOjczNDQ1MSwibl9vcGVuX3RhZ3MiOjAsImZnX2NvbG9yIjpudWxsLCJiZ19jb2xvciI6bnVsbCwic3R5bGVfbWFzayI6MH0='; - mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - state: stateLog, - }); - - expect(stateCopy.jobLogState).toEqual(stateLog); - }); - }); - - describe('when jobLogSize is smaller than the total size', () => { - it('sets isJobLogSizeVisible to true', () => { - mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { total: 51184600, size: 1231 }); - - expect(stateCopy.isJobLogSizeVisible).toEqual(true); - }); - }); - - describe('when jobLogSize is bigger than the total size', () => { - it('sets isJobLogSizeVisible to false', () => { - const copy = { ...stateCopy, jobLogSize: 5118460, size: 2321312 }; - - mutations[types.RECEIVE_JOB_LOG_SUCCESS](copy, { total: 511846 }); - - expect(copy.isJobLogSizeVisible).toEqual(false); - }); - }); - - it('sets job log size and isJobLogComplete', () => { - mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - append: true, - html, - size: 511846, - complete: true, - lines: [], - }); - - expect(stateCopy.jobLogSize).toEqual(511846); - expect(stateCopy.isJobLogComplete).toEqual(true); - }); - - 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: 0, - }, - ]); - }); - }); - - describe('when it is 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: 0, - }, - ]); - }); - }); - - describe('when it is 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([]); - }); - }); - }); - }); - }); - - describe('SET_JOB_LOG_TIMEOUT', () => { - it('sets the jobLogTimeout id', () => { - const id = 7; - - expect(stateCopy.jobLogTimeout).not.toEqual(id); - - mutations[types.SET_JOB_LOG_TIMEOUT](stateCopy, id); - - expect(stateCopy.jobLogTimeout).toEqual(id); - }); - }); - - describe('STOP_POLLING_JOB_LOG', () => { - it('sets isJobLogComplete to true', () => { - mutations[types.STOP_POLLING_JOB_LOG](stateCopy); - - expect(stateCopy.isJobLogComplete).toEqual(true); - }); - }); - - describe('TOGGLE_COLLAPSIBLE_LINE', () => { - it('toggles the `isClosed` property of the provided object', () => { - const section = { isClosed: true }; - mutations[types.TOGGLE_COLLAPSIBLE_LINE](stateCopy, section); - expect(section.isClosed).toEqual(false); - }); - }); - - describe('REQUEST_JOB', () => { - it('sets isLoading to true', () => { - mutations[types.REQUEST_JOB](stateCopy); - - expect(stateCopy.isLoading).toEqual(true); - }); - }); - - describe('RECEIVE_JOB_SUCCESS', () => { - it('sets is loading to false', () => { - mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 }); - - expect(stateCopy.isLoading).toEqual(false); - }); - - it('sets hasError to false', () => { - mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 }); - - expect(stateCopy.hasError).toEqual(false); - }); - - it('sets job data', () => { - mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 }); - - expect(stateCopy.job).toEqual({ id: 1312321 }); - }); - - it('sets selectedStage when the selectedStage is empty', () => { - expect(stateCopy.selectedStage).toEqual(''); - mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321, stage: 'deploy' }); - - expect(stateCopy.selectedStage).toEqual('deploy'); - }); - - it('does not set selectedStage when the selectedStage is not More', () => { - stateCopy.selectedStage = 'notify'; - - expect(stateCopy.selectedStage).toEqual('notify'); - mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321, stage: 'deploy' }); - - expect(stateCopy.selectedStage).toEqual('notify'); - }); - }); - - describe('RECEIVE_JOB_ERROR', () => { - it('resets job data', () => { - mutations[types.RECEIVE_JOB_ERROR](stateCopy); - - expect(stateCopy.isLoading).toEqual(false); - expect(stateCopy.job).toEqual({}); - }); - }); - - describe('REQUEST_JOBS_FOR_STAGE', () => { - it('sets isLoadingJobs to true', () => { - mutations[types.REQUEST_JOBS_FOR_STAGE](stateCopy, { name: 'deploy' }); - - expect(stateCopy.isLoadingJobs).toEqual(true); - }); - - it('sets selectedStage', () => { - mutations[types.REQUEST_JOBS_FOR_STAGE](stateCopy, { name: 'deploy' }); - - expect(stateCopy.selectedStage).toEqual('deploy'); - }); - }); - - describe('RECEIVE_JOBS_FOR_STAGE_SUCCESS', () => { - beforeEach(() => { - mutations[types.RECEIVE_JOBS_FOR_STAGE_SUCCESS](stateCopy, [{ name: 'karma' }]); - }); - - it('sets isLoadingJobs to false', () => { - expect(stateCopy.isLoadingJobs).toEqual(false); - }); - - it('sets jobs', () => { - expect(stateCopy.jobs).toEqual([{ name: 'karma' }]); - }); - }); - - describe('RECEIVE_JOBS_FOR_STAGE_ERROR', () => { - beforeEach(() => { - mutations[types.RECEIVE_JOBS_FOR_STAGE_ERROR](stateCopy); - }); - - it('sets isLoadingJobs to false', () => { - expect(stateCopy.isLoadingJobs).toEqual(false); - }); - - it('resets jobs', () => { - expect(stateCopy.jobs).toEqual([]); - }); - }); -}); diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js deleted file mode 100644 index 37a6722c555..00000000000 --- a/spec/frontend/jobs/store/utils_spec.js +++ /dev/null @@ -1,510 +0,0 @@ -import { - logLinesParser, - updateIncrementalJobLog, - parseHeaderLine, - parseLine, - addDurationToHeader, - isCollapsibleSection, - findOffsetAndRemove, - getIncrementalLineNumber, -} from '~/jobs/store/utils'; -import { - utilsMockData, - originalTrace, - regularIncremental, - regularIncrementalRepeated, - headerTrace, - headerTraceIncremental, - collapsibleTrace, - collapsibleTraceIncremental, -} from '../components/log/mock_data'; - -describe('Jobs Store Utils', () => { - describe('parseHeaderLine', () => { - it('returns a new object with the header keys and the provided line parsed', () => { - const headerLine = { content: [{ text: 'foo' }] }; - const parsedHeaderLine = parseHeaderLine(headerLine, 2); - - expect(parsedHeaderLine).toEqual({ - isClosed: false, - isHeader: true, - line: { - ...headerLine, - lineNumber: 2, - }, - lines: [], - }); - }); - - it('pre-closes a section when specified in options', () => { - const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } }; - - const parsedHeaderLine = parseHeaderLine(headerLine, 2); - - expect(parsedHeaderLine.isClosed).toBe(true); - }); - - it('expands all pre-closed sections if hash is present', () => { - const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } }; - - const parsedHeaderLine = parseHeaderLine(headerLine, 2, '#L33'); - - expect(parsedHeaderLine.isClosed).toBe(false); - }); - }); - - describe('parseLine', () => { - it('returns a new object with the lineNumber key added to the provided line object', () => { - const line = { content: [{ text: 'foo' }] }; - const parsed = parseLine(line, 1); - expect(parsed.content).toEqual(line.content); - expect(parsed.lineNumber).toEqual(1); - }); - }); - - describe('addDurationToHeader', () => { - const duration = { - offset: 106, - content: [], - section: 'prepare-script', - section_duration: '00:03', - }; - - it('adds the section duration to the correct header', () => { - const parsed = [ - { - isClosed: false, - isHeader: true, - line: { - section: 'prepare-script', - content: [{ text: 'foo' }], - }, - lines: [], - }, - { - isClosed: false, - isHeader: true, - line: { - section: 'foo-bar', - content: [{ text: 'foo' }], - }, - lines: [], - }, - ]; - - addDurationToHeader(parsed, duration); - - expect(parsed[0].line.section_duration).toEqual(duration.section_duration); - expect(parsed[1].line.section_duration).toEqual(undefined); - }); - - it('does not add the section duration when the headers do not match', () => { - const parsed = [ - { - isClosed: false, - isHeader: true, - line: { - section: 'bar-foo', - content: [{ text: 'foo' }], - }, - lines: [], - }, - { - isClosed: false, - isHeader: true, - line: { - section: 'foo-bar', - content: [{ text: 'foo' }], - }, - lines: [], - }, - ]; - addDurationToHeader(parsed, duration); - - expect(parsed[0].line.section_duration).toEqual(undefined); - expect(parsed[1].line.section_duration).toEqual(undefined); - }); - - it('does not add when content has no headers', () => { - const parsed = [ - { - section: 'bar-foo', - content: [{ text: 'foo' }], - lineNumber: 1, - }, - { - section: 'foo-bar', - content: [{ text: 'foo' }], - lineNumber: 2, - }, - ]; - - addDurationToHeader(parsed, duration); - - expect(parsed[0].line).toEqual(undefined); - expect(parsed[1].line).toEqual(undefined); - }); - }); - - describe('isCollapsibleSection', () => { - const header = { - isHeader: true, - line: { - section: 'foo', - }, - }; - const line = { - lineNumber: 1, - section: 'foo', - content: [], - }; - - it('returns true when line belongs to the last section', () => { - expect(isCollapsibleSection([header], header, { section: 'foo', content: [] })).toEqual(true); - }); - - it('returns false when last line was not an header', () => { - expect(isCollapsibleSection([line], line, { section: 'bar' })).toEqual(false); - }); - - it('returns false when accumulator is empty', () => { - expect(isCollapsibleSection([], { isHeader: true }, { section: 'bar' })).toEqual(false); - }); - - it('returns false when section_duration is defined', () => { - expect(isCollapsibleSection([header], header, { section_duration: '10:00' })).toEqual(false); - }); - - it('returns false when `section` is not a match', () => { - expect(isCollapsibleSection([header], header, { section: 'bar' })).toEqual(false); - }); - - it('returns false when no parameters are provided', () => { - expect(isCollapsibleSection()).toEqual(false); - }); - }); - describe('logLinesParser', () => { - let result; - - beforeEach(() => { - result = logLinesParser(utilsMockData); - }); - - describe('regular line', () => { - it('adds a lineNumber property with correct index', () => { - expect(result[0].lineNumber).toEqual(0); - expect(result[1].line.lineNumber).toEqual(1); - }); - }); - - describe('collapsible section', () => { - it('adds a `isClosed` property', () => { - expect(result[1].isClosed).toEqual(false); - }); - - it('adds a `isHeader` property', () => { - expect(result[1].isHeader).toEqual(true); - }); - - it('creates a lines array property with the content of the collapsible section', () => { - expect(result[1].lines.length).toEqual(2); - expect(result[1].lines[0].content).toEqual(utilsMockData[2].content); - expect(result[1].lines[1].content).toEqual(utilsMockData[3].content); - }); - }); - - describe('section duration', () => { - it('adds the section information to the header section', () => { - expect(result[1].line.section_duration).toEqual(utilsMockData[4].section_duration); - }); - - it('does not add section duration as a line', () => { - expect(result[1].lines.includes(utilsMockData[4])).toEqual(false); - }); - }); - }); - - describe('findOffsetAndRemove', () => { - describe('when last item is header', () => { - const existingLog = [ - { - isHeader: true, - isClosed: false, - line: { content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }, - }, - ]; - - describe('and matches the offset', () => { - it('returns an array with the item removed', () => { - const newData = [{ offset: 10, content: [{ text: 'foobar' }] }]; - const result = findOffsetAndRemove(newData, existingLog); - - expect(result).toEqual([]); - }); - }); - - describe('and does not match the offset', () => { - it('returns the provided existing log', () => { - const newData = [{ offset: 110, content: [{ text: 'foobar' }] }]; - const result = findOffsetAndRemove(newData, existingLog); - - expect(result).toEqual(existingLog); - }); - }); - }); - - describe('when last item is a regular line', () => { - const existingLog = [{ content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }]; - - describe('and matches the offset', () => { - it('returns an array with the item removed', () => { - const newData = [{ offset: 10, content: [{ text: 'foobar' }] }]; - const result = findOffsetAndRemove(newData, existingLog); - - expect(result).toEqual([]); - }); - }); - - describe('and does not match the fofset', () => { - it('returns the provided old log', () => { - const newData = [{ offset: 101, content: [{ text: 'foobar' }] }]; - const result = findOffsetAndRemove(newData, existingLog); - - expect(result).toEqual(existingLog); - }); - }); - }); - - describe('when last item is nested', () => { - const existingLog = [ - { - isHeader: true, - isClosed: false, - lines: [{ offset: 101, content: [{ text: 'foobar' }], lineNumber: 2 }], - line: { - offset: 10, - lineNumber: 1, - section_duration: '10:00', - }, - }, - ]; - - describe('and matches the offset', () => { - it('returns an array with the last nested line item removed', () => { - const newData = [{ offset: 101, content: [{ text: 'foobar' }] }]; - - const result = findOffsetAndRemove(newData, existingLog); - expect(result[0].lines).toEqual([]); - }); - }); - - describe('and does not match the offset', () => { - it('returns the provided old log', () => { - const newData = [{ offset: 120, content: [{ text: 'foobar' }] }]; - - const result = findOffsetAndRemove(newData, existingLog); - expect(result).toEqual(existingLog); - }); - }); - }); - - describe('when no data is provided', () => { - it('returns an empty array', () => { - const result = findOffsetAndRemove(); - expect(result).toEqual([]); - }); - }); - }); - - describe('getIncrementalLineNumber', () => { - describe('when last line is 0', () => { - it('returns 1', () => { - const log = [ - { - content: [], - lineNumber: 0, - }, - ]; - - expect(getIncrementalLineNumber(log)).toEqual(1); - }); - }); - - describe('with unnested line', () => { - it('returns the lineNumber of the last item in the array', () => { - const log = [ - { - content: [], - lineNumber: 10, - }, - { - content: [], - lineNumber: 101, - }, - ]; - - expect(getIncrementalLineNumber(log)).toEqual(102); - }); - }); - - describe('when last line is the header section', () => { - it('returns the lineNumber of the last item in the array', () => { - const log = [ - { - content: [], - lineNumber: 10, - }, - { - isHeader: true, - line: { - lineNumber: 101, - content: [], - }, - lines: [], - }, - ]; - - expect(getIncrementalLineNumber(log)).toEqual(102); - }); - }); - - describe('when last line is a nested line', () => { - it('returns the lineNumber of the last item in the nested array', () => { - const log = [ - { - content: [], - lineNumber: 10, - }, - { - isHeader: true, - line: { - lineNumber: 101, - content: [], - }, - lines: [ - { - lineNumber: 102, - content: [], - }, - { lineNumber: 103, content: [] }, - ], - }, - ]; - - expect(getIncrementalLineNumber(log)).toEqual(104); - }); - }); - }); - - describe('updateIncrementalJobLog', () => { - describe('without repeated section', () => { - it('concats and parses both arrays', () => { - const oldLog = logLinesParser(originalTrace); - const result = updateIncrementalJobLog(regularIncremental, oldLog); - - expect(result).toEqual([ - { - offset: 1, - content: [ - { - text: 'Downloading', - }, - ], - lineNumber: 0, - }, - { - offset: 2, - content: [ - { - text: 'log line', - }, - ], - lineNumber: 1, - }, - ]); - }); - }); - - describe('with regular line repeated offset', () => { - it('updates the last line and formats with the incremental part', () => { - const oldLog = logLinesParser(originalTrace); - const result = updateIncrementalJobLog(regularIncrementalRepeated, oldLog); - - expect(result).toEqual([ - { - offset: 1, - content: [ - { - text: 'log line', - }, - ], - lineNumber: 0, - }, - ]); - }); - }); - - describe('with header line repeated', () => { - it('updates the header line and formats with the incremental part', () => { - const oldLog = logLinesParser(headerTrace); - const result = updateIncrementalJobLog(headerTraceIncremental, oldLog); - - expect(result).toEqual([ - { - isClosed: false, - isHeader: true, - line: { - offset: 1, - section_header: true, - content: [ - { - text: 'updated log line', - }, - ], - section: 'section', - lineNumber: 0, - }, - lines: [], - }, - ]); - }); - }); - - describe('with collapsible line repeated', () => { - it('updates the collapsible line and formats with the incremental part', () => { - const oldLog = logLinesParser(collapsibleTrace); - const result = updateIncrementalJobLog(collapsibleTraceIncremental, oldLog); - - expect(result).toEqual([ - { - isClosed: false, - isHeader: true, - line: { - offset: 1, - section_header: true, - content: [ - { - text: 'log line', - }, - ], - section: 'section', - lineNumber: 0, - }, - lines: [ - { - offset: 2, - content: [ - { - text: 'updated log line', - }, - ], - section: 'section', - lineNumber: 1, - }, - ], - }, - ]); - }); - }); - }); -}); diff --git a/spec/frontend/lib/utils/array_utility_spec.js b/spec/frontend/lib/utils/array_utility_spec.js index 64ddd400114..94461c72106 100644 --- a/spec/frontend/lib/utils/array_utility_spec.js +++ b/spec/frontend/lib/utils/array_utility_spec.js @@ -42,4 +42,40 @@ describe('array_utility', () => { expect(arrayUtils.getDuplicateItemsFromArray(array)).toEqual(result); }); }); + + describe('toggleArrayItem', () => { + it('adds an item to the array if it does not exist', () => { + expect(arrayUtils.toggleArrayItem([], 'item')).toStrictEqual(['item']); + }); + + it('removes an item from the array if it already exists', () => { + expect(arrayUtils.toggleArrayItem(['item'], 'item')).toStrictEqual([]); + }); + + describe('pass by value', () => { + it('does not toggle the array item when passed a new object', () => { + expect(arrayUtils.toggleArrayItem([{ a: 1 }], { a: 1 })).toStrictEqual([ + { a: 1 }, + { a: 1 }, + ]); + }); + + it('does not toggle the array item when passed a new array', () => { + expect(arrayUtils.toggleArrayItem([[1]], [1])).toStrictEqual([[1], [1]]); + }); + }); + + describe('pass by reference', () => { + const array = [1]; + const object = { a: 1 }; + + it('toggles the array item when passed a object reference', () => { + expect(arrayUtils.toggleArrayItem([object], object)).toStrictEqual([]); + }); + + it('toggles the array item when passed an array reference', () => { + expect(arrayUtils.toggleArrayItem([array], array)).toStrictEqual([]); + }); + }); + }); }); diff --git a/spec/frontend/lib/utils/breadcrumbs_spec.js b/spec/frontend/lib/utils/breadcrumbs_spec.js new file mode 100644 index 00000000000..3c29e3723d3 --- /dev/null +++ b/spec/frontend/lib/utils/breadcrumbs_spec.js @@ -0,0 +1,84 @@ +import { createWrapper } from '@vue/test-utils'; +import Vue from 'vue'; +import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; +import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +describe('Breadcrumbs utils', () => { + const breadcrumbsHTML = ` + + `; + + const emptyBreadcrumbsHTML = ` + + `; + + const mockRouter = jest.fn(); + let MockComponent; + let mockApolloProvider; + + beforeEach(() => { + MockComponent = Vue.component('MockComponent', { + render: (createElement) => + createElement('span', { + attrs: { + 'data-testid': 'mock-component', + }, + }), + }); + mockApolloProvider = createMockApollo(); + }); + + afterEach(() => { + resetHTMLFixture(); + MockComponent = null; + }); + + describe('injectVueAppBreadcrumbs', () => { + describe('without any breadcrumbs', () => { + beforeEach(() => { + setHTMLFixture(emptyBreadcrumbsHTML); + }); + + it('returns early and stops trying to inject', () => { + expect(injectVueAppBreadcrumbs(mockRouter, MockComponent)).toBe(false); + }); + }); + + describe('with breadcrumbs', () => { + beforeEach(() => { + setHTMLFixture(breadcrumbsHTML); + }); + + describe.each` + testLabel | apolloProvider + ${'set'} | ${mockApolloProvider} + ${'not set'} | ${null} + `('given the apollo provider is $testLabel', ({ apolloProvider }) => { + beforeEach(() => { + createWrapper(injectVueAppBreadcrumbs(mockRouter, MockComponent, apolloProvider)); + }); + + it('returns a new breadcrumbs component replacing the inject HTML', () => { + // Using `querySelectorAll` because we're not testing a full Vue app. + // We are testing a partial Vue app added into the pages HTML. + expect(document.querySelectorAll('[data-testid="existing-crumb"]')).toHaveLength(1); + expect(document.querySelectorAll('[data-testid="last-crumb"]')).toHaveLength(0); + expect(document.querySelectorAll('[data-testid="mock-component"]')).toHaveLength(1); + }); + }); + }); + }); +}); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 444d4a96f9c..8697249ebf5 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -1174,4 +1174,43 @@ describe('common_utils', () => { }); }); }); + + describe('cloneWithoutReferences', () => { + it('clones the provided object', () => { + const obj = { + foo: 'bar', + cool: 1337, + nested: { + peanut: 'butter', + }, + arrays: [0, 1, 2], + }; + + const cloned = commonUtils.cloneWithoutReferences(obj); + + expect(cloned).toMatchObject({ + foo: 'bar', + cool: 1337, + nested: { + peanut: 'butter', + }, + arrays: [0, 1, 2], + }); + }); + + it('does not persist object references after cloning', () => { + const ref = { + foo: 'bar', + }; + + const obj = { + ref, + }; + + const cloned = commonUtils.cloneWithoutReferences(obj); + + expect(cloned.ref).toMatchObject({ foo: 'bar' }); + expect(cloned.ref === ref).toBe(false); + }); + }); }); diff --git a/spec/frontend/lib/utils/datetime_range_spec.js b/spec/frontend/lib/utils/datetime_range_spec.js deleted file mode 100644 index 996a8e2e47b..00000000000 --- a/spec/frontend/lib/utils/datetime_range_spec.js +++ /dev/null @@ -1,382 +0,0 @@ -import _ from 'lodash'; -import { - getRangeType, - convertToFixedRange, - isEqualTimeRanges, - findTimeRange, - timeRangeToParams, - timeRangeFromParams, -} from '~/lib/utils/datetime_range'; - -const MOCK_NOW = Date.UTC(2020, 0, 23, 20); - -const MOCK_NOW_ISO_STRING = new Date(MOCK_NOW).toISOString(); - -const mockFixedRange = { - label: 'January 2020', - start: '2020-01-01T00:00:00.000Z', - end: '2020-01-31T23:59:00.000Z', -}; - -const mockAnchoredRange = { - label: 'First two minutes of 2020', - anchor: '2020-01-01T00:00:00.000Z', - direction: 'after', - duration: { - seconds: 60 * 2, - }, -}; - -const mockRollingRange = { - label: 'Next 2 minutes', - direction: 'after', - duration: { - seconds: 60 * 2, - }, -}; - -const mockOpenRange = { - label: '2020 so far', - anchor: '2020-01-01T00:00:00.000Z', - direction: 'after', -}; - -describe('Date time range utils', () => { - describe('getRangeType', () => { - it('infers correctly the range type from the input object', () => { - const rangeTypes = { - fixed: [{ start: MOCK_NOW_ISO_STRING, end: MOCK_NOW_ISO_STRING }], - anchored: [{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: 0 } }], - rolling: [{ duration: { seconds: 0 } }], - open: [{ anchor: MOCK_NOW_ISO_STRING }], - invalid: [ - {}, - { start: MOCK_NOW_ISO_STRING }, - { end: MOCK_NOW_ISO_STRING }, - { start: 'NOT_A_DATE', end: 'NOT_A_DATE' }, - { duration: { seconds: 'NOT_A_NUMBER' } }, - { duration: { seconds: Infinity } }, - { duration: { minutes: 20 } }, - { anchor: MOCK_NOW_ISO_STRING, duration: { seconds: 'NOT_A_NUMBER' } }, - { anchor: MOCK_NOW_ISO_STRING, duration: { seconds: Infinity } }, - { junk: 'exists' }, - ], - }; - - Object.entries(rangeTypes).forEach(([type, examples]) => { - examples.forEach((example) => expect(getRangeType(example)).toEqual(type)); - }); - }); - }); - - describe('convertToFixedRange', () => { - beforeEach(() => { - jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW); - }); - - afterEach(() => { - Date.now.mockRestore(); - }); - - describe('When a fixed range is input', () => { - it('converts a fixed range to an equal fixed range', () => { - expect(convertToFixedRange(mockFixedRange)).toEqual({ - start: mockFixedRange.start, - end: mockFixedRange.end, - }); - }); - - it('throws an error when fixed range does not contain an end time', () => { - const aFixedRangeMissingEnd = _.omit(mockFixedRange, 'end'); - - expect(() => convertToFixedRange(aFixedRangeMissingEnd)).toThrow(); - }); - - it('throws an error when fixed range does not contain a start time', () => { - const aFixedRangeMissingStart = _.omit(mockFixedRange, 'start'); - - expect(() => convertToFixedRange(aFixedRangeMissingStart)).toThrow(); - }); - - it('throws an error when the dates cannot be parsed', () => { - const wrongStart = { ...mockFixedRange, start: 'I_CANNOT_BE_PARSED' }; - const wrongEnd = { ...mockFixedRange, end: 'I_CANNOT_BE_PARSED' }; - - expect(() => convertToFixedRange(wrongStart)).toThrow(); - expect(() => convertToFixedRange(wrongEnd)).toThrow(); - }); - }); - - describe('When an anchored range is input', () => { - it('converts to a fixed range', () => { - expect(convertToFixedRange(mockAnchoredRange)).toEqual({ - start: '2020-01-01T00:00:00.000Z', - end: '2020-01-01T00:02:00.000Z', - }); - }); - - it('converts to a fixed range with a `before` direction', () => { - expect(convertToFixedRange({ ...mockAnchoredRange, direction: 'before' })).toEqual({ - start: '2019-12-31T23:58:00.000Z', - end: '2020-01-01T00:00:00.000Z', - }); - }); - - it('converts to a fixed range without an explicit direction, defaulting to `before`', () => { - const defaultDirectionRange = _.omit(mockAnchoredRange, 'direction'); - - expect(convertToFixedRange(defaultDirectionRange)).toEqual({ - start: '2019-12-31T23:58:00.000Z', - end: '2020-01-01T00:00:00.000Z', - }); - }); - - it('throws an error when the anchor cannot be parsed', () => { - const wrongAnchor = { ...mockAnchoredRange, anchor: 'I_CANNOT_BE_PARSED' }; - - expect(() => convertToFixedRange(wrongAnchor)).toThrow(); - }); - }); - - describe('when a rolling range is input', () => { - it('converts to a fixed range', () => { - expect(convertToFixedRange(mockRollingRange)).toEqual({ - start: '2020-01-23T20:00:00.000Z', - end: '2020-01-23T20:02:00.000Z', - }); - }); - - it('converts to a fixed range with an implicit `before` direction', () => { - const noDirection = _.omit(mockRollingRange, 'direction'); - - expect(convertToFixedRange(noDirection)).toEqual({ - start: '2020-01-23T19:58:00.000Z', - end: '2020-01-23T20:00:00.000Z', - }); - }); - - it('throws an error when the duration is not in the right format', () => { - const wrongDuration = { ...mockRollingRange, duration: { minutes: 20 } }; - - expect(() => convertToFixedRange(wrongDuration)).toThrow(); - }); - - it('throws an error when the anchor is not valid', () => { - const wrongAnchor = { ...mockRollingRange, anchor: 'CAN_T_PARSE_THIS' }; - - expect(() => convertToFixedRange(wrongAnchor)).toThrow(); - }); - }); - - describe('when an open range is input', () => { - it('converts to a fixed range with an `after` direction', () => { - expect(convertToFixedRange(mockOpenRange)).toEqual({ - start: '2020-01-01T00:00:00.000Z', - end: '2020-01-23T20:00:00.000Z', - }); - }); - - it('converts to a fixed range with the explicit `before` direction', () => { - const beforeOpenRange = { ...mockOpenRange, direction: 'before' }; - - expect(convertToFixedRange(beforeOpenRange)).toEqual({ - start: '1970-01-01T00:00:00.000Z', - end: '2020-01-01T00:00:00.000Z', - }); - }); - - it('converts to a fixed range with the implicit `before` direction', () => { - const noDirectionOpenRange = _.omit(mockOpenRange, 'direction'); - - expect(convertToFixedRange(noDirectionOpenRange)).toEqual({ - start: '1970-01-01T00:00:00.000Z', - end: '2020-01-01T00:00:00.000Z', - }); - }); - - it('throws an error when the anchor cannot be parsed', () => { - const wrongAnchor = { ...mockOpenRange, anchor: 'CAN_T_PARSE_THIS' }; - - expect(() => convertToFixedRange(wrongAnchor)).toThrow(); - }); - }); - }); - - describe('isEqualTimeRanges', () => { - it('equal only compares relevant properies', () => { - expect( - isEqualTimeRanges( - { - ...mockFixedRange, - label: 'A label', - default: true, - }, - { - ...mockFixedRange, - label: 'Another label', - default: false, - anotherKey: 'anotherValue', - }, - ), - ).toBe(true); - - expect( - isEqualTimeRanges( - { - ...mockAnchoredRange, - label: 'A label', - default: true, - }, - { - ...mockAnchoredRange, - anotherKey: 'anotherValue', - }, - ), - ).toBe(true); - }); - }); - - describe('findTimeRange', () => { - const timeRanges = [ - { - label: 'Before 2020', - anchor: '2020-01-01T00:00:00.000Z', - }, - { - label: 'Last 30 minutes', - duration: { seconds: 60 * 30 }, - }, - { - label: 'In 2019', - start: '2019-01-01T00:00:00.000Z', - end: '2019-12-31T12:59:59.999Z', - }, - { - label: 'Next 2 minutes', - direction: 'after', - duration: { - seconds: 60 * 2, - }, - }, - ]; - - it('finds a time range', () => { - const tr0 = { - anchor: '2020-01-01T00:00:00.000Z', - }; - expect(findTimeRange(tr0, timeRanges)).toBe(timeRanges[0]); - - const tr1 = { - duration: { seconds: 60 * 30 }, - }; - expect(findTimeRange(tr1, timeRanges)).toBe(timeRanges[1]); - - const tr1Direction = { - direction: 'before', - duration: { - seconds: 60 * 30, - }, - }; - expect(findTimeRange(tr1Direction, timeRanges)).toBe(timeRanges[1]); - - const tr2 = { - someOtherLabel: 'Added arbitrarily', - start: '2019-01-01T00:00:00.000Z', - end: '2019-12-31T12:59:59.999Z', - }; - expect(findTimeRange(tr2, timeRanges)).toBe(timeRanges[2]); - - const tr3 = { - direction: 'after', - duration: { - seconds: 60 * 2, - }, - }; - expect(findTimeRange(tr3, timeRanges)).toBe(timeRanges[3]); - }); - - it('doesnot finds a missing time range', () => { - const nonExistant = { - direction: 'before', - duration: { - seconds: 200, - }, - }; - expect(findTimeRange(nonExistant, timeRanges)).toBeUndefined(); - }); - }); - - describe('conversion to/from params', () => { - const mockFixedParams = { - start: '2020-01-01T00:00:00.000Z', - end: '2020-01-31T23:59:00.000Z', - }; - - const mockAnchoredParams = { - anchor: '2020-01-01T00:00:00.000Z', - direction: 'after', - duration_seconds: '120', - }; - - const mockRollingParams = { - direction: 'after', - duration_seconds: '120', - }; - - describe('timeRangeToParams', () => { - it('converts fixed ranges to params', () => { - expect(timeRangeToParams(mockFixedRange)).toEqual(mockFixedParams); - }); - - it('converts anchored ranges to params', () => { - expect(timeRangeToParams(mockAnchoredRange)).toEqual(mockAnchoredParams); - }); - - it('converts rolling ranges to params', () => { - expect(timeRangeToParams(mockRollingRange)).toEqual(mockRollingParams); - }); - }); - - describe('timeRangeFromParams', () => { - it('converts fixed ranges from params', () => { - const params = { ...mockFixedParams, other_param: 'other_value' }; - const expectedRange = _.omit(mockFixedRange, 'label'); - - expect(timeRangeFromParams(params)).toEqual(expectedRange); - }); - - it('converts anchored ranges to params', () => { - const expectedRange = _.omit(mockRollingRange, 'label'); - - expect(timeRangeFromParams(mockRollingParams)).toEqual(expectedRange); - }); - - it('converts rolling ranges from params', () => { - const params = { ...mockRollingParams, other_param: 'other_value' }; - const expectedRange = _.omit(mockRollingRange, 'label'); - - expect(timeRangeFromParams(params)).toEqual(expectedRange); - }); - - it('converts rolling ranges from params with a default direction', () => { - const params = { - ...mockRollingParams, - direction: 'before', - other_param: 'other_value', - }; - const expectedRange = _.omit(mockRollingRange, 'label', 'direction'); - - expect(timeRangeFromParams(params)).toEqual(expectedRange); - }); - - it('converts to null when for no relevant params', () => { - const range = { - useless_param_1: 'value1', - useless_param_2: 'value2', - }; - - expect(timeRangeFromParams(range)).toBe(null); - }); - }); - }); -}); diff --git a/spec/frontend/lib/utils/secret_detection_spec.js b/spec/frontend/lib/utils/secret_detection_spec.js index 3213ecf3fe1..761062f0340 100644 --- a/spec/frontend/lib/utils/secret_detection_spec.js +++ b/spec/frontend/lib/utils/secret_detection_spec.js @@ -28,6 +28,7 @@ describe('containsSensitiveToken', () => { 'token: feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'token: feed_token=glft-ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'token: feed_token=glft-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693-1234', + 'token: gloas-a8cc74ccb0de004d09a968705ba49099229b288b3de43f26c473a9d8d7fb7693', 'https://example.com/feed?feed_token=123456789_abcdefghij', 'glpat-1234567890 and feed_token=ABCDEFGHIJKLMNOPQRSTUVWXYZ', ]; diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index b7d6bbd3991..6821ed56857 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -221,23 +221,6 @@ describe('text_utility', () => { }); }); - describe('getFirstCharacterCapitalized', () => { - it('returns the first character capitalized, if first character is alphabetic', () => { - expect(textUtils.getFirstCharacterCapitalized('loremIpsumDolar')).toEqual('L'); - expect(textUtils.getFirstCharacterCapitalized('Sit amit !')).toEqual('S'); - }); - - it('returns the first character, if first character is non-alphabetic', () => { - expect(textUtils.getFirstCharacterCapitalized(' lorem')).toEqual(' '); - expect(textUtils.getFirstCharacterCapitalized('%#!')).toEqual('%'); - }); - - it('returns an empty string, if string is falsey', () => { - expect(textUtils.getFirstCharacterCapitalized('')).toEqual(''); - expect(textUtils.getFirstCharacterCapitalized(null)).toEqual(''); - }); - }); - describe('slugifyWithUnderscore', () => { it('should replaces whitespaces with underscore and convert to lower case', () => { expect(textUtils.slugifyWithUnderscore('My Input String')).toEqual('my_input_string'); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 450eeefd898..ecd2d7f888d 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1,11 +1,8 @@ -import * as Sentry from '@sentry/browser'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import * as urlUtils from '~/lib/utils/url_utility'; import { safeUrls, unsafeUrls } from './mock_data'; -jest.mock('@sentry/browser'); - const shas = { valid: [ 'ad9be38573f9ee4c4daec22673478c2dd1d81cd8', @@ -434,11 +431,10 @@ describe('URL utility', () => { it('does not navigate to unsafe urls', () => { // eslint-disable-next-line no-script-url const url = 'javascript:alert(document.domain)'; - urlUtils.visitUrl(url); - expect(Sentry.captureException).toHaveBeenCalledWith( - new RangeError(`Only http and https protocols are allowed: ${url}`), - ); + expect(() => { + urlUtils.visitUrl(url); + }).toThrow(new RangeError(`Only http and https protocols are allowed: ${url}`)); }); it('navigates to a page', () => { diff --git a/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap b/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap index a0d9bae8a0b..3ad02d3851d 100644 --- a/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap +++ b/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap @@ -2,21 +2,14 @@ exports[`MemberActivity with a member that does not have all of the fields renders \`User created\` field 1`] = `
    - -
    Access granted: - - - Aug 06, 2020 - + Aug 06, 2020
    - -
    `; @@ -26,35 +19,24 @@ exports[`MemberActivity with a member that has all fields renders \`User created User created: - - - Mar 10, 2022 - + Mar 10, 2022
    -
    Access granted: - - - Jul 17, 2020 - + Jul 17, 2020
    -
    Last activity: - - - Mar 15, 2022 - + Mar 15, 2022
    diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index 3b8c9dd3bf3..6c4ea7063ad 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -281,6 +281,14 @@ describe('MergeRequestTabs', () => { testContext.class.expandViewContainer(); expect($('.content-wrapper .container-limited')).toHaveLength(0); }); + + it('adds the diff-specific width-limiter', () => { + testContext.class.expandViewContainer(); + + expect(testContext.class.contentWrapper.classList.contains('diffs-container-limited')).toBe( + true, + ); + }); }); describe('resetViewContainer', () => { @@ -302,6 +310,14 @@ describe('MergeRequestTabs', () => { expect($('.content-wrapper .container-limited')).toHaveLength(1); }); + + it('removes the diff-specific width-limiter', () => { + testContext.class.resetViewContainer(); + + expect(testContext.class.contentWrapper.classList.contains('diffs-container-limited')).toBe( + false, + ); + }); }); describe('tabShown', () => { diff --git a/spec/frontend/merge_requests/components/compare_app_spec.js b/spec/frontend/merge_requests/components/compare_app_spec.js index ba129363ffd..887f79f9fad 100644 --- a/spec/frontend/merge_requests/components/compare_app_spec.js +++ b/spec/frontend/merge_requests/components/compare_app_spec.js @@ -1,10 +1,14 @@ -import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; import CompareApp from '~/merge_requests/components/compare_app.vue'; let wrapper; +let mock; function factory(provideData = {}) { - wrapper = shallowMount(CompareApp, { + wrapper = shallowMountExtended(CompareApp, { provide: { inputs: { project: { @@ -16,6 +20,7 @@ function factory(provideData = {}) { name: 'branch', }, }, + branchCommitPath: '/commit', toggleClass: { project: 'project', branch: 'branch', @@ -29,7 +34,18 @@ function factory(provideData = {}) { }); } +const findCommitBox = () => wrapper.findByTestId('commit-box'); + describe('Merge requests compare app component', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet('/commit').reply(200, 'commit content'); + }); + + afterEach(() => { + mock.restore(); + }); + it('shows commit box when selected branch is empty', () => { factory({ currentBranch: { @@ -38,9 +54,41 @@ describe('Merge requests compare app component', () => { }, }); - const commitBox = wrapper.find('[data-testid="commit-box"]'); + const commitBox = findCommitBox(); expect(commitBox.exists()).toBe(true); expect(commitBox.text()).toBe('Select a branch to compare'); }); + + it('emits select-branch on selected event', () => { + factory({ + currentBranch: { + text: '', + value: '', + }, + }); + + wrapper.findByTestId('compare-dropdown').vm.$emit('selected', { value: 'main' }); + + expect(wrapper.emitted('select-branch')).toEqual([['main']]); + }); + + describe('currentBranch watcher', () => { + it('changes selected value', async () => { + factory({ + currentBranch: { + text: '', + value: '', + }, + }); + + expect(findCommitBox().text()).toBe('Select a branch to compare'); + + wrapper.setProps({ currentBranch: { text: 'main', value: 'main ' } }); + + await waitForPromises(); + + expect(findCommitBox().text()).toBe('commit content'); + }); + }); }); diff --git a/spec/frontend/merge_requests/components/header_metadata_spec.js b/spec/frontend/merge_requests/components/header_metadata_spec.js new file mode 100644 index 00000000000..2823b4b9d97 --- /dev/null +++ b/spec/frontend/merge_requests/components/header_metadata_spec.js @@ -0,0 +1,93 @@ +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import HeaderMetadata from '~/merge_requests/components/header_metadata.vue'; +import mrStore from '~/mr_notes/stores'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; + +jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); + +describe('HeaderMetadata component', () => { + let wrapper; + + const findConfidentialIcon = () => wrapper.findComponent(ConfidentialityBadge); + const findLockedIcon = () => wrapper.findByTestId('locked'); + const findHiddenIcon = () => wrapper.findByTestId('hidden'); + + const renderTestMessage = (renders) => (renders ? 'renders' : 'does not render'); + + const createComponent = ({ store, provide }) => { + wrapper = shallowMountExtended(HeaderMetadata, { + mocks: { + $store: store, + }, + provide, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + describe.each` + lockStatus | confidentialStatus | hiddenStatus + ${true} | ${true} | ${false} + ${true} | ${false} | ${false} + ${false} | ${true} | ${false} + ${false} | ${false} | ${false} + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${true} + `( + `when locked=$lockStatus, confidential=$confidentialStatus, and hidden=$hiddenStatus`, + ({ lockStatus, confidentialStatus, hiddenStatus }) => { + const store = mrStore; + + beforeEach(() => { + store.getters.getNoteableData = {}; + store.getters.getNoteableData.confidential = confidentialStatus; + store.getters.getNoteableData.discussion_locked = lockStatus; + store.getters.getNoteableData.targetType = 'merge_request'; + + createComponent({ store, provide: { hidden: hiddenStatus } }); + }); + + it(`${renderTestMessage(lockStatus)} the locked icon`, () => { + const lockedIcon = findLockedIcon(); + + expect(lockedIcon.exists()).toBe(lockStatus); + + if (lockStatus) { + expect(lockedIcon.attributes('title')).toBe( + `This merge request is locked. Only project members can comment.`, + ); + expect(getBinding(lockedIcon.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); + + it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => { + const confidentialIcon = findConfidentialIcon(); + expect(confidentialIcon.exists()).toBe(confidentialStatus); + + if (confidentialStatus && !hiddenStatus) { + expect(confidentialIcon.props()).toMatchObject({ + workspaceType: 'project', + issuableType: 'issue', + }); + } + }); + + it(`${renderTestMessage(confidentialStatus)} the hidden icon`, () => { + const hiddenIcon = findHiddenIcon(); + + expect(hiddenIcon.exists()).toBe(hiddenStatus); + + if (hiddenStatus) { + expect(hiddenIcon.attributes('title')).toBe( + `This merge request is hidden because its author has been banned`, + ); + expect(getBinding(hiddenIcon.element, 'gl-tooltip')).not.toBeUndefined(); + } + }); + }, + ); +}); diff --git a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js index 2cd65307b0b..432ee5e9ecd 100644 --- a/spec/frontend/nav/components/top_nav_new_dropdown_spec.js +++ b/spec/frontend/nav/components/top_nav_new_dropdown_spec.js @@ -57,7 +57,8 @@ describe('~/nav/components/top_nav_menu_sections.vue', () => { if (type === 'divider') { return { type }; - } else if (type === 'header') { + } + if (type === 'header') { return { type, text: child.text() }; } diff --git a/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap b/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap index a4611149432..277ff2aa441 100644 --- a/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap +++ b/spec/frontend/notes/components/__snapshots__/notes_app_spec.js.snap @@ -1,17 +1,37 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`note_app when sort direction is asc shows skeleton notes after the loaded discussions 1`] = ` -"
      - - - -
    " +
      + + +
    `; exports[`note_app when sort direction is desc shows skeleton notes before the loaded discussions 1`] = ` -"
      - - - -
    " +
      + + +
    `; diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 0728646246d..9b1678c0a8a 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -153,21 +153,18 @@ describe('issue_comment_form component', () => { mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - jest.spyOn(wrapper.vm, 'stopPolling'); findCloseReopenButton().trigger('click'); expect(wrapper.vm.isSubmitting).toBe(true); expect(wrapper.vm.note).toBe(''); expect(wrapper.vm.saveNote).toHaveBeenCalled(); - expect(wrapper.vm.stopPolling).toHaveBeenCalled(); }); it('tracks event', () => { mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } }); jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); - jest.spyOn(wrapper.vm, 'stopPolling'); findCloseReopenButton().trigger('click'); @@ -302,7 +299,6 @@ describe('issue_comment_form component', () => { const saveNotePromise = Promise.resolve(); jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise); - jest.spyOn(wrapper.vm, 'stopPolling'); const actionButton = findCloseReopenButton(); @@ -351,7 +347,6 @@ describe('issue_comment_form component', () => { it('should make textarea disabled while requesting', async () => { mountComponent({ mountFunction: mount }); - jest.spyOn(wrapper.vm, 'stopPolling'); jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue(); findMarkdownEditor().vm.$emit('input', 'hello world'); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index caf47febedd..d49ab0d71db 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -288,7 +288,6 @@ describe('note_app', () => { wrapper.vm.$store.hotUpdate({ actions: { toggleAward: toggleAwardAction, - stopPolling() {}, }, }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 0205f606297..104c297b44e 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -8,11 +8,7 @@ import { createAlert } from '~/alert'; import toast from '~/vue_shared/plugins/global_toast'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; -import { - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_OK, - HTTP_STATUS_SERVICE_UNAVAILABLE, -} from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK, HTTP_STATUS_SERVICE_UNAVAILABLE } from '~/lib/utils/http_status'; import * as notesConstants from '~/notes/constants'; import createStore from '~/notes/stores'; import * as actions from '~/notes/stores/actions'; @@ -24,7 +20,6 @@ import updateMergeRequestLockMutation from '~/sidebar/queries/update_merge_reque import promoteTimelineEvent from '~/notes/graphql/promote_timeline_event.mutation.graphql'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import notesEventHub from '~/notes/event_hub'; -import waitForPromises from 'helpers/wait_for_promises'; import { resetStore } from '../helpers'; import { discussionMock, @@ -262,13 +257,7 @@ describe('Actions Notes Store', () => { }); describe('initPolling', () => { - afterEach(() => { - gon.features = {}; - }); - it('creates the Action Cable subscription', () => { - gon.features = { actionCableNotes: true }; - store.dispatch('setNotesData', notesDataMock); store.dispatch('initPolling'); @@ -290,8 +279,6 @@ describe('Actions Notes Store', () => { const response = { notes: [], last_fetched_at: '123456' }; const successMock = () => axiosMock.onGet(notesDataMock.notesPath).reply(HTTP_STATUS_OK, response); - const failureMock = () => - axiosMock.onGet(notesDataMock.notesPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); beforeEach(() => { return store.dispatch('setNotesData', notesDataMock); @@ -304,153 +291,6 @@ describe('Actions Notes Store', () => { expect(store.state.lastFetchedAt).toBe('123456'); }); - - it('shows an alert when fetching fails', async () => { - failureMock(); - - await store.dispatch('fetchUpdatedNotes'); - - expect(createAlert).toHaveBeenCalledTimes(1); - }); - }); - - describe('poll', () => { - const pollInterval = 6000; - const pollResponse = { notes: [], last_fetched_at: '123456' }; - const pollHeaders = { 'poll-interval': `${pollInterval}` }; - const successMock = () => - axiosMock.onGet(notesDataMock.notesPath).reply(HTTP_STATUS_OK, pollResponse, pollHeaders); - const failureMock = () => - axiosMock.onGet(notesDataMock.notesPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - const advanceAndRAF = (time) => { - if (time) { - jest.advanceTimersByTime(time); - } - - return waitForPromises(); - }; - const advanceXMoreIntervals = (number) => { - const timeoutLength = pollInterval * number; - - return advanceAndRAF(timeoutLength); - }; - const startPolling = async () => { - await store.dispatch('poll'); - await advanceAndRAF(2); - }; - const cleanUp = () => { - jest.clearAllTimers(); - - return store.dispatch('stopPolling'); - }; - - beforeEach(() => { - return store.dispatch('setNotesData', notesDataMock); - }); - - afterEach(() => { - return cleanUp(); - }); - - it('calls service with last fetched state', async () => { - successMock(); - - await startPolling(); - - expect(store.state.lastFetchedAt).toBe('123456'); - - await advanceXMoreIntervals(1); - - expect(axiosMock.history.get).toHaveLength(2); - expect(axiosMock.history.get[1].headers).toMatchObject({ - 'X-Last-Fetched-At': '123456', - }); - }); - - describe('polling side effects', () => { - it('retries twice', async () => { - failureMock(); - - await startPolling(); - - // This is the first request, not a retry - expect(axiosMock.history.get).toHaveLength(1); - - await advanceXMoreIntervals(1); - - // Retry #1 - expect(axiosMock.history.get).toHaveLength(2); - - await advanceXMoreIntervals(1); - - // Retry #2 - expect(axiosMock.history.get).toHaveLength(3); - - await advanceXMoreIntervals(10); - - // There are no more retries - expect(axiosMock.history.get).toHaveLength(3); - }); - - it('shows the error display on the second failure', async () => { - failureMock(); - - await startPolling(); - - expect(axiosMock.history.get).toHaveLength(1); - expect(createAlert).not.toHaveBeenCalled(); - - await advanceXMoreIntervals(1); - - expect(axiosMock.history.get).toHaveLength(2); - expect(createAlert).toHaveBeenCalled(); - expect(createAlert).toHaveBeenCalledTimes(1); - }); - - it('resets the failure counter on success', async () => { - // We can't get access to the actual counter in the polling closure. - // So we can infer that it's reset by ensuring that the error is only - // shown when we cause two failures in a row - no successes between - - axiosMock - .onGet(notesDataMock.notesPath) - .replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR) // cause one error - .onGet(notesDataMock.notesPath) - .replyOnce(HTTP_STATUS_OK, pollResponse, pollHeaders) // then a success - .onGet(notesDataMock.notesPath) - .reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); // and then more errors - - await startPolling(); // Failure #1 - await advanceXMoreIntervals(1); // Success #1 - await advanceXMoreIntervals(1); // Failure #2 - - // That was the first failure AFTER a success, so we should NOT see the error displayed - expect(createAlert).not.toHaveBeenCalled(); - - // Now we'll allow another failure - await advanceXMoreIntervals(1); // Failure #3 - - // Since this is the second failure in a row, the error should happen - expect(createAlert).toHaveBeenCalledTimes(1); - }); - - it('hides the error display if it exists on success', async () => { - failureMock(); - - await startPolling(); - await advanceXMoreIntervals(2); - - // After two errors, the error should be displayed - expect(createAlert).toHaveBeenCalledTimes(1); - - axiosMock.reset(); - successMock(); - - await advanceXMoreIntervals(1); - - expect(mockAlertDismiss).toHaveBeenCalledTimes(1); - }); - }); }); describe('setNotesFetchedState', () => { @@ -996,11 +836,7 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(dispatch.mock.calls).toEqual([ - ['stopPolling'], - ['resolveDiscussion', { discussionId }], - ['restartPolling'], - ]); + expect(dispatch.mock.calls).toEqual([['resolveDiscussion', { discussionId }]]); expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -1015,7 +851,6 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(createAlert).toHaveBeenCalledWith({ message: TEST_ERROR_MESSAGE, parent: flashContainer, @@ -1033,7 +868,6 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while applying the suggestion. Please try again.', parent: flashContainer, @@ -1081,10 +915,8 @@ describe('Actions Notes Store', () => { ]); expect(dispatch.mock.calls).toEqual([ - ['stopPolling'], ['resolveDiscussion', { discussionId: discussionIds[0] }], ['resolveDiscussion', { discussionId: discussionIds[1] }], - ['restartPolling'], ]); expect(createAlert).not.toHaveBeenCalled(); @@ -1104,7 +936,6 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(createAlert).toHaveBeenCalledWith({ message: TEST_ERROR_MESSAGE, parent: flashContainer, @@ -1125,7 +956,6 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while applying the batch of suggestions. Please try again.', diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js index 10fdc8c33c4..056175eac07 100644 --- a/spec/frontend/observability/client_spec.js +++ b/spec/frontend/observability/client_spec.js @@ -1,15 +1,19 @@ import MockAdapter from 'axios-mock-adapter'; +import * as Sentry from '@sentry/browser'; import { buildClient } from '~/observability/client'; import axios from '~/lib/utils/axios_utils'; jest.mock('~/lib/utils/axios_utils'); +jest.mock('@sentry/browser'); describe('buildClient', () => { let client; let axiosMock; const tracingUrl = 'https://example.com/tracing'; - const EXPECTED_ERROR_MESSAGE = 'traces are missing/invalid in the response'; + const provisioningUrl = 'https://example.com/provisioning'; + + const FETCHING_TRACES_ERROR = 'traces are missing/invalid in the response'; beforeEach(() => { axiosMock = new MockAdapter(axios); @@ -17,7 +21,7 @@ describe('buildClient', () => { client = buildClient({ tracingUrl, - provisioningUrl: 'https://example.com/provisioning', + provisioningUrl, }); }); @@ -25,10 +29,85 @@ describe('buildClient', () => { axiosMock.restore(); }); + describe('isTracingEnabled', () => { + it('returns true if requests succeedes', async () => { + axiosMock.onGet(provisioningUrl).reply(200, { + status: 'ready', + }); + + const enabled = await client.isTracingEnabled(); + + expect(enabled).toBe(true); + }); + + it('returns false if response is 404', async () => { + axiosMock.onGet(provisioningUrl).reply(404); + + const enabled = await client.isTracingEnabled(); + + expect(enabled).toBe(false); + }); + + // we currently ignore the 'status' payload and just check if the request was successful + // We might improve this as part of https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2315 + it('returns true for any status', async () => { + axiosMock.onGet(provisioningUrl).reply(200, { + status: 'not ready', + }); + + const enabled = await client.isTracingEnabled(); + + expect(enabled).toBe(true); + }); + + it('throws in case of any non-404 error', async () => { + axiosMock.onGet(provisioningUrl).reply(500); + + const e = 'Request failed with status code 500'; + await expect(client.isTracingEnabled()).rejects.toThrow(e); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(e)); + }); + + it('throws in case of unexpected response', async () => { + axiosMock.onGet(provisioningUrl).reply(200, {}); + + const e = 'Failed to check provisioning'; + await expect(client.isTracingEnabled()).rejects.toThrow(e); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(e)); + }); + }); + + describe('enableTraces', () => { + it('makes a PUT request to the provisioning URL', async () => { + let putConfig; + axiosMock.onPut(provisioningUrl).reply((config) => { + putConfig = config; + return [200]; + }); + + await client.enableTraces(); + + expect(putConfig.withCredentials).toBe(true); + }); + + it('reports an error if the req fails', async () => { + axiosMock.onPut(provisioningUrl).reply(401); + + const e = 'Request failed with status code 401'; + + await expect(client.enableTraces()).rejects.toThrow(e); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(e)); + }); + }); + describe('fetchTrace', () => { it('fetches the trace from the tracing URL', async () => { const mockTraces = [ - { trace_id: 'trace-1', spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }] }, + { + trace_id: 'trace-1', + duration_nano: 3000, + spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }], + }, ]; axiosMock.onGet(tracingUrl).reply(200, { @@ -42,34 +121,37 @@ describe('buildClient', () => { withCredentials: true, params: { trace_id: 'trace-1' }, }); - expect(result).toEqual({ - ...mockTraces[0], - duration: 1, - }); + expect(result).toEqual(mockTraces[0]); }); it('rejects if trace id is missing', () => { return expect(client.fetchTrace()).rejects.toThrow('traceId is required.'); }); - it('rejects if traces are empty', () => { + it('rejects if traces are empty', async () => { axiosMock.onGet(tracingUrl).reply(200, { traces: [] }); - return expect(client.fetchTrace('trace-1')).rejects.toThrow(EXPECTED_ERROR_MESSAGE); + await expect(client.fetchTrace('trace-1')).rejects.toThrow(FETCHING_TRACES_ERROR); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(FETCHING_TRACES_ERROR)); }); - it('rejects if traces are invalid', () => { + it('rejects if traces are invalid', async () => { axiosMock.onGet(tracingUrl).reply(200, { traces: 'invalid' }); - return expect(client.fetchTraces()).rejects.toThrow(EXPECTED_ERROR_MESSAGE); + await expect(client.fetchTraces()).rejects.toThrow(FETCHING_TRACES_ERROR); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(FETCHING_TRACES_ERROR)); }); }); describe('fetchTraces', () => { it('fetches traces from the tracing URL', async () => { const mockTraces = [ - { trace_id: 'trace-1', spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }] }, - { trace_id: 'trace-2', spans: [{ duration_nano: 2000 }] }, + { + trace_id: 'trace-1', + duration_nano: 3000, + spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }], + }, + { trace_id: 'trace-2', duration_nano: 3000, spans: [{ duration_nano: 2000 }] }, ]; axiosMock.onGet(tracingUrl).reply(200, { @@ -83,28 +165,21 @@ describe('buildClient', () => { withCredentials: true, params: new URLSearchParams(), }); - expect(result).toEqual([ - { - ...mockTraces[0], - duration: 1, - }, - { - ...mockTraces[1], - duration: 2, - }, - ]); + expect(result).toEqual(mockTraces); }); - it('rejects if traces are missing', () => { + it('rejects if traces are missing', async () => { axiosMock.onGet(tracingUrl).reply(200, {}); - return expect(client.fetchTraces()).rejects.toThrow(EXPECTED_ERROR_MESSAGE); + await expect(client.fetchTraces()).rejects.toThrow(FETCHING_TRACES_ERROR); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(FETCHING_TRACES_ERROR)); }); - it('rejects if traces are invalid', () => { + it('rejects if traces are invalid', async () => { axiosMock.onGet(tracingUrl).reply(200, { traces: 'invalid' }); - return expect(client.fetchTraces()).rejects.toThrow(EXPECTED_ERROR_MESSAGE); + await expect(client.fetchTraces()).rejects.toThrow(FETCHING_TRACES_ERROR); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error(FETCHING_TRACES_ERROR)); }); describe('query filter', () => { diff --git a/spec/frontend/organizations/groups_and_projects/components/app_spec.js b/spec/frontend/organizations/groups_and_projects/components/app_spec.js index 64182b74e4f..e2301de8607 100644 --- a/spec/frontend/organizations/groups_and_projects/components/app_spec.js +++ b/spec/frontend/organizations/groups_and_projects/components/app_spec.js @@ -1,10 +1,9 @@ import { GlCollapsibleListbox, GlSorting, GlSortingItem } from '@gitlab/ui'; import App from '~/organizations/groups_and_projects/components/app.vue'; -import GroupsPage from '~/organizations/groups_and_projects/components/groups_page.vue'; -import ProjectsPage from '~/organizations/groups_and_projects/components/projects_page.vue'; +import GroupsView from '~/organizations/shared/components/groups_view.vue'; +import ProjectsView from '~/organizations/shared/components/projects_view.vue'; +import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '~/organizations/constants'; import { - DISPLAY_QUERY_GROUPS, - DISPLAY_QUERY_PROJECTS, SORT_ITEM_CREATED, SORT_DIRECTION_DESC, } from '~/organizations/groups_and_projects/constants'; @@ -36,10 +35,10 @@ describe('GroupsAndProjectsApp', () => { describe.each` display | expectedComponent | expectedDisplayListboxSelectedProp - ${null} | ${GroupsPage} | ${DISPLAY_QUERY_GROUPS} - ${'unsupported_value'} | ${GroupsPage} | ${DISPLAY_QUERY_GROUPS} - ${DISPLAY_QUERY_GROUPS} | ${GroupsPage} | ${DISPLAY_QUERY_GROUPS} - ${DISPLAY_QUERY_PROJECTS} | ${ProjectsPage} | ${DISPLAY_QUERY_PROJECTS} + ${null} | ${GroupsView} | ${RESOURCE_TYPE_GROUPS} + ${'unsupported_value'} | ${GroupsView} | ${RESOURCE_TYPE_GROUPS} + ${RESOURCE_TYPE_GROUPS} | ${GroupsView} | ${RESOURCE_TYPE_GROUPS} + ${RESOURCE_TYPE_PROJECTS} | ${ProjectsView} | ${RESOURCE_TYPE_PROJECTS} `( 'when `display` query string is $display', ({ display, expectedComponent, expectedDisplayListboxSelectedProp }) => { @@ -122,11 +121,11 @@ describe('GroupsAndProjectsApp', () => { beforeEach(() => { createComponent(); - findListbox().vm.$emit('select', DISPLAY_QUERY_PROJECTS); + findListbox().vm.$emit('select', RESOURCE_TYPE_PROJECTS); }); it('updates `display` query string', () => { - expect(routerMock.push).toHaveBeenCalledWith({ query: { display: DISPLAY_QUERY_PROJECTS } }); + expect(routerMock.push).toHaveBeenCalledWith({ query: { display: RESOURCE_TYPE_PROJECTS } }); }); }); diff --git a/spec/frontend/organizations/groups_and_projects/components/groups_page_spec.js b/spec/frontend/organizations/groups_and_projects/components/groups_page_spec.js deleted file mode 100644 index 537f8114fcf..00000000000 --- a/spec/frontend/organizations/groups_and_projects/components/groups_page_spec.js +++ /dev/null @@ -1,88 +0,0 @@ -import VueApollo from 'vue-apollo'; -import Vue from 'vue'; -import { GlLoadingIcon } from '@gitlab/ui'; -import GroupsPage from '~/organizations/groups_and_projects/components/groups_page.vue'; -import { formatGroups } from '~/organizations/groups_and_projects/utils'; -import resolvers from '~/organizations/groups_and_projects/graphql/resolvers'; -import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; -import { createAlert } from '~/alert'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { organizationGroups } from '../mock_data'; - -jest.mock('~/alert'); - -Vue.use(VueApollo); -jest.useFakeTimers(); - -describe('GroupsPage', () => { - let wrapper; - let mockApollo; - - const createComponent = ({ mockResolvers = resolvers } = {}) => { - mockApollo = createMockApollo([], mockResolvers); - - wrapper = shallowMountExtended(GroupsPage, { apolloProvider: mockApollo }); - }; - - afterEach(() => { - mockApollo = null; - }); - - describe('when API call is loading', () => { - beforeEach(() => { - const mockResolvers = { - Query: { - organization: jest.fn().mockReturnValueOnce(new Promise(() => {})), - }, - }; - - createComponent({ mockResolvers }); - }); - - it('renders loading icon', () => { - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - }); - - describe('when API call is successful', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders `GroupsList` component and passes correct props', async () => { - jest.runAllTimers(); - await waitForPromises(); - - expect(wrapper.findComponent(GroupsList).props()).toEqual({ - groups: formatGroups(organizationGroups.nodes), - showGroupIcon: true, - }); - }); - }); - - describe('when API call is not successful', () => { - const error = new Error(); - - beforeEach(() => { - const mockResolvers = { - Query: { - organization: jest.fn().mockRejectedValueOnce(error), - }, - }; - - createComponent({ mockResolvers }); - }); - - it('displays error alert', async () => { - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message: GroupsPage.i18n.errorMessage, - error, - captureError: true, - }); - }); - }); -}); diff --git a/spec/frontend/organizations/groups_and_projects/components/projects_page_spec.js b/spec/frontend/organizations/groups_and_projects/components/projects_page_spec.js deleted file mode 100644 index 7cadcab5021..00000000000 --- a/spec/frontend/organizations/groups_and_projects/components/projects_page_spec.js +++ /dev/null @@ -1,88 +0,0 @@ -import VueApollo from 'vue-apollo'; -import Vue from 'vue'; -import { GlLoadingIcon } from '@gitlab/ui'; -import ProjectsPage from '~/organizations/groups_and_projects/components/projects_page.vue'; -import { formatProjects } from '~/organizations/groups_and_projects/utils'; -import resolvers from '~/organizations/groups_and_projects/graphql/resolvers'; -import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; -import { createAlert } from '~/alert'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { organizationProjects } from '../mock_data'; - -jest.mock('~/alert'); - -Vue.use(VueApollo); -jest.useFakeTimers(); - -describe('ProjectsPage', () => { - let wrapper; - let mockApollo; - - const createComponent = ({ mockResolvers = resolvers } = {}) => { - mockApollo = createMockApollo([], mockResolvers); - - wrapper = shallowMountExtended(ProjectsPage, { apolloProvider: mockApollo }); - }; - - afterEach(() => { - mockApollo = null; - }); - - describe('when API call is loading', () => { - beforeEach(() => { - const mockResolvers = { - Query: { - organization: jest.fn().mockReturnValueOnce(new Promise(() => {})), - }, - }; - - createComponent({ mockResolvers }); - }); - - it('renders loading icon', () => { - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - }); - - describe('when API call is successful', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders `ProjectsList` component and passes correct props', async () => { - jest.runAllTimers(); - await waitForPromises(); - - expect(wrapper.findComponent(ProjectsList).props()).toEqual({ - projects: formatProjects(organizationProjects.nodes), - showProjectIcon: true, - }); - }); - }); - - describe('when API call is not successful', () => { - const error = new Error(); - - beforeEach(() => { - const mockResolvers = { - Query: { - organization: jest.fn().mockRejectedValueOnce(error), - }, - }; - - createComponent({ mockResolvers }); - }); - - it('displays error alert', async () => { - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message: ProjectsPage.i18n.errorMessage, - error, - captureError: true, - }); - }); - }); -}); diff --git a/spec/frontend/organizations/groups_and_projects/mock_data.js b/spec/frontend/organizations/groups_and_projects/mock_data.js deleted file mode 100644 index eb829a24f50..00000000000 --- a/spec/frontend/organizations/groups_and_projects/mock_data.js +++ /dev/null @@ -1,252 +0,0 @@ -export const organization = { - id: 'gid://gitlab/Organization/1', - __typename: 'Organization', -}; - -export const organizationProjects = { - nodes: [ - { - id: 'gid://gitlab/Project/8', - nameWithNamespace: 'Twitter / Typeahead.Js', - webUrl: 'http://127.0.0.1:3000/twitter/Typeahead.Js', - topics: ['JavaScript', 'Vue.js'], - forksCount: 4, - avatarUrl: null, - starCount: 0, - visibility: 'public', - openIssuesCount: 48, - descriptionHtml: - '

    Optio et reprehenderit enim doloremque deserunt et commodi.

    ', - issuesAccessLevel: 'enabled', - forkingAccessLevel: 'enabled', - isForked: true, - accessLevel: { - integerValue: 30, - }, - }, - { - id: 'gid://gitlab/Project/7', - nameWithNamespace: 'Flightjs / Flight', - webUrl: 'http://127.0.0.1:3000/flightjs/Flight', - topics: [], - forksCount: 0, - avatarUrl: null, - starCount: 0, - visibility: 'private', - openIssuesCount: 37, - descriptionHtml: - '

    Dolor dicta rerum et ut eius voluptate earum qui.

    ', - issuesAccessLevel: 'enabled', - forkingAccessLevel: 'enabled', - isForked: false, - accessLevel: { - integerValue: 20, - }, - }, - { - id: 'gid://gitlab/Project/6', - nameWithNamespace: 'Jashkenas / Underscore', - webUrl: 'http://127.0.0.1:3000/jashkenas/Underscore', - topics: [], - forksCount: 0, - avatarUrl: null, - starCount: 0, - visibility: 'private', - openIssuesCount: 34, - descriptionHtml: - '

    Incidunt est aliquam autem nihil eveniet quis autem.

    ', - issuesAccessLevel: 'enabled', - forkingAccessLevel: 'enabled', - isForked: false, - accessLevel: { - integerValue: 40, - }, - }, - { - id: 'gid://gitlab/Project/5', - nameWithNamespace: 'Commit451 / Lab Coat', - webUrl: 'http://127.0.0.1:3000/Commit451/lab-coat', - topics: [], - forksCount: 0, - avatarUrl: null, - starCount: 0, - visibility: 'internal', - openIssuesCount: 49, - descriptionHtml: - '

    Sint eos dolorem impedit rerum et.

    ', - issuesAccessLevel: 'enabled', - forkingAccessLevel: 'enabled', - isForked: false, - accessLevel: { - integerValue: 10, - }, - }, - { - id: 'gid://gitlab/Project/1', - nameWithNamespace: 'Toolbox / Gitlab Smoke Tests', - webUrl: 'http://127.0.0.1:3000/toolbox/gitlab-smoke-tests', - topics: [], - forksCount: 0, - avatarUrl: null, - starCount: 0, - visibility: 'internal', - openIssuesCount: 34, - descriptionHtml: - '

    Veritatis error laboriosam libero autem.

    ', - issuesAccessLevel: 'enabled', - forkingAccessLevel: 'enabled', - isForked: false, - accessLevel: { - integerValue: 30, - }, - }, - ], -}; - -export const organizationGroups = { - nodes: [ - { - id: 'gid://gitlab/Group/29', - fullName: 'Commit451', - parent: null, - webUrl: 'http://127.0.0.1:3000/groups/Commit451', - descriptionHtml: - '

    Autem praesentium vel ut ratione itaque ullam culpa.

    ', - avatarUrl: null, - descendantGroupsCount: 0, - projectsCount: 3, - groupMembersCount: 2, - visibility: 'public', - accessLevel: { - integerValue: 30, - }, - }, - { - id: 'gid://gitlab/Group/33', - fullName: 'Flightjs', - parent: null, - webUrl: 'http://127.0.0.1:3000/groups/flightjs', - descriptionHtml: - '

    Ipsa reiciendis deleniti officiis illum nostrum quo aliquam.

    ', - avatarUrl: null, - descendantGroupsCount: 4, - projectsCount: 3, - groupMembersCount: 1, - visibility: 'private', - accessLevel: { - integerValue: 20, - }, - }, - { - id: 'gid://gitlab/Group/24', - fullName: 'Gitlab Org', - parent: null, - webUrl: 'http://127.0.0.1:3000/groups/gitlab-org', - descriptionHtml: - '

    Dolorem dolorem omnis impedit cupiditate pariatur officia velit.

    ', - avatarUrl: null, - descendantGroupsCount: 1, - projectsCount: 1, - groupMembersCount: 2, - visibility: 'internal', - accessLevel: { - integerValue: 10, - }, - }, - { - id: 'gid://gitlab/Group/27', - fullName: 'Gnuwget', - parent: null, - webUrl: 'http://127.0.0.1:3000/groups/gnuwgetf', - descriptionHtml: - '

    Culpa soluta aut eius dolores est vel sapiente.

    ', - avatarUrl: null, - descendantGroupsCount: 4, - projectsCount: 2, - groupMembersCount: 3, - visibility: 'public', - accessLevel: { - integerValue: 40, - }, - }, - { - id: 'gid://gitlab/Group/31', - fullName: 'Jashkenas', - parent: null, - webUrl: 'http://127.0.0.1:3000/groups/jashkenas', - descriptionHtml: '

    Ut ut id aliquid nostrum.

    ', - avatarUrl: null, - descendantGroupsCount: 3, - projectsCount: 3, - groupMembersCount: 10, - visibility: 'private', - accessLevel: { - integerValue: 10, - }, - }, - { - id: 'gid://gitlab/Group/22', - fullName: 'Toolbox', - parent: null, - webUrl: 'http://127.0.0.1:3000/groups/toolbox', - descriptionHtml: - '

    Quo voluptatem magnam facere voluptates alias.

    ', - avatarUrl: null, - descendantGroupsCount: 2, - projectsCount: 3, - groupMembersCount: 40, - visibility: 'internal', - accessLevel: { - integerValue: 30, - }, - }, - { - id: 'gid://gitlab/Group/35', - fullName: 'Twitter', - parent: null, - webUrl: 'http://127.0.0.1:3000/groups/twitter', - descriptionHtml: - '

    Quae nulla consequatur assumenda id quo.

    ', - avatarUrl: null, - descendantGroupsCount: 20, - projectsCount: 30, - groupMembersCount: 100, - visibility: 'public', - accessLevel: { - integerValue: 40, - }, - }, - { - id: 'gid://gitlab/Group/73', - fullName: 'test', - parent: null, - webUrl: 'http://127.0.0.1:3000/groups/test', - descriptionHtml: '', - avatarUrl: null, - descendantGroupsCount: 1, - projectsCount: 1, - groupMembersCount: 1, - visibility: 'private', - accessLevel: { - integerValue: 30, - }, - }, - { - id: 'gid://gitlab/Group/74', - fullName: 'Twitter / test subgroup', - parent: { - id: 'gid://gitlab/Group/35', - }, - webUrl: 'http://127.0.0.1:3000/groups/twitter/test-subgroup', - descriptionHtml: '', - avatarUrl: null, - descendantGroupsCount: 4, - projectsCount: 4, - groupMembersCount: 4, - visibility: 'internal', - accessLevel: { - integerValue: 20, - }, - }, - ], -}; diff --git a/spec/frontend/organizations/groups_and_projects/utils_spec.js b/spec/frontend/organizations/groups_and_projects/utils_spec.js deleted file mode 100644 index 2cb1ee02061..00000000000 --- a/spec/frontend/organizations/groups_and_projects/utils_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import { formatProjects, formatGroups } from '~/organizations/groups_and_projects/utils'; -import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/projects_list/constants'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { organizationProjects, organizationGroups } from './mock_data'; - -describe('formatProjects', () => { - it('correctly formats the projects', () => { - const [firstMockProject] = organizationProjects.nodes; - const formattedProjects = formatProjects(organizationProjects.nodes); - const [firstFormattedProject] = formattedProjects; - - expect(firstFormattedProject).toMatchObject({ - id: getIdFromGraphQLId(firstMockProject.id), - name: firstMockProject.nameWithNamespace, - permissions: { - projectAccess: { - accessLevel: firstMockProject.accessLevel.integerValue, - }, - }, - actions: [ACTION_EDIT, ACTION_DELETE], - }); - expect(formattedProjects.length).toBe(organizationProjects.nodes.length); - }); -}); - -describe('formatGroups', () => { - it('correctly formats the groups', () => { - const [firstMockGroup] = organizationGroups.nodes; - const formattedGroups = formatGroups(organizationGroups.nodes); - const [firstFormattedGroup] = formattedGroups; - - expect(firstFormattedGroup.id).toBe(getIdFromGraphQLId(firstMockGroup.id)); - expect(formattedGroups.length).toBe(organizationGroups.nodes.length); - }); -}); diff --git a/spec/frontend/organizations/shared/components/groups_view_spec.js b/spec/frontend/organizations/shared/components/groups_view_spec.js new file mode 100644 index 00000000000..8d6ea60ffd2 --- /dev/null +++ b/spec/frontend/organizations/shared/components/groups_view_spec.js @@ -0,0 +1,146 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import GroupsView from '~/organizations/shared/components/groups_view.vue'; +import { formatGroups } from '~/organizations/shared/utils'; +import resolvers from '~/organizations/shared/graphql/resolvers'; +import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; +import { createAlert } from '~/alert'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { organizationGroups } from '~/organizations/mock_data'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); +jest.useFakeTimers(); + +describe('GroupsView', () => { + let wrapper; + let mockApollo; + + const defaultProvide = { + groupsEmptyStateSvgPath: 'illustrations/empty-state/empty-groups-md.svg', + newGroupPath: '/groups/new', + }; + + const createComponent = ({ mockResolvers = resolvers, propsData = {} } = {}) => { + mockApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMountExtended(GroupsView, { + apolloProvider: mockApollo, + provide: defaultProvide, + propsData, + }); + }; + + afterEach(() => { + mockApollo = null; + }); + + describe('when API call is loading', () => { + beforeEach(() => { + const mockResolvers = { + Query: { + organization: jest.fn().mockReturnValueOnce(new Promise(() => {})), + }, + }; + + createComponent({ mockResolvers }); + }); + + it('renders loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('when API call is successful', () => { + describe('when there are no groups', () => { + it('renders empty state without buttons by default', async () => { + const mockResolvers = { + Query: { + organization: jest.fn().mockResolvedValueOnce({ + groups: { nodes: [] }, + }), + }, + }; + createComponent({ mockResolvers }); + + jest.runAllTimers(); + await waitForPromises(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: "You don't have any groups yet.", + description: + 'A group is a collection of several projects. If you organize your projects under a group, it works like a folder.', + svgHeight: 144, + svgPath: defaultProvide.groupsEmptyStateSvgPath, + primaryButtonLink: null, + primaryButtonText: null, + }); + }); + + describe('when `shouldShowEmptyStateButtons` is `true` and `groupsEmptyStateSvgPath` is set', () => { + it('renders empty state with buttons', async () => { + const mockResolvers = { + Query: { + organization: jest.fn().mockResolvedValueOnce({ + groups: { nodes: [] }, + }), + }, + }; + createComponent({ mockResolvers, propsData: { shouldShowEmptyStateButtons: true } }); + + jest.runAllTimers(); + await waitForPromises(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + primaryButtonLink: defaultProvide.newGroupPath, + primaryButtonText: 'New group', + }); + }); + }); + }); + + describe('when there are groups', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders `GroupsList` component and passes correct props', async () => { + jest.runAllTimers(); + await waitForPromises(); + + expect(wrapper.findComponent(GroupsList).props()).toEqual({ + groups: formatGroups(organizationGroups.nodes), + showGroupIcon: true, + }); + }); + }); + }); + + describe('when API call is not successful', () => { + const error = new Error(); + + beforeEach(() => { + const mockResolvers = { + Query: { + organization: jest.fn().mockRejectedValueOnce(error), + }, + }; + + createComponent({ mockResolvers }); + }); + + it('displays error alert', async () => { + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: GroupsView.i18n.errorMessage, + error, + captureError: true, + }); + }); + }); +}); diff --git a/spec/frontend/organizations/shared/components/projects_view_spec.js b/spec/frontend/organizations/shared/components/projects_view_spec.js new file mode 100644 index 00000000000..490b0c89348 --- /dev/null +++ b/spec/frontend/organizations/shared/components/projects_view_spec.js @@ -0,0 +1,146 @@ +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import ProjectsView from '~/organizations/shared/components/projects_view.vue'; +import { formatProjects } from '~/organizations/shared/utils'; +import resolvers from '~/organizations/shared/graphql/resolvers'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; +import { createAlert } from '~/alert'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { organizationProjects } from '~/organizations/mock_data'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); +jest.useFakeTimers(); + +describe('ProjectsView', () => { + let wrapper; + let mockApollo; + + const defaultProvide = { + projectsEmptyStateSvgPath: 'illustrations/empty-state/empty-projects-md.svg', + newProjectPath: '/projects/new', + }; + + const createComponent = ({ mockResolvers = resolvers, propsData = {} } = {}) => { + mockApollo = createMockApollo([], mockResolvers); + + wrapper = shallowMountExtended(ProjectsView, { + apolloProvider: mockApollo, + provide: defaultProvide, + propsData, + }); + }; + + afterEach(() => { + mockApollo = null; + }); + + describe('when API call is loading', () => { + beforeEach(() => { + const mockResolvers = { + Query: { + organization: jest.fn().mockReturnValueOnce(new Promise(() => {})), + }, + }; + + createComponent({ mockResolvers }); + }); + + it('renders loading icon', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('when API call is successful', () => { + describe('when there are no projects', () => { + it('renders empty state without buttons by default', async () => { + const mockResolvers = { + Query: { + organization: jest.fn().mockResolvedValueOnce({ + projects: { nodes: [] }, + }), + }, + }; + createComponent({ mockResolvers }); + + jest.runAllTimers(); + await waitForPromises(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: "You don't have any projects yet.", + description: + 'Projects are where you can store your code, access issues, wiki, and other features of Gitlab.', + svgHeight: 144, + svgPath: defaultProvide.projectsEmptyStateSvgPath, + primaryButtonLink: null, + primaryButtonText: null, + }); + }); + + describe('when `shouldShowEmptyStateButtons` is `true` and `projectsEmptyStateSvgPath` is set', () => { + it('renders empty state with buttons', async () => { + const mockResolvers = { + Query: { + organization: jest.fn().mockResolvedValueOnce({ + projects: { nodes: [] }, + }), + }, + }; + createComponent({ mockResolvers, propsData: { shouldShowEmptyStateButtons: true } }); + + jest.runAllTimers(); + await waitForPromises(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + primaryButtonLink: defaultProvide.newProjectPath, + primaryButtonText: 'New project', + }); + }); + }); + }); + + describe('when there are projects', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders `ProjectsList` component and passes correct props', async () => { + jest.runAllTimers(); + await waitForPromises(); + + expect(wrapper.findComponent(ProjectsList).props()).toEqual({ + projects: formatProjects(organizationProjects.nodes), + showProjectIcon: true, + }); + }); + }); + }); + + describe('when API call is not successful', () => { + const error = new Error(); + + beforeEach(() => { + const mockResolvers = { + Query: { + organization: jest.fn().mockRejectedValueOnce(error), + }, + }; + + createComponent({ mockResolvers }); + }); + + it('displays error alert', async () => { + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: ProjectsView.i18n.errorMessage, + error, + captureError: true, + }); + }); + }); +}); diff --git a/spec/frontend/organizations/shared/utils_spec.js b/spec/frontend/organizations/shared/utils_spec.js new file mode 100644 index 00000000000..778a18ab2bc --- /dev/null +++ b/spec/frontend/organizations/shared/utils_spec.js @@ -0,0 +1,39 @@ +import { formatProjects, formatGroups } from '~/organizations/shared/utils'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { organizationProjects, organizationGroups } from '~/organizations/mock_data'; + +describe('formatProjects', () => { + it('correctly formats the projects', () => { + const [firstMockProject] = organizationProjects.nodes; + const formattedProjects = formatProjects(organizationProjects.nodes); + const [firstFormattedProject] = formattedProjects; + + expect(firstFormattedProject).toMatchObject({ + id: getIdFromGraphQLId(firstMockProject.id), + name: firstMockProject.nameWithNamespace, + permissions: { + projectAccess: { + accessLevel: firstMockProject.accessLevel.integerValue, + }, + }, + availableActions: [ACTION_EDIT, ACTION_DELETE], + }); + expect(formattedProjects.length).toBe(organizationProjects.nodes.length); + }); +}); + +describe('formatGroups', () => { + it('correctly formats the groups', () => { + const [firstMockGroup] = organizationGroups.nodes; + const formattedGroups = formatGroups(organizationGroups.nodes); + const [firstFormattedGroup] = formattedGroups; + + expect(firstFormattedGroup).toMatchObject({ + id: getIdFromGraphQLId(firstMockGroup.id), + editPath: `${firstFormattedGroup.webUrl}/-/edit`, + availableActions: [ACTION_EDIT, ACTION_DELETE], + }); + expect(formattedGroups.length).toBe(organizationGroups.nodes.length); + }); +}); diff --git a/spec/frontend/organizations/show/components/app_spec.js b/spec/frontend/organizations/show/components/app_spec.js new file mode 100644 index 00000000000..46496e40bdd --- /dev/null +++ b/spec/frontend/organizations/show/components/app_spec.js @@ -0,0 +1,49 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import App from '~/organizations/show/components/app.vue'; +import OrganizationAvatar from '~/organizations/show/components/organization_avatar.vue'; +import GroupsAndProjects from '~/organizations/show/components/groups_and_projects.vue'; +import AssociationCount from '~/organizations/show/components/association_counts.vue'; + +describe('OrganizationShowApp', () => { + let wrapper; + + const defaultPropsData = { + organization: { + id: 1, + name: 'GitLab', + }, + associationCounts: { + groups: 10, + projects: 5, + users: 6, + }, + groupsAndProjectsOrganizationPath: '/-/organizations/default/groups_and_projects', + }; + + const createComponent = () => { + wrapper = shallowMountExtended(App, { propsData: defaultPropsData }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders organization avatar and passes organization prop', () => { + expect(wrapper.findComponent(OrganizationAvatar).props('organization')).toEqual( + defaultPropsData.organization, + ); + }); + + it('renders groups and projects component and passes `groupsAndProjectsOrganizationPath` prop', () => { + expect( + wrapper.findComponent(GroupsAndProjects).props('groupsAndProjectsOrganizationPath'), + ).toEqual(defaultPropsData.groupsAndProjectsOrganizationPath); + }); + + it('renders associations count component and passes expected props', () => { + expect(wrapper.findComponent(AssociationCount).props()).toEqual({ + associationCounts: defaultPropsData.associationCounts, + groupsAndProjectsOrganizationPath: defaultPropsData.groupsAndProjectsOrganizationPath, + }); + }); +}); diff --git a/spec/frontend/organizations/show/components/association_count_card_spec.js b/spec/frontend/organizations/show/components/association_count_card_spec.js new file mode 100644 index 00000000000..752a02110b6 --- /dev/null +++ b/spec/frontend/organizations/show/components/association_count_card_spec.js @@ -0,0 +1,48 @@ +import { GlCard, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import AssociationCountCard from '~/organizations/show/components/association_count_card.vue'; + +describe('AssociationCountCard', () => { + let wrapper; + + const defaultPropsData = { + title: 'Groups', + iconName: 'group', + count: 1050, + linkHref: '/-/organizations/default/groups_and_projects?display=groups', + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(AssociationCountCard, { + propsData: { ...defaultPropsData, ...propsData }, + }); + }; + + const findCard = () => wrapper.findComponent(GlCard); + const findLink = () => findCard().findComponent(GlLink); + + it('renders card with title, link and count', () => { + createComponent(); + + const card = findCard(); + const link = findLink(); + + expect(card.text()).toContain(defaultPropsData.title); + expect(card.text()).toContain('1k'); + expect(link.text()).toBe('View all'); + expect(link.attributes('href')).toBe(defaultPropsData.linkHref); + }); + + describe('when `linkText` prop is set', () => { + const linkText = 'Manage'; + beforeEach(() => { + createComponent({ + propsData: { linkText }, + }); + }); + + it('sets link text', () => { + expect(findLink().text()).toBe(linkText); + }); + }); +}); diff --git a/spec/frontend/organizations/show/components/association_counts_spec.js b/spec/frontend/organizations/show/components/association_counts_spec.js new file mode 100644 index 00000000000..80e57ede502 --- /dev/null +++ b/spec/frontend/organizations/show/components/association_counts_spec.js @@ -0,0 +1,61 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import AssociationCounts from '~/organizations/show/components/association_counts.vue'; +import AssociationCountCard from '~/organizations/show/components/association_count_card.vue'; + +describe('AssociationCounts', () => { + let wrapper; + + const defaultPropsData = { + associationCounts: { + groups: 10, + projects: 5, + users: 6, + }, + groupsAndProjectsOrganizationPath: '/-/organizations/default/groups_and_projects', + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMountExtended(AssociationCounts, { + propsData: { ...defaultPropsData, ...propsData }, + }); + }; + + const findAssociationCountCardAt = (index) => + wrapper.findAllComponents(AssociationCountCard).at(index); + + it('renders groups association count card', () => { + createComponent(); + + expect(findAssociationCountCardAt(0).props()).toEqual({ + title: 'Groups', + iconName: 'group', + count: defaultPropsData.associationCounts.groups, + linkText: 'View all', + linkHref: '/-/organizations/default/groups_and_projects?display=groups', + }); + }); + + it('renders projects association count card', () => { + createComponent(); + + expect(findAssociationCountCardAt(1).props()).toEqual({ + title: 'Projects', + iconName: 'project', + count: defaultPropsData.associationCounts.projects, + linkText: 'View all', + linkHref: '/-/organizations/default/groups_and_projects?display=projects', + }); + }); + + it('renders users association count card', () => { + createComponent(); + + expect(findAssociationCountCardAt(2).props()).toEqual({ + title: 'Users', + iconName: 'users', + count: defaultPropsData.associationCounts.users, + linkText: 'Manage', + linkHref: '/', + }); + }); +}); diff --git a/spec/frontend/organizations/show/components/groups_and_projects_spec.js b/spec/frontend/organizations/show/components/groups_and_projects_spec.js new file mode 100644 index 00000000000..83970d4e76d --- /dev/null +++ b/spec/frontend/organizations/show/components/groups_and_projects_spec.js @@ -0,0 +1,106 @@ +import { GlCollapsibleListbox, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GroupsAndProjects from '~/organizations/show/components/groups_and_projects.vue'; +import { createRouter } from '~/organizations/show'; +import GroupsView from '~/organizations/shared/components/groups_view.vue'; +import ProjectsView from '~/organizations/shared/components/projects_view.vue'; + +describe('OrganizationShowGroupsAndProjects', () => { + const router = createRouter(); + const routerMock = { + push: jest.fn(), + }; + const defaultPropsData = { + groupsAndProjectsOrganizationPath: '/-/organizations/default/groups_and_projects', + }; + + let wrapper; + + const createComponent = ({ routeQuery = {} } = {}) => { + wrapper = shallowMountExtended(GroupsAndProjects, { + router, + mocks: { $route: { path: '/', query: routeQuery }, $router: routerMock }, + propsData: defaultPropsData, + }); + }; + + const findCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); + + it('renders listbox with expected props', () => { + createComponent(); + + expect(findCollapsibleListbox().props()).toMatchObject({ + items: [ + { + value: 'frequently_visited_projects', + text: 'Frequently visited projects', + }, + { + value: 'frequently_visited_groups', + text: 'Frequently visited groups', + }, + ], + selected: 'frequently_visited_projects', + }); + }); + + describe.each` + displayQueryParam | expectedViewAllLinkQuery | expectedViewComponent | expectedDisplayListboxSelectedProp + ${'frequently_visited_projects'} | ${'?display=projects'} | ${ProjectsView} | ${'frequently_visited_projects'} + ${'frequently_visited_groups'} | ${'?display=groups'} | ${GroupsView} | ${'frequently_visited_groups'} + ${'unsupported'} | ${'?display=projects'} | ${ProjectsView} | ${'frequently_visited_projects'} + `( + 'when display query param is $displayQueryParam', + ({ + displayQueryParam, + expectedViewAllLinkQuery, + expectedViewComponent, + expectedDisplayListboxSelectedProp, + }) => { + beforeEach(() => { + createComponent({ routeQuery: { display: displayQueryParam } }); + }); + + it('sets listbox `selected` prop correctly', () => { + expect(findCollapsibleListbox().props('selected')).toBe(expectedDisplayListboxSelectedProp); + }); + + it('renders "View all" link with correct href', () => { + expect(wrapper.findComponent(GlLink).attributes('href')).toBe( + `${defaultPropsData.groupsAndProjectsOrganizationPath}${expectedViewAllLinkQuery}`, + ); + }); + + it('renders expected view', () => { + expect( + wrapper.findComponent(expectedViewComponent).props('shouldShowEmptyStateButtons'), + ).toBe(true); + }); + }, + ); + + it('renders label and associates listbox with it', () => { + createComponent(); + + const expectedId = 'display-listbox-label'; + + expect(wrapper.findByTestId('label').attributes('id')).toBe(expectedId); + expect(findCollapsibleListbox().props('toggleAriaLabelledBy')).toBe(expectedId); + }); + + describe('when listbox item is selected', () => { + const selectValue = 'frequently_visited_groups'; + + beforeEach(() => { + createComponent(); + + findCollapsibleListbox().vm.$emit('select', selectValue); + }); + + it('updates `display` query param', () => { + expect(routerMock.push).toHaveBeenCalledWith({ + query: { display: selectValue }, + }); + }); + }); +}); diff --git a/spec/frontend/organizations/show/components/organization_avatar_spec.js b/spec/frontend/organizations/show/components/organization_avatar_spec.js new file mode 100644 index 00000000000..c98fa14e49b --- /dev/null +++ b/spec/frontend/organizations/show/components/organization_avatar_spec.js @@ -0,0 +1,64 @@ +import { GlAvatar, GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import OrganizationAvatar from '~/organizations/show/components/organization_avatar.vue'; +import { + VISIBILITY_TYPE_ICON, + ORGANIZATION_VISIBILITY_TYPE, + VISIBILITY_LEVEL_PUBLIC_STRING, +} from '~/visibility_level/constants'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +describe('OrganizationAvatar', () => { + let wrapper; + + const defaultPropsData = { + organization: { + id: 1, + name: 'GitLab', + }, + }; + + const createComponent = () => { + wrapper = shallowMountExtended(OrganizationAvatar, { + propsData: defaultPropsData, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders avatar', () => { + expect(wrapper.findComponent(GlAvatar).props()).toMatchObject({ + entityId: defaultPropsData.organization.id, + entityName: defaultPropsData.organization.name, + }); + }); + + it('renders organization name', () => { + expect( + wrapper.findByRole('heading', { name: defaultPropsData.organization.name }).exists(), + ).toBe(true); + }); + + it('renders visibility icon', () => { + const icon = wrapper.findComponent(GlIcon); + const tooltip = getBinding(icon.element, 'gl-tooltip'); + + expect(icon.props('name')).toBe(VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_PUBLIC_STRING]); + expect(tooltip.value).toBe(ORGANIZATION_VISIBILITY_TYPE[VISIBILITY_LEVEL_PUBLIC_STRING]); + }); + + it('renders button to copy organization ID', () => { + expect(wrapper.findComponent(ClipboardButton).props()).toMatchObject({ + category: 'tertiary', + title: 'Copy organization ID', + text: '1', + size: 'small', + }); + }); +}); diff --git a/spec/frontend/organizations/show/utils_spec.js b/spec/frontend/organizations/show/utils_spec.js new file mode 100644 index 00000000000..583f105c8c0 --- /dev/null +++ b/spec/frontend/organizations/show/utils_spec.js @@ -0,0 +1,20 @@ +import { buildDisplayListboxItem } from '~/organizations/show/utils'; +import { RESOURCE_TYPE_PROJECTS } from '~/organizations/constants'; +import { FILTER_FREQUENTLY_VISITED } from '~/organizations/show/constants'; + +describe('buildDisplayListboxItem', () => { + it('returns list item in correct format', () => { + const text = 'Frequently visited projects'; + + expect( + buildDisplayListboxItem({ + filter: FILTER_FREQUENTLY_VISITED, + resourceType: RESOURCE_TYPE_PROJECTS, + text, + }), + ).toEqual({ + text, + value: 'frequently_visited_projects', + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap index 5f191ef5561..771fb9e4e08 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap @@ -10,7 +10,6 @@ exports[`TagsLoader component has the correct markup 1`] = ` x="0" y="12.5" /> - - - - - -

    - With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. + With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here.

    - With the Container Registry, every project can have its own space to store its Docker images. + With the Container Registry, every project can have its own space to store its Docker images.

    -
    CLI Commands
    -

    - If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have + If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have Two-Factor Authentication - enabled, use a + enabled, use a Personal Access Token - instead of a password. + instead of a password.

    - -

    - - You can add an image to this registry with the following commands: - + You can add an image to this registry with the following commands:

    - - { let wrapper; + let router; let apolloProvider; let resolver; let mock; @@ -53,15 +55,14 @@ describe('DependencyProxyApp', () => { const requestHandlers = [[getDependencyProxyDetailsQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); + router = createRouter('/'); wrapper = shallowMountExtended(DependencyProxyApp, { apolloProvider, provide, + router, stubs: { GlAlert, - GlDropdown, - GlDropdownItem, - GlFormInputGroup, GlFormGroup, GlModal, GlSprintf, @@ -79,7 +80,7 @@ describe('DependencyProxyApp', () => { const findProxyCountText = () => wrapper.findByTestId('proxy-count'); const findManifestList = () => wrapper.findComponent(ManifestsList); const findLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findClearCacheDropdownList = () => wrapper.findComponent(GlDropdown); + const findClearCacheDropdownList = () => wrapper.findComponent(GlDisclosureDropdown); const findClearCacheModal = () => wrapper.findComponent(GlModal); const findClearCacheAlert = () => wrapper.findComponent(GlAlert); const findSettingsLink = () => wrapper.findByTestId('settings-link'); @@ -94,6 +95,7 @@ describe('DependencyProxyApp', () => { mock = new MockAdapter(axios); mock.onDelete(expectedUrl).reply(HTTP_STATUS_ACCEPTED, {}); + setWindowLocation(TEST_HOST); }); afterEach(() => { @@ -123,6 +125,13 @@ describe('DependencyProxyApp', () => { return waitForPromises(); }); + it('resolver is called with right arguments', () => { + expect(resolver).toHaveBeenCalledWith({ + first: GRAPHQL_PAGE_SIZE, + fullPath: provideDefaults.groupPath, + }); + }); + it('renders a form group with a label', () => { expect(findFormGroup().attributes('label')).toBe( DependencyProxyApp.i18n.proxyImagePrefix, @@ -225,6 +234,7 @@ describe('DependencyProxyApp', () => { fullPath: provideDefaults.groupPath, last: GRAPHQL_PAGE_SIZE, }); + expect(window.location.search).toBe(`?before=${pagination().startCursor}`); }); }); @@ -252,6 +262,7 @@ describe('DependencyProxyApp', () => { first: GRAPHQL_PAGE_SIZE, fullPath: provideDefaults.groupPath, }); + expect(window.location.search).toBe(`?after=${pagination().endCursor}`); }); }); @@ -270,7 +281,7 @@ describe('DependencyProxyApp', () => { expect(findClearCacheDropdownList().exists()).toBe(true); const clearCacheDropdownItem = findClearCacheDropdownList().findComponent( - GlDropdownItem, + GlDisclosureDropdownItem, ); expect(clearCacheDropdownItem.text()).toBe('Clear cache'); @@ -315,6 +326,48 @@ describe('DependencyProxyApp', () => { }); }); }); + + describe('pagination params', () => { + it('after is set from the url params', async () => { + setWindowLocation('?after=1234'); + createComponent(); + await waitForPromises(); + + expect(resolver).toHaveBeenCalledWith({ + first: GRAPHQL_PAGE_SIZE, + after: '1234', + fullPath: provideDefaults.groupPath, + }); + }); + + it('before is set from the url params', async () => { + setWindowLocation('?before=1234'); + createComponent(); + await waitForPromises(); + + expect(resolver).toHaveBeenCalledWith({ + first: null, + last: GRAPHQL_PAGE_SIZE, + before: '1234', + fullPath: provideDefaults.groupPath, + }); + }); + + describe('when url params are changed', () => { + it('after is set from the url params', async () => { + createComponent(); + await waitForPromises(); + router.push('?after=1234'); + await waitForPromises(); + + expect(resolver).toHaveBeenCalledWith({ + first: GRAPHQL_PAGE_SIZE, + after: '1234', + fullPath: provideDefaults.groupPath, + }); + }); + }); + }); }); }); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/utils_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/utils_spec.js new file mode 100644 index 00000000000..72072c08537 --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/utils_spec.js @@ -0,0 +1,25 @@ +import { getPageParams } from '~/packages_and_registries/dependency_proxy/utils'; + +describe('getPageParams', () => { + it('should return the previous page params if before cursor is available', () => { + const pageInfo = { before: 'abc123' }; + expect(getPageParams(pageInfo)).toEqual({ + first: null, + before: pageInfo.before, + last: 20, + }); + }); + + it('should return the next page params if after cursor is available', () => { + const pageInfo = { after: 'abc123' }; + expect(getPageParams(pageInfo)).toEqual({ + after: pageInfo.after, + first: 20, + }); + }); + + it('should return an empty object if both before and after cursors are not available', () => { + const pageInfo = {}; + expect(getPageParams(pageInfo)).toEqual({}); + }); +}); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap index f95564e3fad..8e757c136ec 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap @@ -2,18 +2,13 @@ exports[`FileSha renders 1`] = `
    - -
    - - bar: - foo - + bar: foo Provision instructions - -

    Registry setup

    - - diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap index d0841c6110f..7f26ed778a5 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -6,12 +6,10 @@ exports[`packages_list_app renders 1`] = ` count="1" helpurl="foo" /> - -
    -
    - - There are no packages yet - + There are no packages yet -

    - Learn how to + Learn how to publish and share your packages - with GitLab. + with GitLab.

    -
    - - - -
    + />
    diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap index 250b33cbb14..edba81da1f5 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/shared/__snapshots__/package_list_row_spec.js.snap @@ -2,28 +2,26 @@ exports[`packages_list_row renders 1`] = `
    - -
    @@ -32,17 +30,10 @@ exports[`packages_list_row renders 1`] = ` text="Test package" /> - - - -
    - -
    -
    1.0.0 - - -
    -
    -
    -
    -
    - -
    `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap index b3d0d88be4d..cfdaebd889d 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/conan_installation_spec.js.snap @@ -6,7 +6,6 @@ exports[`ConanInstallation renders all the messages 1`] = ` options="[object Object]" packagetype="conan" /> - -

    Registry setup

    - - For more information on the Conan registry, + For more information on the Conan registry,
    Ninject.Extensions.Factory - - (.NETCoreApp3.1) -
    -
    - -
    - - bar: - foo - + bar: foo - - - - -

    - Copy and paste this inside your + Copy and paste this inside your pom.xml - dependencies - block. + block.

    - + + appGroup + + + appName + + + appVersion + + label="" multiline="true" trackingaction="copy_maven_xml" trackinglabel="code_instruction" /> - -

    Registry setup

    -

    - If you haven't already done so, you will need to add the below to your + If you haven't already done so, you will need to add the below to your pom.xml - file. + file.

    - + + + gitlab-maven + + + http://gdk.test:3000/api/v4/projects/1/packages/maven + + + label="" multiline="true" trackingaction="copy_maven_setup_xml" trackinglabel="code_instruction" /> - For more information on the Maven registry, + For more information on the Maven registry, - -

    Registry setup

    - - - You may also need to setup authentication using an auth token. + You may also need to setup authentication using an auth token. See the documentation - to find out more. + to find out more.
    `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap index 92930a6309a..554d4e08523 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/nuget_installation_spec.js.snap @@ -6,7 +6,6 @@ exports[`NugetInstallation renders all the messages 1`] = ` options="[object Object]" packagetype="nuget" /> - -

    Registry setup

    - -Password " @@ -28,7 +25,7 @@ exports[`NugetInstallation renders all the messages 1`] = ` trackingaction="copy_nuget_setup_command" trackinglabel="code_instruction" /> - For more information on the NuGet registry, + For more information on the NuGet registry,

    Installation

    -
    -
    - - - - -
      @@ -81,11 +67,9 @@ exports[`PypiInstallation renders all the messages 1`] = ` class="top-scrim top-scrim-light" /> -
    • - - - Show PyPi commands - + Show PyPi commands
    • - - - - -
    - - -
    -
    - - - - - - - + />
    -
    - - - - -
    - - - You will need a + You will need a
    -

    Registry setup

    -

    - If you haven't already done so, you will need to add the below to your + If you haven't already done so, you will need to add the below to your .pypirc - file. + file.

    -
    - -
    -        [gitlab]
    -repository = http://gdk.test:3000/api/v4/projects/1/packages/pypi
    -username = __token__
    -password = <your personal access token>
    +        [gitlab]repository = http://gdk.test:3000/api/v4/projects/1/packages/pypiusername = __token__password = <your personal access token>
           
    - For more information on the PyPi registry, + For more information on the PyPi registry,
    -
    - -
    - -
    -
    -
    -
    -
    - Published + Published
    -
    - Delete package -
    - -
    `; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap index 4407c4a2003..f202635d717 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap @@ -2,27 +2,24 @@ exports[`publish_method renders 1`] = `
    - master - - b83d6e39 - { const defaultProps = { list: [firstPackage, secondPackage], isLoading: false, - pageInfo: {}, groupSettings: defaultPackageGroupSettings, }; @@ -113,7 +112,6 @@ describe('packages_list', () => { expect(findRegistryList().props()).toMatchObject({ title: '2 packages', items: defaultProps.list, - pagination: defaultProps.pageInfo, hiddenDelete: false, isLoading: false, }); @@ -314,22 +312,4 @@ describe('packages_list', () => { expect(emptySlot.exists()).toBe(true); }); }); - - describe('pagination', () => { - beforeEach(() => { - mountComponent({ props: { pageInfo: { hasPreviousPage: true } } }); - }); - - it('emits prev-page events when the prev event is fired', () => { - findRegistryList().vm.$emit('prev-page'); - - expect(wrapper.emitted('prev-page')).toHaveLength(1); - }); - - it('emits next-page events when the next event is fired', () => { - findRegistryList().vm.$emit('next-page'); - - expect(wrapper.emitted('next-page')).toHaveLength(1); - }); - }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index 82fa5b76367..f4e36f51c27 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -3,19 +3,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { sortableFields } from '~/packages_and_registries/package_registry/utils'; import component from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageTypeToken from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue'; -import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; -import UrlSync from '~/vue_shared/components/url_sync.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants'; -import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; import { TOKEN_TYPE_TYPE } from '~/vue_shared/components/filtered_search_bar/constants'; -jest.mock('~/packages_and_registries/shared/utils'); - -useMockLocationHelper(); - describe('Package Search', () => { let wrapper; @@ -24,8 +17,7 @@ describe('Package Search', () => { sorting: { sort: 'desc' }, }; - const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); - const findUrlSync = () => wrapper.findComponent(UrlSync); + const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const mountComponent = (isGroupPage = false) => { @@ -36,34 +28,23 @@ describe('Package Search', () => { }; }, stubs: { - UrlSync, LocalStorageSync, }, }); }; - beforeEach(() => { - extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock); - }); - it('has a registry search component', async () => { mountComponent(); await nextTick(); - expect(findRegistrySearch().exists()).toBe(true); + expect(findPersistedSearch().exists()).toBe(true); }); it('registry search is mounted after mount', () => { mountComponent(); - expect(findRegistrySearch().exists()).toBe(false); - }); - - it('has a UrlSync component', () => { - mountComponent(); - - expect(findUrlSync().exists()).toBe(true); + expect(findPersistedSearch().exists()).toBe(false); }); it('has a LocalStorageSync component', () => { @@ -87,7 +68,7 @@ describe('Package Search', () => { await nextTick(); - expect(findRegistrySearch().props()).toMatchObject({ + expect(findPersistedSearch().props()).toMatchObject({ tokens: expect.arrayContaining([ expect.objectContaining({ token: PackageTypeToken, @@ -99,85 +80,63 @@ describe('Package Search', () => { }); }); - it('on sorting:changed emits update event and update internal sort', async () => { - const payload = { sort: 'foo' }; + it('on update event re-emits update event and updates internal sort', async () => { + const payload = { + sort: 'CREATED_FOO', + filters: defaultQueryParamsMock.filters, + sorting: { sort: 'foo', orderBy: 'created_at' }, + }; mountComponent(); await nextTick(); - findRegistrySearch().vm.$emit('sorting:changed', payload); + findPersistedSearch().vm.$emit('update', payload); await nextTick(); - expect(findRegistrySearch().props('sorting')).toEqual({ sort: 'foo', orderBy: 'created_at' }); + expect(findLocalStorageSync().props('value')).toEqual({ sort: 'foo', orderBy: 'created_at' }); - // there is always a first call on mounted that emits up default values - expect(wrapper.emitted('update')[1]).toEqual([ + expect(wrapper.emitted('update')[0]).toEqual([ { filters: { packageName: '', packageType: undefined, }, - sort: 'CREATED_FOO', + sort: payload.sort, + sorting: payload.sorting, }, ]); }); - it('on filter:changed updates the filters', async () => { - const payload = ['foo']; + it('on update event, re-emits update event with formatted filters', async () => { + const payload = { + sort: 'CREATED_FOO', + filters: [ + { type: 'type', value: { data: 'Generic', operator: '=' }, id: 'token-3' }, + { id: 'token-4', type: 'filtered-search-term', value: { data: 'gl' } }, + { id: 'token-5', type: 'filtered-search-term', value: { data: '' } }, + ], + sorting: { sort: 'foo', orderBy: 'created_at' }, + }; mountComponent(); await nextTick(); - findRegistrySearch().vm.$emit('filter:changed', payload); + findPersistedSearch().vm.$emit('update', payload); await nextTick(); - expect(findRegistrySearch().props('filters')).toEqual(['foo']); - }); - - it('on filter:submit emits update event', async () => { - mountComponent(); - - await nextTick(); - - findRegistrySearch().vm.$emit('filter:submit'); - - expect(wrapper.emitted('update')[1]).toEqual([ + expect(wrapper.emitted('update')[0]).toEqual([ { filters: { - packageName: '', - packageType: undefined, + packageName: 'gl', + packageType: 'GENERIC', }, - sort: 'CREATED_DESC', + sort: payload.sort, + sorting: payload.sorting, }, ]); }); - - it('on query:changed calls updateQuery from UrlSync', async () => { - jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {}); - - mountComponent(); - - await nextTick(); - - findRegistrySearch().vm.$emit('query:changed'); - - expect(UrlSync.methods.updateQuery).toHaveBeenCalled(); - }); - - it('sets the component sorting and filtering based on the querystring', async () => { - mountComponent(); - - await nextTick(); - - expect(getQueryParams).toHaveBeenCalled(); - - expect(findRegistrySearch().props()).toMatchObject({ - filters: defaultQueryParamsMock.filters, - sorting: defaultQueryParamsMock.sorting, - }); - }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js index 0d262036ee7..0ce2b86b9a4 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/list_spec.js @@ -17,7 +17,7 @@ import { EMPTY_LIST_HELP_URL, PACKAGE_HELP_URL, } from '~/packages_and_registries/package_registry/constants'; - +import PersistedPagination from '~/packages_and_registries/shared/components/persisted_pagination.vue'; import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql'; import { packagesListQuery, packageData, pagination } from '../mock_data'; @@ -53,6 +53,7 @@ describe('PackagesListApp', () => { const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findDeletePackages = () => wrapper.findComponent(DeletePackages); const findSettingsLink = () => wrapper.findComponent(GlButton); + const findPagination = () => wrapper.findComponent(PersistedPagination); const mountComponent = ({ resolver = jest.fn().mockResolvedValue(packagesListQuery()), @@ -99,6 +100,15 @@ describe('PackagesListApp', () => { expect(resolver).not.toHaveBeenCalled(); }); + it('has persisted pagination', async () => { + const resolver = jest.fn().mockResolvedValue(packagesListQuery()); + + mountComponent({ resolver }); + await waitForFirstRequest(); + + expect(findPagination().props('pagination')).toEqual(pagination()); + }); + it('has a package title', async () => { mountComponent(); @@ -194,7 +204,6 @@ describe('PackagesListApp', () => { expect(findListComponent().props()).toMatchObject({ list: expect.arrayContaining([expect.objectContaining({ id: packageData().id })]), isLoading: false, - pageInfo: expect.objectContaining({ endCursor: pagination().endCursor }), groupSettings: expect.objectContaining({ mavenPackageRequestsForwarding: true, npmPackageRequestsForwarding: true, @@ -203,9 +212,9 @@ describe('PackagesListApp', () => { }); }); - it('when list emits next-page fetches the next set of records', async () => { + it('when pagination emits next event fetches the next set of records', async () => { await waitForFirstRequest(); - findListComponent().vm.$emit('next-page'); + findPagination().vm.$emit('next'); await waitForPromises(); expect(resolver).toHaveBeenCalledWith( @@ -213,9 +222,9 @@ describe('PackagesListApp', () => { ); }); - it('when list emits prev-page fetches the prev set of records', async () => { + it('when pagination emits prev event fetches the prev set of records', async () => { await waitForFirstRequest(); - findListComponent().vm.$emit('prev-page'); + findPagination().vm.$emit('prev'); await waitForPromises(); expect(resolver).toHaveBeenCalledWith( diff --git a/spec/frontend/packages_and_registries/package_registry/utils_spec.js b/spec/frontend/packages_and_registries/package_registry/utils_spec.js index 019f94aaec2..ecb5a8a77f1 100644 --- a/spec/frontend/packages_and_registries/package_registry/utils_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/utils_spec.js @@ -1,4 +1,9 @@ -import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils'; +import { + getPackageTypeLabel, + getNextPageParams, + getPreviousPageParams, + getPageParams, +} from '~/packages_and_registries/package_registry/utils'; describe('Packages shared utils', () => { describe('getPackageTypeLabel', () => { @@ -21,3 +26,48 @@ describe('Packages shared utils', () => { }); }); }); + +describe('getNextPageParams', () => { + it('should return the next page params with the provided cursor', () => { + const cursor = 'abc123'; + expect(getNextPageParams(cursor)).toEqual({ + after: cursor, + first: 20, + }); + }); +}); + +describe('getPreviousPageParams', () => { + it('should return the previous page params with the provided cursor', () => { + const cursor = 'abc123'; + expect(getPreviousPageParams(cursor)).toEqual({ + first: null, + before: cursor, + last: 20, + }); + }); +}); + +describe('getPageParams', () => { + it('should return the previous page params if before cursor is available', () => { + const pageInfo = { before: 'abc123' }; + expect(getPageParams(pageInfo)).toEqual({ + first: null, + before: pageInfo.before, + last: 20, + }); + }); + + it('should return the next page params if after cursor is available', () => { + const pageInfo = { after: 'abc123' }; + expect(getPageParams(pageInfo)).toEqual({ + after: pageInfo.after, + first: 20, + }); + }); + + it('should return an empty object if both before and after cursors are not available', () => { + const pageInfo = {}; + expect(getPageParams(pageInfo)).toEqual({}); + }); +}); 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 5d08574234c..d3298984f9d 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 @@ -2,7 +2,7 @@ exports[`Container Expiration Policy Settings Form Cadence matches snapshot 1`] = ` - branch-name - - sha-baz - - - - - +
  • - -
  • - - - - +
  • - - `; @@ -57,17 +45,11 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` > - - - - + - - `; diff --git a/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js b/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js index 328f83394f9..9041cb757ab 100644 --- a/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports @@ -23,7 +23,7 @@ Vue.use(Vuex); describe('cli_commands', () => { let wrapper; - const findDropdownButton = () => wrapper.findComponent(GlDropdown); + const findDropdownButton = () => wrapper.findComponent(GlDisclosureDropdown); const findCodeInstruction = () => wrapper.findAllComponents(CodeInstruction); const mountComponent = () => { diff --git a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js index 296caf091d5..615fba2e282 100644 --- a/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/persisted_search_spec.js @@ -86,6 +86,7 @@ describe('Persisted Search', () => { after: '123', before: null, }, + sorting: defaultQueryParamsMock.sorting, }, ]); }); @@ -109,6 +110,7 @@ describe('Persisted Search', () => { { filters: [], sort: 'TEST_DESC', + sorting: defaultQueryParamsMock.sorting, pageInfo: { before: '456', after: null, @@ -136,6 +138,7 @@ describe('Persisted Search', () => { filters: ['foo'], sort: 'TEST_DESC', pageInfo: {}, + sorting: payload, }, ]); }); @@ -169,6 +172,7 @@ describe('Persisted Search', () => { after: '123', before: null, }, + sorting: defaultQueryParamsMock.sorting, }, ]); }); diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js deleted file mode 100644 index 6cf30e84288..00000000000 --- a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import $ from 'jquery'; -import htmlAbuseReportsList from 'test_fixtures/abuse_reports/abuse_reports_list.html'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import AbuseReports from '~/pages/admin/abuse_reports/abuse_reports'; - -describe('Abuse Reports', () => { - const MAX_MESSAGE_LENGTH = 500; - - let $messages; - - const assertMaxLength = ($message) => { - expect($message.text().length).toEqual(MAX_MESSAGE_LENGTH); - }; - const findMessage = (searchText) => - $messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first(); - - beforeEach(() => { - setHTMLFixture(htmlAbuseReportsList); - new AbuseReports(); // eslint-disable-line no-new - $messages = $('.abuse-reports .message'); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('should truncate long messages', () => { - const $longMessage = findMessage('LONG MESSAGE'); - - expect($longMessage.data('originalMessage')).toEqual(expect.anything()); - assertMaxLength($longMessage); - }); - - it('should not truncate short messages', () => { - const $shortMessage = findMessage('SHORT MESSAGE'); - - expect($shortMessage.data('originalMessage')).not.toEqual(expect.anything()); - }); - - it('should allow clicking a truncated message to expand and collapse the full message', () => { - const $longMessage = findMessage('LONG MESSAGE'); - $longMessage.click(); - - expect($longMessage.data('originalMessage').length).toEqual($longMessage.text().length); - $longMessage.click(); - assertMaxLength($longMessage); - }); -}); diff --git a/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js deleted file mode 100644 index d90393d8ab3..00000000000 --- a/spec/frontend/pages/admin/jobs/components/cancel_jobs_modal_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { nextTick } from 'vue'; -import { mount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; -import { TEST_HOST } from 'helpers/test_constants'; -import axios from '~/lib/utils/axios_utils'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import CancelJobsModal from '~/pages/admin/jobs/components/cancel_jobs_modal.vue'; -import { setVueErrorHandler } from '../../../../__helpers__/set_vue_error_handler'; - -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - redirectTo: jest.fn(), -})); - -describe('Cancel jobs modal', () => { - const props = { - url: `${TEST_HOST}/cancel_jobs_modal.vue/cancelAll`, - modalId: 'cancel-jobs-modal', - }; - let wrapper; - - beforeEach(() => { - wrapper = mount(CancelJobsModal, { propsData: props }); - }); - - describe('on submit', () => { - it('cancels jobs and redirects to overview page', async () => { - const responseURL = `${TEST_HOST}/cancel_jobs_modal.vue/jobs`; - // TODO: We can't use axios-mock-adapter because our current version - // does not support responseURL - // - // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details - jest.spyOn(axios, 'post').mockImplementation((url) => { - expect(url).toBe(props.url); - return Promise.resolve({ - request: { - responseURL, - }, - }); - }); - - wrapper.findComponent(GlModal).vm.$emit('primary'); - await nextTick(); - - expect(redirectTo).toHaveBeenCalledWith(responseURL); // eslint-disable-line import/no-deprecated - }); - - it('displays error if canceling jobs failed', async () => { - const dummyError = new Error('canceling jobs failed'); - // TODO: We can't use axios-mock-adapter because our current version - // does not support responseURL - // - // see https://gitlab.com/gitlab-org/gitlab/-/issues/375308 for details - jest.spyOn(axios, 'post').mockImplementation((url) => { - expect(url).toBe(props.url); - return Promise.reject(dummyError); - }); - - setVueErrorHandler({ instance: wrapper.vm, handler: () => {} }); // silencing thrown error - wrapper.findComponent(GlModal).vm.$emit('primary'); - await nextTick(); - - expect(redirectTo).not.toHaveBeenCalled(); // eslint-disable-line import/no-deprecated - }); - }); -}); diff --git a/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js b/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js index d94de48f238..2884e4ed521 100644 --- a/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js +++ b/spec/frontend/pages/admin/jobs/components/cancel_jobs_spec.js @@ -2,12 +2,9 @@ import { GlButton } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { TEST_HOST } from 'helpers/test_constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue'; -import CancelJobsModal from '~/pages/admin/jobs/components/cancel_jobs_modal.vue'; -import { - CANCEL_JOBS_MODAL_ID, - CANCEL_BUTTON_TOOLTIP, -} from '~/pages/admin/jobs/components/constants'; +import CancelJobs from '~/ci/admin/jobs_table/components/cancel_jobs.vue'; +import CancelJobsModal from '~/ci/admin/jobs_table/components/cancel_jobs_modal.vue'; +import { CANCEL_JOBS_MODAL_ID, CANCEL_BUTTON_TOOLTIP } from '~/ci/admin/jobs_table/constants'; describe('CancelJobs component', () => { let wrapper; diff --git a/spec/frontend/pages/admin/jobs/components/jobs_skeleton_loader_spec.js b/spec/frontend/pages/admin/jobs/components/jobs_skeleton_loader_spec.js deleted file mode 100644 index 03e5cd75420..00000000000 --- a/spec/frontend/pages/admin/jobs/components/jobs_skeleton_loader_spec.js +++ /dev/null @@ -1,28 +0,0 @@ -import { GlSkeletonLoader } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue'; - -describe('jobs_skeleton_loader.vue', () => { - let wrapper; - - const findGlSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - - const WIDTH = '1248'; - const HEIGHT = '73'; - - beforeEach(() => { - wrapper = shallowMount(JobsSkeletonLoader); - }); - - it('renders a GlSkeletonLoader', () => { - expect(findGlSkeletonLoader().exists()).toBe(true); - }); - - it('has correct width', () => { - expect(findGlSkeletonLoader().attributes('width')).toBe(WIDTH); - }); - - it('has correct height', () => { - expect(findGlSkeletonLoader().attributes('height')).toBe(HEIGHT); - }); -}); diff --git a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js index 71ebf64f43c..d14b78d2f4d 100644 --- a/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js +++ b/spec/frontend/pages/admin/jobs/components/table/admin_job_table_app_spec.js @@ -4,17 +4,17 @@ 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 JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue'; -import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue'; -import getAllJobsQuery from '~/pages/admin/jobs/components/table/graphql/queries/get_all_jobs.query.graphql'; -import getAllJobsCount from '~/pages/admin/jobs/components/table/graphql/queries/get_all_jobs_count.query.graphql'; -import getCancelableJobsQuery from '~/pages/admin/jobs/components/table/graphql/queries/get_cancelable_jobs_count.query.graphql'; -import AdminJobsTableApp from '~/pages/admin/jobs/components/table/admin_jobs_table_app.vue'; -import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue'; -import JobsTable from '~/jobs/components/table/jobs_table.vue'; +import JobsTableTabs from '~/ci/jobs_page/components/jobs_table_tabs.vue'; +import JobsSkeletonLoader from '~/ci/admin/jobs_table/components/jobs_skeleton_loader.vue'; +import getAllJobsQuery from '~/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql'; +import getAllJobsCount from '~/ci/admin/jobs_table/graphql/queries/get_all_jobs_count.query.graphql'; +import getCancelableJobsQuery from '~/ci/admin/jobs_table/graphql/queries/get_cancelable_jobs_count.query.graphql'; +import AdminJobsTableApp from '~/ci/admin/jobs_table/admin_jobs_table_app.vue'; +import CancelJobs from '~/ci/admin/jobs_table/components/cancel_jobs.vue'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; import { createAlert } from '~/alert'; import { TEST_HOST } from 'spec/test_constants'; -import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; +import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue'; import * as urlUtils from '~/lib/utils/url_utility'; import { JOBS_FETCH_ERROR_MSG, @@ -22,7 +22,8 @@ import { LOADING_ARIA_LABEL, RAW_TEXT_WARNING_ADMIN, JOBS_COUNT_ERROR_MESSAGE, -} from '~/pages/admin/jobs/components/constants'; +} from '~/ci/admin/jobs_table/constants'; +import { TOKEN_TYPE_JOBS_RUNNER_TYPE } from '~/vue_shared/components/filtered_search_bar/constants'; import { mockAllJobsResponsePaginated, mockCancelableJobsCountResponse, @@ -30,7 +31,7 @@ import { statuses, mockFailedSearchToken, mockAllJobsCountResponse, -} from '../../../../../jobs/mock_data'; +} from 'jest/ci/jobs_mock_data'; Vue.use(VueApollo); @@ -54,6 +55,11 @@ describe('Job table app', () => { const findCancelJobsButton = () => wrapper.findComponent(CancelJobs); const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch); + const mockSearchTokenRunnerType = { + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + value: { data: 'INSTANCE_TYPE', operator: '=' }, + }; + const triggerInfiniteScroll = () => wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); @@ -73,6 +79,7 @@ describe('Job table app', () => { countHandler = countSuccessHandler, mountFn = shallowMount, data = {}, + provideOptions = {}, } = {}) => { wrapper = mountFn(AdminJobsTableApp, { data() { @@ -82,6 +89,8 @@ describe('Job table app', () => { }, provide: { jobStatuses: statuses, + glFeatures: { adminJobsFilterRunnerType: true }, + ...provideOptions, }, apolloProvider: createMockApolloProvider(handler, cancelableHandler, countHandler), }); @@ -304,24 +313,37 @@ describe('Job table app', () => { }, ); - it('refetches jobs query when filtering', async () => { - createComponent(); + describe.each` + searchTokens | expectedQueryParams + ${[]} | ${{ runnerTypes: null, statuses: null }} + ${[mockFailedSearchToken]} | ${{ runnerTypes: null, statuses: 'FAILED' }} + ${[mockFailedSearchToken, mockSearchTokenRunnerType]} | ${{ runnerTypes: 'INSTANCE_TYPE', statuses: 'FAILED' }} + `('when filtering jobs by searchTokens', ({ searchTokens, expectedQueryParams }) => { + it(`refetches jobs query including filters ${JSON.stringify( + expectedQueryParams, + )}`, async () => { + createComponent(); - expect(successHandler).toHaveBeenCalledTimes(1); + expect(successHandler).toHaveBeenCalledTimes(1); - await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + await findFilteredSearch().vm.$emit('filterJobsBySearch', searchTokens); - expect(successHandler).toHaveBeenCalledTimes(2); - }); + expect(successHandler).toHaveBeenCalledTimes(2); + expect(successHandler).toHaveBeenNthCalledWith(2, { first: 50, ...expectedQueryParams }); + }); - it('refetches jobs count query when filtering', async () => { - createComponent(); + it(`refetches jobs count query including filters ${JSON.stringify( + expectedQueryParams, + )}`, async () => { + createComponent(); - expect(countSuccessHandler).toHaveBeenCalledTimes(1); + expect(countSuccessHandler).toHaveBeenCalledTimes(1); - await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]); + await findFilteredSearch().vm.$emit('filterJobsBySearch', searchTokens); - expect(countSuccessHandler).toHaveBeenCalledTimes(2); + expect(countSuccessHandler).toHaveBeenCalledTimes(2); + expect(countSuccessHandler).toHaveBeenNthCalledWith(2, expectedQueryParams); + }); }); it('shows raw text warning when user inputs raw text', async () => { @@ -364,6 +386,7 @@ describe('Job table app', () => { expect(successHandler).toHaveBeenCalledWith({ first: 50, statuses: 'FAILED', + runnerTypes: null, }); expect(urlUtils.updateHistory).toHaveBeenCalledWith({ url: `${TEST_HOST}/?statuses=FAILED`, @@ -378,6 +401,44 @@ describe('Job table app', () => { expect(successHandler).toHaveBeenCalledWith({ first: 50, statuses: null, + runnerTypes: null, + }); + }); + + describe('when feature flag `adminJobsFilterRunnerType` is disabled', () => { + const provideOptions = { glFeatures: { adminJobsFilterRunnerType: false } }; + + describe.each` + searchTokens | expectedQueryParams + ${[]} | ${{ statuses: null }} + ${[mockFailedSearchToken]} | ${{ statuses: 'FAILED' }} + ${[mockFailedSearchToken, mockSearchTokenRunnerType]} | ${{ statuses: 'FAILED' }} + `('when filtering jobs by searchTokens', ({ searchTokens, expectedQueryParams }) => { + it(`refetches jobs query including filters ${JSON.stringify( + expectedQueryParams, + )}`, async () => { + createComponent({ provideOptions }); + + expect(successHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', searchTokens); + + expect(successHandler).toHaveBeenCalledTimes(2); + expect(successHandler).toHaveBeenNthCalledWith(2, { first: 50, ...expectedQueryParams }); + }); + + it(`refetches jobs count query including filters ${JSON.stringify( + expectedQueryParams, + )}`, async () => { + createComponent({ provideOptions }); + + expect(countSuccessHandler).toHaveBeenCalledTimes(1); + + await findFilteredSearch().vm.$emit('filterJobsBySearch', searchTokens); + + expect(countSuccessHandler).toHaveBeenCalledTimes(2); + expect(countSuccessHandler).toHaveBeenNthCalledWith(2, expectedQueryParams); + }); }); }); }); diff --git a/spec/frontend/pages/admin/jobs/components/table/cells/project_cell_spec.js b/spec/frontend/pages/admin/jobs/components/table/cells/project_cell_spec.js deleted file mode 100644 index 3366d60d9f3..00000000000 --- a/spec/frontend/pages/admin/jobs/components/table/cells/project_cell_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import ProjectCell from '~/pages/admin/jobs/components/table/cell/project_cell.vue'; -import { mockAllJobsNodes } from '../../../../../../jobs/mock_data'; - -const mockJob = mockAllJobsNodes[0]; - -describe('Project cell', () => { - let wrapper; - - const findProjectLink = () => wrapper.findComponent(GlLink); - - const createComponent = (props = {}) => { - wrapper = shallowMount(ProjectCell, { - propsData: { - ...props, - }, - }); - }; - - describe('Project Link', () => { - beforeEach(() => { - createComponent({ job: mockJob }); - }); - - it('shows and links to the project', () => { - expect(findProjectLink().exists()).toBe(true); - expect(findProjectLink().text()).toBe(mockJob.pipeline.project.fullPath); - expect(findProjectLink().attributes('href')).toBe(mockJob.pipeline.project.webUrl); - }); - }); -}); diff --git a/spec/frontend/pages/admin/jobs/components/table/cells/runner_cell_spec.js b/spec/frontend/pages/admin/jobs/components/table/cells/runner_cell_spec.js deleted file mode 100644 index 2f76ad66dd5..00000000000 --- a/spec/frontend/pages/admin/jobs/components/table/cells/runner_cell_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import RunnerCell from '~/pages/admin/jobs/components/table/cells/runner_cell.vue'; -import { RUNNER_EMPTY_TEXT } from '~/pages/admin/jobs/components/constants'; -import { allRunnersData } from '../../../../../../ci/runner/mock_data'; - -const mockRunner = allRunnersData.data.runners.nodes[0]; - -const mockJobWithRunner = { - id: 'gid://gitlab/Ci::Build/2264', - runner: mockRunner, -}; - -const mockJobWithoutRunner = { - id: 'gid://gitlab/Ci::Build/2265', -}; - -describe('Runner Cell', () => { - let wrapper; - - const findRunnerLink = () => wrapper.findComponent(GlLink); - const findEmptyRunner = () => wrapper.find('[data-testid="empty-runner-text"]'); - - const createComponent = (props = {}) => { - wrapper = shallowMount(RunnerCell, { - propsData: { - ...props, - }, - }); - }; - - describe('Runner Link', () => { - describe('Job with runner', () => { - beforeEach(() => { - createComponent({ job: mockJobWithRunner }); - }); - - it('shows and links to the runner', () => { - expect(findRunnerLink().exists()).toBe(true); - expect(findRunnerLink().text()).toBe(mockRunner.description); - expect(findRunnerLink().attributes('href')).toBe(mockRunner.adminUrl); - }); - - it('hides the empty runner text', () => { - expect(findEmptyRunner().exists()).toBe(false); - }); - }); - - describe('Job without runner', () => { - beforeEach(() => { - createComponent({ job: mockJobWithoutRunner }); - }); - - it('shows default `empty` text', () => { - expect(findEmptyRunner().exists()).toBe(true); - expect(findEmptyRunner().text()).toBe(RUNNER_EMPTY_TEXT); - }); - - it('hides the runner link', () => { - expect(findRunnerLink().exists()).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js deleted file mode 100644 index 59e9eda6343..00000000000 --- a/spec/frontend/pages/admin/jobs/components/table/graphql/cache_config_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import cacheConfig from '~/pages/admin/jobs/components/table/graphql/cache_config'; -import { - CIJobConnectionExistingCache, - CIJobConnectionIncomingCache, - CIJobConnectionIncomingCacheRunningStatus, -} from '../../../../../../jobs/mock_data'; - -const firstLoadArgs = { first: 3, statuses: 'PENDING' }; -const runningArgs = { first: 3, statuses: 'RUNNING' }; - -describe('jobs/components/table/graphql/cache_config', () => { - describe('when fetching data with the same statuses', () => { - it('should contain cache nodes and a status when merging caches on first load', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { - args: firstLoadArgs, - }); - - expect(res.nodes).toHaveLength(CIJobConnectionIncomingCache.nodes.length); - expect(res.statuses).toBe('PENDING'); - }); - - it('should add to existing caches when merging caches after first load', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge( - CIJobConnectionExistingCache, - CIJobConnectionIncomingCache, - { - args: firstLoadArgs, - }, - ); - - expect(res.nodes).toHaveLength( - CIJobConnectionIncomingCache.nodes.length + CIJobConnectionExistingCache.nodes.length, - ); - }); - - it('should not add to existing cache if the incoming elements are the same', () => { - // simulate that this is the last page - const finalExistingCache = { - ...CIJobConnectionExistingCache, - pageInfo: { - hasNextPage: false, - }, - }; - - const res = cacheConfig.typePolicies.CiJobConnection.merge( - CIJobConnectionExistingCache, - finalExistingCache, - { - args: firstLoadArgs, - }, - ); - - expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length); - }); - - it('should contain the pageInfo key as part of the result', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, { - args: firstLoadArgs, - }); - - expect(res.pageInfo).toEqual( - expect.objectContaining({ - __typename: 'PageInfo', - endCursor: 'eyJpZCI6IjIwNTEifQ', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'eyJpZCI6IjIxNzMifQ', - }), - ); - }); - }); - - describe('when fetching data with different statuses', () => { - it('should reset cache when a cache already exists', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge( - CIJobConnectionExistingCache, - CIJobConnectionIncomingCacheRunningStatus, - { - args: runningArgs, - }, - ); - - expect(res.nodes).not.toEqual(CIJobConnectionExistingCache.nodes); - expect(res.nodes).toHaveLength(CIJobConnectionIncomingCacheRunningStatus.nodes.length); - }); - }); - - describe('when incoming data has no nodes', () => { - it('should return existing cache', () => { - const res = cacheConfig.typePolicies.CiJobConnection.merge( - CIJobConnectionExistingCache, - { __typename: 'CiJobConnection', count: 500 }, - { - args: { statuses: 'SUCCESS' }, - }, - ); - - const expectedResponse = { - ...CIJobConnectionExistingCache, - statuses: 'SUCCESS', - }; - - expect(res).toEqual(expectedResponse); - }); - }); -}); diff --git a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js index 40d5dff9d06..50bc5bc590b 100644 --- a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js +++ b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js @@ -12,11 +12,7 @@ const BitbucketStatusTableStub = { describe('BitbucketServerStatusTable', () => { let wrapper; - const findReconfigureButton = () => - wrapper - .findAllComponents(GlButton) - .filter((w) => w.props().variant === 'info') - .at(0); + const findReconfigureButton = () => wrapper.findComponent(GlButton); function createComponent(bitbucketStatusTableStub = true) { wrapper = shallowMount(BitbucketServerStatusTable, { diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js deleted file mode 100644 index e20c2fa47a7..00000000000 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js +++ /dev/null @@ -1,92 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import Cookies from '~/lib/utils/cookies'; -import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue'; - -const cookieKey = 'pipeline_schedules_callout_dismissed'; -const docsUrl = 'help/ci/scheduled_pipelines'; -const illustrationUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; - -describe('Pipeline Schedule Callout', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(PipelineSchedulesCallout, { - provide: { - docsUrl, - illustrationUrl, - }, - }); - }; - - const findInnerContentOfCallout = () => wrapper.find('[data-testid="innerContent"]'); - const findDismissCalloutBtn = () => wrapper.findComponent(GlButton); - - describe(`when ${cookieKey} cookie is set`, () => { - beforeEach(async () => { - Cookies.set(cookieKey, true); - createComponent(); - - await nextTick(); - }); - - it('does not render the callout', () => { - expect(findInnerContentOfCallout().exists()).toBe(false); - }); - }); - - describe('when cookie is not set', () => { - beforeEach(() => { - Cookies.remove(cookieKey); - createComponent(); - }); - - it('renders the callout container', () => { - expect(findInnerContentOfCallout().exists()).toBe(true); - }); - - it('renders the callout title', () => { - expect(wrapper.find('h4').text()).toBe('Scheduling Pipelines'); - }); - - it('renders the callout text', () => { - expect(wrapper.find('p').text()).toContain('runs pipelines in the future'); - }); - - it('renders the documentation url', () => { - expect(wrapper.find('a').attributes('href')).toBe(docsUrl); - }); - - describe('methods', () => { - it('#dismissCallout sets calloutDismissed to true', async () => { - expect(wrapper.vm.calloutDismissed).toBe(false); - - findDismissCalloutBtn().vm.$emit('click'); - - await nextTick(); - - expect(findInnerContentOfCallout().exists()).toBe(false); - }); - - it('sets cookie on dismiss', () => { - const setCookiesSpy = jest.spyOn(Cookies, 'set'); - - findDismissCalloutBtn().vm.$emit('click'); - - expect(setCookiesSpy).toHaveBeenCalledWith('pipeline_schedules_callout_dismissed', true, { - expires: 365, - secure: false, - }); - }); - }); - - it('is hidden when close button is clicked', async () => { - findDismissCalloutBtn().vm.$emit('click'); - - await nextTick(); - - expect(findInnerContentOfCallout().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap b/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap deleted file mode 100644 index cb5f6ff5307..00000000000 --- a/spec/frontend/pipelines/components/dag/__snapshots__/dag_graph_spec.js.snap +++ /dev/null @@ -1,230 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`The DAG graph in the basic case renders the graph svg 1`] = ` -" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    build_a
    -
    - -
    test_a
    -
    - -
    test_b
    -
    - -
    post_test_a
    -
    - -
    post_test_b
    -
    - -
    post_test_c
    -
    - -
    staging_a
    -
    - -
    staging_b
    -
    - -
    canary_a
    -
    - -
    canary_c
    -
    - -
    production_a
    -
    - -
    production_d
    -
    -
    -
    " -`; diff --git a/spec/frontend/pipelines/components/dag/dag_annotations_spec.js b/spec/frontend/pipelines/components/dag/dag_annotations_spec.js deleted file mode 100644 index 124f02bcec7..00000000000 --- a/spec/frontend/pipelines/components/dag/dag_annotations_spec.js +++ /dev/null @@ -1,98 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; -import { singleNote, multiNote } from './mock_data'; - -describe('The DAG annotations', () => { - let wrapper; - - const getColorBlock = () => wrapper.find('[data-testid="dag-color-block"]'); - const getAllColorBlocks = () => wrapper.findAll('[data-testid="dag-color-block"]'); - const getTextBlock = () => wrapper.find('[data-testid="dag-note-text"]'); - const getAllTextBlocks = () => wrapper.findAll('[data-testid="dag-note-text"]'); - const getToggleButton = () => wrapper.findComponent(GlButton); - - const createComponent = (propsData = {}, method = shallowMount) => { - wrapper = method(DagAnnotations, { - propsData, - data() { - return { - showList: true, - }; - }, - }); - }; - - describe('when there is one annotation', () => { - const currentNote = singleNote['dag-link103']; - - beforeEach(() => { - createComponent({ annotations: singleNote }); - }); - - it('displays the color block', () => { - expect(getColorBlock().exists()).toBe(true); - }); - - it('displays the text block', () => { - expect(getTextBlock().exists()).toBe(true); - expect(getTextBlock().text()).toBe(`${currentNote.source.name} → ${currentNote.target.name}`); - }); - - it('does not display the list toggle link', () => { - expect(getToggleButton().exists()).toBe(false); - }); - }); - - describe('when there are multiple annoataions', () => { - beforeEach(() => { - createComponent({ annotations: multiNote }); - }); - - it('displays a color block for each link', () => { - expect(getAllColorBlocks().length).toBe(Object.keys(multiNote).length); - }); - - it('displays a text block for each link', () => { - expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); - - Object.values(multiNote).forEach((item, idx) => { - expect(getAllTextBlocks().at(idx).text()).toBe(`${item.source.name} → ${item.target.name}`); - }); - }); - - it('displays the list toggle link', () => { - expect(getToggleButton().exists()).toBe(true); - expect(getToggleButton().text()).toBe('Hide list'); - }); - }); - - describe('the list toggle', () => { - beforeEach(() => { - createComponent({ annotations: multiNote }, mount); - }); - - describe('clicking hide', () => { - it('hides listed items and changes text to show', async () => { - expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); - expect(getToggleButton().text()).toBe('Hide list'); - getToggleButton().trigger('click'); - await nextTick(); - expect(getAllTextBlocks().length).toBe(0); - expect(getToggleButton().text()).toBe('Show list'); - }); - }); - - describe('clicking show', () => { - it('shows listed items and changes text to hide', async () => { - getToggleButton().trigger('click'); - getToggleButton().trigger('click'); - - await nextTick(); - expect(getAllTextBlocks().length).toBe(Object.keys(multiNote).length); - expect(getToggleButton().text()).toBe('Hide list'); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/dag/dag_graph_spec.js b/spec/frontend/pipelines/components/dag/dag_graph_spec.js deleted file mode 100644 index 6b46be3dd49..00000000000 --- a/spec/frontend/pipelines/components/dag/dag_graph_spec.js +++ /dev/null @@ -1,209 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants'; -import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; -import { createSankey } from '~/pipelines/components/dag/drawing_utils'; -import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions'; -import { removeOrphanNodes } from '~/pipelines/components/parsing_utils'; -import { parsedData } from './mock_data'; - -describe('The DAG graph', () => { - let wrapper; - - const getGraph = () => wrapper.find('.dag-graph-container > svg'); - const getAllLinks = () => wrapper.findAll(`.${LINK_SELECTOR}`); - const getAllNodes = () => wrapper.findAll(`.${NODE_SELECTOR}`); - const getAllLabels = () => wrapper.findAll('foreignObject'); - - const createComponent = (propsData = {}) => { - if (wrapper?.destroy) { - wrapper.destroy(); - } - - wrapper = shallowMount(DagGraph, { - attachTo: document.body, - propsData, - data() { - return { - color: () => {}, - width: 0, - height: 0, - }; - }, - }); - }; - - beforeEach(() => { - createComponent({ graphData: parsedData }); - }); - - describe('in the basic case', () => { - beforeEach(() => { - /* - The graph uses random to offset links. To keep the snapshot consistent, - we mock Math.random. Wheeeee! - */ - const randomNumber = jest.spyOn(global.Math, 'random'); - randomNumber.mockImplementation(() => 0.2); - createComponent({ graphData: parsedData }); - }); - - it('renders the graph svg', () => { - expect(getGraph().exists()).toBe(true); - expect(getGraph().html()).toMatchSnapshot(); - }); - }); - - describe('links', () => { - it('renders the expected number of links', () => { - expect(getAllLinks()).toHaveLength(parsedData.links.length); - }); - - it('renders the expected number of gradients', () => { - expect(wrapper.findAll('linearGradient')).toHaveLength(parsedData.links.length); - }); - - it('renders the expected number of clip paths', () => { - expect(wrapper.findAll('clipPath')).toHaveLength(parsedData.links.length); - }); - }); - - describe('nodes and labels', () => { - const sankeyNodes = createSankey()(parsedData).nodes; - const processedNodes = removeOrphanNodes(sankeyNodes); - - describe('nodes', () => { - it('renders the expected number of nodes', () => { - expect(getAllNodes()).toHaveLength(processedNodes.length); - }); - }); - - describe('labels', () => { - it('renders the expected number of labels as foreignObjects', () => { - expect(getAllLabels()).toHaveLength(processedNodes.length); - }); - - it('renders the title as text', () => { - expect(getAllLabels().at(0).text()).toBe(parsedData.nodes[0].name); - }); - }); - }); - - describe('interactions', () => { - const strokeOpacity = (opacity) => `stroke-opacity: ${opacity};`; - const baseOpacity = () => wrapper.vm.$options.viewOptions.baseOpacity; - - describe('links', () => { - const liveLink = () => getAllLinks().at(4); - const otherLink = () => getAllLinks().at(1); - - describe('on hover', () => { - it('sets the link opacity to baseOpacity and background links to 0.2', () => { - liveLink().trigger('mouseover'); - expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); - expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); - }); - - it('reverts the styles on mouseout', () => { - liveLink().trigger('mouseover'); - liveLink().trigger('mouseout'); - expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); - expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); - }); - }); - - describe('on click', () => { - describe('toggles link liveness', () => { - it('turns link on', () => { - liveLink().trigger('click'); - expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); - expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); - }); - - it('turns link off on second click', () => { - liveLink().trigger('click'); - liveLink().trigger('click'); - expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); - expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); - }); - }); - - it('the link remains live even after mouseout', () => { - liveLink().trigger('click'); - liveLink().trigger('mouseout'); - expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); - expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); - }); - - it('preserves state when multiple links are toggled on and off', () => { - const anotherLiveLink = () => getAllLinks().at(2); - - liveLink().trigger('click'); - anotherLiveLink().trigger('click'); - expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); - expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); - expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); - - anotherLiveLink().trigger('click'); - expect(liveLink().attributes('style')).toBe(strokeOpacity(highlightIn)); - expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(highlightOut)); - expect(otherLink().attributes('style')).toBe(strokeOpacity(highlightOut)); - - liveLink().trigger('click'); - expect(liveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); - expect(anotherLiveLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); - expect(otherLink().attributes('style')).toBe(strokeOpacity(baseOpacity())); - }); - }); - }); - - describe('nodes', () => { - const liveNode = () => getAllNodes().at(10); - const anotherLiveNode = () => getAllNodes().at(5); - const nodesNotHighlighted = () => getAllNodes().filter((n) => !n.classes(IS_HIGHLIGHTED)); - const linksNotHighlighted = () => getAllLinks().filter((n) => !n.classes(IS_HIGHLIGHTED)); - const nodesHighlighted = () => getAllNodes().filter((n) => n.classes(IS_HIGHLIGHTED)); - const linksHighlighted = () => getAllLinks().filter((n) => n.classes(IS_HIGHLIGHTED)); - - describe('on click', () => { - it('highlights the clicked node and predecessors', () => { - liveNode().trigger('click'); - - expect(nodesNotHighlighted().length < getAllNodes().length).toBe(true); - expect(linksNotHighlighted().length < getAllLinks().length).toBe(true); - - linksHighlighted().wrappers.forEach((link) => { - expect(link.attributes('style')).toBe(strokeOpacity(highlightIn)); - }); - - nodesHighlighted().wrappers.forEach((node) => { - expect(node.attributes('stroke')).not.toBe('#f2f2f2'); - }); - - linksNotHighlighted().wrappers.forEach((link) => { - expect(link.attributes('style')).toBe(strokeOpacity(highlightOut)); - }); - - nodesNotHighlighted().wrappers.forEach((node) => { - expect(node.attributes('stroke')).toBe('#f2f2f2'); - }); - }); - - it('toggles path off on second click', () => { - liveNode().trigger('click'); - liveNode().trigger('click'); - - expect(nodesNotHighlighted().length).toBe(getAllNodes().length); - expect(linksNotHighlighted().length).toBe(getAllLinks().length); - }); - - it('preserves state when multiple nodes are toggled on and off', () => { - anotherLiveNode().trigger('click'); - liveNode().trigger('click'); - anotherLiveNode().trigger('click'); - expect(nodesNotHighlighted().length < getAllNodes().length).toBe(true); - expect(linksNotHighlighted().length < getAllLinks().length).toBe(true); - }); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js deleted file mode 100644 index 53719065611..00000000000 --- a/spec/frontend/pipelines/components/dag/dag_spec.js +++ /dev/null @@ -1,168 +0,0 @@ -import { GlAlert, GlEmptyState } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/pipelines/components/dag/constants'; -import Dag from '~/pipelines/components/dag/dag.vue'; -import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; -import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; - -import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/pipelines/constants'; -import { - mockParsedGraphQLNodes, - tooSmallGraph, - unparseableGraph, - graphWithoutDependencies, - singleNote, - multiNote, -} from './mock_data'; - -describe('Pipeline DAG graph wrapper', () => { - let wrapper; - const getAlert = () => wrapper.findComponent(GlAlert); - const getAllAlerts = () => wrapper.findAllComponents(GlAlert); - const getGraph = () => wrapper.findComponent(DagGraph); - const getNotes = () => wrapper.findComponent(DagAnnotations); - const getErrorText = (type) => wrapper.vm.$options.errorTexts[type]; - const getEmptyState = () => wrapper.findComponent(GlEmptyState); - - const createComponent = ({ - graphData = mockParsedGraphQLNodes, - provideOverride = {}, - method = shallowMount, - } = {}) => { - wrapper = method(Dag, { - provide: { - pipelineProjectPath: 'root/abc-dag', - pipelineIid: '1', - emptySvgPath: '/my-svg', - dagDocPath: '/my-doc', - ...provideOverride, - }, - data() { - return { - graphData, - showFailureAlert: false, - }; - }, - }); - }; - - describe('when a query argument is undefined', () => { - beforeEach(() => { - createComponent({ - provideOverride: { pipelineProjectPath: undefined }, - graphData: null, - }); - }); - - it('does not render the graph', () => { - expect(getGraph().exists()).toBe(false); - }); - - it('does not render the empty state', () => { - expect(getEmptyState().exists()).toBe(false); - }); - }); - - describe('when all query variables are defined', () => { - describe('but the parse fails', () => { - beforeEach(() => { - createComponent({ - graphData: unparseableGraph, - }); - }); - - it('shows the PARSE_FAILURE alert and not the graph', () => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(PARSE_FAILURE)); - expect(getGraph().exists()).toBe(false); - }); - - it('does not render the empty state', () => { - expect(getEmptyState().exists()).toBe(false); - }); - }); - - describe('parse succeeds', () => { - beforeEach(() => { - createComponent({ method: mount }); - }); - - it('shows the graph', () => { - expect(getGraph().exists()).toBe(true); - }); - - it('does not render the empty state', () => { - expect(getEmptyState().exists()).toBe(false); - }); - }); - - describe('parse succeeds, but the resulting graph is too small', () => { - beforeEach(() => { - createComponent({ - graphData: tooSmallGraph, - }); - }); - - it('shows the UNSUPPORTED_DATA alert and not the graph', () => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(UNSUPPORTED_DATA)); - expect(getGraph().exists()).toBe(false); - }); - - it('does not show the empty dag graph state', () => { - expect(getEmptyState().exists()).toBe(false); - }); - }); - - describe('the returned data is empty', () => { - beforeEach(() => { - createComponent({ - method: mount, - graphData: graphWithoutDependencies, - }); - }); - - it('does not render an error alert or the graph', () => { - expect(getAllAlerts().length).toBe(0); - expect(getGraph().exists()).toBe(false); - }); - - it('shows the empty dag graph state', () => { - expect(getEmptyState().exists()).toBe(true); - }); - }); - }); - - describe('annotations', () => { - beforeEach(() => { - createComponent(); - }); - - it('toggles on link mouseover and mouseout', async () => { - const currentNote = singleNote['dag-link103']; - - expect(getNotes().exists()).toBe(false); - - getGraph().vm.$emit('update-annotation', { type: ADD_NOTE, data: currentNote }); - await nextTick(); - expect(getNotes().exists()).toBe(true); - - getGraph().vm.$emit('update-annotation', { type: REMOVE_NOTE, data: currentNote }); - await nextTick(); - expect(getNotes().exists()).toBe(false); - }); - - it('toggles on node and link click', async () => { - expect(getNotes().exists()).toBe(false); - - getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: multiNote }); - await nextTick(); - expect(getNotes().exists()).toBe(true); - - getGraph().vm.$emit('update-annotation', { type: REPLACE_NOTES, data: {} }); - await nextTick(); - expect(getNotes().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/dag/drawing_utils_spec.js b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js deleted file mode 100644 index 095ded01298..00000000000 --- a/spec/frontend/pipelines/components/dag/drawing_utils_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { createSankey } from '~/pipelines/components/dag/drawing_utils'; -import { parseData } from '~/pipelines/components/parsing_utils'; -import { mockParsedGraphQLNodes } from './mock_data'; - -describe('DAG visualization drawing utilities', () => { - const parsed = parseData(mockParsedGraphQLNodes); - - const layoutSettings = { - width: 200, - height: 200, - nodeWidth: 10, - nodePadding: 20, - paddingForLabels: 100, - }; - - const sankeyLayout = createSankey(layoutSettings)(parsed); - - describe('createSankey', () => { - it('returns a nodes data structure with expected d3-added properties', () => { - const exampleNode = sankeyLayout.nodes[0]; - expect(exampleNode).toHaveProperty('sourceLinks'); - expect(exampleNode).toHaveProperty('targetLinks'); - expect(exampleNode).toHaveProperty('depth'); - expect(exampleNode).toHaveProperty('layer'); - expect(exampleNode).toHaveProperty('x0'); - expect(exampleNode).toHaveProperty('x1'); - expect(exampleNode).toHaveProperty('y0'); - expect(exampleNode).toHaveProperty('y1'); - }); - - it('returns a links data structure with expected d3-added properties', () => { - const exampleLink = sankeyLayout.links[0]; - expect(exampleLink).toHaveProperty('source'); - expect(exampleLink).toHaveProperty('target'); - expect(exampleLink).toHaveProperty('width'); - expect(exampleLink).toHaveProperty('y0'); - expect(exampleLink).toHaveProperty('y1'); - }); - - describe('data structure integrity', () => { - const newObject = { name: 'bad-actor' }; - - beforeEach(() => { - sankeyLayout.nodes.unshift(newObject); - }); - - it('sankey does not propagate changes back to the original', () => { - expect(sankeyLayout.nodes[0]).toBe(newObject); - expect(parsed.nodes[0]).not.toBe(newObject); - }); - - afterEach(() => { - sankeyLayout.nodes.shift(); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/dag/mock_data.js b/spec/frontend/pipelines/components/dag/mock_data.js deleted file mode 100644 index f27e7cf3d6b..00000000000 --- a/spec/frontend/pipelines/components/dag/mock_data.js +++ /dev/null @@ -1,674 +0,0 @@ -export const tooSmallGraph = [ - { - category: 'test', - name: 'jest', - size: 2, - jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], - }, - { - category: 'test', - name: 'rspec', - size: 1, - jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], - }, - { - category: 'fixtures', - name: 'frontend fixtures', - size: 1, - jobs: [{ name: 'frontend fixtures' }], - }, - { - category: 'un-needed', - name: 'un-needed', - size: 1, - jobs: [{ name: 'un-needed' }], - }, -]; - -export const graphWithoutDependencies = [ - { - category: 'test', - name: 'jest', - size: 2, - jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], - }, - { - category: 'test', - name: 'rspec', - size: 1, - jobs: [{ name: 'rspec' }], - }, - { - category: 'fixtures', - name: 'frontend fixtures', - size: 1, - jobs: [{ name: 'frontend fixtures' }], - }, - { - category: 'un-needed', - name: 'un-needed', - size: 1, - jobs: [{ name: 'un-needed' }], - }, -]; - -export const unparseableGraph = [ - { - name: 'test', - groups: [ - { - name: 'jest', - size: 2, - jobs: [{ name: 'jest 1/2', needs: ['frontend fixtures'] }, { name: 'jest 2/2' }], - }, - { - name: 'rspec', - size: 1, - jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], - }, - ], - }, - { - name: 'un-needed', - groups: [ - { - name: 'un-needed', - size: 1, - jobs: [{ name: 'un-needed' }], - }, - ], - }, -]; - -/* - This represents data that has been parsed by the wrapper -*/ -export const parsedData = { - nodes: [ - { - name: 'build_a', - size: 1, - jobs: [ - { - name: 'build_a', - }, - ], - category: 'build', - }, - { - name: 'build_b', - size: 1, - jobs: [ - { - name: 'build_b', - }, - ], - category: 'build', - }, - { - name: 'test_a', - size: 1, - jobs: [ - { - name: 'test_a', - needs: ['build_a'], - }, - ], - category: 'test', - }, - { - name: 'test_b', - size: 1, - jobs: [ - { - name: 'test_b', - }, - ], - category: 'test', - }, - { - name: 'test_c', - size: 1, - jobs: [ - { - name: 'test_c', - }, - ], - category: 'test', - }, - { - name: 'test_d', - size: 1, - jobs: [ - { - name: 'test_d', - }, - ], - category: 'test', - }, - { - name: 'post_test_a', - size: 1, - jobs: [ - { - name: 'post_test_a', - }, - ], - category: 'post-test', - }, - { - name: 'post_test_b', - size: 1, - jobs: [ - { - name: 'post_test_b', - }, - ], - category: 'post-test', - }, - { - name: 'post_test_c', - size: 1, - jobs: [ - { - name: 'post_test_c', - needs: ['test_a', 'test_b'], - }, - ], - category: 'post-test', - }, - { - name: 'staging_a', - size: 1, - jobs: [ - { - name: 'staging_a', - needs: ['post_test_a'], - }, - ], - category: 'staging', - }, - { - name: 'staging_b', - size: 1, - jobs: [ - { - name: 'staging_b', - needs: ['post_test_b'], - }, - ], - category: 'staging', - }, - { - name: 'staging_c', - size: 1, - jobs: [ - { - name: 'staging_c', - }, - ], - category: 'staging', - }, - { - name: 'staging_d', - size: 1, - jobs: [ - { - name: 'staging_d', - }, - ], - category: 'staging', - }, - { - name: 'staging_e', - size: 1, - jobs: [ - { - name: 'staging_e', - }, - ], - category: 'staging', - }, - { - name: 'canary_a', - size: 1, - jobs: [ - { - name: 'canary_a', - needs: ['staging_a', 'staging_b'], - }, - ], - category: 'canary', - }, - { - name: 'canary_b', - size: 1, - jobs: [ - { - name: 'canary_b', - }, - ], - category: 'canary', - }, - { - name: 'canary_c', - size: 1, - jobs: [ - { - name: 'canary_c', - needs: ['staging_b'], - }, - ], - category: 'canary', - }, - { - name: 'production_a', - size: 1, - jobs: [ - { - name: 'production_a', - needs: ['canary_a'], - }, - ], - category: 'production', - }, - { - name: 'production_b', - size: 1, - jobs: [ - { - name: 'production_b', - }, - ], - category: 'production', - }, - { - name: 'production_c', - size: 1, - jobs: [ - { - name: 'production_c', - }, - ], - category: 'production', - }, - { - name: 'production_d', - size: 1, - jobs: [ - { - name: 'production_d', - needs: ['canary_c'], - }, - ], - category: 'production', - }, - ], - links: [ - { - source: 'build_a', - target: 'test_a', - value: 10, - }, - { - source: 'test_a', - target: 'post_test_c', - value: 10, - }, - { - source: 'test_b', - target: 'post_test_c', - value: 10, - }, - { - source: 'post_test_a', - target: 'staging_a', - value: 10, - }, - { - source: 'post_test_b', - target: 'staging_b', - value: 10, - }, - { - source: 'staging_a', - target: 'canary_a', - value: 10, - }, - { - source: 'staging_b', - target: 'canary_a', - value: 10, - }, - { - source: 'staging_b', - target: 'canary_c', - value: 10, - }, - { - source: 'canary_a', - target: 'production_a', - value: 10, - }, - { - source: 'canary_c', - target: 'production_d', - value: 10, - }, - ], -}; - -export const singleNote = { - 'dag-link103': { - uid: 'dag-link103', - source: { - name: 'canary_a', - color: '#b31756', - }, - target: { - name: 'production_a', - color: '#b24800', - }, - }, -}; - -export const multiNote = { - ...singleNote, - 'dag-link104': { - uid: 'dag-link104', - source: { - name: 'build_a', - color: '#e17223', - }, - target: { - name: 'test_c', - color: '#006887', - }, - }, - 'dag-link105': { - uid: 'dag-link105', - source: { - name: 'test_c', - color: '#006887', - }, - target: { - name: 'post_test_c', - color: '#3547de', - }, - }, -}; - -export const missingJob = 'missing_job'; - -/* - It is important that the base include parallel jobs - as well as non-parallel jobs with spaces in the name to prevent - us relying on spaces as an indicator. -*/ - -export const mockParsedGraphQLNodes = [ - { - category: 'build', - name: 'build_a', - size: 1, - jobs: [ - { - name: 'build_a', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'build', - name: 'build_b', - size: 1, - jobs: [ - { - name: 'build_b', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'test', - name: 'test_a', - size: 1, - jobs: [ - { - name: 'test_a', - needs: ['build_a'], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'test', - name: 'test_b', - size: 1, - jobs: [ - { - name: 'test_b', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'test', - name: 'test_c', - size: 1, - jobs: [ - { - name: 'test_c', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'test', - name: 'test_d', - size: 1, - jobs: [ - { - name: 'test_d', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'post-test', - name: 'post_test_a', - size: 1, - jobs: [ - { - name: 'post_test_a', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'post-test', - name: 'post_test_b', - size: 1, - jobs: [ - { - name: 'post_test_b', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'post-test', - name: 'post_test_c', - size: 1, - jobs: [ - { - name: 'post_test_c', - needs: ['test_b', 'test_a'], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'staging', - name: 'staging_a', - size: 1, - jobs: [ - { - name: 'staging_a', - needs: ['post_test_a'], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'staging', - name: 'staging_b', - size: 1, - jobs: [ - { - name: 'staging_b', - needs: ['post_test_b'], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'staging', - name: 'staging_c', - size: 1, - jobs: [ - { - name: 'staging_c', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'staging', - name: 'staging_d', - size: 1, - jobs: [ - { - name: 'staging_d', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'staging', - name: 'staging_e', - size: 1, - jobs: [ - { - name: 'staging_e', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'canary', - name: 'canary_a', - size: 1, - jobs: [ - { - name: 'canary_a', - needs: ['staging_b', 'staging_a'], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'canary', - name: 'canary_b', - size: 1, - jobs: [ - { - name: 'canary_b', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'canary', - name: 'canary_c', - size: 1, - jobs: [ - { - name: 'canary_c', - needs: ['staging_b'], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'production', - name: 'production_a', - size: 1, - jobs: [ - { - name: 'production_a', - needs: ['canary_a'], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'production', - name: 'production_b', - size: 1, - jobs: [ - { - name: 'production_b', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'production', - name: 'production_c', - size: 1, - jobs: [ - { - name: 'production_c', - needs: [], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'production', - name: 'production_d', - size: 1, - jobs: [ - { - name: 'production_d', - needs: ['canary_c'], - }, - ], - __typename: 'CiGroup', - }, - { - category: 'production', - name: 'production_e', - size: 1, - jobs: [ - { - name: 'production_e', - needs: [missingJob], - }, - ], - __typename: 'CiGroup', - }, -]; diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js deleted file mode 100644 index 6a2453704db..00000000000 --- a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import { GlLoadingIcon } 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 { createAlert } from '~/alert'; -import FailedJobsApp from '~/pipelines/components/jobs/failed_jobs_app.vue'; -import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue'; -import GetFailedJobsQuery from '~/pipelines/graphql/queries/get_failed_jobs.query.graphql'; -import { mockFailedJobsQueryResponse } from '../../mock_data'; - -Vue.use(VueApollo); - -jest.mock('~/alert'); - -describe('Failed Jobs App', () => { - let wrapper; - let resolverSpy; - - const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); - const findJobsTable = () => wrapper.findComponent(FailedJobsTable); - - const createMockApolloProvider = (resolver) => { - const requestHandlers = [[GetFailedJobsQuery, resolver]]; - - return createMockApollo(requestHandlers); - }; - - const createComponent = (resolver) => { - wrapper = shallowMount(FailedJobsApp, { - provide: { - fullPath: 'root/ci-project', - pipelineIid: 1, - }, - apolloProvider: createMockApolloProvider(resolver), - }); - }; - - beforeEach(() => { - resolverSpy = jest.fn().mockResolvedValue(mockFailedJobsQueryResponse); - }); - - describe('loading spinner', () => { - it('displays loading spinner when fetching failed jobs', () => { - createComponent(resolverSpy); - - 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); - }); - }); - - it('displays the failed jobs table', async () => { - createComponent(resolverSpy); - - await waitForPromises(); - - expect(findJobsTable().exists()).toBe(true); - expect(createAlert).not.toHaveBeenCalled(); - }); - - it('handles query fetch error correctly', async () => { - resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); - - createComponent(resolverSpy); - - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message: 'There was a problem fetching the failed jobs.', - }); - }); -}); diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js deleted file mode 100644 index 99a178120cc..00000000000 --- a/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js +++ /dev/null @@ -1,141 +0,0 @@ -import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { createAlert } from '~/alert'; -import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue'; -import RetryFailedJobMutation from '~/pipelines/graphql/mutations/retry_failed_job.mutation.graphql'; -import { TRACKING_CATEGORIES } from '~/pipelines/constants'; -import { - successRetryMutationResponse, - failedRetryMutationResponse, - mockFailedJobsData, - mockFailedJobsDataNoPermission, -} from '../../mock_data'; - -jest.mock('~/alert'); -jest.mock('~/lib/utils/url_utility'); - -Vue.use(VueApollo); - -describe('Failed Jobs Table', () => { - let wrapper; - - const successRetryMutationHandler = jest.fn().mockResolvedValue(successRetryMutationResponse); - const failedRetryMutationHandler = jest.fn().mockResolvedValue(failedRetryMutationResponse); - - const findJobsTable = () => wrapper.findComponent(GlTableLite); - const findRetryButton = () => wrapper.findComponent(GlButton); - const findJobLink = () => wrapper.findComponent(GlLink); - const findJobLog = () => wrapper.findByTestId('job-log'); - const findSummary = (index) => wrapper.findAllByTestId('job-trace-summary').at(index); - const findFirstFailureMessage = () => wrapper.findAllByTestId('job-failure-message').at(0); - - const createMockApolloProvider = (resolver) => { - const requestHandlers = [[RetryFailedJobMutation, resolver]]; - return createMockApollo(requestHandlers); - }; - - const createComponent = (resolver, failedJobsData = mockFailedJobsData) => { - wrapper = mountExtended(FailedJobsTable, { - propsData: { - failedJobs: failedJobsData, - }, - apolloProvider: createMockApolloProvider(resolver), - }); - }; - - it('displays the failed jobs table', () => { - createComponent(); - - expect(findJobsTable().exists()).toBe(true); - }); - - it('displays failed job summary', () => { - createComponent(); - - expect(findSummary(0).text()).toBe('Html Summary'); - }); - - it('displays no job log when no trace', () => { - createComponent(); - - expect(findSummary(1).text()).toBe('No job log'); - }); - - it('displays failure reason', () => { - createComponent(); - - expect(findFirstFailureMessage().text()).toBe('Job failed'); - }); - - it('calls the retry failed job mutation and tracks the click', () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - createComponent(successRetryMutationHandler); - - findRetryButton().trigger('click'); - - expect(successRetryMutationHandler).toHaveBeenCalledWith({ - id: mockFailedJobsData[0].id, - }); - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry', { - label: TRACKING_CATEGORIES.failed, - }); - - unmockTracking(); - }); - - it('redirects to the new job after the mutation', async () => { - const { - data: { - jobRetry: { job }, - }, - } = successRetryMutationResponse; - - createComponent(successRetryMutationHandler); - - findRetryButton().trigger('click'); - - await waitForPromises(); - - expect(redirectTo).toHaveBeenCalledWith(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated - }); - - it('shows error message if the retry failed job mutation fails', async () => { - createComponent(failedRetryMutationHandler); - - findRetryButton().trigger('click'); - - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message: 'There was a problem retrying the failed job.', - }); - }); - - it('hides the job log and retry button if a user does not have permission', () => { - createComponent([[]], mockFailedJobsDataNoPermission); - - expect(findJobLog().exists()).toBe(false); - expect(findRetryButton().exists()).toBe(false); - }); - - it('displays the job log and retry button if a user has permission', () => { - createComponent(); - - expect(findJobLog().exists()).toBe(true); - expect(findRetryButton().exists()).toBe(true); - }); - - it('job name links to the correct job', () => { - createComponent(); - - expect(findJobLink().attributes('href')).toBe(mockFailedJobsData[0].detailedStatus.detailsPath); - }); -}); diff --git a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/jobs_app_spec.js deleted file mode 100644 index 39475788fe2..00000000000 --- a/spec/frontend/pipelines/components/jobs/jobs_app_spec.js +++ /dev/null @@ -1,127 +0,0 @@ -import { GlIntersectionObserver, GlSkeletonLoader, GlLoadingIcon } 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 { createAlert } from '~/alert'; -import JobsApp from '~/pipelines/components/jobs/jobs_app.vue'; -import JobsTable from '~/jobs/components/table/jobs_table.vue'; -import getPipelineJobsQuery from '~/pipelines/graphql/queries/get_pipeline_jobs.query.graphql'; -import { mockPipelineJobsQueryResponse } from '../../mock_data'; - -Vue.use(VueApollo); - -jest.mock('~/alert'); - -describe('Jobs app', () => { - let wrapper; - let resolverSpy; - - const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); - const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon); - const findJobsTable = () => wrapper.findComponent(JobsTable); - - const triggerInfiniteScroll = () => - wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); - - const createMockApolloProvider = (resolver) => { - const requestHandlers = [[getPipelineJobsQuery, resolver]]; - - return createMockApollo(requestHandlers); - }; - - const createComponent = (resolver) => { - wrapper = shallowMount(JobsApp, { - provide: { - projectPath: 'root/ci-project', - pipelineIid: 1, - }, - apolloProvider: createMockApolloProvider(resolver), - }); - }; - - beforeEach(() => { - resolverSpy = jest.fn().mockResolvedValue(mockPipelineJobsQueryResponse); - }); - - describe('loading spinner', () => { - const setup = async () => { - createComponent(resolverSpy); - - await waitForPromises(); - - triggerInfiniteScroll(); - }; - - it('displays loading spinner when fetching more jobs', async () => { - await setup(); - - 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); - expect(findSkeletonLoader().exists()).toBe(false); - }); - }); - - it('displays the skeleton loader', () => { - createComponent(resolverSpy); - - expect(findSkeletonLoader().exists()).toBe(true); - expect(findJobsTable().exists()).toBe(false); - }); - - it('displays the jobs table', async () => { - createComponent(resolverSpy); - - await waitForPromises(); - - expect(findJobsTable().exists()).toBe(true); - expect(findSkeletonLoader().exists()).toBe(false); - expect(createAlert).not.toHaveBeenCalled(); - }); - - it('handles job fetch error correctly', async () => { - resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); - - createComponent(resolverSpy); - - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message: 'An error occurred while fetching the pipelines jobs.', - }); - }); - - it('handles infinite scrolling by calling fetchMore', async () => { - createComponent(resolverSpy); - await waitForPromises(); - - triggerInfiniteScroll(); - await waitForPromises(); - - expect(resolverSpy).toHaveBeenCalledWith({ - after: 'eyJpZCI6Ijg0NyJ9', - fullPath: 'root/ci-project', - iid: 1, - }); - }); - - it('does not display skeleton loader again after fetchMore', async () => { - createComponent(resolverSpy); - - expect(findSkeletonLoader().exists()).toBe(true); - await waitForPromises(); - - triggerInfiniteScroll(); - await waitForPromises(); - - expect(findSkeletonLoader().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/job_item_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/job_item_spec.js deleted file mode 100644 index b89f27e5c05..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/job_item_spec.js +++ /dev/null @@ -1,29 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import JobItem from '~/pipelines/components/pipeline_mini_graph/job_item.vue'; - -describe('JobItem', () => { - let wrapper; - - const defaultProps = { - job: { id: '3' }, - }; - - const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(JobItem, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - describe('when mounted', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the received HTML', () => { - expect(wrapper.html()).toContain(defaultProps.job.id); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js deleted file mode 100644 index 6661bb079d2..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph_spec.js +++ /dev/null @@ -1,122 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; -import PipelineStages from '~/pipelines/components/pipeline_mini_graph/pipeline_stages.vue'; -import mockLinkedPipelines from './linked_pipelines_mock_data'; - -const mockStages = pipelines[0].details.stages; - -describe('Legacy Pipeline Mini Graph', () => { - let wrapper; - - const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); - const findPipelineStages = () => wrapper.findComponent(PipelineStages); - - const findLinkedPipelineUpstream = () => - wrapper.findComponent('[data-testid="pipeline-mini-graph-upstream"]'); - const findLinkedPipelineDownstream = () => - wrapper.findComponent('[data-testid="pipeline-mini-graph-downstream"]'); - const findDownstreamArrowIcon = () => wrapper.find('[data-testid="downstream-arrow-icon"]'); - const findUpstreamArrowIcon = () => wrapper.find('[data-testid="upstream-arrow-icon"]'); - - const createComponent = (props = {}) => { - wrapper = mount(LegacyPipelineMiniGraph, { - propsData: { - stages: mockStages, - ...props, - }, - }); - }; - - describe('rendered state without upstream or downstream pipelines', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render the pipeline stages', () => { - expect(findPipelineStages().exists()).toBe(true); - }); - - it('should have the correct props', () => { - expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ - downstreamPipelines: [], - isMergeTrain: false, - pipelinePath: '', - stages: expect.any(Array), - updateDropdown: false, - upstreamPipeline: undefined, - }); - }); - - it('should have no linked pipelines', () => { - expect(findLinkedPipelineDownstream().exists()).toBe(false); - expect(findLinkedPipelineUpstream().exists()).toBe(false); - }); - - it('should not render arrow icons', () => { - expect(findUpstreamArrowIcon().exists()).toBe(false); - expect(findDownstreamArrowIcon().exists()).toBe(false); - }); - }); - - describe('rendered state with upstream pipeline', () => { - beforeEach(() => { - createComponent({ - upstreamPipeline: mockLinkedPipelines.triggered_by, - }); - }); - - it('should have the correct props', () => { - expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ - downstreamPipelines: [], - isMergeTrain: false, - pipelinePath: '', - stages: expect.any(Array), - updateDropdown: false, - upstreamPipeline: expect.any(Object), - }); - }); - - it('should render the upstream linked pipelines mini list only', () => { - expect(findLinkedPipelineUpstream().exists()).toBe(true); - expect(findLinkedPipelineDownstream().exists()).toBe(false); - }); - - it('should render an upstream arrow icon only', () => { - expect(findDownstreamArrowIcon().exists()).toBe(false); - expect(findUpstreamArrowIcon().exists()).toBe(true); - expect(findUpstreamArrowIcon().props('name')).toBe('long-arrow'); - }); - }); - - describe('rendered state with downstream pipelines', () => { - beforeEach(() => { - createComponent({ - downstreamPipelines: mockLinkedPipelines.triggered, - pipelinePath: 'my/pipeline/path', - }); - }); - - it('should have the correct props', () => { - expect(findLegacyPipelineMiniGraph().props()).toMatchObject({ - downstreamPipelines: expect.any(Array), - isMergeTrain: false, - pipelinePath: 'my/pipeline/path', - stages: expect.any(Array), - updateDropdown: false, - upstreamPipeline: undefined, - }); - }); - - it('should render the downstream linked pipelines mini list only', () => { - expect(findLinkedPipelineDownstream().exists()).toBe(true); - expect(findLinkedPipelineUpstream().exists()).toBe(false); - }); - - it('should render a downstream arrow icon only', () => { - expect(findUpstreamArrowIcon().exists()).toBe(false); - expect(findDownstreamArrowIcon().exists()).toBe(true); - expect(findDownstreamArrowIcon().props('name')).toBe('long-arrow'); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage_spec.js deleted file mode 100644 index 3697eaeea1a..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage_spec.js +++ /dev/null @@ -1,247 +0,0 @@ -import { GlDropdown } from '@gitlab/ui'; -import { nextTick } from 'vue'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import LegacyPipelineStage from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue'; -import eventHub from '~/pipelines/event_hub'; -import waitForPromises from 'helpers/wait_for_promises'; -import { stageReply } from '../../mock_data'; - -const dropdownPath = 'path.json'; - -describe('Pipelines stage component', () => { - let wrapper; - let mock; - let glTooltipDirectiveMock; - - const createComponent = (props = {}) => { - glTooltipDirectiveMock = jest.fn(); - wrapper = mount(LegacyPipelineStage, { - attachTo: document.body, - directives: { - GlTooltip: glTooltipDirectiveMock, - }, - propsData: { - stage: { - status: { - group: 'success', - icon: 'status_success', - title: 'success', - }, - dropdown_path: dropdownPath, - }, - updateDropdown: false, - ...props, - }, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - jest.spyOn(eventHub, '$emit'); - }); - - afterEach(() => { - eventHub.$emit.mockRestore(); - mock.restore(); - // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy - wrapper.destroy(); - }); - - const findCiActionBtn = () => wrapper.find('.js-ci-action'); - const findCiIcon = () => wrapper.findComponent(CiIcon); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); - const findDropdownMenu = () => - wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); - const findDropdownMenuTitle = () => - wrapper.find('[data-testid="pipeline-stage-dropdown-menu-title"]'); - const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); - const findLoadingState = () => wrapper.find('[data-testid="pipeline-stage-loading-state"]'); - - const openStageDropdown = async () => { - await findDropdownToggle().trigger('click'); - await waitForPromises(); - await nextTick(); - }; - - describe('loading state', () => { - beforeEach(async () => { - createComponent({ updateDropdown: true }); - - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - - await openStageDropdown(); - }); - - it('displays loading state while jobs are being fetched', async () => { - jest.runOnlyPendingTimers(); - await nextTick(); - - expect(findLoadingState().exists()).toBe(true); - expect(findLoadingState().text()).toBe(LegacyPipelineStage.i18n.loadingText); - }); - - it('does not display loading state after jobs have been fetched', async () => { - await waitForPromises(); - - expect(findLoadingState().exists()).toBe(false); - }); - }); - - describe('default appearance', () => { - beforeEach(() => { - createComponent(); - }); - - it('sets up the tooltip to not have a show delay animation', () => { - expect(glTooltipDirectiveMock.mock.calls[0][1].modifiers.ds0).toBe(true); - }); - - it('renders a dropdown with the status icon', () => { - expect(findDropdown().exists()).toBe(true); - expect(findDropdownToggle().exists()).toBe(true); - expect(findCiIcon().exists()).toBe(true); - }); - - it('renders a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('renders a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - expect(findCiIcon().classes('gl-border')).toBe(true); - }); - }); - - describe('when user opens dropdown and stage request is successful', () => { - beforeEach(async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - createComponent(); - - await openStageDropdown(); - await jest.runAllTimers(); - await axios.waitForAll(); - }); - - it('renders the received data and emits the correct events', () => { - expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); - expect(findDropdownMenuTitle().text()).toContain(stageReply.name); - expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); - expect(wrapper.emitted('miniGraphStageClick')).toEqual([[]]); - }); - - it('refreshes when updateDropdown is set to true', async () => { - expect(mock.history.get).toHaveLength(1); - - wrapper.setProps({ updateDropdown: true }); - await axios.waitForAll(); - - expect(mock.history.get).toHaveLength(2); - }); - }); - - describe('when user opens dropdown and stage request fails', () => { - it('should close the dropdown', async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - createComponent(); - - await openStageDropdown(); - await axios.waitForAll(); - await waitForPromises(); - - expect(findDropdown().classes('show')).toBe(false); - }); - }); - - describe('update endpoint correctly', () => { - beforeEach(async () => { - const copyStage = { ...stageReply }; - copyStage.latest_statuses[0].name = 'this is the updated content'; - mock.onGet('bar.json').reply(HTTP_STATUS_OK, copyStage); - createComponent({ - stage: { - status: { - group: 'running', - icon: 'status_running', - title: 'running', - }, - dropdown_path: 'bar.json', - }, - }); - await axios.waitForAll(); - }); - - it('should update the stage to request the new endpoint provided', async () => { - await openStageDropdown(); - jest.runOnlyPendingTimers(); - await waitForPromises(); - - expect(findDropdownMenu().text()).toContain('this is the updated content'); - }); - }); - - describe('job update in dropdown', () => { - beforeEach(async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(HTTP_STATUS_OK); - - createComponent(); - await waitForPromises(); - await nextTick(); - }); - - const clickCiAction = async () => { - await openStageDropdown(); - jest.runOnlyPendingTimers(); - await waitForPromises(); - - await findCiActionBtn().trigger('click'); - }; - - it('keeps dropdown open when job item action is clicked', async () => { - await clickCiAction(); - await waitForPromises(); - - expect(findDropdown().classes('show')).toBe(true); - }); - }); - - describe('With merge trains enabled', () => { - it('shows a warning on the dropdown', async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - createComponent({ - isMergeTrain: true, - }); - - await openStageDropdown(); - jest.runOnlyPendingTimers(); - await waitForPromises(); - - const warning = findMergeTrainWarning(); - - expect(warning.text()).toBe('Merge train pipeline jobs can not be retried'); - }); - }); - - describe('With merge trains disabled', () => { - beforeEach(async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - createComponent(); - - await openStageDropdown(); - await axios.waitForAll(); - }); - - it('does not show a warning on the dropdown', () => { - const warning = findMergeTrainWarning(); - - expect(warning.exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js deleted file mode 100644 index a4ecb9041c9..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list_spec.js +++ /dev/null @@ -1,166 +0,0 @@ -import { mount } from '@vue/test-utils'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import LinkedPipelinesMiniList from '~/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue'; -import mockData from './linked_pipelines_mock_data'; - -describe('Linked pipeline mini list', () => { - let wrapper; - - const findCiIcon = () => wrapper.findComponent(CiIcon); - const findCiIcons = () => wrapper.findAllComponents(CiIcon); - const findLinkedPipelineCounter = () => wrapper.find('[data-testid="linked-pipeline-counter"]'); - const findLinkedPipelineMiniItem = () => - wrapper.find('[data-testid="linked-pipeline-mini-item"]'); - const findLinkedPipelineMiniItems = () => - wrapper.findAll('[data-testid="linked-pipeline-mini-item"]'); - const findLinkedPipelineMiniList = () => wrapper.findComponent(LinkedPipelinesMiniList); - - const createComponent = (props = {}) => { - wrapper = mount(LinkedPipelinesMiniList, { - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, - propsData: { - ...props, - }, - }); - }; - - describe('when passed an upstream pipeline as prop', () => { - beforeEach(() => { - createComponent({ - triggeredBy: [mockData.triggered_by], - }); - }); - - it('should render one linked pipeline item', () => { - expect(findLinkedPipelineMiniItem().exists()).toBe(true); - }); - - it('should render a linked pipeline with the correct href', () => { - expect(findLinkedPipelineMiniItem().exists()).toBe(true); - - expect(findLinkedPipelineMiniItem().attributes('href')).toBe( - '/gitlab-org/gitlab-foss/-/pipelines/129', - ); - }); - - it('should render one ci status icon', () => { - expect(findCiIcon().exists()).toBe(true); - }); - - it('should render a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('should render a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().classes('gl-border')).toBe(true); - }); - - it('should render the correct ci status icon', () => { - expect(findCiIcon().classes('ci-status-icon-running')).toBe(true); - }); - - it('should have an activated tooltip', () => { - expect(findLinkedPipelineMiniItem().exists()).toBe(true); - const tooltip = getBinding(findLinkedPipelineMiniItem().element, 'gl-tooltip'); - - expect(tooltip.value.title).toBe('GitLabCE - running'); - }); - - it('should correctly set is-upstream', () => { - expect(findLinkedPipelineMiniList().exists()).toBe(true); - - expect(findLinkedPipelineMiniList().classes('is-upstream')).toBe(true); - }); - - it('should correctly compute shouldRenderCounter', () => { - expect(findLinkedPipelineMiniList().vm.shouldRenderCounter).toBe(false); - }); - - it('should not render the pipeline counter', () => { - expect(findLinkedPipelineCounter().exists()).toBe(false); - }); - }); - - describe('when passed downstream pipelines as props', () => { - beforeEach(() => { - createComponent({ - triggered: mockData.triggered, - pipelinePath: 'my/pipeline/path', - }); - }); - - it('should render three linked pipeline items', () => { - expect(findLinkedPipelineMiniItems().exists()).toBe(true); - expect(findLinkedPipelineMiniItems().length).toBe(3); - }); - - it('should render three ci status icons', () => { - expect(findCiIcons().exists()).toBe(true); - expect(findCiIcons().length).toBe(3); - }); - - it('should render the correct ci status icon', () => { - expect(findCiIcon().classes('ci-status-icon-running')).toBe(true); - }); - - it('should have an activated tooltip', () => { - expect(findLinkedPipelineMiniItem().exists()).toBe(true); - const tooltip = getBinding(findLinkedPipelineMiniItem().element, 'gl-tooltip'); - - expect(tooltip.value.title).toBe('GitLabCE - running'); - }); - - it('should correctly set is-downstream', () => { - expect(findLinkedPipelineMiniList().exists()).toBe(true); - - expect(findLinkedPipelineMiniList().classes('is-downstream')).toBe(true); - }); - - it('should render a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('should render a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - - expect(findCiIcon().classes('gl-border')).toBe(true); - }); - - it('should render the pipeline counter', () => { - expect(findLinkedPipelineCounter().exists()).toBe(true); - }); - - it('should correctly compute shouldRenderCounter', () => { - expect(findLinkedPipelineMiniList().vm.shouldRenderCounter).toBe(true); - }); - - it('should correctly trim linkedPipelines', () => { - expect(findLinkedPipelineMiniList().props('triggered').length).toBe(6); - expect(findLinkedPipelineMiniList().vm.linkedPipelinesTrimmed.length).toBe(3); - }); - - it('should set the correct pipeline path', () => { - expect(findLinkedPipelineCounter().exists()).toBe(true); - - expect(findLinkedPipelineCounter().attributes('href')).toBe('my/pipeline/path'); - }); - - it('should render the correct counterTooltipText', () => { - expect(findLinkedPipelineCounter().exists()).toBe(true); - const tooltip = getBinding(findLinkedPipelineCounter().element, 'gl-tooltip'); - - expect(tooltip.value.title).toBe(findLinkedPipelineMiniList().vm.counterTooltipText); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mock_data.js deleted file mode 100644 index 117c7f2ae52..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/linked_pipelines_mock_data.js +++ /dev/null @@ -1,407 +0,0 @@ -export default { - triggered_by: { - id: 129, - active: true, - path: '/gitlab-org/gitlab-foss/-/pipelines/129', - project: { - name: 'GitLabCE', - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-foss/-/pipelines/129', - favicon: - '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', - }, - }, - flags: { - latest: false, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: true, - }, - ref: { - name: '7-5-stable', - path: '/gitlab-org/gitlab-foss/commits/7-5-stable', - tag: false, - branch: true, - }, - commit: { - id: '23433d4d8b20d7e45c103d0b6048faad38a130ab', - short_id: '23433d4d', - title: 'Version 7.5.0.rc1', - created_at: '2014-11-17T15:44:14.000+01:00', - parent_ids: ['30ac909f30f58d319b42ed1537664483894b18cd'], - message: 'Version 7.5.0.rc1\n', - author_name: 'Jacob Vosmaer', - author_email: 'contact@jacobvosmaer.nl', - authored_date: '2014-11-17T15:44:14.000+01:00', - committer_name: 'Jacob Vosmaer', - committer_email: 'contact@jacobvosmaer.nl', - committed_date: '2014-11-17T15:44:14.000+01:00', - author_gravatar_url: - 'http://www.gravatar.com/avatar/e66d11c0eedf8c07b3b18fca46599807?s=80&d=identicon', - commit_url: - 'http://localhost:3000/gitlab-org/gitlab-foss/commit/23433d4d8b20d7e45c103d0b6048faad38a130ab', - commit_path: '/gitlab-org/gitlab-foss/commit/23433d4d8b20d7e45c103d0b6048faad38a130ab', - }, - retry_path: '/gitlab-org/gitlab-foss/-/pipelines/129/retry', - cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/129/cancel', - created_at: '2017-05-24T14:46:20.090Z', - updated_at: '2017-05-24T14:46:29.906Z', - }, - triggered: [ - { - id: 132, - active: true, - path: '/gitlab-org/gitlab-foss/-/pipelines/132', - project: { - name: 'GitLabCE', - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-foss/-/pipelines/132', - favicon: - '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', - }, - }, - flags: { - latest: false, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: true, - }, - ref: { - name: 'crowd', - path: '/gitlab-org/gitlab-foss/commits/crowd', - tag: false, - branch: true, - }, - commit: { - id: 'b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', - short_id: 'b9d58c4c', - title: 'getting user keys publically through http without any authentication, the github…', - created_at: '2013-10-03T12:50:33.000+05:30', - parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], - message: - 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n\nchangelog updated to include ssh key retrieval feature update\n', - author_name: 'devaroop', - author_email: 'devaroop123@yahoo.co.in', - authored_date: '2013-10-02T20:39:29.000+05:30', - committer_name: 'devaroop', - committer_email: 'devaroop123@yahoo.co.in', - committed_date: '2013-10-03T12:50:33.000+05:30', - author_gravatar_url: - 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', - commit_url: - 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', - commit_path: '/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', - }, - retry_path: '/gitlab-org/gitlab-foss/-/pipelines/132/retry', - cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/132/cancel', - created_at: '2017-05-24T14:46:24.644Z', - updated_at: '2017-05-24T14:48:55.226Z', - }, - { - id: 133, - active: true, - path: '/gitlab-org/gitlab-foss/-/pipelines/133', - project: { - name: 'GitLabCE', - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-foss/-/pipelines/133', - favicon: - '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', - }, - }, - flags: { - latest: false, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: true, - }, - ref: { - name: 'crowd', - path: '/gitlab-org/gitlab-foss/commits/crowd', - tag: false, - branch: true, - }, - commit: { - id: 'b6bd4856a33df3d144be66c4ed1f1396009bb08b', - short_id: 'b6bd4856', - title: 'getting user keys publically through http without any authentication, the github…', - created_at: '2013-10-02T20:39:29.000+05:30', - parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], - message: - 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n', - author_name: 'devaroop', - author_email: 'devaroop123@yahoo.co.in', - authored_date: '2013-10-02T20:39:29.000+05:30', - committer_name: 'devaroop', - committer_email: 'devaroop123@yahoo.co.in', - committed_date: '2013-10-02T20:39:29.000+05:30', - author_gravatar_url: - 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', - commit_url: - 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', - commit_path: '/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', - }, - retry_path: '/gitlab-org/gitlab-foss/-/pipelines/133/retry', - cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/133/cancel', - created_at: '2017-05-24T14:46:24.648Z', - updated_at: '2017-05-24T14:48:59.673Z', - }, - { - id: 130, - active: true, - path: '/gitlab-org/gitlab-foss/-/pipelines/130', - project: { - name: 'GitLabCE', - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-foss/-/pipelines/130', - favicon: - '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', - }, - }, - flags: { - latest: false, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: true, - }, - ref: { - name: 'crowd', - path: '/gitlab-org/gitlab-foss/commits/crowd', - tag: false, - branch: true, - }, - commit: { - id: '6d7ced4a2311eeff037c5575cca1868a6d3f586f', - short_id: '6d7ced4a', - title: 'Whitespace fixes to patch', - created_at: '2013-10-08T13:53:22.000-05:00', - parent_ids: ['1875141a963a4238bda29011d8f7105839485253'], - message: 'Whitespace fixes to patch\n', - author_name: 'Dale Hamel', - author_email: 'dale.hamel@srvthe.net', - authored_date: '2013-10-08T13:53:22.000-05:00', - committer_name: 'Dale Hamel', - committer_email: 'dale.hamel@invenia.ca', - committed_date: '2013-10-08T13:53:22.000-05:00', - author_gravatar_url: - 'http://www.gravatar.com/avatar/cd08930e69fa5ad1a669206e7bafe476?s=80&d=identicon', - commit_url: - 'http://localhost:3000/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', - commit_path: '/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', - }, - retry_path: '/gitlab-org/gitlab-foss/-/pipelines/130/retry', - cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/130/cancel', - created_at: '2017-05-24T14:46:24.630Z', - updated_at: '2017-05-24T14:49:45.091Z', - }, - { - id: 131, - active: true, - path: '/gitlab-org/gitlab-foss/-/pipelines/132', - project: { - name: 'GitLabCE', - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-foss/-/pipelines/132', - favicon: - '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', - }, - }, - flags: { - latest: false, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: true, - }, - ref: { - name: 'crowd', - path: '/gitlab-org/gitlab-foss/commits/crowd', - tag: false, - branch: true, - }, - commit: { - id: 'b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', - short_id: 'b9d58c4c', - title: 'getting user keys publically through http without any authentication, the github…', - created_at: '2013-10-03T12:50:33.000+05:30', - parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], - message: - 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n\nchangelog updated to include ssh key retrieval feature update\n', - author_name: 'devaroop', - author_email: 'devaroop123@yahoo.co.in', - authored_date: '2013-10-02T20:39:29.000+05:30', - committer_name: 'devaroop', - committer_email: 'devaroop123@yahoo.co.in', - committed_date: '2013-10-03T12:50:33.000+05:30', - author_gravatar_url: - 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', - commit_url: - 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', - commit_path: '/gitlab-org/gitlab-foss/commit/b9d58c4cecd06be74c3cc32ccfb522b31544ab2e', - }, - retry_path: '/gitlab-org/gitlab-foss/-/pipelines/132/retry', - cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/132/cancel', - created_at: '2017-05-24T14:46:24.644Z', - updated_at: '2017-05-24T14:48:55.226Z', - }, - { - id: 134, - active: true, - path: '/gitlab-org/gitlab-foss/-/pipelines/133', - project: { - name: 'GitLabCE', - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-foss/-/pipelines/133', - favicon: - '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', - }, - }, - flags: { - latest: false, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: true, - }, - ref: { - name: 'crowd', - path: '/gitlab-org/gitlab-foss/commits/crowd', - tag: false, - branch: true, - }, - commit: { - id: 'b6bd4856a33df3d144be66c4ed1f1396009bb08b', - short_id: 'b6bd4856', - title: 'getting user keys publically through http without any authentication, the github…', - created_at: '2013-10-02T20:39:29.000+05:30', - parent_ids: ['e219cf7246c6a0495e4507deaffeba11e79f13b8'], - message: - 'getting user keys publically through http without any authentication, the github way. E.g: http://github.com/devaroop.keys\n', - author_name: 'devaroop', - author_email: 'devaroop123@yahoo.co.in', - authored_date: '2013-10-02T20:39:29.000+05:30', - committer_name: 'devaroop', - committer_email: 'devaroop123@yahoo.co.in', - committed_date: '2013-10-02T20:39:29.000+05:30', - author_gravatar_url: - 'http://www.gravatar.com/avatar/35df4b155ec66a3127d53459941cf8a2?s=80&d=identicon', - commit_url: - 'http://localhost:3000/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', - commit_path: '/gitlab-org/gitlab-foss/commit/b6bd4856a33df3d144be66c4ed1f1396009bb08b', - }, - retry_path: '/gitlab-org/gitlab-foss/-/pipelines/133/retry', - cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/133/cancel', - created_at: '2017-05-24T14:46:24.648Z', - updated_at: '2017-05-24T14:48:59.673Z', - }, - { - id: 135, - active: true, - path: '/gitlab-org/gitlab-foss/-/pipelines/130', - project: { - name: 'GitLabCE', - }, - details: { - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - has_details: true, - details_path: '/gitlab-org/gitlab-foss/-/pipelines/130', - favicon: - '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', - }, - }, - flags: { - latest: false, - triggered: false, - stuck: false, - yaml_errors: false, - retryable: true, - cancelable: true, - }, - ref: { - name: 'crowd', - path: '/gitlab-org/gitlab-foss/commits/crowd', - tag: false, - branch: true, - }, - commit: { - id: '6d7ced4a2311eeff037c5575cca1868a6d3f586f', - short_id: '6d7ced4a', - title: 'Whitespace fixes to patch', - created_at: '2013-10-08T13:53:22.000-05:00', - parent_ids: ['1875141a963a4238bda29011d8f7105839485253'], - message: 'Whitespace fixes to patch\n', - author_name: 'Dale Hamel', - author_email: 'dale.hamel@srvthe.net', - authored_date: '2013-10-08T13:53:22.000-05:00', - committer_name: 'Dale Hamel', - committer_email: 'dale.hamel@invenia.ca', - committed_date: '2013-10-08T13:53:22.000-05:00', - author_gravatar_url: - 'http://www.gravatar.com/avatar/cd08930e69fa5ad1a669206e7bafe476?s=80&d=identicon', - commit_url: - 'http://localhost:3000/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', - commit_path: '/gitlab-org/gitlab-foss/commit/6d7ced4a2311eeff037c5575cca1868a6d3f586f', - }, - retry_path: '/gitlab-org/gitlab-foss/-/pipelines/130/retry', - cancel_path: '/gitlab-org/gitlab-foss/-/pipelines/130/cancel', - created_at: '2017-05-24T14:46:24.630Z', - updated_at: '2017-05-24T14:49:45.091Z', - }, - ], -}; diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js b/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js deleted file mode 100644 index 1c13e9eb62b..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/mock_data.js +++ /dev/null @@ -1,150 +0,0 @@ -export const mockDownstreamPipelinesGraphql = ({ includeSourceJobRetried = true } = {}) => ({ - nodes: [ - { - id: 'gid://gitlab/Ci::Pipeline/612', - path: '/root/job-log-sections/-/pipelines/612', - project: { - id: 'gid://gitlab/Project/21', - name: 'job-log-sections', - __typename: 'Project', - }, - detailedStatus: { - id: 'success-612-612', - group: 'success', - icon: 'status_success', - label: 'passed', - __typename: 'DetailedStatus', - }, - sourceJob: { - id: 'gid://gitlab/Ci::Bridge/532', - retried: includeSourceJobRetried ? false : null, - }, - __typename: 'Pipeline', - }, - { - id: 'gid://gitlab/Ci::Pipeline/611', - path: '/root/job-log-sections/-/pipelines/611', - project: { - id: 'gid://gitlab/Project/21', - name: 'job-log-sections', - __typename: 'Project', - }, - detailedStatus: { - id: 'success-611-611', - group: 'success', - icon: 'status_success', - label: 'passed', - __typename: 'DetailedStatus', - }, - sourceJob: { - id: 'gid://gitlab/Ci::Bridge/531', - retried: includeSourceJobRetried ? true : null, - }, - __typename: 'Pipeline', - }, - { - id: 'gid://gitlab/Ci::Pipeline/609', - path: '/root/job-log-sections/-/pipelines/609', - project: { - id: 'gid://gitlab/Project/21', - name: 'job-log-sections', - __typename: 'Project', - }, - detailedStatus: { - id: 'success-609-609', - group: 'success', - icon: 'status_success', - label: 'passed', - __typename: 'DetailedStatus', - }, - sourceJob: { - id: 'gid://gitlab/Ci::Bridge/530', - retried: includeSourceJobRetried ? true : null, - }, - __typename: 'Pipeline', - }, - ], - __typename: 'PipelineConnection', -}); - -const upstream = { - id: 'gid://gitlab/Ci::Pipeline/610', - path: '/root/trigger-downstream/-/pipelines/610', - project: { - id: 'gid://gitlab/Project/21', - name: 'trigger-downstream', - __typename: 'Project', - }, - detailedStatus: { - id: 'success-610-610', - group: 'success', - icon: 'status_success', - label: 'passed', - __typename: 'DetailedStatus', - }, - __typename: 'Pipeline', -}; - -export const mockPipelineStagesQueryResponse = { - data: { - project: { - id: 'gid://gitlab/Project/20', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/320', - stages: { - nodes: [ - { - __typename: 'CiStage', - id: 'gid://gitlab/Ci::Stage/409', - name: 'build', - detailedStatus: { - __typename: 'DetailedStatus', - id: 'success-409-409', - icon: 'status_success', - group: 'success', - }, - }, - ], - }, - }, - }, - }, -}; - -export const mockPipelineStatusResponse = { - data: { - project: { - id: 'gid://gitlab/Project/20', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/320', - detailedStatus: { - id: 'pending-320-320', - detailsPath: '/root/ci-project/-/pipelines/320', - icon: 'status_pending', - group: 'pending', - __typename: 'DetailedStatus', - }, - __typename: 'Pipeline', - }, - __typename: 'Project', - }, - }, -}; - -export const mockUpstreamDownstreamQueryResponse = { - data: { - project: { - id: '1', - pipeline: { - id: 'pipeline-1', - path: '/root/ci-project/-/pipelines/790', - downstream: mockDownstreamPipelinesGraphql(), - upstream, - }, - __typename: 'Project', - }, - }, -}; - -export const linkedPipelinesFetchError = 'There was a problem fetching linked pipelines.'; -export const stagesFetchError = 'There was a problem fetching the pipeline stages.'; diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js deleted file mode 100644 index b3e157f75f6..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js +++ /dev/null @@ -1,123 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { GlLoadingIcon } from '@gitlab/ui'; - -import { createAlert } from '~/alert'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import createMockApollo from 'helpers/mock_apollo_helper'; - -import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; -import getPipelineStagesQuery from '~/pipelines/graphql/queries/get_pipeline_stages.query.graphql'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; -import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; -import * as sharedGraphQlUtils from '~/graphql_shared/utils'; - -import { - linkedPipelinesFetchError, - stagesFetchError, - mockPipelineStagesQueryResponse, - mockUpstreamDownstreamQueryResponse, -} from './mock_data'; - -Vue.use(VueApollo); -jest.mock('~/alert'); - -describe('PipelineMiniGraph', () => { - let wrapper; - let linkedPipelinesResponse; - let pipelineStagesResponse; - - const fullPath = 'gitlab-org/gitlab'; - const iid = '315'; - const pipelineEtag = '/api/graphql:pipelines/id/315'; - - const createComponent = ({ - pipelineStagesHandler = pipelineStagesResponse, - linkedPipelinesHandler = linkedPipelinesResponse, - } = {}) => { - const handlers = [ - [getLinkedPipelinesQuery, linkedPipelinesHandler], - [getPipelineStagesQuery, pipelineStagesHandler], - ]; - const mockApollo = createMockApollo(handlers); - - wrapper = shallowMountExtended(PipelineMiniGraph, { - propsData: { - fullPath, - iid, - pipelineEtag, - }, - apolloProvider: mockApollo, - }); - - return waitForPromises(); - }; - - const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - - beforeEach(() => { - linkedPipelinesResponse = jest.fn().mockResolvedValue(mockUpstreamDownstreamQueryResponse); - pipelineStagesResponse = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse); - }); - - describe('when initial queries are loading', () => { - beforeEach(() => { - createComponent(); - }); - - it('shows a loading icon and no mini graph', () => { - expect(findLoadingIcon().exists()).toBe(true); - expect(findLegacyPipelineMiniGraph().exists()).toBe(false); - }); - }); - - describe('when queries have loaded', () => { - it('does not show a loading icon', async () => { - await createComponent(); - - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('renders the Pipeline Mini Graph', async () => { - await createComponent(); - - expect(findLegacyPipelineMiniGraph().exists()).toBe(true); - }); - - it('fires the queries', async () => { - await createComponent(); - - expect(linkedPipelinesResponse).toHaveBeenCalledWith({ iid, fullPath }); - expect(pipelineStagesResponse).toHaveBeenCalledWith({ iid, fullPath }); - }); - }); - - describe('polling', () => { - it('toggles query polling with visibility check', async () => { - jest.spyOn(sharedGraphQlUtils, 'toggleQueryPollingByVisibility'); - - createComponent(); - - await waitForPromises(); - - expect(sharedGraphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledTimes(2); - }); - }); - - describe('when pipeline queries are unsuccessful', () => { - const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); - it.each` - query | handlerName | errorMessage - ${'pipeline stages'} | ${'pipelineStagesHandler'} | ${stagesFetchError} - ${'linked pipelines'} | ${'linkedPipelinesHandler'} | ${linkedPipelinesFetchError} - `('throws an error for the $query query', async ({ errorMessage, handlerName }) => { - await createComponent({ [handlerName]: failedHandler }); - - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ message: errorMessage }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js deleted file mode 100644 index 1989aad12b0..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js +++ /dev/null @@ -1,46 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import createMockApollo from 'helpers/mock_apollo_helper'; - -import getPipelineStageQuery from '~/pipelines/graphql/queries/get_pipeline_stage.query.graphql'; -import PipelineStage from '~/pipelines/components/pipeline_mini_graph/pipeline_stage.vue'; - -Vue.use(VueApollo); - -describe('PipelineStage', () => { - let wrapper; - let pipelineStageResponse; - - const defaultProps = { - pipelineEtag: '/etag', - stageId: '1', - }; - - const createComponent = ({ pipelineStageHandler = pipelineStageResponse } = {}) => { - const handlers = [[getPipelineStageQuery, pipelineStageHandler]]; - const mockApollo = createMockApollo(handlers); - - wrapper = shallowMountExtended(PipelineStage, { - propsData: { - ...defaultProps, - }, - apolloProvider: mockApollo, - }); - - return waitForPromises(); - }; - - const findPipelineStage = () => wrapper.findComponent(PipelineStage); - - describe('when mounted', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders job item', () => { - expect(findPipelineStage().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js deleted file mode 100644 index c212087b7e3..00000000000 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; -import LegacyPipelineStage from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue'; -import PipelineStages from '~/pipelines/components/pipeline_mini_graph/pipeline_stages.vue'; - -const mockStages = pipelines[0].details.stages; - -describe('Pipeline Stages', () => { - let wrapper; - - const findLegacyPipelineStages = () => wrapper.findAllComponents(LegacyPipelineStage); - const findPipelineStagesAt = (i) => findLegacyPipelineStages().at(i); - - const createComponent = (props = {}) => { - wrapper = shallowMount(PipelineStages, { - propsData: { - stages: mockStages, - ...props, - }, - }); - }; - - it('renders stages', () => { - createComponent(); - - expect(findLegacyPipelineStages()).toHaveLength(mockStages.length); - }); - - it('does not fail when stages are empty', () => { - createComponent({ stages: [] }); - - expect(wrapper.exists()).toBe(true); - expect(findLegacyPipelineStages()).toHaveLength(0); - }); - - it('update dropdown is false by default', () => { - createComponent(); - - expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(false); - expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(false); - }); - - it('update dropdown is set to true', () => { - createComponent({ updateDropdown: true }); - - expect(findPipelineStagesAt(0).props('updateDropdown')).toBe(true); - expect(findPipelineStagesAt(1).props('updateDropdown')).toBe(true); - }); - - it('is merge train is false by default', () => { - createComponent(); - - expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(false); - expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(false); - }); - - it('is merge train is set to true', () => { - createComponent({ isMergeTrain: true }); - - expect(findPipelineStagesAt(0).props('isMergeTrain')).toBe(true); - expect(findPipelineStagesAt(1).props('isMergeTrain')).toBe(true); - }); -}); diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js deleted file mode 100644 index 0951e1ffb46..00000000000 --- a/spec/frontend/pipelines/components/pipeline_tabs_spec.js +++ /dev/null @@ -1,114 +0,0 @@ -import { GlTab } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import PipelineTabs from '~/pipelines/components/pipeline_tabs.vue'; -import { TRACKING_CATEGORIES } from '~/pipelines/constants'; - -describe('The Pipeline Tabs', () => { - let wrapper; - let trackingSpy; - - const $router = { push: jest.fn() }; - - const findDagTab = () => wrapper.findByTestId('dag-tab'); - const findFailedJobsTab = () => wrapper.findByTestId('failed-jobs-tab'); - const findJobsTab = () => wrapper.findByTestId('jobs-tab'); - const findPipelineTab = () => wrapper.findByTestId('pipeline-tab'); - const findTestsTab = () => wrapper.findByTestId('tests-tab'); - - const findFailedJobsBadge = () => wrapper.findByTestId('failed-builds-counter'); - const findJobsBadge = () => wrapper.findByTestId('builds-counter'); - const findTestsBadge = () => wrapper.findByTestId('tests-counter'); - - const defaultProvide = { - defaultTabValue: '', - failedJobsCount: 1, - totalJobCount: 10, - testsCount: 123, - }; - - const createComponent = (provide = {}) => { - wrapper = shallowMountExtended(PipelineTabs, { - provide: { - ...defaultProvide, - ...provide, - }, - stubs: { - GlTab, - RouterView: true, - }, - mocks: { - $router, - }, - }); - }; - - describe('Tabs', () => { - it.each` - tabName | tabComponent - ${'Pipeline'} | ${findPipelineTab} - ${'Dag'} | ${findDagTab} - ${'Jobs'} | ${findJobsTab} - ${'Failed Jobs'} | ${findFailedJobsTab} - ${'Tests'} | ${findTestsTab} - `('shows $tabName tab', ({ tabComponent }) => { - createComponent(); - - expect(tabComponent().exists()).toBe(true); - }); - - describe('with no failed jobs', () => { - beforeEach(() => { - createComponent({ failedJobsCount: 0 }); - }); - - it('hides the failed jobs tab', () => { - expect(findFailedJobsTab().exists()).toBe(false); - }); - }); - }); - - describe('Tabs badges', () => { - it.each` - tabName | badgeComponent | badgeText - ${'Jobs'} | ${findJobsBadge} | ${String(defaultProvide.totalJobCount)} - ${'Failed Jobs'} | ${findFailedJobsBadge} | ${String(defaultProvide.failedJobsCount)} - ${'Tests'} | ${findTestsBadge} | ${String(defaultProvide.testsCount)} - `('shows badge for $tabName with the correct text', ({ badgeComponent, badgeText }) => { - createComponent(); - - expect(badgeComponent().exists()).toBe(true); - expect(badgeComponent().text()).toBe(badgeText); - }); - }); - - describe('Tab tracking', () => { - beforeEach(() => { - createComponent(); - - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('tracks failed jobs tab click', () => { - findFailedJobsTab().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { - label: TRACKING_CATEGORIES.failed, - }); - }); - - it('tracks tests tab click', () => { - findTestsTab().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { - label: TRACKING_CATEGORIES.tests, - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js deleted file mode 100644 index 51a4487a3ef..00000000000 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ /dev/null @@ -1,199 +0,0 @@ -import { GlFilteredSearch } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import Api from '~/api'; -import axios from '~/lib/utils/axios_utils'; -import PipelinesFilteredSearch from '~/pipelines/components/pipelines_list/pipelines_filtered_search.vue'; -import { - FILTERED_SEARCH_TERM, - OPERATORS_IS, -} from '~/vue_shared/components/filtered_search_bar/constants'; -import { TRACKING_CATEGORIES } from '~/pipelines/constants'; -import { users, mockSearch, branches, tags } from '../mock_data'; - -describe('Pipelines filtered search', () => { - let wrapper; - let mock; - - const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); - const getSearchToken = (type) => - findFilteredSearch() - .props('availableTokens') - .find((token) => token.type === type); - const findBranchToken = () => getSearchToken('ref'); - const findTagToken = () => getSearchToken('tag'); - const findUserToken = () => getSearchToken('username'); - const findStatusToken = () => getSearchToken('status'); - const findSourceToken = () => getSearchToken('source'); - - const createComponent = (params = {}) => { - wrapper = mount(PipelinesFilteredSearch, { - propsData: { - projectId: '21', - defaultBranchName: 'main', - params, - }, - attachTo: document.body, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - - jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); - jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); - jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); - - createComponent(); - }); - - afterEach(() => { - mock.restore(); - }); - - it('displays UI elements', () => { - expect(findFilteredSearch().exists()).toBe(true); - }); - - it('displays search tokens', () => { - expect(findUserToken()).toMatchObject({ - type: 'username', - icon: 'user', - title: 'Trigger author', - unique: true, - projectId: '21', - operators: OPERATORS_IS, - }); - - expect(findBranchToken()).toMatchObject({ - type: 'ref', - icon: 'branch', - title: 'Branch name', - unique: true, - projectId: '21', - defaultBranchName: 'main', - operators: OPERATORS_IS, - }); - - expect(findSourceToken()).toMatchObject({ - type: 'source', - icon: 'trigger-source', - title: 'Source', - unique: true, - operators: OPERATORS_IS, - }); - - expect(findStatusToken()).toMatchObject({ - type: 'status', - icon: 'status', - title: 'Status', - unique: true, - operators: OPERATORS_IS, - }); - - expect(findTagToken()).toMatchObject({ - type: 'tag', - icon: 'tag', - title: 'Tag name', - unique: true, - operators: OPERATORS_IS, - }); - }); - - it('emits filterPipelines on submit with correct filter', () => { - findFilteredSearch().vm.$emit('submit', mockSearch); - - expect(wrapper.emitted('filterPipelines')).toHaveLength(1); - expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]); - }); - - it('disables tag name token when branch name token is active', async () => { - findFilteredSearch().vm.$emit('input', [ - { type: 'ref', value: { data: 'branch-1', operator: '=' } }, - { type: FILTERED_SEARCH_TERM, value: { data: '' } }, - ]); - - await nextTick(); - expect(findBranchToken().disabled).toBe(false); - expect(findTagToken().disabled).toBe(true); - }); - - it('disables branch name token when tag name token is active', async () => { - findFilteredSearch().vm.$emit('input', [ - { type: 'tag', value: { data: 'tag-1', operator: '=' } }, - { type: FILTERED_SEARCH_TERM, value: { data: '' } }, - ]); - - await nextTick(); - expect(findBranchToken().disabled).toBe(true); - expect(findTagToken().disabled).toBe(false); - }); - - it('resets tokens disabled state on clear', async () => { - findFilteredSearch().vm.$emit('clearInput'); - - await nextTick(); - expect(findBranchToken().disabled).toBe(false); - expect(findTagToken().disabled).toBe(false); - }); - - it('resets tokens disabled state when clearing tokens by backspace', async () => { - findFilteredSearch().vm.$emit('input', [{ type: FILTERED_SEARCH_TERM, value: { data: '' } }]); - - await nextTick(); - expect(findBranchToken().disabled).toBe(false); - expect(findTagToken().disabled).toBe(false); - }); - - describe('Url query params', () => { - const params = { - username: 'deja.green', - ref: 'main', - }; - - beforeEach(() => { - createComponent(params); - }); - - it('sets default value if url query params', () => { - const expectedValueProp = [ - { - type: 'username', - value: { - data: params.username, - operator: '=', - }, - }, - { - type: 'ref', - value: { - data: params.ref, - operator: '=', - }, - }, - { type: FILTERED_SEARCH_TERM, value: { data: '' } }, - ]; - - expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp); - expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length); - }); - }); - - describe('tracking', () => { - afterEach(() => { - unmockTracking(); - }); - - it('tracks filtered search click', () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - findFilteredSearch().vm.$emit('submit', mockSearch); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filtered_search', { - label: TRACKING_CATEGORIES.search, - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js deleted file mode 100644 index b560eea4882..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js +++ /dev/null @@ -1,107 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; - -const pipelineEditorPath = '/-/ci/editor'; -const suggestedCiTemplates = [ - { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, - { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, - { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, -]; - -describe('CI Templates', () => { - let wrapper; - let trackingSpy; - - const createWrapper = (propsData = {}) => { - wrapper = shallowMountExtended(CiTemplates, { - provide: { - pipelineEditorPath, - suggestedCiTemplates, - }, - propsData, - }); - }; - - const findTemplateDescription = () => wrapper.findByTestId('template-description'); - const findTemplateLink = () => wrapper.findByTestId('template-link'); - const findTemplateNames = () => wrapper.findAllByTestId('template-name'); - const findTemplateName = () => wrapper.findByTestId('template-name'); - const findTemplateLogo = () => wrapper.findByTestId('template-logo'); - - describe('renders template list', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders all suggested templates', () => { - expect(findTemplateNames().length).toBe(3); - expect(wrapper.text()).toContain('Android', 'Bash', 'C++'); - }); - - it('has the correct template name', () => { - expect(findTemplateName().text()).toBe('Android'); - }); - - it('links to the correct template', () => { - expect(findTemplateLink().attributes('href')).toBe( - pipelineEditorPath.concat('?template=Android'), - ); - }); - - it('has the link button enabled', () => { - expect(findTemplateLink().props('disabled')).toBe(false); - }); - - it('has the description of the template', () => { - expect(findTemplateDescription().text()).toBe( - 'Continuous integration and deployment template to test and deploy your Android project.', - ); - }); - - it('has the right logo of the template', () => { - expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg'); - }); - }); - - describe('filtering the templates', () => { - beforeEach(() => { - createWrapper({ filterTemplates: ['Bash'] }); - }); - - it('renders only the filtered templates', () => { - expect(findTemplateNames()).toHaveLength(1); - expect(findTemplateName().text()).toBe('Bash'); - }); - }); - - describe('disabling the templates', () => { - beforeEach(() => { - createWrapper({ disabled: true }); - }); - - it('has the link button disabled', () => { - expect(findTemplateLink().props('disabled')).toBe(true); - }); - }); - - describe('tracking', () => { - beforeEach(() => { - createWrapper(); - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('sends an event when template is clicked', () => { - findTemplateLink().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { - label: 'Android', - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js deleted file mode 100644 index 700be076e0c..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import '~/commons'; -import { nextTick } from 'vue'; -import { GlPopover, GlButton } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; -import IosTemplates from '~/pipelines/components/pipelines_list/empty_state/ios_templates.vue'; -import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; - -const pipelineEditorPath = '/-/ci/editor'; -const registrationToken = 'SECRET_TOKEN'; -const iOSTemplateName = 'iOS-Fastlane'; - -describe('iOS Templates', () => { - let wrapper; - - const createWrapper = (providedPropsData = {}) => { - return shallowMountExtended(IosTemplates, { - provide: { - pipelineEditorPath, - iosRunnersAvailable: true, - ...providedPropsData, - }, - propsData: { - registrationToken, - }, - stubs: { - GlButton, - }, - }); - }; - - const findIosTemplate = () => wrapper.findComponent(CiTemplates); - const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); - const findRunnerInstructionsPopover = () => wrapper.findComponent(GlPopover); - const findRunnerSetupTodoEmoji = () => wrapper.findByTestId('runner-setup-marked-todo'); - const findRunnerSetupCompletedEmoji = () => wrapper.findByTestId('runner-setup-marked-completed'); - const findSetupRunnerLink = () => wrapper.findByText('Set up a runner'); - const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link'); - - describe('when ios runners are not available', () => { - beforeEach(() => { - wrapper = createWrapper({ iosRunnersAvailable: false }); - }); - - describe('the runner setup section', () => { - it('marks the section as todo', () => { - expect(findRunnerSetupTodoEmoji().isVisible()).toBe(true); - expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(false); - }); - - it('renders the setup runner link', () => { - expect(findSetupRunnerLink().exists()).toBe(true); - }); - - it('renders the runner instructions modal with a popover once clicked', async () => { - findSetupRunnerLink().element.parentElement.click(); - - await nextTick(); - - expect(findRunnerInstructionsModal().exists()).toBe(true); - expect(findRunnerInstructionsModal().props('registrationToken')).toBe(registrationToken); - expect(findRunnerInstructionsModal().props('defaultPlatformName')).toBe('osx'); - - findRunnerInstructionsModal().vm.$emit('shown'); - - await nextTick(); - - expect(findRunnerInstructionsPopover().exists()).toBe(true); - }); - }); - - describe('the configure pipeline section', () => { - it('has a disabled link button', () => { - expect(configurePipelineLink().props('disabled')).toBe(true); - }); - }); - - describe('the ios-Fastlane template', () => { - it('renders the template', () => { - expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); - }); - - it('has a disabled link button', () => { - expect(findIosTemplate().props('disabled')).toBe(true); - }); - }); - }); - - describe('when ios runners are available', () => { - beforeEach(() => { - wrapper = createWrapper(); - }); - - describe('the runner setup section', () => { - it('marks the section as completed', () => { - expect(findRunnerSetupTodoEmoji().isVisible()).toBe(false); - expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(true); - }); - - it('does not render the setup runner link', () => { - expect(findSetupRunnerLink().exists()).toBe(false); - }); - }); - - describe('the configure pipeline section', () => { - it('has an enabled link button', () => { - expect(configurePipelineLink().props('disabled')).toBe(false); - }); - - it('links to the pipeline editor with the right template', () => { - expect(configurePipelineLink().attributes('href')).toBe( - `${pipelineEditorPath}?template=${iOSTemplateName}`, - ); - }); - }); - - describe('the ios-Fastlane template', () => { - it('renders the template', () => { - expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); - }); - - it('has an enabled link button', () => { - expect(findIosTemplate().props('disabled')).toBe(false); - }); - - it('links to the pipeline editor with the right template', () => { - expect(configurePipelineLink().attributes('href')).toBe( - `${pipelineEditorPath}?template=${iOSTemplateName}`, - ); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js deleted file mode 100644 index 4bf4257f462..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -import '~/commons'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; -import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; - -const pipelineEditorPath = '/-/ci/editor'; - -describe('Pipelines CI Templates', () => { - let wrapper; - let trackingSpy; - - const createWrapper = (propsData = {}, stubs = {}) => { - return shallowMountExtended(PipelinesCiTemplates, { - provide: { - pipelineEditorPath, - ...propsData, - }, - stubs, - }); - }; - - const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); - const findCiTemplates = () => wrapper.findComponent(CiTemplates); - - describe('templates', () => { - beforeEach(() => { - wrapper = createWrapper(); - }); - - it('renders test template and Ci templates', () => { - expect(findTestTemplateLink().attributes('href')).toBe( - pipelineEditorPath.concat('?template=Getting-Started'), - ); - expect(findCiTemplates().exists()).toBe(true); - }); - }); - - describe('tracking', () => { - beforeEach(() => { - wrapper = createWrapper(); - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('sends an event when Getting-Started template is clicked', () => { - findTestTemplateLink().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { - label: 'Getting-Started', - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_job_details_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_job_details_spec.js deleted file mode 100644 index 479ee854ecf..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_job_details_spec.js +++ /dev/null @@ -1,254 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { GlIcon, GlLink } from '@gitlab/ui'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/alert'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import FailedJobDetails from '~/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue'; -import RetryMrFailedJobMutation from '~/pipelines/graphql/mutations/retry_mr_failed_job.mutation.graphql'; -import { BRIDGE_KIND } from '~/pipelines/components/graph/constants'; -import { job } from './mock'; - -Vue.use(VueApollo); -jest.mock('~/alert'); - -const createFakeEvent = () => ({ stopPropagation: jest.fn() }); - -describe('FailedJobDetails component', () => { - let wrapper; - let mockRetryResponse; - - const retrySuccessResponse = { - data: { - jobRetry: { - errors: [], - }, - }, - }; - - const defaultProps = { - job, - }; - - const createComponent = ({ props = {} } = {}) => { - const handlers = [[RetryMrFailedJobMutation, mockRetryResponse]]; - - wrapper = shallowMountExtended(FailedJobDetails, { - propsData: { - ...defaultProps, - ...props, - }, - apolloProvider: createMockApollo(handlers), - }); - }; - - const findArrowIcon = () => wrapper.findComponent(GlIcon); - const findJobId = () => wrapper.findComponent(GlLink); - const findJobLog = () => wrapper.findByTestId('job-log'); - const findJobName = () => wrapper.findByText(defaultProps.job.name); - const findRetryButton = () => wrapper.findByLabelText('Retry'); - const findRow = () => wrapper.findByTestId('widget-row'); - const findStageName = () => wrapper.findByText(defaultProps.job.stage.name); - - beforeEach(() => { - mockRetryResponse = jest.fn(); - mockRetryResponse.mockResolvedValue(retrySuccessResponse); - }); - - describe('ui', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the job name', () => { - expect(findJobName().exists()).toBe(true); - }); - - it('renders the stage name', () => { - expect(findStageName().exists()).toBe(true); - }); - - it('renders the job id as a link', () => { - const jobId = getIdFromGraphQLId(defaultProps.job.id); - - expect(findJobId().exists()).toBe(true); - expect(findJobId().text()).toContain(String(jobId)); - }); - - it('does not renders the job lob', () => { - expect(findJobLog().exists()).toBe(false); - }); - }); - - describe('Retry action', () => { - describe('when the job is not retryable', () => { - beforeEach(() => { - createComponent({ props: { job: { ...job, retryable: false } } }); - }); - - it('disables the retry button', () => { - expect(findRetryButton().props().disabled).toBe(true); - }); - }); - - describe('when the job is a bridge', () => { - beforeEach(() => { - createComponent({ props: { job: { ...job, kind: BRIDGE_KIND } } }); - }); - - it('disables the retry button', () => { - expect(findRetryButton().props().disabled).toBe(true); - }); - }); - - describe('when the job is retryable', () => { - describe('and user has permission to update the build', () => { - beforeEach(() => { - createComponent(); - }); - - it('enables the retry button', () => { - expect(findRetryButton().props().disabled).toBe(false); - }); - - describe('when clicking on the retry button', () => { - it('passes the loading state to the button', async () => { - await findRetryButton().vm.$emit('click', createFakeEvent()); - - expect(findRetryButton().props().loading).toBe(true); - }); - - describe('and it succeeds', () => { - beforeEach(async () => { - findRetryButton().vm.$emit('click', createFakeEvent()); - await waitForPromises(); - }); - - it('is no longer loading', () => { - expect(findRetryButton().props().loading).toBe(false); - }); - - it('calls the retry mutation', () => { - expect(mockRetryResponse).toHaveBeenCalled(); - expect(mockRetryResponse).toHaveBeenCalledWith({ - id: job.id, - }); - }); - - it('emits the `retried-job` event', () => { - expect(wrapper.emitted('job-retried')).toStrictEqual([[job.name]]); - }); - }); - - describe('and it fails', () => { - const customErrorMsg = 'Custom error message from API'; - - beforeEach(async () => { - mockRetryResponse.mockResolvedValue({ - data: { jobRetry: { errors: [customErrorMsg] } }, - }); - findRetryButton().vm.$emit('click', createFakeEvent()); - - await waitForPromises(); - }); - - it('shows an error message', () => { - expect(createAlert).toHaveBeenCalledWith({ message: customErrorMsg }); - }); - - it('does not emits the `refetch-jobs` event', () => { - expect(wrapper.emitted('refetch-jobs')).toBeUndefined(); - }); - }); - }); - }); - - describe('and user does not have permission to update the build', () => { - beforeEach(() => { - createComponent({ - props: { job: { ...job, retryable: true, userPermissions: { updateBuild: false } } }, - }); - }); - - it('disables the retry button', () => { - expect(findRetryButton().props().disabled).toBe(true); - }); - }); - }); - }); - - describe('Job log', () => { - describe('without permissions', () => { - beforeEach(async () => { - createComponent({ props: { job: { ...job, userPermissions: { readBuild: false } } } }); - await findRow().trigger('click'); - }); - - it('does not renders the received html of the job log', () => { - expect(findJobLog().html()).not.toContain(defaultProps.job.trace.htmlSummary); - }); - - it('shows a permission error message', () => { - expect(findJobLog().text()).toBe("You do not have permission to read this job's log."); - }); - }); - - describe('with permissions', () => { - beforeEach(() => { - createComponent(); - }); - - describe('when clicking on the row', () => { - beforeEach(async () => { - await findRow().trigger('click'); - }); - - describe('while collapsed', () => { - it('expands the job log', () => { - expect(findJobLog().exists()).toBe(true); - }); - - it('renders the down arrow', () => { - expect(findArrowIcon().props().name).toBe('chevron-down'); - }); - - it('renders the received html of the job log', () => { - expect(findJobLog().html()).toContain(defaultProps.job.trace.htmlSummary); - }); - }); - - describe('while expanded', () => { - it('collapes the job log', async () => { - expect(findJobLog().exists()).toBe(true); - - await findRow().trigger('click'); - - expect(findJobLog().exists()).toBe(false); - }); - - it('renders the right arrow', async () => { - expect(findArrowIcon().props().name).toBe('chevron-down'); - - await findRow().trigger('click'); - - expect(findArrowIcon().props().name).toBe('chevron-right'); - }); - }); - }); - - describe('when clicking on a link element within the row', () => { - it('does not expands/collapse the job log', async () => { - expect(findJobLog().exists()).toBe(false); - expect(findArrowIcon().props().name).toBe('chevron-right'); - - await findJobId().vm.$emit('click'); - - expect(findJobLog().exists()).toBe(false); - expect(findArrowIcon().props().name).toBe('chevron-right'); - }); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_jobs_list_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_jobs_list_spec.js deleted file mode 100644 index 967812cc627..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/failure_widget/failed_jobs_list_spec.js +++ /dev/null @@ -1,279 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; - -import { GlLoadingIcon, GlToast } from '@gitlab/ui'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/alert'; -import FailedJobsList from '~/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue'; -import FailedJobDetails from '~/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue'; -import * as utils from '~/pipelines/components/pipelines_list/failure_widget/utils'; -import getPipelineFailedJobs from '~/pipelines/graphql/queries/get_pipeline_failed_jobs.query.graphql'; -import { failedJobsMock, failedJobsMock2, failedJobsMockEmpty, activeFailedJobsMock } from './mock'; - -Vue.use(VueApollo); -Vue.use(GlToast); - -jest.mock('~/alert'); - -describe('FailedJobsList component', () => { - let wrapper; - let mockFailedJobsResponse; - const showToast = jest.fn(); - - const defaultProps = { - failedJobsCount: 0, - graphqlResourceEtag: 'api/graphql', - isPipelineActive: false, - pipelineIid: 1, - projectPath: 'namespace/project/', - }; - - const defaultProvide = { - graphqlPath: 'api/graphql', - }; - - const createComponent = ({ props = {}, provide } = {}) => { - const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]]; - const mockApollo = createMockApollo(handlers); - - wrapper = shallowMountExtended(FailedJobsList, { - propsData: { - ...defaultProps, - ...props, - }, - provide: { - ...defaultProvide, - ...provide, - }, - apolloProvider: mockApollo, - mocks: { - $toast: { - show: showToast, - }, - }, - }); - }; - - const findAllHeaders = () => wrapper.findAllByTestId('header'); - const findFailedJobRows = () => wrapper.findAllComponents(FailedJobDetails); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findNoFailedJobsText = () => wrapper.findByText('No failed jobs in this pipeline 🎉'); - - beforeEach(() => { - mockFailedJobsResponse = jest.fn(); - }); - - describe('on mount', () => { - beforeEach(() => { - mockFailedJobsResponse.mockResolvedValue(failedJobsMock); - createComponent(); - }); - - it('fires the graphql query', () => { - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); - expect(mockFailedJobsResponse).toHaveBeenCalledWith({ - fullPath: defaultProps.projectPath, - pipelineIid: defaultProps.pipelineIid, - }); - }); - }); - - describe('when loading failed jobs', () => { - beforeEach(() => { - mockFailedJobsResponse.mockResolvedValue(failedJobsMock); - createComponent(); - }); - - it('shows a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - }); - - describe('when failed jobs have loaded', () => { - beforeEach(async () => { - mockFailedJobsResponse.mockResolvedValue(failedJobsMock); - jest.spyOn(utils, 'sortJobsByStatus'); - - createComponent(); - - await waitForPromises(); - }); - - it('does not renders a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('renders table column', () => { - expect(findAllHeaders()).toHaveLength(3); - }); - - it('shows the list of failed jobs', () => { - expect(findFailedJobRows()).toHaveLength( - failedJobsMock.data.project.pipeline.jobs.nodes.length, - ); - }); - - it('does not renders the empty state', () => { - expect(findNoFailedJobsText().exists()).toBe(false); - }); - - it('calls sortJobsByStatus', () => { - expect(utils.sortJobsByStatus).toHaveBeenCalledWith( - failedJobsMock.data.project.pipeline.jobs.nodes, - ); - }); - }); - - describe('when there are no failed jobs', () => { - beforeEach(async () => { - mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); - jest.spyOn(utils, 'sortJobsByStatus'); - - createComponent(); - - await waitForPromises(); - }); - - it('renders the empty state', () => { - expect(findNoFailedJobsText().exists()).toBe(true); - }); - }); - - describe('polling', () => { - it.each` - isGraphqlActive | text - ${true} | ${'polls'} - ${false} | ${'does not poll'} - `(`$text when isGraphqlActive: $isGraphqlActive`, async ({ isGraphqlActive }) => { - const defaultCount = 2; - const newCount = 1; - - const expectedCount = isGraphqlActive ? newCount : defaultCount; - const expectedCallCount = isGraphqlActive ? 2 : 1; - const mockResponse = isGraphqlActive ? activeFailedJobsMock : failedJobsMock; - - // Second result is to simulate polling with a different response - mockFailedJobsResponse.mockResolvedValueOnce(mockResponse); - mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); - - createComponent(); - await waitForPromises(); - - // Initially, we get the first response which is always the default - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); - expect(findFailedJobRows()).toHaveLength(defaultCount); - - jest.advanceTimersByTime(10000); - await waitForPromises(); - - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(expectedCallCount); - expect(findFailedJobRows()).toHaveLength(expectedCount); - }); - }); - - describe('when a REST action occurs', () => { - beforeEach(() => { - // Second result is to simulate polling with a different response - mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); - mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); - }); - - it.each([true, false])('triggers a refetch of the jobs count', async (isPipelineActive) => { - const defaultCount = 2; - const newCount = 1; - - createComponent({ props: { isPipelineActive } }); - await waitForPromises(); - - // Initially, we get the first response which is always the default - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); - expect(findFailedJobRows()).toHaveLength(defaultCount); - - wrapper.setProps({ isPipelineActive: !isPipelineActive }); - await waitForPromises(); - - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); - expect(findFailedJobRows()).toHaveLength(newCount); - }); - }); - - describe('When the job count changes from REST', () => { - beforeEach(() => { - mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); - - createComponent(); - }); - - describe('and the count is the same', () => { - it('does not re-fetch the query', async () => { - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); - - await wrapper.setProps({ failedJobsCount: 0 }); - - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); - }); - }); - - describe('and the count is different', () => { - it('re-fetches the query', async () => { - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); - - await wrapper.setProps({ failedJobsCount: 10 }); - - expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); - }); - }); - }); - - describe('when an error occurs loading jobs', () => { - const errorMessage = "We couldn't fetch jobs for you because you are not qualified"; - - beforeEach(async () => { - mockFailedJobsResponse.mockRejectedValue({ message: errorMessage }); - - createComponent(); - - await waitForPromises(); - }); - it('does not renders a loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('calls create Alert with the error message and danger variant', () => { - expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' }); - }); - }); - - describe('when `refetch-jobs` job is fired from the widget', () => { - beforeEach(async () => { - mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); - mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); - - createComponent(); - - await waitForPromises(); - }); - - it('refetches all failed jobs', async () => { - expect(findFailedJobRows()).not.toHaveLength( - failedJobsMock2.data.project.pipeline.jobs.nodes.length, - ); - - await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); - await waitForPromises(); - - expect(findFailedJobRows()).toHaveLength( - failedJobsMock2.data.project.pipeline.jobs.nodes.length, - ); - }); - - it('shows a toast message', async () => { - await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); - await waitForPromises(); - - expect(showToast).toHaveBeenCalledWith('job-name job is being retried'); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js deleted file mode 100644 index 318d787a984..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/failure_widget/mock.js +++ /dev/null @@ -1,78 +0,0 @@ -export const job = { - id: 'gid://gitlab/Ci::Build/5241', - allowFailure: false, - detailedStatus: { - id: 'status', - detailsPath: '/jobs/5241', - action: { - id: 'action', - path: '/retry', - icon: 'retry', - }, - group: 'running', - icon: 'status_running_icon', - }, - name: 'job-name', - retried: false, - retryable: true, - kind: 'BUILD', - stage: { - id: '1', - name: 'build', - }, - trace: { - htmlSummary: '

    Hello

    ', - }, - userPermissions: { - readBuild: true, - updateBuild: true, - }, -}; - -export const allowedToFailJob = { - ...job, - id: 'gid://gitlab/Ci::Build/5242', - allowFailure: true, -}; - -export const createFailedJobsMockCount = ({ count = 4, active = false } = {}) => { - return { - data: { - project: { - id: 'gid://gitlab/Project/20', - pipeline: { - id: 'gid://gitlab/Pipeline/20', - active, - jobs: { - count, - }, - }, - }, - }, - }; -}; - -const createFailedJobsMock = (nodes, active = false) => { - return { - data: { - project: { - id: 'gid://gitlab/Project/20', - pipeline: { - active, - id: 'gid://gitlab/Pipeline/20', - jobs: { - count: nodes.length, - nodes, - }, - }, - }, - }, - }; -}; - -export const failedJobsMock = createFailedJobsMock([allowedToFailJob, job]); -export const failedJobsMockEmpty = createFailedJobsMock([]); - -export const activeFailedJobsMock = createFailedJobsMock([allowedToFailJob, job], true); - -export const failedJobsMock2 = createFailedJobsMock([job]); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js deleted file mode 100644 index 5bbb874edb0..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget_spec.js +++ /dev/null @@ -1,139 +0,0 @@ -import { GlButton, GlCard, GlIcon, GlPopover } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; -import FailedJobsList from '~/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue'; - -jest.mock('~/alert'); - -describe('PipelineFailedJobsWidget component', () => { - let wrapper; - - const defaultProps = { - failedJobsCount: 4, - isPipelineActive: false, - pipelineIid: 1, - pipelinePath: '/pipelines/1', - projectPath: 'namespace/project/', - }; - - const defaultProvide = { - fullPath: 'namespace/project/', - }; - - const createComponent = ({ props = {}, provide = {} } = {}) => { - wrapper = shallowMountExtended(PipelineFailedJobsWidget, { - propsData: { - ...defaultProps, - ...props, - }, - provide: { - ...defaultProvide, - ...provide, - }, - stubs: { GlCard }, - }); - }; - - const findFailedJobsCard = () => wrapper.findByTestId('failed-jobs-card'); - const findFailedJobsButton = () => wrapper.findComponent(GlButton); - const findFailedJobsList = () => wrapper.findAllComponents(FailedJobsList); - const findInfoIcon = () => wrapper.findComponent(GlIcon); - const findInfoPopover = () => wrapper.findComponent(GlPopover); - - describe('when there are no failed jobs', () => { - beforeEach(() => { - createComponent({ props: { failedJobsCount: 0 } }); - }); - - it('renders the show failed jobs button with a count of 0', () => { - expect(findFailedJobsButton().exists()).toBe(true); - expect(findFailedJobsButton().text()).toBe('Failed jobs (0)'); - }); - }); - - describe('when there are failed jobs', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the show failed jobs button with correct count', () => { - expect(findFailedJobsButton().exists()).toBe(true); - expect(findFailedJobsButton().text()).toBe(`Failed jobs (${defaultProps.failedJobsCount})`); - }); - - it('renders the info icon', () => { - expect(findInfoIcon().exists()).toBe(true); - }); - - it('renders the info popover', () => { - expect(findInfoPopover().exists()).toBe(true); - }); - - it('does not render the failed jobs widget', () => { - expect(findFailedJobsList().exists()).toBe(false); - }); - }); - - describe('when the job button is clicked', () => { - beforeEach(async () => { - createComponent(); - await findFailedJobsButton().vm.$emit('click'); - }); - - it('renders the failed jobs widget', () => { - expect(findFailedJobsList().exists()).toBe(true); - }); - - it('removes the CSS border classes', () => { - expect(findFailedJobsCard().attributes('class')).not.toContain( - 'gl-border-white gl-hover-border-gray-100', - ); - }); - }); - - describe('when the job details are not expanded', () => { - beforeEach(() => { - createComponent(); - }); - - it('has the CSS border classes', () => { - expect(findFailedJobsCard().attributes('class')).toContain( - 'gl-border-white gl-hover-border-gray-100', - ); - }); - }); - - describe('when the job count changes', () => { - beforeEach(() => { - createComponent(); - }); - - describe('from the prop', () => { - it('updates the job count', async () => { - const newJobCount = 12; - - expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); - - await wrapper.setProps({ failedJobsCount: newJobCount }); - - expect(findFailedJobsButton().text()).toContain(String(newJobCount)); - }); - }); - - describe('from the event', () => { - beforeEach(async () => { - await findFailedJobsButton().vm.$emit('click'); - }); - - it('updates the job count', async () => { - const newJobCount = 12; - - expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); - - await findFailedJobsList().at(0).vm.$emit('failed-jobs-count', newJobCount); - - expect(findFailedJobsButton().text()).toContain(String(newJobCount)); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js b/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js deleted file mode 100644 index 44f16478151..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/failure_widget/utils_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -import { - isFailedJob, - sortJobsByStatus, -} from '~/pipelines/components/pipelines_list/failure_widget/utils'; - -describe('isFailedJob', () => { - describe('when the job argument is undefined', () => { - it('returns false', () => { - expect(isFailedJob()).toBe(false); - }); - }); - - describe('when the job is of status `failed`', () => { - it('returns false', () => { - expect(isFailedJob({ detailedStatus: { group: 'success' } })).toBe(false); - }); - }); - - describe('when the job status is `failed`', () => { - it('returns true', () => { - expect(isFailedJob({ detailedStatus: { group: 'failed' } })).toBe(true); - }); - }); -}); - -describe('sortJobsByStatus', () => { - describe('when the arg is undefined', () => { - it('returns an empty array', () => { - expect(sortJobsByStatus()).toEqual([]); - }); - }); - - describe('when receiving an empty array', () => { - it('returns an empty array', () => { - expect(sortJobsByStatus([])).toEqual([]); - }); - }); - - describe('when reciving a list of jobs', () => { - const jobArr = [ - { detailedStatus: { group: 'failed' } }, - { detailedStatus: { group: 'allowed_to_fail' } }, - { detailedStatus: { group: 'failed' } }, - { detailedStatus: { group: 'success' } }, - ]; - - const expectedResult = [ - { detailedStatus: { group: 'failed' } }, - { detailedStatus: { group: 'failed' } }, - { detailedStatus: { group: 'allowed_to_fail' } }, - { detailedStatus: { group: 'success' } }, - ]; - - it('sorts failed jobs first', () => { - expect(sortJobsByStatus(jobArr)).toEqual(expectedResult); - }); - }); -}); diff --git a/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js deleted file mode 100644 index 249126390f1..00000000000 --- a/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlSprintf } from '@gitlab/ui'; -import PipelineStopModal from '~/pipelines/components/pipelines_list/pipeline_stop_modal.vue'; -import { mockPipelineHeader } from '../../mock_data'; - -describe('PipelineStopModal', () => { - let wrapper; - - const createComponent = () => { - wrapper = shallowMount(PipelineStopModal, { - propsData: { - pipeline: mockPipelineHeader, - }, - stubs: { - GlSprintf, - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('should render "stop pipeline" warning', () => { - expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`); - }); -}); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js deleted file mode 100644 index 5465e4d77da..00000000000 --- a/spec/frontend/pipelines/empty_state_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import '~/commons'; -import { shallowMount } from '@vue/test-utils'; -import { GlEmptyState } from '@gitlab/ui'; -import { stubExperiments } from 'helpers/experimentation_helper'; -import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; -import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; -import IosTemplates from '~/pipelines/components/pipelines_list/empty_state/ios_templates.vue'; - -describe('Pipelines Empty State', () => { - let wrapper; - - const findIllustration = () => wrapper.find('img'); - const findButton = () => wrapper.find('a'); - const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates); - const iosTemplates = () => wrapper.findComponent(IosTemplates); - - const createWrapper = (props = {}) => { - wrapper = shallowMount(EmptyState, { - provide: { - pipelineEditorPath: '', - suggestedCiTemplates: [], - anyRunnersAvailable: true, - ciRunnerSettingsPath: '', - }, - propsData: { - emptyStateSvgPath: 'foo.svg', - canSetCi: true, - ...props, - }, - stubs: { - GlEmptyState, - GitlabExperiment, - }, - }); - }; - - describe('when user can configure CI', () => { - describe('when the ios_specific_templates experiment is active', () => { - beforeEach(() => { - stubExperiments({ ios_specific_templates: 'candidate' }); - createWrapper(); - }); - - it('should render the iOS templates', () => { - expect(iosTemplates().exists()).toBe(true); - }); - - it('should not render the CI/CD templates', () => { - expect(pipelinesCiTemplates().exists()).toBe(false); - }); - }); - - describe('when the ios_specific_templates experiment is inactive', () => { - beforeEach(() => { - stubExperiments({ ios_specific_templates: 'control' }); - createWrapper(); - }); - - it('should render the CI/CD templates', () => { - expect(pipelinesCiTemplates().exists()).toBe(true); - }); - - it('should not render the iOS templates', () => { - expect(iosTemplates().exists()).toBe(false); - }); - }); - }); - - describe('when user cannot configure CI', () => { - beforeEach(() => { - createWrapper({ canSetCi: false }); - }); - - it('should render empty state SVG', () => { - expect(findIllustration().attributes('src')).toBe('foo.svg'); - }); - - it('should render empty state header', () => { - expect(wrapper.text()).toBe('This project is not currently set up to run pipelines.'); - }); - - it('should not render a link', () => { - expect(findButton().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js deleted file mode 100644 index 890255f225e..00000000000 --- a/spec/frontend/pipelines/graph/action_component_spec.js +++ /dev/null @@ -1,116 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import ActionComponent from '~/pipelines/components/jobs_shared/action_component.vue'; - -describe('pipeline graph action component', () => { - let wrapper; - let mock; - const findButton = () => wrapper.findComponent(GlButton); - const findTooltipWrapper = () => wrapper.find('[data-testid="ci-action-icon-tooltip-wrapper"]'); - - const defaultProps = { - tooltipText: 'bar', - link: 'foo', - actionIcon: 'cancel', - }; - - const createComponent = ({ props } = {}) => { - wrapper = mount(ActionComponent, { - propsData: { ...defaultProps, ...props }, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - - mock.onPost('foo.json').reply(HTTP_STATUS_OK); - }); - - afterEach(() => { - mock.restore(); - }); - - describe('render', () => { - beforeEach(() => { - createComponent(); - }); - - it('should render the provided title as a bootstrap tooltip', () => { - expect(findTooltipWrapper().attributes('title')).toBe('bar'); - }); - - it('should update bootstrap tooltip when title changes', async () => { - wrapper.setProps({ tooltipText: 'changed' }); - - await nextTick(); - expect(findTooltipWrapper().attributes('title')).toBe('changed'); - }); - - it('should render an svg', () => { - expect(wrapper.find('.ci-action-icon-wrapper').exists()).toBe(true); - expect(wrapper.find('svg').exists()).toBe(true); - }); - }); - - describe('on click', () => { - beforeEach(() => { - createComponent(); - }); - - it('emits `pipelineActionRequestComplete` after a successful request', async () => { - findButton().trigger('click'); - - await waitForPromises(); - - expect(wrapper.emitted().pipelineActionRequestComplete).toHaveLength(1); - }); - - it('renders a loading icon while waiting for request', async () => { - findButton().trigger('click'); - - await nextTick(); - expect(wrapper.find('.js-action-icon-loading').exists()).toBe(true); - }); - }); - - describe('when has a confirmation modal', () => { - beforeEach(() => { - createComponent({ props: { withConfirmationModal: true, shouldTriggerClick: false } }); - }); - - describe('and a first click is initiated', () => { - beforeEach(async () => { - findButton().trigger('click'); - - await waitForPromises(); - }); - - it('emits `showActionConfirmationModal` event', () => { - expect(wrapper.emitted().showActionConfirmationModal).toHaveLength(1); - }); - - it('does not emit `pipelineActionRequestComplete` event', () => { - expect(wrapper.emitted().pipelineActionRequestComplete).toBeUndefined(); - }); - }); - - describe('and the `shouldTriggerClick` value becomes true', () => { - beforeEach(async () => { - await wrapper.setProps({ shouldTriggerClick: true }); - }); - - it('does not emit `showActionConfirmationModal` event', () => { - expect(wrapper.emitted().showActionConfirmationModal).toBeUndefined(); - }); - - it('emits `actionButtonClicked` event', () => { - expect(wrapper.emitted().actionButtonClicked).toHaveLength(1); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js deleted file mode 100644 index e9bce037800..00000000000 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ /dev/null @@ -1,182 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; -import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; -import JobItem from '~/pipelines/components/graph/job_item.vue'; -import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; -import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; -import { calculatePipelineLayersInfo } from '~/pipelines/components/graph/utils'; -import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; - -import { generateResponse, pipelineWithUpstreamDownstream } from './mock_data'; - -describe('graph component', () => { - let wrapper; - - const findDownstreamColumn = () => wrapper.findByTestId('downstream-pipelines'); - const findLinkedColumns = () => wrapper.findAllComponents(LinkedPipelinesColumn); - const findLinksLayer = () => wrapper.findComponent(LinksLayer); - const findStageColumns = () => wrapper.findAllComponents(StageColumnComponent); - const findStageNameInJob = () => wrapper.findByTestId('stage-name-in-job'); - - const defaultProps = { - pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), - showLinks: false, - viewType: STAGE_VIEW, - configPaths: { - metricsPath: '', - graphqlResourceEtag: 'this/is/a/path', - }, - }; - - const defaultData = { - measurements: { - width: 800, - height: 800, - }, - }; - - const createComponent = ({ - data = {}, - mountFn = shallowMount, - props = {}, - stubOverride = {}, - } = {}) => { - wrapper = mountFn(PipelineGraph, { - propsData: { - ...defaultProps, - ...props, - }, - data() { - return { - ...defaultData, - ...data, - }; - }, - stubs: { - 'links-inner': true, - 'linked-pipeline': true, - 'job-item': true, - 'job-group-dropdown': true, - ...stubOverride, - }, - }); - }; - - describe('with data', () => { - beforeEach(() => { - createComponent({ mountFn: mountExtended }); - }); - - it('renders the main columns in the graph', () => { - expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); - }); - - it('renders the links layer', () => { - expect(findLinksLayer().exists()).toBe(true); - }); - - it('does not display stage name on the job in default (stage) mode', () => { - expect(findStageNameInJob().exists()).toBe(false); - }); - - describe('when column requests a refresh', () => { - beforeEach(() => { - findStageColumns().at(0).vm.$emit('refreshPipelineGraph'); - }); - - it('refreshPipelineGraph is emitted', () => { - expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); - }); - }); - - describe('when column request an update to the retry confirmation modal', () => { - beforeEach(() => { - findStageColumns().at(0).vm.$emit('setSkipRetryModal'); - }); - - it('setSkipRetryModal is emitted', () => { - expect(wrapper.emitted().setSkipRetryModal).toHaveLength(1); - }); - }); - - describe('when links are present', () => { - beforeEach(() => { - createComponent({ - mountFn: mountExtended, - stubOverride: { 'job-item': false }, - data: { hoveredJobName: 'test_a' }, - }); - findLinksLayer().vm.$emit('highlightedJobsChange', ['test_c', 'build_c']); - }); - - it('dims unrelated jobs', () => { - const unrelatedJob = wrapper.findComponent(JobItem); - expect(findLinksLayer().emitted().highlightedJobsChange).toHaveLength(1); - expect(unrelatedJob.classes('gl-opacity-3')).toBe(true); - }); - }); - }); - - describe('when linked pipelines are not present', () => { - beforeEach(() => { - createComponent({ mountFn: mountExtended }); - }); - - it('should not render a linked pipelines column', () => { - expect(findLinkedColumns()).toHaveLength(0); - }); - }); - - describe('when linked pipelines are present', () => { - beforeEach(() => { - createComponent({ - mountFn: mountExtended, - props: { pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse) }, - }); - }); - - it('should render linked pipelines columns', () => { - expect(findLinkedColumns()).toHaveLength(2); - }); - }); - - describe('in layers mode', () => { - beforeEach(() => { - createComponent({ - mountFn: mountExtended, - stubOverride: { - 'job-item': false, - 'job-group-dropdown': false, - }, - props: { - viewType: LAYER_VIEW, - computedPipelineInfo: calculatePipelineLayersInfo(defaultProps.pipeline, 'layer', ''), - }, - }); - }); - - it('displays the stage name on the job', () => { - expect(findStageNameInJob().exists()).toBe(true); - }); - }); - - describe('downstream pipelines', () => { - beforeEach(() => { - createComponent({ - mountFn: mountExtended, - props: { - pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse), - }, - }); - }); - - it('filters pipelines spawned from the same trigger job', () => { - // The mock data has one downstream with `retried: true and one - // with retried false. We filter the `retried: true` out so we - // should only pass one downstream - expect(findDownstreamColumn().props().linkedPipelines).toHaveLength(1); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js deleted file mode 100644 index 7b59d82ae6f..00000000000 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ /dev/null @@ -1,603 +0,0 @@ -import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_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'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { - PIPELINES_DETAIL_LINK_DURATION, - PIPELINES_DETAIL_LINKS_TOTAL, - PIPELINES_DETAIL_LINKS_JOB_RATIO, -} from '~/performance/constants'; -import * as perfUtils from '~/performance/utils'; -import { - ACTION_FAILURE, - LAYER_VIEW, - STAGE_VIEW, - VIEW_TYPE_KEY, -} from '~/pipelines/components/graph/constants'; -import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; -import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; -import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; -import * as Api from '~/pipelines/components/graph_shared/api'; -import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; -import * as parsingUtils from '~/pipelines/components/parsing_utils'; -import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql'; -import * as sentryUtils from '~/pipelines/utils'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { mockRunningPipelineHeaderData } from '../mock_data'; -import { - mapCallouts, - mockCalloutsResponse, - mockPipelineResponseWithTooManyJobs, -} from './mock_data'; - -const defaultProvide = { - graphqlResourceEtag: 'frog/amphibirama/etag/', - metricsPath: '', - pipelineProjectPath: 'frog/amphibirama', - pipelineIid: '22', -}; - -describe('Pipeline graph wrapper', () => { - Vue.use(VueApollo); - useLocalStorageSpy(); - - let wrapper; - let requestHandlers; - let pipelineDetailsHandler; - - const findAlert = () => wrapper.findByTestId('error-alert'); - const findJobCountWarning = () => wrapper.findByTestId('job-count-warning'); - const findDependenciesToggle = () => wrapper.findByTestId('show-links-toggle'); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findLinksLayer = () => wrapper.findComponent(LinksLayer); - const findGraph = () => wrapper.findComponent(PipelineGraph); - const findStageColumnTitle = () => wrapper.findByTestId('stage-column-title'); - const findViewSelector = () => wrapper.findComponent(GraphViewSelector); - const findViewSelectorToggle = () => findViewSelector().findComponent(GlToggle); - const findViewSelectorTrip = () => findViewSelector().findComponent(GlAlert); - const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); - - const createComponent = ({ - apolloProvider, - data = {}, - provide = {}, - mountFn = shallowMountExtended, - } = {}) => { - wrapper = mountFn(PipelineGraphWrapper, { - provide: { - ...defaultProvide, - ...provide, - }, - apolloProvider, - data() { - return { - ...data, - }; - }, - }); - }; - - const createComponentWithApollo = ({ - calloutsList = [], - data = {}, - mountFn = shallowMountExtended, - provide = {}, - } = {}) => { - const callouts = mapCallouts(calloutsList); - - requestHandlers = { - getUserCalloutsHandler: jest.fn().mockResolvedValue(mockCalloutsResponse(callouts)), - getPipelineHeaderDataHandler: jest.fn().mockResolvedValue(mockRunningPipelineHeaderData), - getPipelineDetailsHandler: pipelineDetailsHandler, - }; - - const handlers = [ - [getPipelineHeaderData, requestHandlers.getPipelineHeaderDataHandler], - [getPipelineDetails, requestHandlers.getPipelineDetailsHandler], - [getUserCallouts, requestHandlers.getUserCalloutsHandler], - ]; - - const apolloProvider = createMockApollo(handlers); - createComponent({ apolloProvider, data, provide, mountFn }); - }; - - beforeEach(() => { - pipelineDetailsHandler = jest.fn(); - pipelineDetailsHandler.mockResolvedValue(mockPipelineResponse); - }); - - describe('when data is loading', () => { - beforeEach(() => { - createComponentWithApollo(); - }); - - it('displays the loading icon', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('does not display the alert', () => { - expect(findAlert().exists()).toBe(false); - }); - - it('does not display the graph', () => { - expect(findGraph().exists()).toBe(false); - }); - - it('skips querying headerPipeline', () => { - expect(wrapper.vm.$apollo.queries.headerPipeline.skip).toBe(true); - }); - }); - - describe('when data has loaded', () => { - beforeEach(async () => { - createComponentWithApollo(); - await waitForPromises(); - }); - - it('does not display the loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('does not display the alert', () => { - expect(findAlert().exists()).toBe(false); - }); - - it('displays the graph', () => { - expect(findGraph().exists()).toBe(true); - }); - - it('passes the etag resource and metrics path to the graph', () => { - expect(findGraph().props('configPaths')).toMatchObject({ - graphqlResourceEtag: defaultProvide.graphqlResourceEtag, - metricsPath: defaultProvide.metricsPath, - }); - }); - }); - - describe('when a stage has 100 jobs or more', () => { - beforeEach(async () => { - pipelineDetailsHandler.mockResolvedValue(mockPipelineResponseWithTooManyJobs); - createComponentWithApollo(); - await waitForPromises(); - }); - - it('show a warning alert', () => { - expect(findJobCountWarning().exists()).toBe(true); - expect(findJobCountWarning().props().title).toBe( - 'Only the first 100 jobs per stage are displayed', - ); - }); - }); - - describe('when there is an error', () => { - beforeEach(async () => { - pipelineDetailsHandler.mockRejectedValue(new Error('GraphQL error')); - createComponentWithApollo(); - await waitForPromises(); - }); - - it('does not display the loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('displays the alert', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('does not display the graph', () => { - expect(findGraph().exists()).toBe(false); - }); - }); - - describe('when there is no pipeline iid available', () => { - beforeEach(async () => { - createComponentWithApollo({ - provide: { - pipelineIid: '', - }, - }); - await waitForPromises(); - }); - - it('does not display the loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('displays the no iid alert', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe( - 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', - ); - }); - - it('does not display the graph', () => { - expect(findGraph().exists()).toBe(false); - }); - }); - - describe('events', () => { - beforeEach(async () => { - createComponentWithApollo(); - await waitForPromises(); - }); - describe('when receiving `setSkipRetryModal` event', () => { - it('passes down `skipRetryModal` value as true', async () => { - expect(findGraph().props('skipRetryModal')).toBe(false); - - await findGraph().vm.$emit('setSkipRetryModal'); - - expect(findGraph().props('skipRetryModal')).toBe(true); - }); - }); - }); - - describe('when there is an error with an action in the graph', () => { - beforeEach(async () => { - createComponentWithApollo(); - await waitForPromises(); - await findGraph().vm.$emit('error', { type: ACTION_FAILURE }); - }); - - it('does not display the loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('displays the action error alert', () => { - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe('An error occurred while performing this action.'); - }); - - it('displays the graph', () => { - expect(findGraph().exists()).toBe(true); - }); - }); - - describe('when refresh action is emitted', () => { - beforeEach(async () => { - createComponentWithApollo(); - await waitForPromises(); - findGraph().vm.$emit('refreshPipelineGraph'); - }); - - it('calls refetch', () => { - expect(requestHandlers.getPipelineHeaderDataHandler).toHaveBeenCalledWith({ - fullPath: 'frog/amphibirama', - iid: '22', - }); - expect(requestHandlers.getPipelineDetailsHandler).toHaveBeenCalledTimes(2); - expect(requestHandlers.getUserCalloutsHandler).toHaveBeenCalledWith({}); - }); - }); - - describe('when query times out', () => { - const advanceApolloTimers = async () => { - jest.runOnlyPendingTimers(); - await waitForPromises(); - }; - - beforeEach(async () => { - const errorData = { - data: { - project: { - pipelines: null, - }, - }, - errors: [{ message: 'timeout' }], - }; - - pipelineDetailsHandler - .mockResolvedValueOnce(errorData) - .mockResolvedValueOnce(mockPipelineResponse) - .mockResolvedValueOnce(errorData); - - createComponentWithApollo(); - await waitForPromises(); - }); - - it('shows correct errors and does not overwrite populated data when data is empty', async () => { - /* fails at first, shows error, no data yet */ - expect(findAlert().exists()).toBe(true); - expect(findGraph().exists()).toBe(false); - - /* succeeds, clears error, shows graph */ - await advanceApolloTimers(); - expect(findAlert().exists()).toBe(false); - expect(findGraph().exists()).toBe(true); - - /* fails again, alert returns but data persists */ - await advanceApolloTimers(); - expect(findAlert().exists()).toBe(true); - expect(findGraph().exists()).toBe(true); - }); - }); - - describe('view dropdown', () => { - describe('default', () => { - let layersFn; - beforeEach(async () => { - layersFn = jest.spyOn(parsingUtils, 'listByLayers'); - createComponentWithApollo({ - mountFn: mountExtended, - }); - - await waitForPromises(); - }); - - it('appears when pipeline uses needs', () => { - expect(findViewSelector().exists()).toBe(true); - }); - - it('switches between views', async () => { - expect(findStageColumnTitle().text()).toBe('deploy'); - - await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); - - expect(findStageColumnTitle().text()).toBe(''); - }); - - it('saves the view type to local storage', async () => { - await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); - expect(localStorage.setItem.mock.calls).toEqual([[VIEW_TYPE_KEY, LAYER_VIEW]]); - }); - - it('calls listByLayers only once no matter how many times view is switched', async () => { - expect(layersFn).not.toHaveBeenCalled(); - await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); - expect(layersFn).toHaveBeenCalledTimes(1); - await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW); - await findViewSelector().vm.$emit('updateViewType', LAYER_VIEW); - await findViewSelector().vm.$emit('updateViewType', STAGE_VIEW); - expect(layersFn).toHaveBeenCalledTimes(1); - }); - }); - - describe('when layers view is selected', () => { - beforeEach(async () => { - createComponentWithApollo({ - data: { - currentViewType: LAYER_VIEW, - }, - mountFn: mountExtended, - }); - - jest.runOnlyPendingTimers(); - await waitForPromises(); - }); - - it('sets showLinks to true', async () => { - /* This spec uses .props for performance reasons. */ - expect(findLinksLayer().exists()).toBe(true); - expect(findLinksLayer().props('showLinks')).toBe(false); - expect(findViewSelector().props('type')).toBe(LAYER_VIEW); - await findDependenciesToggle().vm.$emit('change', true); - - jest.runOnlyPendingTimers(); - await waitForPromises(); - expect(wrapper.findComponent(LinksLayer).props('showLinks')).toBe(true); - }); - }); - - describe('when layers view is selected, and links are active', () => { - beforeEach(async () => { - createComponentWithApollo({ - data: { - currentViewType: LAYER_VIEW, - showLinks: true, - }, - mountFn: mountExtended, - }); - - await waitForPromises(); - }); - - it('shows the hover tip in the view selector', async () => { - await findViewSelectorToggle().vm.$emit('change', true); - expect(findViewSelectorTrip().exists()).toBe(true); - }); - }); - - describe('when hover tip would otherwise show, but it has been previously dismissed', () => { - beforeEach(async () => { - createComponentWithApollo({ - data: { - currentViewType: LAYER_VIEW, - showLinks: true, - }, - mountFn: mountExtended, - calloutsList: ['pipeline_needs_hover_tip'.toUpperCase()], - }); - - jest.runOnlyPendingTimers(); - await waitForPromises(); - }); - - it('does not show the hover tip', async () => { - await findViewSelectorToggle().vm.$emit('change', true); - expect(findViewSelectorTrip().exists()).toBe(false); - }); - }); - - describe('when feature flag is on and local storage is set', () => { - beforeEach(async () => { - localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); - - createComponentWithApollo({ - mountFn: mountExtended, - }); - - await waitForPromises(); - }); - - afterEach(() => { - localStorage.clear(); - }); - - it('sets the asString prop on the LocalStorageSync component', () => { - expect(getLocalStorageSync().props('asString')).toBe(true); - }); - - it('reads the view type from localStorage when available', () => { - const viewSelectorNeedsSegment = wrapper - .findComponent(GlButtonGroup) - .findAllComponents(GlButton) - .at(1); - expect(viewSelectorNeedsSegment.classes()).toContain('selected'); - }); - }); - - describe('when feature flag is on and local storage is set, but the graph does not use needs', () => { - beforeEach(async () => { - const nonNeedsResponse = { ...mockPipelineResponse }; - nonNeedsResponse.data.project.pipeline.usesNeeds = false; - - localStorage.setItem(VIEW_TYPE_KEY, LAYER_VIEW); - - pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); - createComponentWithApollo({ - mountFn: mountExtended, - }); - - await waitForPromises(); - }); - - afterEach(() => { - localStorage.clear(); - }); - - it('still passes stage type to graph', () => { - expect(findGraph().props('viewType')).toBe(STAGE_VIEW); - }); - }); - - describe('when feature flag is on but pipeline does not use needs', () => { - beforeEach(async () => { - const nonNeedsResponse = { ...mockPipelineResponse }; - nonNeedsResponse.data.project.pipeline.usesNeeds = false; - - pipelineDetailsHandler.mockResolvedValue(nonNeedsResponse); - createComponentWithApollo({ - mountFn: mountExtended, - }); - - jest.runOnlyPendingTimers(); - await waitForPromises(); - }); - - it('does not appear when pipeline does not use needs', () => { - expect(findViewSelector().exists()).toBe(false); - }); - }); - }); - - describe('performance metrics', () => { - const metricsPath = '/root/project/-/ci/prometheus_metrics/histograms.json'; - let markAndMeasure; - let reportToSentry; - let reportPerformance; - let mock; - - beforeEach(() => { - jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => cb()); - markAndMeasure = jest.spyOn(perfUtils, 'performanceMarkAndMeasure'); - reportToSentry = jest.spyOn(sentryUtils, 'reportToSentry'); - reportPerformance = jest.spyOn(Api, 'reportPerformance'); - }); - - describe('with no metrics path', () => { - beforeEach(async () => { - createComponentWithApollo(); - await waitForPromises(); - }); - - it('is not called', () => { - expect(markAndMeasure).not.toHaveBeenCalled(); - expect(reportToSentry).not.toHaveBeenCalled(); - expect(reportPerformance).not.toHaveBeenCalled(); - }); - }); - - describe('with metrics path', () => { - const duration = 500; - const numLinks = 3; - const totalGroups = 7; - const metricsData = { - histograms: [ - { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, - { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, - { - name: PIPELINES_DETAIL_LINKS_JOB_RATIO, - value: numLinks / totalGroups, - }, - ], - }; - - describe('when no duration is obtained', () => { - beforeEach(async () => { - stubPerformanceWebAPI(); - - createComponentWithApollo({ - provide: { - metricsPath, - glFeatures: { - pipelineGraphLayersView: true, - }, - }, - data: { - currentViewType: LAYER_VIEW, - }, - }); - - await waitForPromises(); - }); - - it('attempts to collect metrics', () => { - expect(markAndMeasure).toHaveBeenCalled(); - expect(reportPerformance).not.toHaveBeenCalled(); - expect(reportToSentry).not.toHaveBeenCalled(); - }); - }); - - describe('with duration and no error', () => { - beforeEach(async () => { - mock = new MockAdapter(axios); - mock.onPost(metricsPath).reply(HTTP_STATUS_OK, {}); - - jest.spyOn(window.performance, 'getEntriesByName').mockImplementation(() => { - return [{ duration }]; - }); - - createComponentWithApollo({ - provide: { - metricsPath, - }, - data: { - currentViewType: LAYER_VIEW, - }, - }); - await waitForPromises(); - }); - - afterEach(() => { - mock.restore(); - }); - - it('calls reportPerformance with expected arguments', () => { - expect(markAndMeasure).toHaveBeenCalled(); - expect(reportPerformance).toHaveBeenCalled(); - expect(reportPerformance).toHaveBeenCalledWith(metricsPath, metricsData); - expect(reportToSentry).not.toHaveBeenCalled(); - }); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/graph_view_selector_spec.js b/spec/frontend/pipelines/graph/graph_view_selector_spec.js deleted file mode 100644 index 65ae9d19978..00000000000 --- a/spec/frontend/pipelines/graph/graph_view_selector_spec.js +++ /dev/null @@ -1,217 +0,0 @@ -import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import { LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants'; -import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector.vue'; - -describe('the graph view selector component', () => { - let wrapper; - - const findDependenciesToggle = () => wrapper.find('[data-testid="show-links-toggle"]'); - const findViewTypeSelector = () => wrapper.findComponent(GlButtonGroup); - const findStageViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(0); - const findLayerViewButton = () => findViewTypeSelector().findAllComponents(GlButton).at(1); - const findSwitcherLoader = () => wrapper.find('[data-testid="switcher-loading-state"]'); - const findToggleLoader = () => findDependenciesToggle().findComponent(GlLoadingIcon); - const findHoverTip = () => wrapper.findComponent(GlAlert); - - const defaultProps = { - showLinks: false, - tipPreviouslyDismissed: false, - type: STAGE_VIEW, - }; - - const defaultData = { - hoverTipDismissed: false, - isToggleLoading: false, - isSwitcherLoading: false, - showLinksActive: false, - }; - - const createComponent = ({ data = {}, mountFn = shallowMount, props = {} } = {}) => { - wrapper = mountFn(GraphViewSelector, { - propsData: { - ...defaultProps, - ...props, - }, - data() { - return { - ...defaultData, - ...data, - }; - }, - }); - }; - - describe('when showing stage view', () => { - beforeEach(() => { - createComponent({ mountFn: mount }); - }); - - it('shows the Stage view button as selected', () => { - expect(findStageViewButton().classes('selected')).toBe(true); - }); - - it('shows the Job dependencies view button not selected', () => { - expect(findLayerViewButton().exists()).toBe(true); - expect(findLayerViewButton().classes('selected')).toBe(false); - }); - - it('does not show the Job dependencies (links) toggle', () => { - expect(findDependenciesToggle().exists()).toBe(false); - }); - }); - - describe('when showing Job dependencies view', () => { - beforeEach(() => { - createComponent({ - mountFn: mount, - props: { - type: LAYER_VIEW, - }, - }); - }); - - it('shows the Job dependencies view as selected', () => { - expect(findLayerViewButton().classes('selected')).toBe(true); - }); - - it('shows the Stage button as not selected', () => { - expect(findStageViewButton().exists()).toBe(true); - expect(findStageViewButton().classes('selected')).toBe(false); - }); - - it('shows the Job dependencies (links) toggle', () => { - expect(findDependenciesToggle().exists()).toBe(true); - }); - }); - - describe('events', () => { - beforeEach(() => { - createComponent({ - mountFn: mount, - props: { - type: LAYER_VIEW, - }, - }); - }); - - it('shows loading state and emits updateViewType when view type toggled', async () => { - expect(wrapper.emitted().updateViewType).toBeUndefined(); - expect(findSwitcherLoader().exists()).toBe(false); - - await findStageViewButton().trigger('click'); - /* - Loading happens before the event is emitted or timers are run. - Then we run the timer because the event is emitted in setInterval - which is what gives the loader a chace to show up. - */ - expect(findSwitcherLoader().exists()).toBe(true); - jest.runOnlyPendingTimers(); - - expect(wrapper.emitted().updateViewType).toHaveLength(1); - expect(wrapper.emitted().updateViewType).toEqual([[STAGE_VIEW]]); - }); - - it('shows loading state and emits updateShowLinks when show links toggle is clicked', async () => { - expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); - expect(findToggleLoader().exists()).toBe(false); - - await findDependenciesToggle().vm.$emit('change', true); - /* - Loading happens before the event is emitted or timers are run. - Then we run the timer because the event is emitted in setInterval - which is what gives the loader a chace to show up. - */ - expect(findToggleLoader().exists()).toBe(true); - jest.runOnlyPendingTimers(); - - expect(wrapper.emitted().updateShowLinksState).toHaveLength(1); - expect(wrapper.emitted().updateShowLinksState).toEqual([[true]]); - }); - - it('does not emit an event if the click occurs on the currently selected view button', async () => { - expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); - - await findLayerViewButton().trigger('click'); - - expect(wrapper.emitted().updateShowLinksState).toBeUndefined(); - }); - }); - - describe('hover tip callout', () => { - describe('when links are live and it has not been previously dismissed', () => { - beforeEach(() => { - createComponent({ - props: { - showLinks: true, - type: LAYER_VIEW, - }, - data: { - showLinksActive: true, - }, - mountFn: mount, - }); - }); - - it('is displayed', () => { - expect(findHoverTip().exists()).toBe(true); - expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); - }); - - it('emits dismissHoverTip event when the tip is dismissed', async () => { - expect(wrapper.emitted().dismissHoverTip).toBeUndefined(); - await findHoverTip().find('button').trigger('click'); - expect(wrapper.emitted().dismissHoverTip).toHaveLength(1); - }); - - it('is displayed at first then hidden on swith to STAGE_VIEW then displayed on switch to LAYER_VIEW', async () => { - expect(findHoverTip().exists()).toBe(true); - expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); - - await findStageViewButton().trigger('click'); - expect(findHoverTip().exists()).toBe(false); - - await findLayerViewButton().trigger('click'); - expect(findHoverTip().exists()).toBe(true); - expect(findHoverTip().text()).toBe(wrapper.vm.$options.i18n.hoverTipText); - }); - }); - - describe('when links are live and it has been previously dismissed', () => { - beforeEach(() => { - createComponent({ - props: { - showLinks: true, - tipPreviouslyDismissed: true, - type: LAYER_VIEW, - }, - data: { - showLinksActive: true, - }, - }); - }); - - it('is not displayed', () => { - expect(findHoverTip().exists()).toBe(false); - }); - }); - - describe('when links are not live', () => { - beforeEach(() => { - createComponent({ - props: { - showLinks: true, - type: LAYER_VIEW, - }, - data: { - showLinksActive: false, - }, - }); - }); - - it('is not displayed', () => { - expect(findHoverTip().exists()).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js b/spec/frontend/pipelines/graph/job_group_dropdown_spec.js deleted file mode 100644 index 1419a7b9982..00000000000 --- a/spec/frontend/pipelines/graph/job_group_dropdown_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; -import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue'; - -describe('job group dropdown component', () => { - const group = { - jobs: [ - { - id: 4256, - name: '', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - tooltip: 'passed', - group: 'success', - details_path: '/root/ci-mock/builds/4256', - has_details: true, - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4256/retry', - method: 'post', - }, - }, - }, - { - id: 4299, - name: 'test', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - tooltip: 'passed', - group: 'success', - details_path: '/root/ci-mock/builds/4299', - has_details: true, - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4299/retry', - method: 'post', - }, - }, - }, - ], - name: 'rspec:linux', - size: 2, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - tooltip: 'passed', - group: 'success', - details_path: '/root/ci-mock/builds/4256', - has_details: true, - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4256/retry', - method: 'post', - }, - }, - }; - - let wrapper; - const findButton = () => wrapper.find('button'); - - const createComponent = ({ mountFn = shallowMount }) => { - wrapper = mountFn(JobGroupDropdown, { propsData: { group } }); - }; - - beforeEach(() => { - createComponent({ mountFn: mount }); - }); - - it('renders button with group name and size', () => { - expect(findButton().text()).toContain(group.name); - expect(findButton().text()).toContain(group.size.toString()); - }); - - it('renders dropdown with jobs', () => { - expect(wrapper.findAll('.scrollable-menu>ul>li').length).toBe(group.jobs.length); - }); -}); diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js deleted file mode 100644 index 8a8b0e9aa63..00000000000 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ /dev/null @@ -1,492 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; -import { GlBadge, GlModal, GlToast } from '@gitlab/ui'; -import JobItem from '~/pipelines/components/graph/job_item.vue'; -import axios from '~/lib/utils/axios_utils'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import ActionComponent from '~/pipelines/components/jobs_shared/action_component.vue'; - -import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { - delayedJob, - mockJob, - mockJobWithoutDetails, - mockJobWithUnauthorizedAction, - mockFailedJob, - triggerJob, - triggerJobWithRetryAction, -} from './mock_data'; - -describe('pipeline graph job item', () => { - useLocalStorageSpy(); - Vue.use(GlToast); - - let wrapper; - let mockAxios; - - const findJobWithoutLink = () => wrapper.findByTestId('job-without-link'); - const findJobWithLink = () => wrapper.findByTestId('job-with-link'); - const findActionVueComponent = () => wrapper.findComponent(ActionComponent); - const findActionComponent = () => wrapper.findByTestId('ci-action-component'); - const findBadge = () => wrapper.findComponent(GlBadge); - const findJobLink = () => wrapper.findByTestId('job-with-link'); - const findModal = () => wrapper.findComponent(GlModal); - - const clickOnModalPrimaryBtn = () => findModal().vm.$emit('primary'); - const clickOnModalCancelBtn = () => findModal().vm.$emit('hide'); - const clickOnModalCloseBtn = () => findModal().vm.$emit('close'); - - const myCustomClass1 = 'my-class-1'; - const myCustomClass2 = 'my-class-2'; - - const defaultProps = { - job: mockJob, - }; - - const createWrapper = ({ props, data, mountFn = mountExtended, mocks = {} } = {}) => { - wrapper = mountFn(JobItem, { - data() { - return { - ...data, - }; - }, - propsData: { - ...defaultProps, - ...props, - }, - mocks: { - ...mocks, - }, - }); - }; - - const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; - - beforeEach(() => { - mockAxios = new MockAdapter(axios); - }); - - afterEach(() => { - mockAxios.restore(); - }); - - describe('name with link', () => { - it('should render the job name and status with a link', async () => { - createWrapper(); - - await nextTick(); - const link = findJobLink(); - - expect(link.attributes('href')).toBe(mockJob.status.detailsPath); - - expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`); - - expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); - - expect(wrapper.text()).toBe(mockJob.name); - }); - }); - - describe('name without link', () => { - beforeEach(() => { - createWrapper({ - props: { - job: mockJobWithoutDetails, - cssClassJobName: 'css-class-job-name', - jobHovered: 'test', - }, - }); - }); - - it('should render status and name', () => { - expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); - expect(findJobLink().exists()).toBe(false); - - expect(wrapper.text()).toBe(mockJobWithoutDetails.name); - }); - - it('should apply hover class and provided class name', () => { - expect(findJobWithoutLink().classes()).toContain('css-class-job-name'); - }); - }); - - describe('action icon', () => { - it('should render the action icon', () => { - createWrapper(); - - const actionComponent = findActionComponent(); - - expect(actionComponent.exists()).toBe(true); - expect(actionComponent.props('actionIcon')).toBe('retry'); - expect(actionComponent.attributes('disabled')).toBeUndefined(); - }); - - it('should render disabled action icon when user cannot run the action', () => { - createWrapper({ - props: { - job: mockJobWithUnauthorizedAction, - }, - }); - - const actionComponent = findActionComponent(); - - expect(actionComponent.exists()).toBe(true); - expect(actionComponent.props('actionIcon')).toBe('stop'); - expect(actionComponent.attributes('disabled')).toBeDefined(); - }); - - it('action icon tooltip text when job has passed but can be ran again', () => { - createWrapper({ props: { job: mockJob } }); - - expect(findActionComponent().props('tooltipText')).toBe('Run again'); - }); - - it('action icon tooltip text when job has failed and can be retried', () => { - createWrapper({ props: { job: mockFailedJob } }); - - expect(findActionComponent().props('tooltipText')).toBe('Retry'); - }); - }); - - describe('job style', () => { - beforeEach(() => { - createWrapper({ - props: { - job: mockJob, - cssClassJobName: 'css-class-job-name', - }, - }); - }); - - it('should render provided class name', () => { - expect(findJobLink().classes()).toContain('css-class-job-name'); - }); - - it('does not show a badge on the job item', () => { - expect(findBadge().exists()).toBe(false); - }); - - it('does not apply the trigger job class', () => { - expect(findJobWithLink().classes()).not.toContain('gl-rounded-lg'); - }); - }); - - describe('status label', () => { - it('should not render status label when it is not provided', () => { - createWrapper({ - props: { - job: { - id: 4258, - name: 'test', - status: { - icon: 'status_success', - }, - }, - }, - }); - - expect(findJobWithoutLink().attributes('title')).toBe('test'); - }); - - it('should not render status label when it is provided', () => { - createWrapper({ - props: { - job: { - id: 4259, - name: 'test', - status: { - icon: 'status_success', - label: 'success', - tooltip: 'success', - }, - }, - }, - }); - - expect(findJobWithoutLink().attributes('title')).toBe('test - success'); - }); - }); - - describe('for delayed job', () => { - it('displays remaining time in tooltip', () => { - createWrapper({ - props: { - job: delayedJob, - }, - }); - - expect(findJobWithLink().attributes('title')).toBe( - `delayed job - delayed manual action (00:00:00)`, - ); - }); - }); - - describe('trigger job', () => { - describe('card', () => { - beforeEach(() => { - createWrapper({ - props: { - job: triggerJob, - }, - }); - }); - - it('shows a badge on the job item', () => { - expect(findBadge().exists()).toBe(true); - expect(findBadge().text()).toBe('Trigger job'); - }); - - it('applies a rounded corner style instead of the usual pill shape', () => { - expect(findJobWithoutLink().classes()).toContain('gl-rounded-lg'); - }); - }); - - describe('when retrying', () => { - const mockToastShow = jest.fn(); - - beforeEach(async () => { - createWrapper({ - mountFn: shallowMountExtended, - props: { - skipRetryModal: true, - job: triggerJobWithRetryAction, - }, - mocks: { - $toast: { - show: mockToastShow, - }, - }, - }); - - await findActionVueComponent().vm.$emit('pipelineActionRequestComplete'); - await nextTick(); - }); - - it('shows a toast message that the downstream is being created', () => { - expect(mockToastShow).toHaveBeenCalledTimes(1); - }); - }); - - describe('highlighting', () => { - it.each` - job | jobName | expanded | link - ${mockJob} | ${mockJob.name} | ${true} | ${true} - ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false} - `( - `trigger job should stay highlighted when downstream is expanded`, - ({ job, jobName, expanded, link }) => { - createWrapper({ - props: { - job, - pipelineExpanded: { jobName, expanded }, - }, - }); - const findJobEl = link ? findJobWithLink : findJobWithoutLink; - - expect(findJobEl().classes()).toContain(triggerActiveClass); - }, - ); - - it.each` - job | jobName | expanded | link - ${mockJob} | ${mockJob.name} | ${false} | ${true} - ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false} - `( - `trigger job should not be highlighted when downstream is not expanded`, - ({ job, jobName, expanded, link }) => { - createWrapper({ - props: { - job, - pipelineExpanded: { jobName, expanded }, - }, - }); - const findJobEl = link ? findJobWithLink : findJobWithoutLink; - - expect(findJobEl().classes()).not.toContain(triggerActiveClass); - }, - ); - }); - }); - - describe('job classes', () => { - it('job class is shown', () => { - createWrapper({ - props: { - job: mockJob, - cssClassJobName: 'my-class', - }, - }); - - const jobLinkEl = findJobLink(); - - expect(jobLinkEl.classes()).toContain('my-class'); - - expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); - }); - - it('job class is shown, along with hover', () => { - createWrapper({ - props: { - job: mockJob, - cssClassJobName: 'my-class', - sourceJobHovered: mockJob.name, - }, - }); - - const jobLinkEl = findJobLink(); - - expect(jobLinkEl.classes()).toContain('my-class'); - expect(jobLinkEl.classes()).toContain(triggerActiveClass); - }); - - it('multiple job classes are shown', () => { - createWrapper({ - props: { - job: mockJob, - cssClassJobName: [myCustomClass1, myCustomClass2], - }, - }); - - const jobLinkEl = findJobLink(); - - expect(jobLinkEl.classes()).toContain(myCustomClass1); - expect(jobLinkEl.classes()).toContain(myCustomClass2); - - expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); - }); - - it('multiple job classes are shown conditionally', () => { - createWrapper({ - props: { - job: mockJob, - cssClassJobName: { [myCustomClass1]: true, [myCustomClass2]: true }, - }, - }); - - const jobLinkEl = findJobLink(); - - expect(jobLinkEl.classes()).toContain(myCustomClass1); - expect(jobLinkEl.classes()).toContain(myCustomClass2); - - expect(jobLinkEl.classes()).not.toContain(triggerActiveClass); - }); - - it('multiple job classes are shown, along with a hover', () => { - createWrapper({ - props: { - job: mockJob, - cssClassJobName: [myCustomClass1, myCustomClass2], - sourceJobHovered: mockJob.name, - }, - }); - - const jobLinkEl = findJobLink(); - - expect(jobLinkEl.classes()).toContain(myCustomClass1); - expect(jobLinkEl.classes()).toContain(myCustomClass2); - expect(jobLinkEl.classes()).toContain(triggerActiveClass); - }); - }); - - describe('confirmation modal', () => { - describe('when clicking on the action component', () => { - it.each` - skipRetryModal | exists | visibilityText - ${false} | ${true} | ${'shows'} - ${true} | ${false} | ${'hides'} - `( - '$visibilityText the modal when `skipRetryModal` is $skipRetryModal', - async ({ exists, skipRetryModal }) => { - createWrapper({ - props: { - skipRetryModal, - job: triggerJobWithRetryAction, - }, - }); - await findActionComponent().trigger('click'); - - expect(findModal().exists()).toBe(exists); - }, - ); - }); - - describe('when showing the modal', () => { - it.each` - buttonName | shouldTriggerActionClick | actionBtn - ${'primary'} | ${true} | ${clickOnModalPrimaryBtn} - ${'cancel'} | ${false} | ${clickOnModalCancelBtn} - ${'close'} | ${false} | ${clickOnModalCloseBtn} - `( - 'clicking on $buttonName will pass down shouldTriggerActionClick as $shouldTriggerActionClick to the action component', - async ({ shouldTriggerActionClick, actionBtn }) => { - createWrapper({ - props: { - skipRetryModal: false, - job: triggerJobWithRetryAction, - }, - }); - await findActionComponent().trigger('click'); - - await actionBtn(); - - expect(findActionComponent().props().shouldTriggerClick).toBe(shouldTriggerActionClick); - }, - ); - }); - - describe('when not checking the "do not show this again" checkbox', () => { - it.each` - actionName | actionBtn - ${'closing'} | ${clickOnModalCloseBtn} - ${'cancelling'} | ${clickOnModalCancelBtn} - ${'confirming'} | ${clickOnModalPrimaryBtn} - `( - 'does not emit any event and will not modify localstorage on $actionName', - async ({ actionBtn }) => { - createWrapper({ - props: { - skipRetryModal: false, - job: triggerJobWithRetryAction, - }, - }); - await findActionComponent().trigger('click'); - await actionBtn(); - - expect(wrapper.emitted().setSkipRetryModal).toBeUndefined(); - expect(localStorage.setItem).not.toHaveBeenCalled(); - }, - ); - }); - - describe('when checking the "do not show this again" checkbox', () => { - it.each` - actionName | actionBtn - ${'closing'} | ${clickOnModalCloseBtn} - ${'cancelling'} | ${clickOnModalCancelBtn} - ${'confirming'} | ${clickOnModalPrimaryBtn} - `( - 'emits "setSkipRetryModal" and set local storage key on $actionName the modal', - async ({ actionBtn }) => { - // We are passing the checkbox as a slot to the GlModal. - // The way GlModal is mounted, we can neither click on the box - // or emit an event directly. We therefore set the data property - // as it would be if the box was checked. - createWrapper({ - data: { - currentSkipModalValue: true, - }, - props: { - skipRetryModal: false, - job: triggerJobWithRetryAction, - }, - }); - await findActionComponent().trigger('click'); - await actionBtn(); - - expect(wrapper.emitted().setSkipRetryModal).toHaveLength(1); - expect(localStorage.setItem).toHaveBeenCalledWith('skip_retry_modal', 'true'); - }, - ); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/job_name_component_spec.js b/spec/frontend/pipelines/graph/job_name_component_spec.js deleted file mode 100644 index fca4c43d9fa..00000000000 --- a/spec/frontend/pipelines/graph/job_name_component_spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import { mount } from '@vue/test-utils'; -import jobNameComponent from '~/pipelines/components/jobs_shared/job_name_component.vue'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; - -describe('job name component', () => { - let wrapper; - - const propsData = { - name: 'foo', - status: { - icon: 'status_success', - group: 'success', - }, - }; - - beforeEach(() => { - wrapper = mount(jobNameComponent, { - propsData, - }); - }); - - it('should render the provided name', () => { - expect(wrapper.text()).toBe(propsData.name); - }); - - it('should render an icon with the provided status', () => { - expect(wrapper.findComponent(CiIcon).exists()).toBe(true); - expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); - }); -}); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js deleted file mode 100644 index 8dae2aac664..00000000000 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ /dev/null @@ -1,464 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; -import { createWrapper } from '@vue/test-utils'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; -import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; -import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; -import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import mockPipeline from './linked_pipelines_mock_data'; - -describe('Linked pipeline', () => { - let wrapper; - let requestHandlers; - - const downstreamProps = { - pipeline: { - ...mockPipeline, - multiproject: false, - }, - columnTitle: 'Downstream', - type: DOWNSTREAM, - expanded: false, - isLoading: false, - }; - - const upstreamProps = { - ...downstreamProps, - columnTitle: 'Upstream', - type: UPSTREAM, - }; - - const findButton = () => wrapper.findComponent(GlButton); - const findCancelButton = () => wrapper.findByLabelText('Cancel downstream pipeline'); - const findCardTooltip = () => wrapper.findComponent(GlTooltip); - const findDownstreamPipelineTitle = () => wrapper.findByTestId('downstream-title'); - const findExpandButton = () => wrapper.findByTestId('expand-pipeline-button'); - const findLinkedPipeline = () => wrapper.findComponent({ ref: 'linkedPipeline' }); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findPipelineLabel = () => wrapper.findByTestId('downstream-pipeline-label'); - const findPipelineLink = () => wrapper.findByTestId('pipelineLink'); - const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline'); - - const defaultHandlers = { - cancelPipeline: jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [] } } }), - retryPipeline: jest.fn().mockResolvedValue({ data: { pipelineRetry: { errors: [] } } }), - }; - - const createMockApolloProvider = (handlers) => { - Vue.use(VueApollo); - - requestHandlers = handlers; - return createMockApollo([ - [CancelPipelineMutation, requestHandlers.cancelPipeline], - [RetryPipelineMutation, requestHandlers.retryPipeline], - ]); - }; - - const createComponent = ({ propsData, handlers = defaultHandlers }) => { - const mockApollo = createMockApolloProvider(handlers); - - wrapper = mountExtended(LinkedPipelineComponent, { - propsData, - apolloProvider: mockApollo, - }); - }; - - describe('rendered output', () => { - const props = { - pipeline: mockPipeline, - columnTitle: 'Downstream', - type: DOWNSTREAM, - expanded: false, - isLoading: false, - }; - - beforeEach(() => { - createComponent({ propsData: props }); - }); - - it('should render the project name', () => { - expect(wrapper.text()).toContain(props.pipeline.project.name); - }); - - it('should render an svg within the status container', () => { - const pipelineStatusElement = wrapper.findComponent(CiIcon); - - expect(pipelineStatusElement.find('svg').exists()).toBe(true); - }); - - it('should render the pipeline status icon svg', () => { - expect(wrapper.find('.ci-status-icon-success svg').exists()).toBe(true); - }); - - it('should have a ci-status child component', () => { - expect(wrapper.findComponent(CiIcon).exists()).toBe(true); - }); - - it('should render the pipeline id', () => { - expect(wrapper.text()).toContain(`#${props.pipeline.id}`); - }); - - it('adds the card tooltip text to the DOM', () => { - expect(findCardTooltip().exists()).toBe(true); - - 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.toString()); - }); - - it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { - expect(findPipelineLabel().text()).toBe('Multi-project'); - }); - }); - - describe('upstream pipelines', () => { - beforeEach(() => { - createComponent({ propsData: upstreamProps }); - }); - - it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { - expect(findPipelineLabel().exists()).toBe(true); - }); - - it('upstream pipeline should contain the correct link', () => { - expect(findPipelineLink().attributes('href')).toBe(upstreamProps.pipeline.path); - }); - - it('applies the reverse-row css class to the card', () => { - expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row-reverse'); - expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row'); - }); - }); - - describe('downstream pipelines', () => { - describe('styling', () => { - beforeEach(() => { - createComponent({ propsData: downstreamProps }); - }); - - it('parent/child label container should exist', () => { - expect(findPipelineLabel().exists()).toBe(true); - }); - - it('should display child label when pipeline project id is the same as triggered pipeline project id', () => { - expect(findPipelineLabel().exists()).toBe(true); - }); - - it('should have the name of the trigger job on the card when it is a child pipeline', () => { - expect(findDownstreamPipelineTitle().text()).toBe(mockPipeline.sourceJob.name); - }); - - it('downstream pipeline should contain the correct link', () => { - expect(findPipelineLink().attributes('href')).toBe(downstreamProps.pipeline.path); - }); - - it('applies the flex-row css class to the card', () => { - expect(findLinkedPipeline().classes()).toContain('gl-flex-direction-row'); - expect(findLinkedPipeline().classes()).not.toContain('gl-flex-direction-row-reverse'); - }); - }); - - describe('action button', () => { - describe('with permissions', () => { - describe('on an upstream', () => { - describe('when retryable', () => { - beforeEach(() => { - const retryablePipeline = { - ...upstreamProps, - pipeline: { ...mockPipeline, retryable: true }, - }; - - createComponent({ propsData: retryablePipeline }); - }); - - it('does not show the retry or cancel button', () => { - expect(findCancelButton().exists()).toBe(false); - expect(findRetryButton().exists()).toBe(false); - }); - }); - }); - - describe('on a downstream', () => { - const retryablePipeline = { - ...downstreamProps, - pipeline: { ...mockPipeline, retryable: true }, - }; - - describe('when retryable', () => { - beforeEach(() => { - createComponent({ propsData: retryablePipeline }); - }); - - it('shows only the retry button', () => { - expect(findCancelButton().exists()).toBe(false); - expect(findRetryButton().exists()).toBe(true); - }); - - it.each` - findElement | name - ${findRetryButton} | ${'retry button'} - ${findExpandButton} | ${'expand button'} - `('hides the card tooltip when $name is hovered', async ({ findElement }) => { - expect(findCardTooltip().exists()).toBe(true); - - await findElement().trigger('mouseover'); - - expect(findCardTooltip().exists()).toBe(false); - }); - - describe('and the retry button is clicked', () => { - describe('on success', () => { - beforeEach(async () => { - await findRetryButton().trigger('click'); - }); - - it('calls the retry mutation', () => { - expect(requestHandlers.retryPipeline).toHaveBeenCalledTimes(1); - expect(requestHandlers.retryPipeline).toHaveBeenCalledWith({ - id: 'gid://gitlab/Ci::Pipeline/195', - }); - }); - - it('emits the refreshPipelineGraph event', async () => { - await waitForPromises(); - expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1); - }); - }); - - describe('on failure', () => { - beforeEach(async () => { - createComponent({ - propsData: retryablePipeline, - handlers: { - retryPipeline: jest.fn().mockRejectedValue({ errors: [] }), - cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }), - }, - }); - - await findRetryButton().trigger('click'); - }); - - it('emits an error event', async () => { - await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]); - }); - }); - }); - }); - - describe('when cancelable', () => { - const cancelablePipeline = { - ...downstreamProps, - pipeline: { ...mockPipeline, cancelable: true }, - }; - - beforeEach(() => { - createComponent({ propsData: cancelablePipeline }); - }); - - it('shows only the cancel button', () => { - expect(findCancelButton().exists()).toBe(true); - expect(findRetryButton().exists()).toBe(false); - }); - - it.each` - findElement | name - ${findCancelButton} | ${'cancel button'} - ${findExpandButton} | ${'expand button'} - `('hides the card tooltip when $name is hovered', async ({ findElement }) => { - expect(findCardTooltip().exists()).toBe(true); - - await findElement().trigger('mouseover'); - - expect(findCardTooltip().exists()).toBe(false); - }); - - describe('and the cancel button is clicked', () => { - describe('on success', () => { - beforeEach(async () => { - await findCancelButton().trigger('click'); - }); - - it('calls the cancel mutation', () => { - expect(requestHandlers.cancelPipeline).toHaveBeenCalledTimes(1); - expect(requestHandlers.cancelPipeline).toHaveBeenCalledWith({ - id: 'gid://gitlab/Ci::Pipeline/195', - }); - }); - it('emits the refreshPipelineGraph event', async () => { - await waitForPromises(); - expect(wrapper.emitted('refreshPipelineGraph')).toHaveLength(1); - }); - }); - - describe('on failure', () => { - beforeEach(async () => { - createComponent({ - propsData: cancelablePipeline, - handlers: { - retryPipeline: jest.fn().mockRejectedValue({ errors: [] }), - cancelPipeline: jest.fn().mockRejectedValue({ errors: [] }), - }, - }); - - await findCancelButton().trigger('click'); - }); - - it('emits an error event', async () => { - await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([[{ type: ACTION_FAILURE }]]); - }); - }); - }); - }); - - describe('when both cancellable and retryable', () => { - beforeEach(() => { - const pipelineWithTwoActions = { - ...downstreamProps, - pipeline: { ...mockPipeline, cancelable: true, retryable: true }, - }; - - createComponent({ propsData: pipelineWithTwoActions }); - }); - - it('only shows the cancel button', () => { - expect(findRetryButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(true); - }); - }); - }); - }); - - describe('without permissions', () => { - beforeEach(() => { - const pipelineWithTwoActions = { - ...downstreamProps, - pipeline: { - ...mockPipeline, - cancelable: true, - retryable: true, - userPermissions: { updatePipeline: false }, - }, - }; - - createComponent({ propsData: pipelineWithTwoActions }); - }); - - it('does not show any action button', () => { - expect(findRetryButton().exists()).toBe(false); - expect(findCancelButton().exists()).toBe(false); - }); - }); - }); - }); - - describe('expand button', () => { - it.each` - pipelineType | chevronPosition | buttonBorderClasses | expanded - ${downstreamProps} | ${'chevron-lg-right'} | ${'gl-border-l-0!'} | ${false} - ${downstreamProps} | ${'chevron-lg-left'} | ${'gl-border-l-0!'} | ${true} - ${upstreamProps} | ${'chevron-lg-left'} | ${'gl-border-r-0!'} | ${false} - ${upstreamProps} | ${'chevron-lg-right'} | ${'gl-border-r-0!'} | ${true} - `( - '$pipelineType.columnTitle pipeline button icon should be $chevronPosition with $buttonBorderClasses if expanded state is $expanded', - ({ pipelineType, chevronPosition, buttonBorderClasses, expanded }) => { - createComponent({ propsData: { ...pipelineType, expanded } }); - expect(findExpandButton().props('icon')).toBe(chevronPosition); - expect(findExpandButton().classes()).toContain(buttonBorderClasses); - }, - ); - - describe('shadow border', () => { - beforeEach(() => { - createComponent({ propsData: downstreamProps }); - }); - - it.each` - activateEventName | deactivateEventName - ${'mouseover'} | ${'mouseout'} - ${'focus'} | ${'blur'} - `( - 'applies the class on $activateEventName and removes it on $deactivateEventName', - async ({ activateEventName, deactivateEventName }) => { - const shadowClass = 'gl-shadow-none!'; - - expect(findExpandButton().classes()).toContain(shadowClass); - - await findExpandButton().vm.$emit(activateEventName); - expect(findExpandButton().classes()).not.toContain(shadowClass); - - await findExpandButton().vm.$emit(deactivateEventName); - expect(findExpandButton().classes()).toContain(shadowClass); - }, - ); - }); - }); - - describe('when isLoading is true', () => { - const props = { - pipeline: mockPipeline, - columnTitle: 'Downstream', - type: DOWNSTREAM, - expanded: false, - isLoading: true, - }; - - beforeEach(() => { - createComponent({ propsData: props }); - }); - - it('loading icon is visible', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - }); - - describe('on click/hover', () => { - const props = { - pipeline: mockPipeline, - columnTitle: 'Downstream', - type: DOWNSTREAM, - expanded: false, - isLoading: false, - }; - - beforeEach(() => { - createComponent({ propsData: props }); - }); - - it('emits `pipelineClicked` event', () => { - findButton().trigger('click'); - - expect(wrapper.emitted('pipelineClicked')).toHaveLength(1); - }); - - it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, async () => { - const root = createWrapper(wrapper.vm.$root); - await findButton().vm.$emit('click'); - - expect(root.emitted(BV_HIDE_TOOLTIP)).toHaveLength(1); - }); - - it('should emit downstreamHovered with job name on mouseover', () => { - findLinkedPipeline().trigger('mouseover'); - expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['test_c']]); - }); - - it('should emit downstreamHovered with empty string on mouseleave', () => { - findLinkedPipeline().trigger('mouseleave'); - expect(wrapper.emitted('downstreamHovered')).toStrictEqual([['']]); - }); - - it('should emit pipelineExpanded with job name and expanded state on click', () => { - findExpandButton().trigger('click'); - expect(wrapper.emitted('pipelineExpandToggle')).toStrictEqual([['test_c', true]]); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js deleted file mode 100644 index bcea140f2dd..00000000000 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { mount, shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; -import { - DOWNSTREAM, - UPSTREAM, - LAYER_VIEW, - STAGE_VIEW, -} from '~/pipelines/components/graph/constants'; -import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; -import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; -import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; -import * as parsingUtils from '~/pipelines/components/parsing_utils'; -import { LOAD_FAILURE } from '~/pipelines/constants'; - -import { pipelineWithUpstreamDownstream, wrappedPipelineReturn } from './mock_data'; - -const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse); - -describe('Linked Pipelines Column', () => { - const defaultProps = { - columnTitle: 'Downstream', - linkedPipelines: processedPipeline.downstream, - showLinks: false, - type: DOWNSTREAM, - viewType: STAGE_VIEW, - configPaths: { - metricsPath: '', - graphqlResourceEtag: 'this/is/a/path', - }, - }; - - let wrapper; - const findLinkedColumnTitle = () => wrapper.find('[data-testid="linked-column-title"]'); - const findLinkedPipelineElements = () => wrapper.findAllComponents(LinkedPipeline); - const findPipelineGraph = () => wrapper.findComponent(PipelineGraph); - const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); - - Vue.use(VueApollo); - - const createComponent = ({ apolloProvider, mountFn = shallowMount, props = {} } = {}) => { - wrapper = mountFn(LinkedPipelinesColumn, { - apolloProvider, - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - const createComponentWithApollo = ({ - mountFn = shallowMount, - getPipelineDetailsHandler = jest.fn().mockResolvedValue(wrappedPipelineReturn), - props = {}, - } = {}) => { - const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; - - const apolloProvider = createMockApollo(requestHandlers); - createComponent({ apolloProvider, mountFn, props }); - }; - - describe('it renders correctly', () => { - beforeEach(() => { - createComponentWithApollo(); - }); - - it('renders the pipeline title', () => { - expect(findLinkedColumnTitle().text()).toBe(defaultProps.columnTitle); - }); - - it('renders the correct number of linked pipelines', () => { - expect(findLinkedPipelineElements()).toHaveLength(defaultProps.linkedPipelines.length); - }); - }); - - describe('click action', () => { - const clickExpandButton = async () => { - await findExpandButton().trigger('click'); - await waitForPromises(); - }; - - describe('layer type rendering', () => { - let layersFn; - - beforeEach(() => { - layersFn = jest.spyOn(parsingUtils, 'listByLayers'); - createComponentWithApollo({ mountFn: mount }); - }); - - it('calls listByLayers only once no matter how many times view is switched', async () => { - expect(layersFn).not.toHaveBeenCalled(); - await clickExpandButton(); - await wrapper.setProps({ viewType: LAYER_VIEW }); - await nextTick(); - expect(layersFn).toHaveBeenCalledTimes(1); - await wrapper.setProps({ viewType: STAGE_VIEW }); - await wrapper.setProps({ viewType: LAYER_VIEW }); - await wrapper.setProps({ viewType: STAGE_VIEW }); - expect(layersFn).toHaveBeenCalledTimes(1); - }); - }); - - describe('when graph does not use needs', () => { - beforeEach(() => { - const nonNeedsResponse = { ...wrappedPipelineReturn }; - nonNeedsResponse.data.project.pipeline.usesNeeds = false; - - createComponentWithApollo({ - props: { - viewType: LAYER_VIEW, - }, - getPipelineDetailsHandler: jest.fn().mockResolvedValue(nonNeedsResponse), - mountFn: mount, - }); - }); - - it('shows the stage view, even when the main graph view type is layers', async () => { - await clickExpandButton(); - expect(findPipelineGraph().props('viewType')).toBe(STAGE_VIEW); - }); - }); - - describe('downstream', () => { - describe('when successful', () => { - beforeEach(() => { - createComponentWithApollo({ mountFn: mount }); - }); - - it('toggles the pipeline visibility', async () => { - expect(findPipelineGraph().exists()).toBe(false); - await clickExpandButton(); - expect(findPipelineGraph().exists()).toBe(true); - await clickExpandButton(); - expect(findPipelineGraph().exists()).toBe(false); - }); - }); - - describe('on error', () => { - beforeEach(() => { - createComponentWithApollo({ - mountFn: mount, - getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), - }); - }); - - it('emits the error', async () => { - await clickExpandButton(); - expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]); - }); - - it('does not show the pipeline', async () => { - expect(findPipelineGraph().exists()).toBe(false); - await clickExpandButton(); - expect(findPipelineGraph().exists()).toBe(false); - }); - }); - }); - - describe('upstream', () => { - const upstreamProps = { - columnTitle: 'Upstream', - /* - Because the IDs need to match to work, rather - than make new mock data, we are representing - the upstream pipeline with the downstream data. - */ - linkedPipelines: processedPipeline.downstream, - type: UPSTREAM, - }; - - describe('when successful', () => { - beforeEach(() => { - createComponentWithApollo({ - mountFn: mount, - props: upstreamProps, - }); - }); - - it('toggles the pipeline visibility', async () => { - expect(findPipelineGraph().exists()).toBe(false); - await clickExpandButton(); - expect(findPipelineGraph().exists()).toBe(true); - await clickExpandButton(); - expect(findPipelineGraph().exists()).toBe(false); - }); - }); - - describe('on error', () => { - beforeEach(() => { - createComponentWithApollo({ - mountFn: mount, - getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), - props: upstreamProps, - }); - }); - - it('emits the error', async () => { - await clickExpandButton(); - expect(wrapper.emitted().error).toEqual([[{ type: LOAD_FAILURE, skipSentry: true }]]); - }); - - it('does not show the pipeline', async () => { - expect(findPipelineGraph().exists()).toBe(false); - await clickExpandButton(); - expect(findPipelineGraph().exists()).toBe(false); - }); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js deleted file mode 100644 index f7f5738e46d..00000000000 --- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js +++ /dev/null @@ -1,27 +0,0 @@ -export default { - __typename: 'Pipeline', - id: 195, - iid: '5', - retryable: false, - cancelable: false, - userPermissions: { - updatePipeline: true, - }, - path: '/root/elemenohpee/-/pipelines/195', - status: { - __typename: 'DetailedStatus', - group: 'success', - label: 'passed', - icon: 'status_success', - }, - sourceJob: { - __typename: 'CiJob', - name: 'test_c', - }, - project: { - __typename: 'Project', - name: 'elemenohpee', - fullPath: 'root/elemenohpee', - }, - multiproject: true, -}; diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js deleted file mode 100644 index 8d06d6931ed..00000000000 --- a/spec/frontend/pipelines/graph/mock_data.js +++ /dev/null @@ -1,387 +0,0 @@ -import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; -import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; -import { - BUILD_KIND, - BRIDGE_KIND, - RETRY_ACTION_TITLE, -} from '~/pipelines/components/graph/constants'; - -// We mock this instead of using fixtures for performance reason. -const mockPipelineResponseCopy = JSON.parse(JSON.stringify(mockPipelineResponse)); -const groups = new Array(100).fill({ - ...mockPipelineResponse.data.project.pipeline.stages.nodes[0].groups.nodes[0], -}); -mockPipelineResponseCopy.data.project.pipeline.stages.nodes[0].groups.nodes = groups; -export const mockPipelineResponseWithTooManyJobs = mockPipelineResponseCopy; - -export const downstream = { - nodes: [ - { - id: 175, - iid: '31', - path: '/root/elemenohpee/-/pipelines/175', - retryable: true, - cancelable: false, - userPermissions: { - updatePipeline: true, - }, - status: { - id: '70', - group: 'success', - label: 'passed', - icon: 'status_success', - __typename: 'DetailedStatus', - }, - sourceJob: { - name: 'test_c', - id: '71', - retried: false, - __typename: 'CiJob', - }, - project: { - id: 'gid://gitlab/Project/25', - name: 'elemenohpee', - fullPath: 'root/elemenohpee', - __typename: 'Project', - }, - __typename: 'Pipeline', - multiproject: true, - }, - { - id: 181, - iid: '27', - path: '/root/abcd-dag/-/pipelines/181', - retryable: true, - cancelable: false, - userPermissions: { - updatePipeline: true, - }, - status: { - id: '72', - group: 'success', - label: 'passed', - icon: 'status_success', - __typename: 'DetailedStatus', - }, - sourceJob: { - id: '73', - name: 'test_d', - retried: true, - __typename: 'CiJob', - }, - project: { - id: 'gid://gitlab/Project/23', - name: 'abcd-dag', - fullPath: 'root/abcd-dag', - __typename: 'Project', - }, - __typename: 'Pipeline', - multiproject: false, - }, - ], -}; - -export const upstream = { - id: 161, - iid: '24', - path: '/root/abcd-dag/-/pipelines/161', - retryable: true, - cancelable: false, - userPermissions: { - updatePipeline: true, - }, - status: { - id: '74', - group: 'success', - label: 'passed', - icon: 'status_success', - __typename: 'DetailedStatus', - }, - sourceJob: null, - project: { - id: 'gid://gitlab/Project/23', - name: 'abcd-dag', - fullPath: 'root/abcd-dag', - __typename: 'Project', - }, - __typename: 'Pipeline', - multiproject: true, -}; - -export const wrappedPipelineReturn = { - data: { - project: { - __typename: 'Project', - id: '75', - pipeline: { - __typename: 'Pipeline', - id: 'gid://gitlab/Ci::Pipeline/175', - iid: '38', - complete: true, - usesNeeds: true, - userPermissions: { - __typename: 'PipelinePermissions', - updatePipeline: true, - }, - downstream: { - retryable: true, - cancelable: false, - userPermissions: { - updatePipeline: true, - }, - __typename: 'PipelineConnection', - nodes: [], - }, - upstream: { - id: 'gid://gitlab/Ci::Pipeline/174', - iid: '37', - path: '/root/elemenohpee/-/pipelines/174', - retryable: true, - cancelable: false, - userPermissions: { - updatePipeline: true, - }, - __typename: 'Pipeline', - status: { - __typename: 'DetailedStatus', - id: '77', - group: 'success', - label: 'passed', - icon: 'status_success', - }, - sourceJob: { - name: 'test_c', - id: '78', - retried: false, - __typename: 'CiJob', - }, - project: { - id: 'gid://gitlab/Project/25', - name: 'elemenohpee', - fullPath: 'root/elemenohpee', - __typename: 'Project', - }, - }, - stages: { - __typename: 'CiStageConnection', - nodes: [ - { - name: 'build', - __typename: 'CiStage', - id: '79', - status: { - action: null, - id: '80', - __typename: 'DetailedStatus', - }, - groups: { - __typename: 'CiGroupConnection', - nodes: [ - { - __typename: 'CiGroup', - id: '81', - status: { - __typename: 'DetailedStatus', - id: '82', - label: 'passed', - group: 'success', - icon: 'status_success', - }, - name: 'build_n', - size: 1, - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - id: '83', - kind: BUILD_KIND, - name: 'build_n', - scheduledAt: null, - needs: { - __typename: 'CiBuildNeedConnection', - nodes: [], - }, - previousStageJobsOrNeeds: { - __typename: 'CiJobConnection', - nodes: [], - }, - status: { - __typename: 'DetailedStatus', - id: '84', - icon: 'status_success', - tooltip: 'passed', - label: 'passed', - hasDetails: true, - detailsPath: '/root/elemenohpee/-/jobs/1662', - group: 'success', - action: { - __typename: 'StatusAction', - id: '85', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/elemenohpee/-/jobs/1662/retry', - title: 'Retry', - }, - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - }, - }, - }, -}; - -export const generateResponse = (raw, mockPath) => unwrapPipelineData(mockPath, raw.data); - -export const pipelineWithUpstreamDownstream = (base) => { - const pip = { ...base }; - pip.data.project.pipeline.downstream = downstream; - pip.data.project.pipeline.upstream = upstream; - - return generateResponse(pip, 'root/abcd-dag'); -}; - -export const mapCallouts = (callouts) => - callouts.map((callout) => { - return { featureName: callout, __typename: 'UserCallout' }; - }); - -export const mockCalloutsResponse = (mappedCallouts) => ({ - data: { - currentUser: { - id: 45, - __typename: 'User', - callouts: { - id: 5, - __typename: 'UserCalloutConnection', - nodes: mappedCallouts, - }, - }, - }, -}); - -export const delayedJob = { - __typename: 'CiJob', - kind: BUILD_KIND, - name: 'delayed job', - scheduledAt: '2015-07-03T10:01:00.000Z', - needs: [], - status: { - __typename: 'DetailedStatus', - icon: 'status_scheduled', - tooltip: 'delayed manual action (%{remainingTime})', - hasDetails: true, - detailsPath: '/root/kinder-pipe/-/jobs/5339', - group: 'scheduled', - action: { - __typename: 'StatusAction', - icon: 'time-out', - title: 'Unschedule', - path: '/frontend-fixtures/builds-project/-/jobs/142/unschedule', - buttonTitle: 'Unschedule job', - }, - }, -}; - -export const mockJob = { - id: 4256, - name: 'test', - kind: BUILD_KIND, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - tooltip: 'passed', - group: 'success', - detailsPath: '/root/ci-mock/builds/4256', - hasDetails: true, - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4256/retry', - method: 'post', - }, - }, -}; - -export const mockJobWithoutDetails = { - id: 4257, - name: 'job_without_details', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - detailsPath: '/root/ci-mock/builds/4257', - hasDetails: false, - }, -}; - -export const mockJobWithUnauthorizedAction = { - id: 4258, - name: 'stop-environment', - status: { - icon: 'status_manual', - label: 'manual stop action (not allowed)', - tooltip: 'manual action', - group: 'manual', - detailsPath: '/root/ci-mock/builds/4258', - hasDetails: true, - action: null, - }, -}; - -export const triggerJob = { - id: 4259, - name: 'trigger', - kind: BRIDGE_KIND, - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - action: null, - }, -}; - -export const triggerJobWithRetryAction = { - ...triggerJob, - status: { - ...triggerJob.status, - action: { - icon: 'retry', - title: RETRY_ACTION_TITLE, - path: '/root/ci-mock/builds/4259/retry', - method: 'post', - }, - }, -}; - -export const mockFailedJob = { - id: 3999, - name: 'failed job', - kind: BUILD_KIND, - status: { - id: 'failed-3999-3999', - icon: 'status_failed', - tooltip: 'failed - (stuck or timeout failure)', - hasDetails: true, - detailsPath: '/root/ci-project/-/jobs/3999', - group: 'failed', - label: 'failed', - action: { - id: 'Ci::BuildPresenter-failed-3999', - buttonTitle: 'Retry this job', - icon: 'retry', - path: '/root/ci-project/-/jobs/3999/retry', - title: 'Retry', - }, - }, -}; diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js deleted file mode 100644 index d4d7f1618c5..00000000000 --- a/spec/frontend/pipelines/graph/stage_column_component_spec.js +++ /dev/null @@ -1,228 +0,0 @@ -import { mount, shallowMount } from '@vue/test-utils'; -import JobItem from '~/pipelines/components/graph/job_item.vue'; -import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; -import ActionComponent from '~/pipelines/components/jobs_shared/action_component.vue'; - -const mockJob = { - id: 4250, - name: 'test', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - details_path: '/root/ci-mock/builds/4250', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/ci-mock/builds/4250/retry', - method: 'post', - }, - }, -}; - -const mockGroups = Array(4) - .fill(0) - .map((item, idx) => { - return { ...mockJob, jobs: [mockJob], id: idx, name: `fish-${idx}` }; - }); - -const defaultProps = { - name: 'Fish', - groups: mockGroups, - pipelineId: 159, - userPermissions: { - updatePipeline: true, - }, -}; - -describe('stage column component', () => { - let wrapper; - - const findStageColumnTitle = () => wrapper.find('[data-testid="stage-column-title"]'); - const findStageColumnGroup = () => wrapper.find('[data-testid="stage-column-group"]'); - const findAllStageColumnGroups = () => wrapper.findAll('[data-testid="stage-column-group"]'); - const findJobItem = () => wrapper.findComponent(JobItem); - const findActionComponent = () => wrapper.findComponent(ActionComponent); - - const createComponent = ({ method = shallowMount, props = {} } = {}) => { - wrapper = method(StageColumnComponent, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - describe('when mounted', () => { - beforeEach(() => { - createComponent({ method: mount }); - }); - - it('should render provided title', () => { - expect(findStageColumnTitle().text()).toBe(defaultProps.name); - }); - - it('should render the provided groups', () => { - expect(findAllStageColumnGroups().length).toBe(mockGroups.length); - }); - - it('should emit updateMeasurements event on mount', () => { - expect(wrapper.emitted().updateMeasurements).toHaveLength(1); - }); - }); - - describe('when job notifies action is complete', () => { - beforeEach(() => { - createComponent({ - method: mount, - props: { - groups: [ - { - title: 'Fish', - size: 1, - jobs: [mockJob], - }, - ], - }, - }); - findJobItem().vm.$emit('pipelineActionRequestComplete'); - }); - - it('emits refreshPipelineGraph', () => { - expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); - }); - }); - - describe('job', () => { - describe('text handling', () => { - beforeEach(() => { - createComponent({ - method: mount, - props: { - groups: [ - { - ...mockJob, - name: '', - jobs: [ - { - id: 4259, - name: '', - status: { - icon: 'status_success', - label: 'success', - tooltip: '', - }, - }, - ], - }, - ], - name: 'test ', - }, - }); - }); - - it('escapes name', () => { - expect(findStageColumnTitle().html()).toContain( - 'test <img src=x onerror=alert(document.domain)>', - ); - }); - - it('escapes id', () => { - expect(findStageColumnGroup().attributes('id')).toBe( - 'ci-badge-<img src=x onerror=alert(document.domain)>', - ); - }); - }); - - describe('interactions', () => { - beforeEach(() => { - createComponent({ method: mount }); - }); - - it('emits jobHovered event on mouseenter and mouseleave', async () => { - await findStageColumnGroup().trigger('mouseenter'); - expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name]]); - await findStageColumnGroup().trigger('mouseleave'); - expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name], ['']]); - }); - }); - }); - - describe('with action', () => { - const defaults = { - groups: [ - { - id: 4259, - name: '', - status: { - icon: 'status_success', - label: 'success', - tooltip: '', - }, - jobs: [mockJob], - }, - ], - title: 'test', - hasTriggeredBy: false, - action: { - icon: 'play', - title: 'Play all', - path: 'action', - }, - }; - - it('renders action button if permissions are permitted', () => { - createComponent({ - method: mount, - props: { - ...defaults, - }, - }); - - expect(findActionComponent().exists()).toBe(true); - }); - - it('does not render action button if permissions are not permitted', () => { - createComponent({ - method: mount, - props: { - ...defaults, - userPermissions: { - updatePipeline: false, - }, - }, - }); - - expect(findActionComponent().exists()).toBe(false); - }); - }); - - describe('without action', () => { - beforeEach(() => { - createComponent({ - method: mount, - props: { - groups: [ - { - id: 4259, - name: '', - status: { - icon: 'status_success', - label: 'success', - tooltip: '', - }, - jobs: [mockJob], - }, - ], - title: 'test', - hasTriggeredBy: false, - }, - }); - }); - - it('does not render action button', () => { - expect(findActionComponent().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap deleted file mode 100644 index 82206e907ff..00000000000 --- a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap +++ /dev/null @@ -1,30 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Links Inner component with a large number of needs matches snapshot and has expected path 1`] = ` -"
    - - - - - -
    " -`; - -exports[`Links Inner component with a parallel need matches snapshot and has expected path 1`] = ` -"
    - -
    " -`; - -exports[`Links Inner component with one need matches snapshot and has expected path 1`] = ` -"
    - -
    " -`; - -exports[`Links Inner component with same stage needs matches snapshot and has expected path 1`] = ` -"
    - - -
    " -`; diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js deleted file mode 100644 index b4ffd2658fe..00000000000 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ /dev/null @@ -1,223 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; -import { parseData } from '~/pipelines/components/parsing_utils'; -import { createJobsHash } from '~/pipelines/utils'; -import { - jobRect, - largePipelineData, - parallelNeedData, - pipelineData, - pipelineDataWithNoNeeds, - rootRect, - sameStageNeeds, -} from '../pipeline_graph/mock_data'; - -describe('Links Inner component', () => { - const containerId = 'pipeline-graph-container'; - const defaultProps = { - containerId, - containerMeasurements: { width: 1019, height: 445 }, - pipelineId: 1, - pipelineData: [], - totalGroups: 10, - }; - - let wrapper; - - const createComponent = (props) => { - const currentPipelineData = props?.pipelineData || defaultProps.pipelineData; - wrapper = shallowMount(LinksInner, { - propsData: { - ...defaultProps, - ...props, - linksData: parseData(currentPipelineData.flatMap(({ groups }) => groups)).links, - }, - }); - }; - - const findLinkSvg = () => wrapper.find('#link-svg'); - const findAllLinksPath = () => findLinkSvg().findAll('path'); - - // We create fixture so that each job has an empty div that represent - // the JobPill in the DOM. Each `JobPill` would have different coordinates, - // so we increment their coordinates on each iteration to simulate different positions. - const setHTMLFixtureLocal = ({ stages }) => { - const jobs = createJobsHash(stages); - const arrayOfJobs = Object.keys(jobs); - - const linksHtmlElements = arrayOfJobs.map((job) => { - return `
    `; - }); - - setHTMLFixture(`
    ${linksHtmlElements.join(' ')}
    `); - - // We are mocking the clientRect data of each job and the container ID. - jest - .spyOn(document.getElementById(containerId), 'getBoundingClientRect') - .mockImplementation(() => rootRect); - - arrayOfJobs.forEach((job, index) => { - jest - .spyOn( - document.getElementById(`${job}-${defaultProps.pipelineId}`), - 'getBoundingClientRect', - ) - .mockImplementation(() => { - const newValue = 10 * index; - const { left, right, top, bottom, x, y } = jobRect; - return { - ...jobRect, - left: left + newValue, - right: right + newValue, - top: top + newValue, - bottom: bottom + newValue, - x: x + newValue, - y: y + newValue, - }; - }); - }); - }; - - afterEach(() => { - resetHTMLFixture(); - }); - - describe('basic SVG creation', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders an SVG of the right size', () => { - expect(findLinkSvg().exists()).toBe(true); - expect(findLinkSvg().attributes('width')).toBe( - `${defaultProps.containerMeasurements.width}px`, - ); - expect(findLinkSvg().attributes('height')).toBe( - `${defaultProps.containerMeasurements.height}px`, - ); - }); - }); - - describe('no pipeline data', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the component', () => { - expect(findLinkSvg().exists()).toBe(true); - expect(findAllLinksPath()).toHaveLength(0); - }); - }); - - describe('pipeline data with no needs', () => { - beforeEach(() => { - createComponent({ pipelineData: pipelineDataWithNoNeeds.stages }); - }); - - it('renders no links', () => { - expect(findLinkSvg().exists()).toBe(true); - expect(findAllLinksPath()).toHaveLength(0); - }); - }); - - describe('with one need', () => { - beforeEach(() => { - setHTMLFixtureLocal(pipelineData); - createComponent({ pipelineData: pipelineData.stages }); - }); - - it('renders one link', () => { - expect(findAllLinksPath()).toHaveLength(1); - }); - - it('path does not contain NaN values', () => { - expect(wrapper.html()).not.toContain('NaN'); - }); - - it('matches snapshot and has expected path', () => { - expect(wrapper.html()).toMatchSnapshot(); - }); - }); - - describe('with a parallel need', () => { - beforeEach(() => { - setHTMLFixtureLocal(parallelNeedData); - createComponent({ pipelineData: parallelNeedData.stages }); - }); - - it('renders only one link for all the same parallel jobs', () => { - expect(findAllLinksPath()).toHaveLength(1); - }); - - it('path does not contain NaN values', () => { - expect(wrapper.html()).not.toContain('NaN'); - }); - - it('matches snapshot and has expected path', () => { - expect(wrapper.html()).toMatchSnapshot(); - }); - }); - - describe('with same stage needs', () => { - beforeEach(() => { - setHTMLFixtureLocal(sameStageNeeds); - createComponent({ pipelineData: sameStageNeeds.stages }); - }); - - it('renders the correct number of links', () => { - expect(findAllLinksPath()).toHaveLength(2); - }); - - it('path does not contain NaN values', () => { - expect(wrapper.html()).not.toContain('NaN'); - }); - - it('matches snapshot and has expected path', () => { - expect(wrapper.html()).toMatchSnapshot(); - }); - }); - - describe('with a large number of needs', () => { - beforeEach(() => { - setHTMLFixtureLocal(largePipelineData); - createComponent({ pipelineData: largePipelineData.stages }); - }); - - it('renders the correct number of links', () => { - expect(findAllLinksPath()).toHaveLength(5); - }); - - it('path does not contain NaN values', () => { - expect(wrapper.html()).not.toContain('NaN'); - }); - - it('matches snapshot and has expected path', () => { - expect(wrapper.html()).toMatchSnapshot(); - }); - }); - - describe('interactions', () => { - beforeEach(() => { - setHTMLFixtureLocal(largePipelineData); - createComponent({ pipelineData: largePipelineData.stages }); - }); - - it('highlight needs on hover', async () => { - const firstLink = findAllLinksPath().at(0); - - const defaultColorClass = 'gl-stroke-gray-200'; - const hoverColorClass = 'gl-stroke-blue-400'; - - expect(firstLink.classes(defaultColorClass)).toBe(true); - expect(firstLink.classes(hoverColorClass)).toBe(false); - - // Because there is a watcher, we need to set the props after the component - // has mounted. - await wrapper.setProps({ highlightedJob: 'test_1' }); - - expect(firstLink.classes(defaultColorClass)).toBe(false); - expect(firstLink.classes(hoverColorClass)).toBe(true); - }); - }); -}); diff --git a/spec/frontend/pipelines/graph_shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js deleted file mode 100644 index 88ba84c395a..00000000000 --- a/spec/frontend/pipelines/graph_shared/links_layer_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; -import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; -import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; - -import { generateResponse } from '../graph/mock_data'; - -describe('links layer component', () => { - let wrapper; - - const findLinksInner = () => wrapper.findComponent(LinksInner); - - const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); - const containerId = `pipeline-links-container-${pipeline.id}`; - const slotContent = "
    Ceci n'est pas un graphique
    "; - - const defaultProps = { - containerId, - containerMeasurements: { width: 400, height: 400 }, - pipelineId: pipeline.id, - pipelineData: pipeline.stages, - showLinks: false, - }; - - const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { - wrapper = mountFn(LinksLayer, { - propsData: { - ...defaultProps, - ...props, - }, - slots: { - default: slotContent, - }, - stubs: { - 'links-inner': true, - }, - }); - }; - - describe('with show links off', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the default slot', () => { - expect(wrapper.html()).toContain(slotContent); - }); - - it('does not render inner links component', () => { - expect(findLinksInner().exists()).toBe(false); - }); - }); - - describe('with show links on', () => { - beforeEach(() => { - createComponent({ - props: { - showLinks: true, - }, - }); - }); - - it('renders the default slot', () => { - expect(wrapper.html()).toContain(slotContent); - }); - - it('renders the inner links component', () => { - expect(findLinksInner().exists()).toBe(true); - }); - }); - - describe('with width or height measurement at 0', () => { - beforeEach(() => { - createComponent({ props: { containerMeasurements: { width: 0, height: 100 } } }); - }); - - it('renders the default slot', () => { - expect(wrapper.html()).toContain(slotContent); - }); - - it('does not render the inner links component', () => { - expect(findLinksInner().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/linked_pipelines_mock.json b/spec/frontend/pipelines/linked_pipelines_mock.json deleted file mode 100644 index a68283032d2..00000000000 --- a/spec/frontend/pipelines/linked_pipelines_mock.json +++ /dev/null @@ -1,3569 +0,0 @@ -{ - "id": 23211253, - "user": { - "id": 3585, - "name": "Achilleas Pipinellis", - "username": "axil", - "state": "active", - "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png", - "web_url": "https://gitlab.com/axil", - "status_tooltip_html": "🍕", - "path": "/axil" - }, - "active": false, - "coverage": null, - "source": "push", - "created_at": "2018-06-05T11:31:30.452Z", - "updated_at": "2018-10-31T16:35:31.305Z", - "path": "/gitlab-org/gitlab-runner/pipelines/23211253", - "flags": { - "latest": false, - "stuck": false, - "auto_devops": false, - "merge_request": false, - "yaml_errors": false, - "retryable": false, - "cancelable": false, - "failure_reason": false - }, - "details": { - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "duration": 53, - "finished_at": "2018-10-31T16:35:31.299Z", - "stages": [ - { - "name": "prebuild", - "title": "prebuild: passed", - "groups": [ - { - "name": "review-docs-deploy", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "manual play action", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 72469032, - "name": "review-docs-deploy", - "started": "2018-10-31T16:34:58.778Z", - "archived": false, - "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", - "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/retry", - "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", - "playable": true, - "scheduled": false, - "created_at": "2018-06-05T11:31:30.495Z", - "updated_at": "2018-10-31T16:35:31.251Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "manual play action", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469032", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - } - ], - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "path": "/gitlab-org/gitlab-runner/pipelines/23211253#prebuild", - "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=prebuild" - }, - { - "name": "test", - "title": "test: passed", - "groups": [ - { - "name": "docs check links", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 72469033, - "name": "docs check links", - "started": "2018-06-05T11:31:33.240Z", - "archived": false, - "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", - "retry_path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", - "playable": false, - "scheduled": false, - "created_at": "2018-06-05T11:31:30.627Z", - "updated_at": "2018-06-05T11:31:54.363Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469033", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/gitlab-org/gitlab-runner/-/jobs/72469033/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - } - ], - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#test", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "path": "/gitlab-org/gitlab-runner/pipelines/23211253#test", - "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=test" - }, - { - "name": "cleanup", - "title": "cleanup: skipped", - "groups": [ - { - "name": "review-docs-cleanup", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual stop action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "stop", - "title": "Stop", - "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", - "method": "post", - "button_title": "Stop this environment" - } - }, - "jobs": [ - { - "id": 72469034, - "name": "review-docs-cleanup", - "started": null, - "archived": false, - "build_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", - "play_path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", - "playable": true, - "scheduled": false, - "created_at": "2018-06-05T11:31:30.760Z", - "updated_at": "2018-06-05T11:31:56.037Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual stop action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/-/jobs/72469034", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "stop", - "title": "Stop", - "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", - "method": "post", - "button_title": "Stop this environment" - } - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-org/gitlab-runner/pipelines/23211253#cleanup", - "dropdown_path": "/gitlab-org/gitlab-runner/pipelines/23211253/stage.json?stage=cleanup" - } - ], - "artifacts": [ - - ], - "manual_actions": [ - { - "name": "review-docs-cleanup", - "path": "/gitlab-org/gitlab-runner/-/jobs/72469034/play", - "playable": true, - "scheduled": false - }, - { - "name": "review-docs-deploy", - "path": "/gitlab-org/gitlab-runner/-/jobs/72469032/play", - "playable": true, - "scheduled": false - } - ], - "scheduled_actions": [ - - ] - }, - "ref": { - "name": "docs/add-development-guide-to-readme", - "path": "/gitlab-org/gitlab-runner/commits/docs/add-development-guide-to-readme", - "tag": false, - "branch": true, - "merge_request": false - }, - "commit": { - "id": "8083eb0a920572214d0dccedd7981f05d535ad46", - "short_id": "8083eb0a", - "title": "Add link to development guide in readme", - "created_at": "2018-06-05T11:30:48.000Z", - "parent_ids": [ - "1d7cf79b5a1a2121b9474ac20d61c1b8f621289d" - ], - "message": "Add link to development guide in readme\n\nCloses https://gitlab.com/gitlab-org/gitlab-runner/issues/3122\n", - "author_name": "Achilleas Pipinellis", - "author_email": "axil@gitlab.com", - "authored_date": "2018-06-05T11:30:48.000Z", - "committer_name": "Achilleas Pipinellis", - "committer_email": "axil@gitlab.com", - "committed_date": "2018-06-05T11:30:48.000Z", - "author": { - "id": 3585, - "name": "Achilleas Pipinellis", - "username": "axil", - "state": "active", - "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/3585/avatar.png", - "web_url": "https://gitlab.com/axil", - "status_tooltip_html": null, - "path": "/axil" - }, - "author_gravatar_url": "https://secure.gravatar.com/avatar/1d37af00eec153a8333a4ce18e9aea41?s=80&d=identicon", - "commit_url": "https://gitlab.com/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46", - "commit_path": "/gitlab-org/gitlab-runner/commit/8083eb0a920572214d0dccedd7981f05d535ad46" - }, - "project": { - "id": 1794617 - }, - "triggered_by": { - "id": 12, - "user": { - "id": 376774, - "name": "Alessio Caiazza", - "username": "nolith", - "state": "active", - "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", - "web_url": "https://gitlab.com/nolith", - "status_tooltip_html": null, - "path": "/nolith" - }, - "active": false, - "coverage": null, - "source": "pipeline", - "path": "/gitlab-com/gitlab-docs/pipelines/34993051", - "details": { - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" - }, - "duration": 118, - "finished_at": "2018-10-31T16:41:40.615Z", - "stages": [ - { - "name": "build-images", - "title": "build-images: skipped", - "groups": [ - { - "name": "image:bootstrap", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 11421321982853, - "name": "image:bootstrap", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.704Z", - "updated_at": "2018-10-31T16:35:24.118Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - }, - { - "name": "image:builder-onbuild", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 1149822131854, - "name": "image:builder-onbuild", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.728Z", - "updated_at": "2018-10-31T16:35:24.070Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - }, - { - "name": "image:nginx-onbuild", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 11498285523424, - "name": "image:nginx-onbuild", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.753Z", - "updated_at": "2018-10-31T16:35:24.033Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" - }, - { - "name": "build", - "title": "build: failed", - "groups": [ - { - "name": "compile_dev", - "size": 1, - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed - (script failure)", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 1149846949786, - "name": "compile_dev", - "started": "2018-10-31T16:39:41.598Z", - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:39:41.138Z", - "updated_at": "2018-10-31T16:41:40.072Z", - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed - (script failure)", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "recoverable": false - } - ] - } - ], - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" - }, - { - "name": "deploy", - "title": "deploy: skipped", - "groups": [ - { - "name": "review", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 11498282342357, - "name": "review", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.805Z", - "updated_at": "2018-10-31T16:41:40.569Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - }, - { - "name": "review_stop", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 114982858, - "name": "review_stop", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.840Z", - "updated_at": "2018-10-31T16:41:40.480Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" - } - ], - "artifacts": [ - - ], - "manual_actions": [ - { - "name": "image:bootstrap", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "playable": true, - "scheduled": false - }, - { - "name": "image:builder-onbuild", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "playable": true, - "scheduled": false - }, - { - "name": "image:nginx-onbuild", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "playable": true, - "scheduled": false - }, - { - "name": "review_stop", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", - "playable": false, - "scheduled": false - } - ], - "scheduled_actions": [ - - ] - }, - "project": { - "id": 1794617, - "name": "Test", - "full_path": "/gitlab-com/gitlab-docs", - "full_name": "GitLab.com / GitLab Docs" - }, - "triggered_by": { - "id": 349932310342451, - "user": { - "id": 376774, - "name": "Alessio Caiazza", - "username": "nolith", - "state": "active", - "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", - "web_url": "https://gitlab.com/nolith", - "status_tooltip_html": null, - "path": "/nolith" - }, - "active": false, - "coverage": null, - "source": "pipeline", - "path": "/gitlab-com/gitlab-docs/pipelines/34993051", - "details": { - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" - }, - "duration": 118, - "finished_at": "2018-10-31T16:41:40.615Z", - "stages": [ - { - "name": "build-images", - "title": "build-images: skipped", - "groups": [ - { - "name": "image:bootstrap", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 11421321982853, - "name": "image:bootstrap", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.704Z", - "updated_at": "2018-10-31T16:35:24.118Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - }, - { - "name": "image:builder-onbuild", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 1149822131854, - "name": "image:builder-onbuild", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.728Z", - "updated_at": "2018-10-31T16:35:24.070Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - }, - { - "name": "image:nginx-onbuild", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 11498285523424, - "name": "image:nginx-onbuild", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.753Z", - "updated_at": "2018-10-31T16:35:24.033Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" - }, - { - "name": "build", - "title": "build: failed", - "groups": [ - { - "name": "compile_dev", - "size": 1, - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed - (script failure)", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 1149846949786, - "name": "compile_dev", - "started": "2018-10-31T16:39:41.598Z", - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:39:41.138Z", - "updated_at": "2018-10-31T16:41:40.072Z", - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed - (script failure)", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "recoverable": false - } - ] - } - ], - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" - }, - { - "name": "deploy", - "title": "deploy: skipped", - "groups": [ - { - "name": "review", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 11498282342357, - "name": "review", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.805Z", - "updated_at": "2018-10-31T16:41:40.569Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - }, - { - "name": "review_stop", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 114982858, - "name": "review_stop", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.840Z", - "updated_at": "2018-10-31T16:41:40.480Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" - } - ], - "artifacts": [ - - ], - "manual_actions": [ - { - "name": "image:bootstrap", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "playable": true, - "scheduled": false - }, - { - "name": "image:builder-onbuild", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "playable": true, - "scheduled": false - }, - { - "name": "image:nginx-onbuild", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "playable": true, - "scheduled": false - }, - { - "name": "review_stop", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", - "playable": false, - "scheduled": false - } - ], - "scheduled_actions": [ - - ] - }, - "project": { - "id": 1794617, - "name": "GitLab Docs", - "full_path": "/gitlab-com/gitlab-docs", - "full_name": "GitLab.com / GitLab Docs" - } - }, - "triggered": [ - - ] - }, - "triggered": [ - { - "id": 34993051, - "user": { - "id": 376774, - "name": "Alessio Caiazza", - "username": "nolith", - "state": "active", - "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", - "web_url": "https://gitlab.com/nolith", - "status_tooltip_html": null, - "path": "/nolith" - }, - "active": false, - "coverage": null, - "source": "pipeline", - "path": "/gitlab-com/gitlab-docs/pipelines/34993051", - "details": { - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" - }, - "duration": 118, - "finished_at": "2018-10-31T16:41:40.615Z", - "stages": [ - { - "name": "build-images", - "title": "build-images: skipped", - "groups": [ - { - "name": "image:bootstrap", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 114982853, - "name": "image:bootstrap", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.704Z", - "updated_at": "2018-10-31T16:35:24.118Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - }, - { - "name": "image:builder-onbuild", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 114982854, - "name": "image:builder-onbuild", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.728Z", - "updated_at": "2018-10-31T16:35:24.070Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - }, - { - "name": "image:nginx-onbuild", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 114982855, - "name": "image:nginx-onbuild", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.753Z", - "updated_at": "2018-10-31T16:35:24.033Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" - }, - { - "name": "build", - "title": "build: failed", - "groups": [ - { - "name": "compile_dev", - "size": 1, - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed - (script failure)", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 114984694, - "name": "compile_dev", - "started": "2018-10-31T16:39:41.598Z", - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:39:41.138Z", - "updated_at": "2018-10-31T16:41:40.072Z", - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed - (script failure)", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "recoverable": false - } - ] - } - ], - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" - }, - { - "name": "deploy", - "title": "deploy: skipped", - "groups": [ - { - "name": "review", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 114982857, - "name": "review", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.805Z", - "updated_at": "2018-10-31T16:41:40.569Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - }, - { - "name": "review_stop", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 114982858, - "name": "review_stop", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.840Z", - "updated_at": "2018-10-31T16:41:40.480Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" - } - ], - "artifacts": [ - - ], - "manual_actions": [ - { - "name": "image:bootstrap", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "playable": true, - "scheduled": false - }, - { - "name": "image:builder-onbuild", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "playable": true, - "scheduled": false - }, - { - "name": "image:nginx-onbuild", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "playable": true, - "scheduled": false - }, - { - "name": "review_stop", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", - "playable": false, - "scheduled": false - } - ], - "scheduled_actions": [ - - ] - }, - "project": { - "id": 1794617, - "name": "GitLab Docs", - "full_path": "/gitlab-com/gitlab-docs", - "full_name": "GitLab.com / GitLab Docs" - }, - "triggered": [ - { - } - ] - }, - { - "id": 34993052, - "user": { - "id": 376774, - "name": "Alessio Caiazza", - "username": "nolith", - "state": "active", - "avatar_url": "https://assets.gitlab-static.net/uploads/-/system/user/avatar/376774/avatar.png", - "web_url": "https://gitlab.com/nolith", - "status_tooltip_html": null, - "path": "/nolith" - }, - "active": false, - "coverage": null, - "source": "pipeline", - "path": "/gitlab-com/gitlab-docs/pipelines/34993051", - "details": { - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" - }, - "duration": 118, - "finished_at": "2018-10-31T16:41:40.615Z", - "stages": [ - { - "name": "build-images", - "title": "build-images: skipped", - "groups": [ - { - "name": "image:bootstrap", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 114982853, - "name": "image:bootstrap", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.704Z", - "updated_at": "2018-10-31T16:35:24.118Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982853", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - }, - { - "name": "image:builder-onbuild", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 114982854, - "name": "image:builder-onbuild", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.728Z", - "updated_at": "2018-10-31T16:35:24.070Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982854", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - }, - { - "name": "image:nginx-onbuild", - "size": 1, - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 1224982855, - "name": "image:nginx-onbuild", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "play_path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "playable": true, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.753Z", - "updated_at": "2018-10-31T16:35:24.033Z", - "status": { - "icon": "status_manual", - "text": "manual", - "label": "manual play action", - "group": "manual", - "tooltip": "manual action", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982855", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_manual-829a0804612cef47d9efc1618dba38325483657c847dba0546c3b9f0295bb36c.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build-images", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build-images" - }, - { - "name": "build", - "title": "build: failed", - "groups": [ - { - "name": "compile_dev", - "size": 1, - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed - (script failure)", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 1123984694, - "name": "compile_dev", - "started": "2018-10-31T16:39:41.598Z", - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "retry_path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:39:41.138Z", - "updated_at": "2018-10-31T16:41:40.072Z", - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed - (script failure)", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114984694", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/gitlab-com/gitlab-docs/-/jobs/114984694/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "recoverable": false - } - ] - } - ], - "status": { - "icon": "status_failed", - "text": "failed", - "label": "failed", - "group": "failed", - "tooltip": "failed", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#build", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=build" - }, - { - "name": "deploy", - "title": "deploy: skipped", - "groups": [ - { - "name": "review", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 1143232982857, - "name": "review", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.805Z", - "updated_at": "2018-10-31T16:41:40.569Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982857", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - }, - { - "name": "review_stop", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 114921313182858, - "name": "review_stop", - "started": null, - "archived": false, - "build_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "playable": false, - "scheduled": false, - "created_at": "2018-10-31T16:35:23.840Z", - "updated_at": "2018-10-31T16:41:40.480Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/-/jobs/114982858", - "illustration": { - "image": "https://assets.gitlab-static.net/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - } - ], - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", - "illustration": null, - "favicon": "https://gitlab.com/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "path": "/gitlab-com/gitlab-docs/pipelines/34993051#deploy", - "dropdown_path": "/gitlab-com/gitlab-docs/pipelines/34993051/stage.json?stage=deploy" - } - ], - "artifacts": [ - - ], - "manual_actions": [ - { - "name": "image:bootstrap", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982853/play", - "playable": true, - "scheduled": false - }, - { - "name": "image:builder-onbuild", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982854/play", - "playable": true, - "scheduled": false - }, - { - "name": "image:nginx-onbuild", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982855/play", - "playable": true, - "scheduled": false - }, - { - "name": "review_stop", - "path": "/gitlab-com/gitlab-docs/-/jobs/114982858/play", - "playable": false, - "scheduled": false - } - ], - "scheduled_actions": [ - - ] - }, - "project": { - "id": 1794617, - "name": "GitLab Docs", - "full_path": "/gitlab-com/gitlab-docs", - "full_name": "GitLab.com / GitLab Docs" - }, - "triggered": [ - { - "id": 26, - "user": null, - "active": false, - "coverage": null, - "source": "push", - "created_at": "2019-01-06T17:48:37.599Z", - "updated_at": "2019-01-06T17:48:38.371Z", - "path": "/h5bp/html5-boilerplate/pipelines/26", - "flags": { - "latest": true, - "stuck": false, - "auto_devops": false, - "merge_request": false, - "yaml_errors": false, - "retryable": true, - "cancelable": false, - "failure_reason": false - }, - "details": { - "status": { - "icon": "status_warning", - "text": "passed", - "label": "passed with warnings", - "group": "success-with-warnings", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/pipelines/26", - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "duration": null, - "finished_at": "2019-01-06T17:48:38.370Z", - "stages": [ - { - "name": "build", - "title": "build: passed", - "groups": [ - { - "name": "build:linux", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/526", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/526/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 526, - "name": "build:linux", - "started": "2019-01-06T08:48:20.236Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/526", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/526/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:37.806Z", - "updated_at": "2019-01-06T17:48:37.806Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/526", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/526/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "build:osx", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/527", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/527/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 527, - "name": "build:osx", - "started": "2019-01-06T07:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/527", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/527/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:37.846Z", - "updated_at": "2019-01-06T17:48:37.846Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/527", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/527/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - } - ], - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/pipelines/26#build", - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "path": "/h5bp/html5-boilerplate/pipelines/26#build", - "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=build" - }, - { - "name": "test", - "title": "test: passed with warnings", - "groups": [ - { - "name": "jenkins", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": null, - "group": "success", - "tooltip": null, - "has_details": false, - "details_path": null, - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "jobs": [ - { - "id": 546, - "name": "jenkins", - "started": "2019-01-06T11:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/546", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.359Z", - "updated_at": "2019-01-06T17:48:38.359Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": null, - "group": "success", - "tooltip": null, - "has_details": false, - "details_path": null, - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - } - } - ] - }, - { - "name": "rspec:linux", - "size": 3, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": false, - "details_path": null, - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "jobs": [ - { - "id": 528, - "name": "rspec:linux 0 3", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/528", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/528/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:37.885Z", - "updated_at": "2019-01-06T17:48:37.885Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/528", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/528/retry", - "method": "post", - "button_title": "Retry this job" - } - } - }, - { - "id": 529, - "name": "rspec:linux 1 3", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/529", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/529/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:37.907Z", - "updated_at": "2019-01-06T17:48:37.907Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/529", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/529/retry", - "method": "post", - "button_title": "Retry this job" - } - } - }, - { - "id": 530, - "name": "rspec:linux 2 3", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/530", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/530/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:37.927Z", - "updated_at": "2019-01-06T17:48:37.927Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/530", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/530/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "rspec:osx", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/535", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/535/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 535, - "name": "rspec:osx", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/535", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/535/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.018Z", - "updated_at": "2019-01-06T17:48:38.018Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/535", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/535/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "rspec:windows", - "size": 3, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": false, - "details_path": null, - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "jobs": [ - { - "id": 531, - "name": "rspec:windows 0 3", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/531", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/531/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:37.944Z", - "updated_at": "2019-01-06T17:48:37.944Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/531", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/531/retry", - "method": "post", - "button_title": "Retry this job" - } - } - }, - { - "id": 532, - "name": "rspec:windows 1 3", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/532", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/532/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:37.962Z", - "updated_at": "2019-01-06T17:48:37.962Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/532", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/532/retry", - "method": "post", - "button_title": "Retry this job" - } - } - }, - { - "id": 534, - "name": "rspec:windows 2 3", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/534", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/534/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:37.999Z", - "updated_at": "2019-01-06T17:48:37.999Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/534", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/534/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "spinach:linux", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/536", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/536/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 536, - "name": "spinach:linux", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/536", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/536/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.050Z", - "updated_at": "2019-01-06T17:48:38.050Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/536", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/536/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "spinach:osx", - "size": 1, - "status": { - "icon": "status_warning", - "text": "failed", - "label": "failed (allowed to fail)", - "group": "failed-with-warnings", - "tooltip": "failed - (unknown failure) (allowed to fail)", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/537", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/537/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 537, - "name": "spinach:osx", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/537", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/537/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.069Z", - "updated_at": "2019-01-06T17:48:38.069Z", - "status": { - "icon": "status_warning", - "text": "failed", - "label": "failed (allowed to fail)", - "group": "failed-with-warnings", - "tooltip": "failed - (unknown failure) (allowed to fail)", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/537", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/537/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "callout_message": "There is an unknown failure, please try again", - "recoverable": true - } - ] - } - ], - "status": { - "icon": "status_warning", - "text": "passed", - "label": "passed with warnings", - "group": "success-with-warnings", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/pipelines/26#test", - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "path": "/h5bp/html5-boilerplate/pipelines/26#test", - "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=test" - }, - { - "name": "security", - "title": "security: passed", - "groups": [ - { - "name": "container_scanning", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/541", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/541/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 541, - "name": "container_scanning", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/541", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/541/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.186Z", - "updated_at": "2019-01-06T17:48:38.186Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/541", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/541/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "dast", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/538", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/538/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 538, - "name": "dast", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/538", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/538/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.087Z", - "updated_at": "2019-01-06T17:48:38.087Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/538", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/538/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "dependency_scanning", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/540", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/540/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 540, - "name": "dependency_scanning", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/540", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/540/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.153Z", - "updated_at": "2019-01-06T17:48:38.153Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/540", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/540/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "sast", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/539", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/539/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 539, - "name": "sast", - "started": "2019-01-06T09:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/539", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/539/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.121Z", - "updated_at": "2019-01-06T17:48:38.121Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/539", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/539/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - } - ], - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/pipelines/26#security", - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "path": "/h5bp/html5-boilerplate/pipelines/26#security", - "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=security" - }, - { - "name": "deploy", - "title": "deploy: passed", - "groups": [ - { - "name": "production", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/544", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 544, - "name": "production", - "started": null, - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/544", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.313Z", - "updated_at": "2019-01-06T17:48:38.313Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/544", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - }, - { - "name": "staging", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/542", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/542/retry", - "method": "post", - "button_title": "Retry this job" - } - }, - "jobs": [ - { - "id": 542, - "name": "staging", - "started": "2019-01-06T11:48:20.237Z", - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/542", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/542/retry", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.219Z", - "updated_at": "2019-01-06T17:48:38.219Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/542", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job does not have a trace." - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "retry", - "title": "Retry", - "path": "/h5bp/html5-boilerplate/-/jobs/542/retry", - "method": "post", - "button_title": "Retry this job" - } - } - } - ] - }, - { - "name": "stop staging", - "size": 1, - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/543", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - }, - "jobs": [ - { - "id": 543, - "name": "stop staging", - "started": null, - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/543", - "playable": false, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.283Z", - "updated_at": "2019-01-06T17:48:38.283Z", - "status": { - "icon": "status_skipped", - "text": "skipped", - "label": "skipped", - "group": "skipped", - "tooltip": "skipped", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/543", - "illustration": { - "image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg", - "size": "svg-430", - "title": "This job has been skipped" - }, - "favicon": "/assets/ci_favicons/favicon_status_skipped-0b9c5e543588945e8c4ca57786bbf2d0c56631959c9f853300392d0315be829b.png" - } - } - ] - } - ], - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/pipelines/26#deploy", - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "path": "/h5bp/html5-boilerplate/pipelines/26#deploy", - "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=deploy" - }, - { - "name": "notify", - "title": "notify: passed", - "groups": [ - { - "name": "slack", - "size": 1, - "status": { - "icon": "status_success", - "text": "passed", - "label": "manual play action", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/545", - "illustration": { - "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/h5bp/html5-boilerplate/-/jobs/545/play", - "method": "post", - "button_title": "Run job" - } - }, - "jobs": [ - { - "id": 545, - "name": "slack", - "started": null, - "archived": false, - "build_path": "/h5bp/html5-boilerplate/-/jobs/545", - "retry_path": "/h5bp/html5-boilerplate/-/jobs/545/retry", - "play_path": "/h5bp/html5-boilerplate/-/jobs/545/play", - "playable": true, - "scheduled": false, - "created_at": "2019-01-06T17:48:38.341Z", - "updated_at": "2019-01-06T17:48:38.341Z", - "status": { - "icon": "status_success", - "text": "passed", - "label": "manual play action", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/-/jobs/545", - "illustration": { - "image": "/assets/illustrations/manual_action-2b4ca0d1bcfd92aebf33d484e36cbf7a102d007f76b5a0cfea636033a629d601.svg", - "size": "svg-394", - "title": "This job requires a manual action", - "content": "This job depends on a user to trigger its process. Often they are used to deploy code to production environments" - }, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png", - "action": { - "icon": "play", - "title": "Play", - "path": "/h5bp/html5-boilerplate/-/jobs/545/play", - "method": "post", - "button_title": "Run job" - } - } - } - ] - } - ], - "status": { - "icon": "status_success", - "text": "passed", - "label": "passed", - "group": "success", - "tooltip": "passed", - "has_details": true, - "details_path": "/h5bp/html5-boilerplate/pipelines/26#notify", - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - }, - "path": "/h5bp/html5-boilerplate/pipelines/26#notify", - "dropdown_path": "/h5bp/html5-boilerplate/pipelines/26/stage.json?stage=notify" - } - ], - "artifacts": [ - { - "name": "build:linux", - "expired": null, - "expire_at": null, - "path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/download", - "browse_path": "/h5bp/html5-boilerplate/-/jobs/526/artifacts/browse" - }, - { - "name": "build:osx", - "expired": null, - "expire_at": null, - "path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/download", - "browse_path": "/h5bp/html5-boilerplate/-/jobs/527/artifacts/browse" - } - ], - "manual_actions": [ - { - "name": "stop staging", - "path": "/h5bp/html5-boilerplate/-/jobs/543/play", - "playable": false, - "scheduled": false - }, - { - "name": "production", - "path": "/h5bp/html5-boilerplate/-/jobs/544/play", - "playable": false, - "scheduled": false - }, - { - "name": "slack", - "path": "/h5bp/html5-boilerplate/-/jobs/545/play", - "playable": true, - "scheduled": false - } - ], - "scheduled_actions": [ - - ] - }, - "ref": { - "name": "master", - "path": "/h5bp/html5-boilerplate/commits/master", - "tag": false, - "branch": true, - "merge_request": false - }, - "commit": { - "id": "bad98c453eab56d20057f3929989251d45cd1a8b", - "short_id": "bad98c45", - "title": "remove instances of shrink-to-fit=no (#2103)", - "created_at": "2018-12-17T20:52:18.000Z", - "parent_ids": [ - "49130f6cfe9ff1f749015d735649a2bc6f66cf3a" - ], - "message": "remove instances of shrink-to-fit=no (#2103)\n\ncloses #2102\r\n\r\nPer my findings, the need for it as a default was rectified with the release of iOS 9.3, where the viewport no longer shrunk to accommodate overflow, as was introduced in iOS 9.", - "author_name": "Scott O'Hara", - "author_email": "scottaohara@users.noreply.github.com", - "authored_date": "2018-12-17T20:52:18.000Z", - "committer_name": "Rob Larsen", - "committer_email": "rob@drunkenfist.com", - "committed_date": "2018-12-17T20:52:18.000Z", - "author": null, - "author_gravatar_url": "https://www.gravatar.com/avatar/6d597df7cf998d16cbe00ccac063b31e?s=80&d=identicon", - "commit_url": "http://localhost:3001/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b", - "commit_path": "/h5bp/html5-boilerplate/commit/bad98c453eab56d20057f3929989251d45cd1a8b" - }, - "retry_path": "/h5bp/html5-boilerplate/pipelines/26/retry", - "triggered_by": { - "id": 4, - "user": null, - "active": false, - "coverage": null, - "source": "push", - "path": "/gitlab-org/gitlab-test/pipelines/4", - "details": { - "status": { - "icon": "status_warning", - "text": "passed", - "label": "passed with warnings", - "group": "success-with-warnings", - "tooltip": "passed", - "has_details": true, - "details_path": "/gitlab-org/gitlab-test/pipelines/4", - "illustration": null, - "favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png" - } - }, - "project": { - "id": 1, - "name": "Gitlab Test", - "full_path": "/gitlab-org/gitlab-test", - "full_name": "Gitlab Org / Gitlab Test" - } - }, - "triggered": [ - - ], - "project": { - "id": 1794617, - "name": "GitLab Docs", - "full_path": "/gitlab-com/gitlab-docs", - "full_name": "GitLab.com / GitLab Docs" - } - } - ] - } - ] -} diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js deleted file mode 100644 index 673db3b5178..00000000000 --- a/spec/frontend/pipelines/mock_data.js +++ /dev/null @@ -1,1379 +0,0 @@ -import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json'; -import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json'; -import pipelineHeaderRunningWithDuration from 'test_fixtures/graphql/pipelines/pipeline_header_running_with_duration.json'; -import pipelineHeaderFailed from 'test_fixtures/graphql/pipelines/pipeline_header_failed.json'; - -const PIPELINE_RUNNING = 'RUNNING'; -const PIPELINE_CANCELED = 'CANCELED'; -const PIPELINE_FAILED = 'FAILED'; - -const threeWeeksAgo = new Date(); -threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); - -export { - pipelineHeaderSuccess, - pipelineHeaderRunning, - pipelineHeaderRunningWithDuration, - pipelineHeaderFailed, -}; - -export const pipelineRetryMutationResponseSuccess = { - data: { pipelineRetry: { errors: [] } }, -}; - -export const pipelineRetryMutationResponseFailed = { - data: { pipelineRetry: { errors: ['error'] } }, -}; - -export const pipelineCancelMutationResponseSuccess = { - data: { pipelineCancel: { errors: [] } }, -}; - -export const pipelineCancelMutationResponseFailed = { - data: { pipelineCancel: { errors: ['error'] } }, -}; - -export const pipelineDeleteMutationResponseSuccess = { - data: { pipelineDestroy: { errors: [] } }, -}; - -export const pipelineDeleteMutationResponseFailed = { - data: { pipelineDestroy: { errors: ['error'] } }, -}; - -export const mockPipelineHeader = { - detailedStatus: {}, - id: 123, - userPermissions: { - destroyPipeline: true, - updatePipeline: true, - }, - createdAt: threeWeeksAgo.toISOString(), - user: { - id: 'user-1', - name: 'Foo', - username: 'foobar', - email: 'foo@bar.com', - avatarUrl: 'link', - }, -}; - -export const mockFailedPipelineHeader = { - ...mockPipelineHeader, - status: PIPELINE_FAILED, - retryable: true, - cancelable: false, - detailedStatus: { - id: 'status-1', - group: 'failed', - icon: 'status_failed', - label: 'failed', - text: 'failed', - detailsPath: 'path', - }, -}; - -export const mockFailedPipelineNoPermissions = { - id: 123, - userPermissions: { - destroyPipeline: false, - updatePipeline: false, - }, - createdAt: threeWeeksAgo.toISOString(), - user: { - id: 'user-1', - name: 'Foo', - username: 'foobar', - email: 'foo@bar.com', - avatarUrl: 'link', - }, - status: PIPELINE_RUNNING, - retryable: true, - cancelable: false, - detailedStatus: { - id: 'status-1', - group: 'running', - icon: 'status_running', - label: 'running', - text: 'running', - detailsPath: 'path', - }, -}; - -export const mockRunningPipelineHeader = { - ...mockPipelineHeader, - status: PIPELINE_RUNNING, - retryable: false, - cancelable: true, - detailedStatus: { - id: 'status-1', - group: 'running', - icon: 'status_running', - label: 'running', - text: 'running', - detailsPath: 'path', - }, -}; - -export const mockRunningPipelineNoPermissions = { - id: 123, - userPermissions: { - destroyPipeline: false, - updatePipeline: false, - }, - createdAt: threeWeeksAgo.toISOString(), - user: { - id: 'user-1', - name: 'Foo', - username: 'foobar', - email: 'foo@bar.com', - avatarUrl: 'link', - }, - status: PIPELINE_RUNNING, - retryable: false, - cancelable: true, - detailedStatus: { - id: 'status-1', - group: 'running', - icon: 'status_running', - label: 'running', - text: 'running', - detailsPath: 'path', - }, -}; - -export const mockCancelledPipelineHeader = { - ...mockPipelineHeader, - status: PIPELINE_CANCELED, - retryable: true, - cancelable: false, - detailedStatus: { - id: 'status-1', - group: 'cancelled', - icon: 'status_cancelled', - label: 'cancelled', - text: 'cancelled', - detailsPath: 'path', - }, -}; - -export const mockSuccessfulPipelineHeader = { - ...mockPipelineHeader, - status: 'SUCCESS', - retryable: false, - cancelable: false, - detailedStatus: { - id: 'status-1', - group: 'success', - icon: 'status_success', - label: 'success', - text: 'success', - detailsPath: 'path', - }, -}; - -export const mockRunningPipelineHeaderData = { - data: { - project: { - id: '1', - pipeline: { - ...mockRunningPipelineHeader, - iid: '28', - user: { - id: 'user-1', - name: 'Foo', - username: 'foobar', - webPath: '/foo', - webUrl: '/foo', - email: 'foo@bar.com', - avatarUrl: 'link', - status: null, - __typename: 'UserCore', - }, - __typename: 'Pipeline', - }, - __typename: 'Project', - }, - }, -}; - -export const stageReply = { - name: 'deploy', - title: 'deploy: running', - latest_statuses: [ - { - id: 928, - name: 'stop staging', - started: false, - build_path: '/twitter/flight/-/jobs/928', - cancel_path: '/twitter/flight/-/jobs/928/cancel', - playable: false, - created_at: '2018-04-04T20:02:02.728Z', - updated_at: '2018-04-04T20:02:02.766Z', - status: { - icon: 'status_pending', - text: 'pending', - label: 'pending', - group: 'pending', - tooltip: 'pending', - has_details: true, - details_path: '/twitter/flight/-/jobs/928', - favicon: - '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico', - action: { - icon: 'cancel', - title: 'Cancel', - path: '/twitter/flight/-/jobs/928/cancel', - method: 'post', - }, - }, - }, - { - id: 926, - name: 'production', - started: false, - build_path: '/twitter/flight/-/jobs/926', - retry_path: '/twitter/flight/-/jobs/926/retry', - play_path: '/twitter/flight/-/jobs/926/play', - playable: true, - created_at: '2018-04-04T20:00:57.202Z', - updated_at: '2018-04-04T20:11:13.110Z', - status: { - icon: 'status_canceled', - text: 'canceled', - label: 'manual play action', - group: 'canceled', - tooltip: 'canceled', - has_details: true, - details_path: '/twitter/flight/-/jobs/926', - favicon: - '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico', - action: { - icon: 'play', - title: 'Play', - path: '/twitter/flight/-/jobs/926/play', - method: 'post', - }, - }, - }, - { - id: 217, - name: 'staging', - started: '2018-03-07T08:41:46.234Z', - build_path: '/twitter/flight/-/jobs/217', - retry_path: '/twitter/flight/-/jobs/217/retry', - playable: false, - created_at: '2018-03-07T14:41:58.093Z', - updated_at: '2018-03-07T14:41:58.093Z', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/twitter/flight/-/jobs/217', - favicon: - '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico', - action: { - icon: 'retry', - title: 'Retry', - path: '/twitter/flight/-/jobs/217/retry', - method: 'post', - }, - }, - }, - ], - status: { - icon: 'status_running', - text: 'running', - label: 'running', - group: 'running', - tooltip: 'running', - has_details: true, - details_path: '/twitter/flight/pipelines/13#deploy', - favicon: - '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico', - }, - path: '/twitter/flight/pipelines/13#deploy', - dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy', -}; - -export const users = [ - { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', - web_url: 'http://192.168.1.22:3000/root', - }, - { - id: 10, - name: 'Angel Spinka', - username: 'shalonda', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/709df1b65ad06764ee2b0edf1b49fc27?s=80\u0026d=identicon', - web_url: 'http://192.168.1.22:3000/shalonda', - }, - { - id: 11, - name: 'Art Davis', - username: 'deja.green', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/bb56834c061522760e7a6dd7d431a306?s=80\u0026d=identicon', - web_url: 'http://192.168.1.22:3000/deja.green', - }, - { - id: 32, - name: 'Arnold Mante', - username: 'reported_user_10', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/ab558033a82466d7905179e837d7723a?s=80\u0026d=identicon', - web_url: 'http://192.168.1.22:3000/reported_user_10', - }, - { - id: 38, - name: 'Cher Wintheiser', - username: 'reported_user_16', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/2640356e8b5bc4314133090994ed162b?s=80\u0026d=identicon', - web_url: 'http://192.168.1.22:3000/reported_user_16', - }, - { - id: 39, - name: 'Bethel Wolf', - username: 'reported_user_17', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/4b948694fadba4b01e4acfc06b065e8e?s=80\u0026d=identicon', - web_url: 'http://192.168.1.22:3000/reported_user_17', - }, -]; - -export const branches = [ - { - name: 'branch-1', - commit: { - id: '21fb056cc47dcf706670e6de635b1b326490ebdc', - short_id: '21fb056c', - created_at: '2020-05-07T10:58:28.000-04:00', - parent_ids: null, - title: 'Add new file', - message: 'Add new file', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2020-05-07T10:58:28.000-04:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2020-05-07T10:58:28.000-04:00', - web_url: - 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/21fb056cc47dcf706670e6de635b1b326490ebdc', - }, - merged: false, - protected: false, - developers_can_push: false, - developers_can_merge: false, - can_push: true, - default: false, - web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-1', - }, - { - name: 'branch-10', - commit: { - id: '66673b07efef254dab7d537f0433a40e61cf84fe', - short_id: '66673b07', - created_at: '2020-03-16T11:04:46.000-04:00', - parent_ids: null, - title: 'Update .gitlab-ci.yml', - message: 'Update .gitlab-ci.yml', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2020-03-16T11:04:46.000-04:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2020-03-16T11:04:46.000-04:00', - web_url: - 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', - }, - merged: false, - protected: false, - developers_can_push: false, - developers_can_merge: false, - can_push: true, - default: false, - web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-10', - }, - { - name: 'branch-11', - commit: { - id: '66673b07efef254dab7d537f0433a40e61cf84fe', - short_id: '66673b07', - created_at: '2020-03-16T11:04:46.000-04:00', - parent_ids: null, - title: 'Update .gitlab-ci.yml', - message: 'Update .gitlab-ci.yml', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2020-03-16T11:04:46.000-04:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2020-03-16T11:04:46.000-04:00', - web_url: - 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', - }, - merged: false, - protected: false, - developers_can_push: false, - developers_can_merge: false, - can_push: true, - default: false, - web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-11', - }, -]; - -export const tags = [ - { - name: 'tag-3', - message: '', - target: '66673b07efef254dab7d537f0433a40e61cf84fe', - commit: { - id: '66673b07efef254dab7d537f0433a40e61cf84fe', - short_id: '66673b07', - created_at: '2020-03-16T11:04:46.000-04:00', - parent_ids: ['def28bf679235071140180495f25b657e2203587'], - title: 'Update .gitlab-ci.yml', - message: 'Update .gitlab-ci.yml', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2020-03-16T11:04:46.000-04:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2020-03-16T11:04:46.000-04:00', - web_url: - 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', - }, - release: null, - protected: false, - }, - { - name: 'tag-2', - message: '', - target: '66673b07efef254dab7d537f0433a40e61cf84fe', - commit: { - id: '66673b07efef254dab7d537f0433a40e61cf84fe', - short_id: '66673b07', - created_at: '2020-03-16T11:04:46.000-04:00', - parent_ids: ['def28bf679235071140180495f25b657e2203587'], - title: 'Update .gitlab-ci.yml', - message: 'Update .gitlab-ci.yml', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2020-03-16T11:04:46.000-04:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2020-03-16T11:04:46.000-04:00', - web_url: - 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', - }, - release: null, - protected: false, - }, - { - name: 'tag-1', - message: '', - target: '66673b07efef254dab7d537f0433a40e61cf84fe', - commit: { - id: '66673b07efef254dab7d537f0433a40e61cf84fe', - short_id: '66673b07', - created_at: '2020-03-16T11:04:46.000-04:00', - parent_ids: ['def28bf679235071140180495f25b657e2203587'], - title: 'Update .gitlab-ci.yml', - message: 'Update .gitlab-ci.yml', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2020-03-16T11:04:46.000-04:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2020-03-16T11:04:46.000-04:00', - web_url: - 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', - }, - release: null, - protected: false, - }, - { - name: 'main-tag', - message: '', - target: '66673b07efef254dab7d537f0433a40e61cf84fe', - commit: { - id: '66673b07efef254dab7d537f0433a40e61cf84fe', - short_id: '66673b07', - created_at: '2020-03-16T11:04:46.000-04:00', - parent_ids: ['def28bf679235071140180495f25b657e2203587'], - title: 'Update .gitlab-ci.yml', - message: 'Update .gitlab-ci.yml', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2020-03-16T11:04:46.000-04:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2020-03-16T11:04:46.000-04:00', - web_url: - 'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe', - }, - release: null, - protected: false, - }, -]; - -export const mockSearch = [ - { type: 'username', value: { data: 'root', operator: '=' } }, - { type: 'ref', value: { data: 'main', operator: '=' } }, - { type: 'status', value: { data: 'pending', operator: '=' } }, -]; - -export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; - -export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'main-tag']; - -export const mockPipelineJobsQueryResponse = { - data: { - project: { - id: 'gid://gitlab/Project/20', - __typename: 'Project', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/224', - __typename: 'Pipeline', - jobs: { - __typename: 'CiJobConnection', - pageInfo: { - endCursor: 'eyJpZCI6Ijg0NyJ9', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'eyJpZCI6IjYyMCJ9', - __typename: 'PageInfo', - }, - nodes: [ - { - artifacts: { - nodes: [ - { - downloadPath: '/root/ci-project/-/jobs/620/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - allowFailure: false, - status: 'SUCCESS', - scheduledAt: null, - manualJob: false, - triggered: null, - createdByTag: false, - detailedStatus: { - id: 'success-620-620', - detailsPath: '/root/ci-project/-/jobs/620', - group: 'success', - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed (retried)', - action: null, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/620', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '5acce24b', - commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', - stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, - name: 'coverage_job', - duration: 4, - finishedAt: '2021-12-06T14:13:49Z', - coverage: 82.71, - retryable: false, - playable: false, - cancelable: false, - active: false, - stuck: false, - userPermissions: { - readBuild: true, - readJobArtifacts: true, - updateBuild: true, - __typename: 'JobPermissions', - }, - __typename: 'CiJob', - }, - { - artifacts: { - nodes: [ - { - downloadPath: '/root/ci-project/-/jobs/619/artifacts/download?file_type=trace', - fileType: 'TRACE', - __typename: 'CiJobArtifact', - }, - ], - __typename: 'CiJobArtifactConnection', - }, - allowFailure: false, - status: 'SUCCESS', - scheduledAt: null, - manualJob: false, - triggered: null, - createdByTag: false, - detailedStatus: { - id: 'success-619-619', - detailsPath: '/root/ci-project/-/jobs/619', - group: 'success', - icon: 'status_success', - label: 'passed', - text: 'passed', - tooltip: 'passed (retried)', - action: null, - __typename: 'DetailedStatus', - }, - id: 'gid://gitlab/Ci::Build/619', - refName: 'main', - refPath: '/root/ci-project/-/commits/main', - tags: [], - shortSha: '5acce24b', - commitPath: '/root/ci-project/-/commit/5acce24b3737d4f0d649ad0a26ae1903a2b35f5e', - stage: { id: 'gid://gitlab/Ci::Stage/148', name: 'test', __typename: 'CiStage' }, - name: 'test_job_two', - duration: 4, - finishedAt: '2021-12-06T14:13:44Z', - coverage: null, - retryable: false, - playable: false, - cancelable: false, - active: false, - stuck: false, - userPermissions: { - readBuild: true, - readJobArtifacts: true, - updateBuild: true, - __typename: 'JobPermissions', - }, - __typename: 'CiJob', - }, - ], - }, - }, - }, - }, -}; - -export const mockPipeline = (projectPath) => { - return { - pipeline: { - id: 1, - user: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: '', - web_url: 'http://0.0.0.0:3000/root', - show_status: false, - path: '/root', - }, - active: false, - source: 'merge_request_event', - created_at: '2021-10-19T21:17:38.698Z', - updated_at: '2021-10-21T18:00:42.758Z', - path: 'foo', - flags: {}, - merge_request: { - iid: 1, - path: `/${projectPath}/1`, - title: 'commit', - source_branch: 'test-commit-name', - source_branch_path: `/${projectPath}`, - target_branch: 'main', - target_branch_path: `/${projectPath}/-/commit/main`, - }, - ref: { - name: 'refs/merge-requests/1/head', - path: `/${projectPath}/-/commits/refs/merge-requests/1/head`, - tag: false, - branch: false, - merge_request: true, - }, - commit: { - id: 'fd6df5b3229e213c97d308844a6f3e7fd71e8f8c', - short_id: 'fd6df5b3', - created_at: '2021-10-19T21:17:12.000+00:00', - parent_ids: ['7147906b84306e83cb3fec6582a25390b75713c6'], - title: 'Commit', - message: 'Commit', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2021-10-19T21:17:12.000+00:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2021-10-19T21:17:12.000+00:00', - trailers: {}, - web_url: '', - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: '', - web_url: '', - show_status: false, - path: '/root', - }, - author_gravatar_url: '', - commit_url: `/${projectPath}/fd6df5b3229e213c97d308844a6f3e7fd71e8f8c`, - commit_path: `/${projectPath}/commit/fd6df5b3229e213c97d308844a6f3e7fd71e8f8c`, - }, - project: { - full_path: `/${projectPath}`, - }, - triggered_by: null, - triggered: [], - }, - pipelineScheduleUrl: 'foo', - pipelineKey: 'id', - viewType: 'root', - }; -}; - -export const mockPipelineTag = () => { - return { - pipeline: { - id: 311, - iid: 37, - user: { - id: 1, - username: 'root', - name: 'Administrator', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://gdk.test:3000/root', - show_status: false, - path: '/root', - }, - active: false, - source: 'push', - name: 'Build pipeline', - created_at: '2022-02-02T15:39:04.012Z', - updated_at: '2022-02-02T15:40:59.573Z', - path: '/root/mr-widgets/-/pipelines/311', - flags: { - stuck: false, - auto_devops: false, - merge_request: false, - yaml_errors: false, - retryable: true, - cancelable: false, - failure_reason: false, - detached_merge_request_pipeline: false, - merge_request_pipeline: false, - merge_train_pipeline: false, - latest: true, - }, - details: { - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/311', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - stages: [ - { - name: 'accessibility', - title: 'accessibility: passed', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/311#accessibility', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/root/mr-widgets/-/pipelines/311#accessibility', - dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=accessibility', - }, - { - name: 'validate', - title: 'validate: passed with warnings', - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/311#validate', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/root/mr-widgets/-/pipelines/311#validate', - dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=validate', - }, - { - name: 'test', - title: 'test: passed', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/311#test', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/root/mr-widgets/-/pipelines/311#test', - dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=test', - }, - { - name: 'build', - title: 'build: passed', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/311#build', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/root/mr-widgets/-/pipelines/311#build', - dropdown_path: '/root/mr-widgets/-/pipelines/311/stage.json?stage=build', - }, - ], - duration: 93, - finished_at: '2022-02-02T15:40:59.384Z', - event_type_name: 'Pipeline', - manual_actions: [], - scheduled_actions: [], - }, - ref: { - name: 'test', - path: '/root/mr-widgets/-/commits/test', - tag: true, - branch: false, - merge_request: false, - }, - commit: { - id: '9b92b4f730d1611bd9a086ca221ae206d5da1e59', - short_id: '9b92b4f7', - created_at: '2022-01-13T13:59:03.000+00:00', - parent_ids: ['0ba763634114e207dc72c65c8e9459556b1204fb'], - title: 'Update hello_world.js', - message: 'Update hello_world.js', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2022-01-13T13:59:03.000+00:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2022-01-13T13:59:03.000+00:00', - trailers: {}, - web_url: - 'http://gdk.test:3000/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', - author: { - id: 1, - username: 'root', - name: 'Administrator', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://gdk.test:3000/root', - show_status: false, - path: '/root', - }, - author_gravatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - commit_url: - 'http://gdk.test:3000/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', - commit_path: '/root/mr-widgets/-/commit/9b92b4f730d1611bd9a086ca221ae206d5da1e59', - }, - retry_path: '/root/mr-widgets/-/pipelines/311/retry', - delete_path: '/root/mr-widgets/-/pipelines/311', - failed_builds: [ - { - id: 1696, - name: 'fmt', - started: '2022-02-02T15:39:45.192Z', - complete: true, - archived: false, - build_path: '/root/mr-widgets/-/jobs/1696', - retry_path: '/root/mr-widgets/-/jobs/1696/retry', - playable: false, - scheduled: false, - created_at: '2022-02-02T15:39:04.136Z', - updated_at: '2022-02-02T15:39:57.969Z', - status: { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (script failure) (allowed to fail)', - has_details: true, - details_path: '/root/mr-widgets/-/jobs/1696', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/mr-widgets/-/jobs/1696/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - project: { - id: 23, - name: 'mr-widgets', - full_path: '/root/mr-widgets', - full_name: 'Administrator / mr-widgets', - }, - triggered_by: null, - triggered: [], - }, - pipelineScheduleUrl: 'foo', - pipelineKey: 'id', - viewType: 'root', - }; -}; - -export const mockPipelineBranch = () => { - return { - pipeline: { - id: 268, - iid: 34, - user: { - id: 1, - username: 'root', - name: 'Administrator', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://gdk.test:3000/root', - show_status: false, - path: '/root', - }, - active: false, - source: 'push', - name: 'Build pipeline', - created_at: '2022-01-14T17:40:27.866Z', - updated_at: '2022-01-14T18:02:35.850Z', - path: '/root/mr-widgets/-/pipelines/268', - flags: { - stuck: false, - auto_devops: false, - merge_request: false, - yaml_errors: false, - retryable: true, - cancelable: false, - failure_reason: false, - detached_merge_request_pipeline: false, - merge_request_pipeline: false, - merge_train_pipeline: false, - latest: true, - }, - details: { - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/268', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - stages: [ - { - name: 'validate', - title: 'validate: passed with warnings', - status: { - icon: 'status_warning', - text: 'passed', - label: 'passed with warnings', - group: 'success-with-warnings', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/268#validate', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/root/mr-widgets/-/pipelines/268#validate', - dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=validate', - }, - { - name: 'test', - title: 'test: passed', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/268#test', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/root/mr-widgets/-/pipelines/268#test', - dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=test', - }, - { - name: 'build', - title: 'build: passed', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - tooltip: 'passed', - has_details: true, - details_path: '/root/mr-widgets/-/pipelines/268#build', - illustration: null, - favicon: - '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', - }, - path: '/root/mr-widgets/-/pipelines/268#build', - dropdown_path: '/root/mr-widgets/-/pipelines/268/stage.json?stage=build', - }, - ], - duration: 75, - finished_at: '2022-01-14T18:02:35.842Z', - event_type_name: 'Pipeline', - manual_actions: [], - scheduled_actions: [], - }, - ref: { - name: 'update-ci', - path: '/root/mr-widgets/-/commits/update-ci', - tag: false, - branch: true, - merge_request: false, - }, - commit: { - id: '96aef9ecec5752c09371c1ade5fc77860aafc863', - short_id: '96aef9ec', - created_at: '2022-01-14T17:40:26.000+00:00', - parent_ids: ['06860257572d4cf84b73806250b78169050aed83'], - title: 'Update main.tf', - message: 'Update main.tf', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2022-01-14T17:40:26.000+00:00', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2022-01-14T17:40:26.000+00:00', - trailers: {}, - web_url: - 'http://gdk.test:3000/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', - author: { - id: 1, - username: 'root', - name: 'Administrator', - state: 'active', - avatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://gdk.test:3000/root', - show_status: false, - path: '/root', - }, - author_gravatar_url: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - commit_url: - 'http://gdk.test:3000/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', - commit_path: '/root/mr-widgets/-/commit/96aef9ecec5752c09371c1ade5fc77860aafc863', - }, - retry_path: '/root/mr-widgets/-/pipelines/268/retry', - delete_path: '/root/mr-widgets/-/pipelines/268', - failed_builds: [ - { - id: 1260, - name: 'fmt', - started: '2022-01-14T17:40:36.435Z', - complete: true, - archived: false, - build_path: '/root/mr-widgets/-/jobs/1260', - retry_path: '/root/mr-widgets/-/jobs/1260/retry', - playable: false, - scheduled: false, - created_at: '2022-01-14T17:40:27.879Z', - updated_at: '2022-01-14T17:40:42.129Z', - status: { - icon: 'status_warning', - text: 'failed', - label: 'failed (allowed to fail)', - group: 'failed-with-warnings', - tooltip: 'failed - (script failure) (allowed to fail)', - has_details: true, - details_path: '/root/mr-widgets/-/jobs/1260', - illustration: { - image: - '/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg', - size: 'svg-430', - title: 'This job does not have a trace.', - }, - favicon: - '/assets/ci_favicons/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png', - action: { - icon: 'retry', - title: 'Retry', - path: '/root/mr-widgets/-/jobs/1260/retry', - method: 'post', - button_title: 'Retry this job', - }, - }, - recoverable: false, - }, - ], - project: { - id: 23, - name: 'mr-widgets', - full_path: '/root/mr-widgets', - full_name: 'Administrator / mr-widgets', - }, - triggered_by: null, - triggered: [], - }, - pipelineScheduleUrl: 'foo', - pipelineKey: 'id', - viewType: 'root', - }; -}; - -export const mockFailedJobsQueryResponse = { - data: { - project: { - __typename: 'Project', - id: 'gid://gitlab/Project/20', - pipeline: { - __typename: 'Pipeline', - id: 'gid://gitlab/Ci::Pipeline/300', - jobs: { - __typename: 'CiJobConnection', - nodes: [ - { - __typename: 'CiJob', - status: 'FAILED', - detailedStatus: { - __typename: 'DetailedStatus', - id: 'failed-1848-1848', - detailsPath: '/root/ci-project/-/jobs/1848', - group: 'failed', - icon: 'status_failed', - label: 'failed', - text: 'failed', - tooltip: 'failed - (script failure)', - action: { - __typename: 'StatusAction', - id: 'Ci::Build-failed-1848', - buttonTitle: 'Retry this job', - icon: 'retry', - method: 'post', - path: '/root/ci-project/-/jobs/1848/retry', - title: 'Retry', - }, - }, - id: 'gid://gitlab/Ci::Build/1848', - stage: { - __typename: 'CiStage', - id: 'gid://gitlab/Ci::Stage/358', - name: 'build', - }, - name: 'wait_job', - retryable: true, - userPermissions: { - __typename: 'JobPermissions', - readBuild: true, - updateBuild: true, - }, - trace: { - htmlSummary: 'Html Summary', - }, - failureMessage: 'Failed', - }, - { - __typename: 'CiJob', - status: 'FAILED', - detailedStatus: { - __typename: 'DetailedStatus', - id: 'failed-1710-1710', - detailsPath: '/root/ci-project/-/jobs/1710', - group: 'failed', - icon: 'status_failed', - label: 'failed', - text: 'failed', - tooltip: 'failed - (script failure) (retried)', - action: null, - }, - id: 'gid://gitlab/Ci::Build/1710', - stage: { - __typename: 'CiStage', - id: 'gid://gitlab/Ci::Stage/358', - name: 'build', - }, - name: 'wait_job', - retryable: false, - userPermissions: { - __typename: 'JobPermissions', - readBuild: true, - updateBuild: true, - }, - trace: null, - failureMessage: 'Failed', - }, - ], - }, - }, - }, - }, -}; - -export const mockFailedJobsData = [ - { - __typename: 'CiJob', - status: 'FAILED', - detailedStatus: { - __typename: 'DetailedStatus', - id: 'failed-1848-1848', - detailsPath: '/root/ci-project/-/jobs/1848', - group: 'failed', - icon: 'status_failed', - label: 'failed', - text: 'failed', - tooltip: 'failed - (script failure)', - action: { - __typename: 'StatusAction', - id: 'Ci::Build-failed-1848', - buttonTitle: 'Retry this job', - icon: 'retry', - method: 'post', - path: '/root/ci-project/-/jobs/1848/retry', - title: 'Retry', - }, - }, - id: 'gid://gitlab/Ci::Build/1848', - stage: { - __typename: 'CiStage', - id: 'gid://gitlab/Ci::Stage/358', - name: 'build', - }, - name: 'wait_job', - retryable: true, - userPermissions: { - __typename: 'JobPermissions', - readBuild: true, - updateBuild: true, - }, - trace: { - htmlSummary: 'Html Summary', - }, - failureMessage: 'Job failed', - _showDetails: true, - }, - { - __typename: 'CiJob', - status: 'FAILED', - detailedStatus: { - __typename: 'DetailedStatus', - id: 'failed-1710-1710', - detailsPath: '/root/ci-project/-/jobs/1710', - group: 'failed', - icon: 'status_failed', - label: 'failed', - text: 'failed', - tooltip: 'failed - (script failure) (retried)', - action: null, - }, - id: 'gid://gitlab/Ci::Build/1710', - stage: { - __typename: 'CiStage', - id: 'gid://gitlab/Ci::Stage/358', - name: 'build', - }, - name: 'wait_job', - retryable: false, - userPermissions: { - __typename: 'JobPermissions', - readBuild: true, - updateBuild: true, - }, - trace: null, - failureMessage: 'Job failed', - _showDetails: true, - }, -]; - -export const mockFailedJobsDataNoPermission = [ - { - ...mockFailedJobsData[0], - userPermissions: { __typename: 'JobPermissions', readBuild: false, updateBuild: false }, - }, -]; - -export const successRetryMutationResponse = { - data: { - jobRetry: { - job: { - __typename: 'CiJob', - id: '"gid://gitlab/Ci::Build/1985"', - detailedStatus: { - detailsPath: '/root/project/-/jobs/1985', - id: 'pending-1985-1985', - __typename: 'DetailedStatus', - }, - }, - errors: [], - __typename: 'JobRetryPayload', - }, - }, -}; - -export const failedRetryMutationResponse = { - data: { - jobRetry: { - job: {}, - errors: ['New Error'], - __typename: 'JobRetryPayload', - }, - }, -}; diff --git a/spec/frontend/pipelines/nav_controls_spec.js b/spec/frontend/pipelines/nav_controls_spec.js deleted file mode 100644 index 15de7dc51f1..00000000000 --- a/spec/frontend/pipelines/nav_controls_spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import NavControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; - -describe('Pipelines Nav Controls', () => { - let wrapper; - - const createComponent = (props) => { - wrapper = shallowMountExtended(NavControls, { - propsData: { - ...props, - }, - }); - }; - - const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); - const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); - const findClearCacheButton = () => wrapper.findByTestId('clear-cache-button'); - - it('should render link to create a new pipeline', () => { - const mockData = { - newPipelinePath: 'foo', - ciLintPath: 'foo', - resetCachePath: 'foo', - }; - - createComponent(mockData); - - const runPipelineButton = findRunPipelineButton(); - expect(runPipelineButton.text()).toContain('Run pipeline'); - expect(runPipelineButton.attributes('href')).toBe(mockData.newPipelinePath); - }); - - it('should not render link to create pipeline if no path is provided', () => { - const mockData = { - helpPagePath: 'foo', - ciLintPath: 'foo', - resetCachePath: 'foo', - }; - - createComponent(mockData); - - expect(findRunPipelineButton().exists()).toBe(false); - }); - - it('should render link for CI lint', () => { - const mockData = { - newPipelinePath: 'foo', - helpPagePath: 'foo', - ciLintPath: 'foo', - resetCachePath: 'foo', - }; - - createComponent(mockData); - const ciLintButton = findCiLintButton(); - - expect(ciLintButton.text()).toContain('CI lint'); - expect(ciLintButton.attributes('href')).toBe(mockData.ciLintPath); - }); - - describe('Reset Runners Cache', () => { - beforeEach(() => { - const mockData = { - newPipelinePath: 'foo', - ciLintPath: 'foo', - resetCachePath: 'foo', - }; - createComponent(mockData); - }); - - it('should render button for resetting runner caches', () => { - expect(findClearCacheButton().text()).toContain('Clear runner caches'); - }); - - it('should emit postAction event when reset runner cache button is clicked', () => { - findClearCacheButton().vm.$emit('click'); - - expect(wrapper.emitted('resetRunnersCache')).toEqual([['foo']]); - }); - }); -}); diff --git a/spec/frontend/pipelines/notification/mock_data.js b/spec/frontend/pipelines/notification/mock_data.js deleted file mode 100644 index e36f391a854..00000000000 --- a/spec/frontend/pipelines/notification/mock_data.js +++ /dev/null @@ -1,33 +0,0 @@ -const randomWarning = { - content: 'another random warning', - id: 'gid://gitlab/Ci::PipelineMessage/272', -}; - -const rootTypeWarning = { - content: 'root `types` will be removed in 15.0.', - id: 'gid://gitlab/Ci::PipelineMessage/273', -}; - -const typeWarning = { - content: '`type` will be removed in 15.0.', - id: 'gid://gitlab/Ci::PipelineMessage/274', -}; - -function createWarningMock(warnings) { - return { - data: { - project: { - id: 'gid://gitlab/Project/28"', - pipeline: { - id: 'gid://gitlab/Ci::Pipeline/183', - warningMessages: warnings, - }, - }, - }, - }; -} - -export const mockWarningsWithoutDeprecation = createWarningMock([randomWarning]); -export const mockWarningsRootType = createWarningMock([rootTypeWarning]); -export const mockWarningsType = createWarningMock([typeWarning]); -export const mockWarningsTypesAll = createWarningMock([rootTypeWarning, typeWarning]); diff --git a/spec/frontend/pipelines/pipeline_details_header_spec.js b/spec/frontend/pipelines/pipeline_details_header_spec.js deleted file mode 100644 index 5c75020afad..00000000000 --- a/spec/frontend/pipelines/pipeline_details_header_spec.js +++ /dev/null @@ -1,452 +0,0 @@ -import { GlAlert, GlBadge, GlLoadingIcon, GlModal, GlSprintf } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import PipelineDetailsHeader from '~/pipelines/components/pipeline_details_header.vue'; -import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/pipelines/constants'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; -import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; -import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql'; -import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; -import getPipelineDetailsQuery from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql'; -import { - pipelineHeaderSuccess, - pipelineHeaderRunning, - pipelineHeaderRunningWithDuration, - pipelineHeaderFailed, - pipelineRetryMutationResponseSuccess, - pipelineCancelMutationResponseSuccess, - pipelineDeleteMutationResponseSuccess, - pipelineRetryMutationResponseFailed, - pipelineCancelMutationResponseFailed, - pipelineDeleteMutationResponseFailed, -} from './mock_data'; - -Vue.use(VueApollo); - -describe('Pipeline details header', () => { - let wrapper; - let glModalDirective; - - const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess); - const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning); - const runningHandlerWithDuration = jest.fn().mockResolvedValue(pipelineHeaderRunningWithDuration); - const failedHandler = jest.fn().mockResolvedValue(pipelineHeaderFailed); - - const retryMutationHandlerSuccess = jest - .fn() - .mockResolvedValue(pipelineRetryMutationResponseSuccess); - const cancelMutationHandlerSuccess = jest - .fn() - .mockResolvedValue(pipelineCancelMutationResponseSuccess); - const deleteMutationHandlerSuccess = jest - .fn() - .mockResolvedValue(pipelineDeleteMutationResponseSuccess); - const retryMutationHandlerFailed = jest - .fn() - .mockResolvedValue(pipelineRetryMutationResponseFailed); - const cancelMutationHandlerFailed = jest - .fn() - .mockResolvedValue(pipelineCancelMutationResponseFailed); - const deleteMutationHandlerFailed = jest - .fn() - .mockResolvedValue(pipelineDeleteMutationResponseFailed); - - const findAlert = () => wrapper.findComponent(GlAlert); - const findStatus = () => wrapper.findComponent(CiBadgeLink); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findAllBadges = () => wrapper.findAllComponents(GlBadge); - const findDeleteModal = () => wrapper.findComponent(GlModal); - const findCreatedTimeAgo = () => wrapper.findByTestId('pipeline-created-time-ago'); - const findFinishedTimeAgo = () => wrapper.findByTestId('pipeline-finished-time-ago'); - const findPipelineName = () => wrapper.findByTestId('pipeline-name'); - const findCommitTitle = () => wrapper.findByTestId('pipeline-commit-title'); - const findTotalJobs = () => wrapper.findByTestId('total-jobs'); - const findComputeMinutes = () => wrapper.findByTestId('compute-minutes'); - const findCommitLink = () => wrapper.findByTestId('commit-link'); - const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text(); - const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text(); - const findRetryButton = () => wrapper.findByTestId('retry-pipeline'); - const findCancelButton = () => wrapper.findByTestId('cancel-pipeline'); - const findDeleteButton = () => wrapper.findByTestId('delete-pipeline'); - const findPipelineUserLink = () => wrapper.findByTestId('pipeline-user-link'); - const findPipelineDuration = () => wrapper.findByTestId('pipeline-duration-text'); - - const defaultHandlers = [[getPipelineDetailsQuery, successHandler]]; - - const defaultProvideOptions = { - pipelineIid: 1, - paths: { - pipelinesPath: '/namespace/my-project/-/pipelines', - fullProject: '/namespace/my-project', - triggeredByPath: '', - }, - }; - - const defaultProps = { - name: 'Ruby 3.0 master branch pipeline', - totalJobs: '50', - computeMinutes: '0.65', - yamlErrors: 'errors', - failureReason: 'pipeline failed', - badges: { - schedule: true, - child: false, - latest: true, - mergeTrainPipeline: false, - invalid: false, - failed: false, - autoDevops: false, - detached: false, - stuck: false, - }, - refText: - 'Related merge request !1 to merge test', - }; - - const createMockApolloProvider = (handlers) => { - return createMockApollo(handlers); - }; - - const createComponent = (handlers = defaultHandlers, props = defaultProps) => { - glModalDirective = jest.fn(); - - wrapper = shallowMountExtended(PipelineDetailsHeader, { - provide: { - ...defaultProvideOptions, - }, - propsData: { - ...props, - }, - directives: { - glModal: { - bind(_, { value }) { - glModalDirective(value); - }, - }, - }, - stubs: { GlSprintf }, - apolloProvider: createMockApolloProvider(handlers), - }); - }; - - describe('loading state', () => { - it('shows a loading state while graphQL is fetching initial data', () => { - createComponent(); - - expect(findLoadingIcon().exists()).toBe(true); - }); - }); - - describe('defaults', () => { - beforeEach(async () => { - createComponent(); - - await waitForPromises(); - }); - - it('does not display loading icon', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('displays pipeline status', () => { - expect(findStatus().exists()).toBe(true); - }); - - it('displays pipeline name', () => { - expect(findPipelineName().text()).toBe(defaultProps.name); - }); - - it('displays total jobs', () => { - expect(findTotalJobs().text()).toBe('50 Jobs'); - }); - - it('has link to commit', () => { - const { - data: { - project: { pipeline }, - }, - } = pipelineHeaderSuccess; - - expect(findCommitLink().attributes('href')).toBe(pipeline.commit.webPath); - }); - - it('displays correct badges', () => { - expect(findAllBadges()).toHaveLength(2); - expect(wrapper.findByText('latest').exists()).toBe(true); - expect(wrapper.findByText('Scheduled').exists()).toBe(true); - }); - - it('displays ref text', () => { - expect(findPipelineRefText()).toBe('Related merge request !1 to merge test'); - }); - - it('displays pipeline user link with required user popover attributes', () => { - const { - data: { - project: { - pipeline: { user }, - }, - }, - } = pipelineHeaderSuccess; - - const userId = getIdFromGraphQLId(user.id).toString(); - - expect(findPipelineUserLink().classes()).toContain('js-user-link'); - expect(findPipelineUserLink().attributes('data-user-id')).toBe(userId); - expect(findPipelineUserLink().attributes('data-username')).toBe(user.username); - expect(findPipelineUserLink().attributes('href')).toBe(user.webUrl); - }); - }); - - describe('without pipeline name', () => { - it('displays commit title', async () => { - createComponent(defaultHandlers, { ...defaultProps, name: '' }); - - await waitForPromises(); - - const expectedTitle = pipelineHeaderSuccess.data.project.pipeline.commit.title; - - expect(findPipelineName().exists()).toBe(false); - expect(findCommitTitle().text()).toBe(expectedTitle); - }); - }); - - describe('finished pipeline', () => { - it('displays compute minutes when not zero', async () => { - createComponent(); - - await waitForPromises(); - - expect(findComputeMinutes().text()).toBe('0.65'); - }); - - it('does not display compute minutes when zero', async () => { - createComponent(defaultHandlers, { ...defaultProps, computeMinutes: '0.0' }); - - await waitForPromises(); - - expect(findComputeMinutes().exists()).toBe(false); - }); - - it('does not display created time ago', async () => { - createComponent(); - - await waitForPromises(); - - expect(findCreatedTimeAgo().exists()).toBe(false); - }); - - it('displays finished time ago', async () => { - createComponent(); - - await waitForPromises(); - - expect(findFinishedTimeAgo().exists()).toBe(true); - }); - - it('displays pipeline duartion text', async () => { - createComponent(); - - await waitForPromises(); - - expect(findPipelineDuration().text()).toBe( - '120 minutes 10 seconds, queued for 3,600 seconds', - ); - }); - }); - - describe('running pipeline', () => { - beforeEach(async () => { - createComponent([[getPipelineDetailsQuery, runningHandler]]); - - await waitForPromises(); - }); - - it('does not display compute minutes', () => { - expect(findComputeMinutes().exists()).toBe(false); - }); - - it('does not display finished time ago', () => { - expect(findFinishedTimeAgo().exists()).toBe(false); - }); - - it('does not display pipeline duration text', () => { - expect(findPipelineDuration().exists()).toBe(false); - }); - - it('displays pipeline running text', () => { - expect(findPipelineRunningText()).toBe('In progress, queued for 3,600 seconds'); - }); - - it('displays created time ago', () => { - expect(findCreatedTimeAgo().exists()).toBe(true); - }); - }); - - describe('running pipeline with duration', () => { - beforeEach(async () => { - createComponent([[getPipelineDetailsQuery, runningHandlerWithDuration]]); - - await waitForPromises(); - }); - - it('does not display pipeline duration text', () => { - expect(findPipelineDuration().exists()).toBe(false); - }); - }); - - describe('actions', () => { - describe('retry action', () => { - beforeEach(async () => { - createComponent([ - [getPipelineDetailsQuery, failedHandler], - [retryPipelineMutation, retryMutationHandlerSuccess], - ]); - - await waitForPromises(); - }); - - it('should call retryPipeline Mutation with pipeline id', () => { - findRetryButton().vm.$emit('click'); - - expect(retryMutationHandlerSuccess).toHaveBeenCalledWith({ - id: pipelineHeaderFailed.data.project.pipeline.id, - }); - expect(findAlert().exists()).toBe(false); - }); - - it('should render retry action tooltip', () => { - expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); - }); - }); - - describe('retry action failed', () => { - beforeEach(async () => { - createComponent([ - [getPipelineDetailsQuery, failedHandler], - [retryPipelineMutation, retryMutationHandlerFailed], - ]); - - await waitForPromises(); - }); - - it('should display error message on failure', async () => { - findRetryButton().vm.$emit('click'); - - await waitForPromises(); - - expect(findAlert().exists()).toBe(true); - }); - - it('retry button loading state should reset on error', async () => { - findRetryButton().vm.$emit('click'); - - await nextTick(); - - expect(findRetryButton().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findRetryButton().props('loading')).toBe(false); - }); - }); - - describe('cancel action', () => { - it('should call cancelPipeline Mutation with pipeline id', async () => { - createComponent([ - [getPipelineDetailsQuery, runningHandler], - [cancelPipelineMutation, cancelMutationHandlerSuccess], - ]); - - await waitForPromises(); - - findCancelButton().vm.$emit('click'); - - expect(cancelMutationHandlerSuccess).toHaveBeenCalledWith({ - id: pipelineHeaderRunning.data.project.pipeline.id, - }); - expect(findAlert().exists()).toBe(false); - }); - - it('should render cancel action tooltip', async () => { - createComponent([ - [getPipelineDetailsQuery, runningHandler], - [cancelPipelineMutation, cancelMutationHandlerSuccess], - ]); - - await waitForPromises(); - - expect(findCancelButton().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); - }); - - it('should display error message on failure', async () => { - createComponent([ - [getPipelineDetailsQuery, runningHandler], - [cancelPipelineMutation, cancelMutationHandlerFailed], - ]); - - await waitForPromises(); - - findCancelButton().vm.$emit('click'); - - await waitForPromises(); - - expect(findAlert().exists()).toBe(true); - }); - }); - - describe('delete action', () => { - it('displays delete modal when clicking on delete and does not call the delete action', async () => { - createComponent([ - [getPipelineDetailsQuery, successHandler], - [deletePipelineMutation, deleteMutationHandlerSuccess], - ]); - - await waitForPromises(); - - findDeleteButton().vm.$emit('click'); - - const modalId = 'pipeline-delete-modal'; - - expect(findDeleteModal().props('modalId')).toBe(modalId); - expect(glModalDirective).toHaveBeenCalledWith(modalId); - expect(deleteMutationHandlerSuccess).not.toHaveBeenCalled(); - expect(findAlert().exists()).toBe(false); - }); - - it('should call deletePipeline Mutation with pipeline id when modal is submitted', async () => { - createComponent([ - [getPipelineDetailsQuery, successHandler], - [deletePipelineMutation, deleteMutationHandlerSuccess], - ]); - - await waitForPromises(); - - findDeleteModal().vm.$emit('primary'); - - expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ - id: pipelineHeaderSuccess.data.project.pipeline.id, - }); - }); - - it('should display error message on failure', async () => { - createComponent([ - [getPipelineDetailsQuery, successHandler], - [deletePipelineMutation, deleteMutationHandlerFailed], - ]); - - await waitForPromises(); - - findDeleteModal().vm.$emit('primary'); - - await waitForPromises(); - - expect(findAlert().exists()).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js deleted file mode 100644 index db77e0a0573..00000000000 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ /dev/null @@ -1,283 +0,0 @@ -export const yamlString = `stages: -- empty -- build -- test -- deploy -- final - -include: -- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml' - -build_a: - stage: build - script: echo hello -build_b: - stage: build - script: echo hello -build_c: - stage: build - script: echo hello -build_d: - stage: Queen - script: echo hello - -test_a: - stage: test - script: ls - needs: [build_a, build_b, build_c] -test_b: - stage: test - script: ls - needs: [build_a, build_b, build_d] -test_c: - stage: test - script: ls - needs: [build_a, build_b, build_c] - -deploy_a: - stage: deploy - script: echo hello -`; - -export const pipelineDataWithNoNeeds = { - stages: [ - { - name: 'build', - groups: [ - { - name: 'build_1', - jobs: [{ script: 'echo hello', stage: 'build' }], - }, - ], - }, - { - name: 'test', - groups: [ - { - name: 'test_1', - jobs: [{ script: 'yarn test', stage: 'test' }], - }, - ], - }, - ], -}; - -export const pipelineData = { - stages: [ - { - name: 'build', - groups: [ - { - name: 'build_1', - jobs: [{ script: 'echo hello', stage: 'build' }], - }, - ], - }, - { - name: 'test', - groups: [ - { - name: 'test_1', - jobs: [{ script: 'yarn test', stage: 'test' }], - }, - { - name: 'test_2', - jobs: [{ script: 'yarn karma', stage: 'test' }], - }, - ], - }, - { - name: 'deploy', - groups: [ - { - name: 'deploy_1', - jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_1'] }], - }, - ], - }, - ], -}; - -export const invalidNeedsData = { - stages: [ - { - name: 'build', - groups: [ - { - name: 'build_1', - jobs: [{ script: 'echo hello', stage: 'build' }], - }, - ], - }, - { - name: 'test', - groups: [ - { - name: 'test_1', - jobs: [{ script: 'yarn test', stage: 'test' }], - }, - { - name: 'test_2', - jobs: [{ script: 'yarn karma', stage: 'test' }], - }, - ], - }, - { - name: 'deploy', - groups: [ - { - name: 'deploy_1', - jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['invalid_job'] }], - }, - ], - }, - ], -}; - -export const parallelNeedData = { - stages: [ - { - name: 'build', - groups: [ - { - name: 'build_1', - parallel: 3, - jobs: [ - { script: 'echo hello', stage: 'build', name: 'build_1 1/3' }, - { script: 'echo hello', stage: 'build', name: 'build_1 2/3' }, - { script: 'echo hello', stage: 'build', name: 'build_1 3/3' }, - ], - }, - ], - }, - { - name: 'test', - groups: [ - { - name: 'test_1', - jobs: [{ script: 'yarn test', stage: 'test', needs: ['build_1'] }], - }, - ], - }, - ], -}; - -export const sameStageNeeds = { - stages: [ - { - name: 'build', - groups: [ - { - name: 'build_1', - jobs: [{ script: 'echo hello', stage: 'build', name: 'build_1' }], - }, - ], - }, - { - name: 'build', - groups: [ - { - name: 'build_2', - jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_1'] }], - }, - ], - }, - { - name: 'build', - groups: [ - { - name: 'build_3', - jobs: [{ script: 'yarn test', stage: 'build', needs: ['build_2'] }], - }, - ], - }, - ], -}; - -export const largePipelineData = { - stages: [ - { - name: 'build', - groups: [ - { - name: 'build_1', - jobs: [{ script: 'echo hello', stage: 'build' }], - }, - { - name: 'build_2', - jobs: [{ script: 'echo hello', stage: 'build' }], - }, - { - name: 'build_3', - jobs: [{ script: 'echo hello', stage: 'build' }], - }, - ], - }, - { - name: 'test', - groups: [ - { - name: 'test_1', - jobs: [{ script: 'yarn test', stage: 'test', needs: ['build_2'] }], - }, - { - name: 'test_2', - jobs: [{ script: 'yarn karma', stage: 'test', needs: ['build_2'] }], - }, - ], - }, - { - name: 'deploy', - groups: [ - { - name: 'deploy_1', - jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_1'] }], - }, - { - name: 'deploy_2', - jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['build_3'] }], - }, - { - name: 'deploy_3', - jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_2'] }], - }, - ], - }, - ], -}; - -export const singleStageData = { - stages: [ - { - name: 'build', - groups: [ - { - name: 'build_1', - jobs: [{ script: 'echo hello', stage: 'build' }], - }, - ], - }, - ], -}; - -export const rootRect = { - bottom: 463, - height: 271, - left: 236, - right: 1252, - top: 192, - width: 1016, - x: 236, - y: 192, -}; - -export const jobRect = { - bottom: 312, - height: 24, - left: 308, - right: 428, - top: 288, - width: 120, - x: 308, - y: 288, -}; diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js deleted file mode 100644 index 123f2e011c3..00000000000 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { GlAlert } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { setHTMLFixture } from 'helpers/fixtures'; -import { CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants'; -import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; -import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; -import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; -import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import StageName from '~/pipelines/components/pipeline_graph/stage_name.vue'; -import { pipelineData, singleStageData } from './mock_data'; - -describe('pipeline graph component', () => { - const defaultProps = { pipelineData }; - let wrapper; - - const containerId = 'pipeline-graph-container-0'; - setHTMLFixture(`
    `); - - const createComponent = (props = defaultProps) => { - return shallowMount(PipelineGraph, { - propsData: { - ...props, - }, - stubs: { LinksLayer, LinksInner }, - data() { - return { - measurements: { - width: 1000, - height: 1000, - }, - }; - }, - }); - }; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findAllJobPills = () => wrapper.findAllComponents(JobPill); - const findAllStageNames = () => wrapper.findAllComponents(StageName); - const findLinksLayer = () => wrapper.findComponent(LinksLayer); - const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); - - describe('with `VALID` status', () => { - beforeEach(() => { - wrapper = createComponent({ - pipelineData: { - status: CI_CONFIG_STATUS_VALID, - stages: [{ name: 'hello', groups: [] }], - }, - }); - }); - - it('renders the graph with no status error', () => { - expect(findAlert().exists()).toBe(false); - expect(findPipelineGraph().exists()).toBe(true); - expect(findLinksLayer().exists()).toBe(true); - }); - }); - - describe('with only one stage', () => { - beforeEach(() => { - wrapper = createComponent({ pipelineData: singleStageData }); - }); - - it('renders the right number of stage titles', () => { - const expectedStagesLength = singleStageData.stages.length; - - expect(findAllStageNames()).toHaveLength(expectedStagesLength); - }); - - it('renders the right number of job pills', () => { - // We count the number of jobs in the mock data - const expectedJobsLength = singleStageData.stages.reduce((acc, val) => { - return acc + val.groups.length; - }, 0); - - expect(findAllJobPills()).toHaveLength(expectedJobsLength); - }); - }); - - describe('with multiple stages and jobs', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - it('renders the right number of stage titles', () => { - const expectedStagesLength = pipelineData.stages.length; - - expect(findAllStageNames()).toHaveLength(expectedStagesLength); - }); - - it('renders the right number of job pills', () => { - // We count the number of jobs in the mock data - const expectedJobsLength = pipelineData.stages.reduce((acc, val) => { - return acc + val.groups.length; - }, 0); - - expect(findAllJobPills()).toHaveLength(expectedJobsLength); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js deleted file mode 100644 index 96b18fcf96f..00000000000 --- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js +++ /dev/null @@ -1,197 +0,0 @@ -import { createJobsHash, generateJobNeedsDict, getPipelineDefaultTab } from '~/pipelines/utils'; -import { validPipelineTabNames, pipelineTabName } from '~/pipelines/constants'; - -describe('utils functions', () => { - const jobName1 = 'build_1'; - const jobName2 = 'build_2'; - const jobName3 = 'test_1'; - const jobName4 = 'deploy_1'; - const job1 = { name: jobName1, script: 'echo hello', stage: 'build' }; - const job2 = { name: jobName2, script: 'echo build', stage: 'build' }; - const job3 = { - name: jobName3, - script: 'echo test', - stage: 'test', - needs: [jobName1, jobName2], - }; - const job4 = { - name: jobName4, - script: 'echo deploy', - stage: 'deploy', - needs: [jobName3], - }; - const userDefinedStage = 'myStage'; - - const pipelineGraphData = { - stages: [ - { - name: userDefinedStage, - groups: [], - }, - { - name: job4.stage, - groups: [ - { - name: jobName4, - jobs: [{ ...job4 }], - }, - ], - }, - { - name: job1.stage, - groups: [ - { - name: jobName1, - jobs: [{ ...job1 }], - }, - { - name: jobName2, - jobs: [{ ...job2 }], - }, - ], - }, - { - name: job3.stage, - groups: [ - { - name: jobName3, - jobs: [{ ...job3 }], - }, - ], - }, - ], - }; - - describe('createJobsHash', () => { - it('returns an empty object if there are no jobs received as argument', () => { - expect(createJobsHash([])).toEqual({}); - }); - - it('returns a hash with the jobname as key and all its data as value', () => { - const jobs = { - [jobName1]: { jobs: [job1], name: jobName1, needs: [] }, - [jobName2]: { jobs: [job2], name: jobName2, needs: [] }, - [jobName3]: { jobs: [job3], name: jobName3, needs: job3.needs }, - [jobName4]: { jobs: [job4], name: jobName4, needs: job4.needs }, - }; - - expect(createJobsHash(pipelineGraphData.stages)).toEqual(jobs); - }); - }); - - describe('generateJobNeedsDict', () => { - it('generates an empty object if it receives no jobs', () => { - expect(generateJobNeedsDict({})).toEqual({}); - }); - - it('generates a dict with empty needs if there are no dependencies', () => { - const smallGraph = { - [jobName1]: job1, - [jobName2]: job2, - }; - - expect(generateJobNeedsDict(smallGraph)).toEqual({ - [jobName1]: [], - [jobName2]: [], - }); - }); - - it('generates a dict where key is the a job and its value is an array of all its needs', () => { - const jobsWithNeeds = { - [jobName1]: job1, - [jobName2]: job2, - [jobName3]: job3, - [jobName4]: job4, - }; - - expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ - [jobName1]: [], - [jobName2]: [], - [jobName3]: [jobName1, jobName2], - [jobName4]: [jobName3, jobName1, jobName2], - }); - }); - - it('removes needs which are not in the data', () => { - const inexistantJobName = 'job5'; - const jobsWithNeeds = { - [jobName1]: job1, - [jobName2]: job2, - [jobName3]: job3, - [jobName4]: { - name: jobName4, - script: 'echo deploy', - stage: 'deploy', - needs: [inexistantJobName], - }, - }; - - expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({ - [jobName1]: [], - [jobName2]: [], - [jobName3]: [jobName1, jobName2], - [jobName4]: [], - }); - }); - - it('handles parallel jobs by adding the group name as a need', () => { - const size = 3; - const jobOptimize1 = 'optimize_1'; - const jobPrepareA = 'prepare_a'; - const jobPrepareA1 = `${jobPrepareA} 1/${size}`; - const jobPrepareA2 = `${jobPrepareA} 2/${size}`; - const jobPrepareA3 = `${jobPrepareA} 3/${size}`; - - const jobsParallel = { - [jobOptimize1]: { - jobs: [job1], - name: [jobOptimize1], - needs: [jobPrepareA1, jobPrepareA2, jobPrepareA3], - }, - [jobPrepareA]: { jobs: [], name: jobPrepareA, needs: [], size }, - [jobPrepareA1]: { jobs: [], name: jobPrepareA, needs: [], size }, - [jobPrepareA2]: { jobs: [], name: jobPrepareA, needs: [], size }, - [jobPrepareA3]: { jobs: [], name: jobPrepareA, needs: [], size }, - }; - - expect(generateJobNeedsDict(jobsParallel)).toEqual({ - [jobOptimize1]: [ - jobPrepareA1, - // This is the important part, the `jobPrepareA` group name has been - // added to our list of needs. - jobPrepareA, - jobPrepareA2, - jobPrepareA3, - ], - [jobPrepareA]: [], - [jobPrepareA1]: [], - [jobPrepareA2]: [], - [jobPrepareA3]: [], - }); - }); - }); - - describe('getPipelineDefaultTab', () => { - const baseUrl = 'http://gitlab.com/user/multi-projects-small/-/pipelines/332/'; - it('returns pipeline tab name if there is only the base url', () => { - expect(getPipelineDefaultTab(baseUrl)).toBe(pipelineTabName); - }); - - it('returns null if there was no valid last url part', () => { - expect(getPipelineDefaultTab(`${baseUrl}something`)).toBe(null); - }); - - it('returns the correct tab name if present', () => { - validPipelineTabNames.forEach((tabName) => { - expect(getPipelineDefaultTab(`${baseUrl}${tabName}`)).toBe(tabName); - }); - }); - - it('returns the right value even with query params', () => { - const [tabName] = validPipelineTabNames; - expect(getPipelineDefaultTab(`${baseUrl}${tabName}?query="something"&query2="else"`)).toBe( - tabName, - ); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_labels_spec.js b/spec/frontend/pipelines/pipeline_labels_spec.js deleted file mode 100644 index 6a37e36352b..00000000000 --- a/spec/frontend/pipelines/pipeline_labels_spec.js +++ /dev/null @@ -1,164 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { trimText } from 'helpers/text_helper'; -import PipelineLabelsComponent from '~/pipelines/components/pipelines_list/pipeline_labels.vue'; -import { mockPipeline } from './mock_data'; - -const projectPath = 'test/test'; - -describe('Pipeline label component', () => { - let wrapper; - - const findScheduledTag = () => wrapper.findByTestId('pipeline-url-scheduled'); - const findLatestTag = () => wrapper.findByTestId('pipeline-url-latest'); - const findYamlTag = () => wrapper.findByTestId('pipeline-url-yaml'); - const findStuckTag = () => wrapper.findByTestId('pipeline-url-stuck'); - const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops'); - const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link'); - const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached'); - const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure'); - const findForkTag = () => wrapper.findByTestId('pipeline-url-fork'); - const findTrainTag = () => wrapper.findByTestId('pipeline-url-train'); - - const defaultProps = mockPipeline(projectPath); - - const createComponent = (props) => { - wrapper = shallowMountExtended(PipelineLabelsComponent, { - propsData: { ...defaultProps, ...props }, - provide: { - targetProjectFullPath: projectPath, - }, - }); - }; - - it('should not render tags when flags are not set', () => { - createComponent(); - - expect(findStuckTag().exists()).toBe(false); - expect(findLatestTag().exists()).toBe(false); - expect(findYamlTag().exists()).toBe(false); - expect(findAutoDevopsTag().exists()).toBe(false); - expect(findFailureTag().exists()).toBe(false); - expect(findScheduledTag().exists()).toBe(false); - expect(findForkTag().exists()).toBe(false); - expect(findTrainTag().exists()).toBe(false); - }); - - it('should render the stuck tag when flag is provided', () => { - const stuckPipeline = defaultProps.pipeline; - stuckPipeline.flags.stuck = true; - - createComponent({ - ...stuckPipeline.pipeline, - }); - - expect(findStuckTag().text()).toContain('stuck'); - }); - - it('should render latest tag when flag is provided', () => { - const latestPipeline = defaultProps.pipeline; - latestPipeline.flags.latest = true; - - createComponent({ - ...latestPipeline, - }); - - expect(findLatestTag().text()).toContain('latest'); - }); - - it('should render a yaml badge when it is invalid', () => { - const yamlPipeline = defaultProps.pipeline; - yamlPipeline.flags.yaml_errors = true; - - createComponent({ - ...yamlPipeline, - }); - - expect(findYamlTag().text()).toContain('yaml invalid'); - }); - - it('should render an autodevops badge when flag is provided', () => { - const autoDevopsPipeline = defaultProps.pipeline; - autoDevopsPipeline.flags.auto_devops = true; - - createComponent({ - ...autoDevopsPipeline, - }); - - expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps'); - - expect(findAutoDevopsTagLink().attributes()).toMatchObject({ - href: '/help/topics/autodevops/index.md', - target: '_blank', - }); - }); - - it('should render a detached badge when flag is provided', () => { - const detachedMRPipeline = defaultProps.pipeline; - detachedMRPipeline.flags.detached_merge_request_pipeline = true; - - createComponent({ - ...detachedMRPipeline, - }); - - expect(findDetachedTag().text()).toBe('merge request'); - }); - - it('should render error badge when pipeline has a failure reason set', () => { - const failedPipeline = defaultProps.pipeline; - failedPipeline.flags.failure_reason = true; - failedPipeline.failure_reason = 'some reason'; - - createComponent({ - ...failedPipeline, - }); - - expect(findFailureTag().text()).toContain('error'); - expect(findFailureTag().attributes('title')).toContain('some reason'); - }); - - it('should render scheduled badge when pipeline was triggered by a schedule', () => { - const scheduledPipeline = defaultProps.pipeline; - scheduledPipeline.source = 'schedule'; - - createComponent({ - ...scheduledPipeline, - }); - - expect(findScheduledTag().exists()).toBe(true); - expect(findScheduledTag().text()).toContain('Scheduled'); - }); - - it('should render the fork badge when the pipeline was run in a fork', () => { - const forkedPipeline = defaultProps.pipeline; - forkedPipeline.project.full_path = '/test/forked'; - - createComponent({ - ...forkedPipeline, - }); - - expect(findForkTag().exists()).toBe(true); - expect(findForkTag().text()).toBe('fork'); - }); - - it('should render the train badge when the pipeline is a merge train pipeline', () => { - const mergeTrainPipeline = defaultProps.pipeline; - mergeTrainPipeline.flags.merge_train_pipeline = true; - - createComponent({ - ...mergeTrainPipeline, - }); - - expect(findTrainTag().text()).toBe('merge train'); - }); - - it('should not render the train badge when the pipeline is not a merge train pipeline', () => { - const mergeTrainPipeline = defaultProps.pipeline; - mergeTrainPipeline.flags.merge_train_pipeline = false; - - createComponent({ - ...mergeTrainPipeline, - }); - - expect(findTrainTag().exists()).toBe(false); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js deleted file mode 100644 index 0fdc45a5931..00000000000 --- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js +++ /dev/null @@ -1,288 +0,0 @@ -import { nextTick } from 'vue'; -import { GlAlert, GlDropdown, GlSprintf, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { stubComponent } from 'helpers/stub_component'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import PipelineMultiActions, { - i18n, -} from '~/pipelines/components/pipelines_list/pipeline_multi_actions.vue'; -import { TRACKING_CATEGORIES } from '~/pipelines/constants'; - -describe('Pipeline Multi Actions Dropdown', () => { - let wrapper; - let mockAxios; - const focusInputMock = jest.fn(); - - const artifacts = [ - { - name: 'job my-artifact', - path: '/download/path', - }, - { - name: 'job-2 my-artifact-2', - path: '/download/path-two', - }, - ]; - const newArtifacts = [ - { - name: 'job-3 my-new-artifact', - path: '/new/download/path', - }, - { - name: 'job-4 my-new-artifact-2', - path: '/new/download/path-two', - }, - { - name: 'job-5 my-new-artifact-3', - path: '/new/download/path-three', - }, - ]; - const artifactItemTestId = 'artifact-item'; - const artifactsEndpointPlaceholder = ':pipeline_artifacts_id'; - const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`; - const pipelineId = 108; - - const createComponent = () => { - wrapper = extendedWrapper( - shallowMount(PipelineMultiActions, { - provide: { - artifactsEndpoint, - artifactsEndpointPlaceholder, - }, - propsData: { - pipelineId, - }, - stubs: { - GlSprintf, - GlDropdown, - GlSearchBoxByType: stubComponent(GlSearchBoxByType, { - methods: { focusInput: focusInputMock }, - }), - }, - }), - ); - }; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findAllArtifactItems = () => wrapper.findAllByTestId(artifactItemTestId); - const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); - const findAllArtifactItemsData = () => - wrapper.findAllByTestId(artifactItemTestId).wrappers.map((x) => ({ - path: x.attributes('href'), - name: x.text(), - })); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); - const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message'); - const findWarning = () => wrapper.findByTestId('artifacts-fetch-warning'); - const changePipelineId = (newId) => wrapper.setProps({ pipelineId: newId }); - - beforeEach(() => { - mockAxios = new MockAdapter(axios); - }); - - afterEach(() => { - mockAxios.restore(); - }); - - it('should render the dropdown', () => { - createComponent(); - - expect(findDropdown().exists()).toBe(true); - }); - - describe('Artifacts', () => { - const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); - - describe('while loading artifacts', () => { - beforeEach(() => { - mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); - }); - - it('should render a loading spinner and no empty message', async () => { - createComponent(); - - findDropdown().vm.$emit('show'); - await nextTick(); - - expect(findLoadingIcon().exists()).toBe(true); - expect(findEmptyMessage().exists()).toBe(false); - }); - }); - - describe('artifacts loaded successfully', () => { - describe('artifacts exist', () => { - beforeEach(async () => { - mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); - - createComponent(); - - findDropdown().vm.$emit('show'); - await waitForPromises(); - }); - - it('should fetch artifacts and show search box on dropdown click', () => { - expect(mockAxios.history.get).toHaveLength(1); - expect(findSearchBox().exists()).toBe(true); - }); - - it('should focus the search box when opened with artifacts', () => { - findDropdown().vm.$emit('shown'); - - expect(focusInputMock).toHaveBeenCalled(); - }); - - it('should render all the provided artifacts when search query is empty', () => { - findSearchBox().vm.$emit('input', ''); - - expect(findAllArtifactItems()).toHaveLength(artifacts.length); - expect(findEmptyMessage().exists()).toBe(false); - }); - - it('should render filtered artifacts when search query is not empty', async () => { - findSearchBox().vm.$emit('input', 'job-2'); - await waitForPromises(); - - expect(findAllArtifactItems()).toHaveLength(1); - expect(findEmptyMessage().exists()).toBe(false); - }); - - it('should render the correct artifact name and path', () => { - expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); - expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); - }); - - describe('when opened again with new artifacts', () => { - describe('with a successful refetch', () => { - beforeEach(async () => { - mockAxios.resetHistory(); - mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: newArtifacts }); - - findDropdown().vm.$emit('show'); - await nextTick(); - }); - - it('should hide list and render a loading spinner on dropdown click', () => { - expect(findAllArtifactItems()).toHaveLength(0); - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('should not render warning or empty message while loading', () => { - expect(findEmptyMessage().exists()).toBe(false); - expect(findWarning().exists()).toBe(false); - }); - - it('should render the correct new list', async () => { - await waitForPromises(); - - expect(findAllArtifactItemsData()).toEqual(newArtifacts); - }); - }); - - describe('with a failing refetch', () => { - beforeEach(async () => { - mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - findDropdown().vm.$emit('show'); - await waitForPromises(); - }); - - it('should render warning', () => { - expect(findWarning().text()).toBe(i18n.artifactsFetchWarningMessage); - }); - - it('should render old list', () => { - expect(findAllArtifactItemsData()).toEqual(artifacts); - }); - }); - }); - - describe('pipeline id has changed', () => { - const newEndpoint = artifactsEndpoint.replace( - artifactsEndpointPlaceholder, - pipelineId + 1, - ); - - beforeEach(() => { - changePipelineId(pipelineId + 1); - }); - - describe('followed by a failing request', () => { - beforeEach(async () => { - mockAxios.onGet(newEndpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - findDropdown().vm.$emit('show'); - await waitForPromises(); - }); - - it('should render error message and no warning', () => { - expect(findWarning().exists()).toBe(false); - expect(findAlert().text()).toBe(i18n.artifactsFetchErrorMessage); - }); - - it('should clear list', () => { - expect(findAllArtifactItems()).toHaveLength(0); - }); - }); - }); - }); - - describe('artifacts list is empty', () => { - beforeEach(() => { - mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: [] }); - }); - - it('should render empty message and no search box when no artifacts are found', async () => { - createComponent(); - - findDropdown().vm.$emit('show'); - await waitForPromises(); - - expect(findEmptyMessage().exists()).toBe(true); - expect(findSearchBox().exists()).toBe(false); - expect(findLoadingIcon().exists()).toBe(false); - }); - }); - }); - - describe('with a failing request', () => { - beforeEach(() => { - mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); - }); - - it('should render an error message', async () => { - createComponent(); - findDropdown().vm.$emit('show'); - await waitForPromises(); - - const error = findAlert(); - expect(error.exists()).toBe(true); - expect(error.text()).toBe(i18n.artifactsFetchErrorMessage); - }); - }); - }); - - describe('tracking', () => { - afterEach(() => { - unmockTracking(); - }); - - it('tracks artifacts dropdown click', () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - createComponent(); - - findDropdown().vm.$emit('show'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_artifacts_dropdown', { - label: TRACKING_CATEGORIES.table, - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_operations_spec.js b/spec/frontend/pipelines/pipeline_operations_spec.js deleted file mode 100644 index b2191453824..00000000000 --- a/spec/frontend/pipelines/pipeline_operations_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; -import PipelineMultiActions from '~/pipelines/components/pipelines_list/pipeline_multi_actions.vue'; -import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue'; -import eventHub from '~/pipelines/event_hub'; - -describe('Pipeline operations', () => { - let wrapper; - - const defaultProps = { - pipeline: { - id: 329, - iid: 234, - details: { - has_manual_actions: true, - has_scheduled_actions: false, - }, - flags: { - retryable: true, - cancelable: true, - }, - cancel_path: '/root/ci-project/-/pipelines/329/cancel', - retry_path: '/root/ci-project/-/pipelines/329/retry', - }, - }; - - const createComponent = (props = defaultProps) => { - wrapper = shallowMountExtended(PipelineOperations, { - propsData: { - ...props, - }, - }); - }; - - const findManualActions = () => wrapper.findComponent(PipelinesManualActions); - const findMultiActions = () => wrapper.findComponent(PipelineMultiActions); - const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); - const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); - - it('should display pipeline manual actions', () => { - createComponent(); - - expect(findManualActions().exists()).toBe(true); - }); - - it('should display pipeline multi actions', () => { - createComponent(); - - expect(findMultiActions().exists()).toBe(true); - }); - - describe('events', () => { - beforeEach(() => { - createComponent(); - - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - }); - - it('should emit retryPipeline event', () => { - findRetryBtn().vm.$emit('click'); - - expect(eventHub.$emit).toHaveBeenCalledWith( - 'retryPipeline', - defaultProps.pipeline.retry_path, - ); - }); - - it('should emit openConfirmationModal event', () => { - findCancelBtn().vm.$emit('click'); - - expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', { - pipeline: defaultProps.pipeline, - endpoint: defaultProps.pipeline.cancel_path, - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_tabs_spec.js b/spec/frontend/pipelines/pipeline_tabs_spec.js deleted file mode 100644 index 8d1cd98e981..00000000000 --- a/spec/frontend/pipelines/pipeline_tabs_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { createAppOptions } from '~/pipelines/pipeline_tabs'; - -jest.mock('~/lib/utils/url_utility', () => ({ - removeParams: () => 'gitlab.com', - joinPaths: () => {}, - setUrlFragment: () => {}, -})); - -jest.mock('~/pipelines/utils', () => ({ - getPipelineDefaultTab: () => '', -})); - -describe('~/pipelines/pipeline_tabs.js', () => { - describe('createAppOptions', () => { - const SELECTOR = 'SELECTOR'; - - let el; - - const createElement = () => { - el = document.createElement('div'); - el.id = SELECTOR; - el.dataset.canGenerateCodequalityReports = 'true'; - el.dataset.codequalityReportDownloadPath = 'codequalityReportDownloadPath'; - el.dataset.downloadablePathForReportType = 'downloadablePathForReportType'; - el.dataset.exposeSecurityDashboard = 'true'; - el.dataset.exposeLicenseScanningData = 'true'; - el.dataset.failedJobsCount = 1; - el.dataset.graphqlResourceEtag = 'graphqlResourceEtag'; - el.dataset.pipelineIid = '123'; - el.dataset.pipelineProjectPath = 'pipelineProjectPath'; - - document.body.appendChild(el); - }; - - afterEach(() => { - el = null; - }); - - it("extracts the properties from the element's dataset", () => { - createElement(); - const options = createAppOptions(`#${SELECTOR}`, null); - - expect(options).toMatchObject({ - el, - provide: { - canGenerateCodequalityReports: true, - codequalityReportDownloadPath: 'codequalityReportDownloadPath', - downloadablePathForReportType: 'downloadablePathForReportType', - exposeSecurityDashboard: true, - exposeLicenseScanningData: true, - failedJobsCount: '1', - graphqlResourceEtag: 'graphqlResourceEtag', - pipelineIid: '123', - pipelineProjectPath: 'pipelineProjectPath', - }, - }); - }); - - it('returns `null` if el does not exist', () => { - expect(createAppOptions('foo', null)).toBe(null); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js deleted file mode 100644 index 856c0484075..00000000000 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ /dev/null @@ -1,76 +0,0 @@ -import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import pipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - -describe('Pipelines Triggerer', () => { - let wrapper; - - const mockData = { - pipeline: { - user: { - name: 'foo', - avatar_url: '/avatar', - path: '/path', - }, - }, - }; - - const createComponent = (props) => { - wrapper = shallowMountExtended(pipelineTriggerer, { - propsData: { - ...props, - }, - directives: { - GlTooltip: createMockDirective('gl-tooltip'), - }, - }); - }; - - const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); - const findAvatar = () => wrapper.findComponent(GlAvatar); - const findTriggerer = () => wrapper.findByText('API'); - - describe('when user was a triggerer', () => { - beforeEach(() => { - createComponent(mockData); - }); - - it('should render pipeline triggerer table cell', () => { - expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); - }); - - it('should render only user avatar', () => { - expect(findAvatarLink().exists()).toBe(true); - expect(findTriggerer().exists()).toBe(false); - }); - - it('should set correct props on avatar link component', () => { - expect(findAvatarLink().attributes()).toMatchObject({ - title: mockData.pipeline.user.name, - href: mockData.pipeline.user.path, - }); - }); - - it('should add tooltip to avatar link', () => { - const tooltip = getBinding(findAvatarLink().element, 'gl-tooltip'); - - expect(tooltip).toBeDefined(); - }); - - it('should set correct props on avatar component', () => { - expect(findAvatar().attributes().src).toBe(mockData.pipeline.user.avatar_url); - }); - }); - - describe('when API was a triggerer', () => { - beforeEach(() => { - createComponent({ pipeline: {} }); - }); - - it('should render label only', () => { - expect(findAvatarLink().exists()).toBe(false); - expect(findTriggerer().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js deleted file mode 100644 index 797ec676ccc..00000000000 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ /dev/null @@ -1,184 +0,0 @@ -import { merge } from 'lodash'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { TRACKING_CATEGORIES } from '~/pipelines/constants'; -import { mockPipeline, mockPipelineBranch, mockPipelineTag } from './mock_data'; - -const projectPath = 'test/test'; - -describe('Pipeline Url Component', () => { - let wrapper; - let trackingSpy; - - const findTableCell = () => wrapper.findByTestId('pipeline-url-table-cell'); - const findPipelineUrlLink = () => wrapper.findByTestId('pipeline-url-link'); - const findRefName = () => wrapper.findByTestId('merge-request-ref'); - const findCommitShortSha = () => wrapper.findByTestId('commit-short-sha'); - const findCommitIcon = () => wrapper.findByTestId('commit-icon'); - const findCommitIconType = () => wrapper.findByTestId('commit-icon-type'); - const findCommitRefName = () => wrapper.findByTestId('commit-ref-name'); - - const findCommitTitleContainer = () => wrapper.findByTestId('commit-title-container'); - const findPipelineNameContainer = () => wrapper.findByTestId('pipeline-name-container'); - const findCommitTitle = (commitWrapper) => commitWrapper.find('[data-testid="commit-title"]'); - - const defaultProps = { ...mockPipeline(projectPath), refClass: 'gl-text-black' }; - - const createComponent = (props) => { - wrapper = shallowMountExtended(PipelineUrlComponent, { - propsData: { ...defaultProps, ...props }, - provide: { - targetProjectFullPath: projectPath, - }, - }); - }; - - it('should render pipeline url table cell', () => { - createComponent(); - - expect(findTableCell().exists()).toBe(true); - }); - - it('should render a link the provided path and id', () => { - createComponent(); - - expect(findPipelineUrlLink().attributes('href')).toBe('foo'); - - expect(findPipelineUrlLink().text()).toBe('#1'); - }); - - it('should render the pipeline name instead of commit title', () => { - createComponent(merge(mockPipeline(projectPath), { pipeline: { name: 'Build pipeline' } })); - - expect(findCommitTitleContainer().exists()).toBe(false); - expect(findPipelineNameContainer().exists()).toBe(true); - expect(findRefName().exists()).toBe(true); - expect(findCommitShortSha().exists()).toBe(true); - }); - - it('should render the commit title when pipeline has no name', () => { - createComponent(); - - const commitWrapper = findCommitTitleContainer(); - - expect(findCommitTitle(commitWrapper).exists()).toBe(true); - expect(findRefName().exists()).toBe(true); - expect(findCommitShortSha().exists()).toBe(true); - expect(findPipelineNameContainer().exists()).toBe(false); - }); - - it('should pass the refClass prop to merge request link', () => { - createComponent(); - - expect(findRefName().classes()).toContain(defaultProps.refClass); - }); - - it('should pass the refClass prop to the commit ref name link', () => { - createComponent(mockPipelineBranch()); - - expect(findCommitRefName().classes()).toContain(defaultProps.refClass); - }); - - describe('commit user avatar', () => { - it('renders when commit author exists', () => { - const pipelineBranch = mockPipelineBranch(); - const { avatar_url: imgSrc, name, path } = pipelineBranch.pipeline.commit.author; - createComponent(pipelineBranch); - - const component = wrapper.findComponent(UserAvatarLink); - expect(component.exists()).toBe(true); - expect(component.props()).toMatchObject({ - imgSize: 16, - imgSrc, - imgAlt: name, - linkHref: path, - tooltipText: name, - }); - }); - - it('does not render when commit author does not exist', () => { - createComponent(); - - expect(wrapper.findComponent(UserAvatarLink).exists()).toBe(false); - }); - }); - - it('should render commit icon tooltip', () => { - createComponent(); - - expect(findCommitIcon().attributes('title')).toBe('Commit'); - }); - - it.each` - pipeline | expectedTitle - ${mockPipelineTag()} | ${'Tag'} - ${mockPipelineBranch()} | ${'Branch'} - ${mockPipeline()} | ${'Merge Request'} - `('should render tooltip $expectedTitle for commit icon type', ({ pipeline, expectedTitle }) => { - createComponent(pipeline); - - expect(findCommitIconType().attributes('title')).toBe(expectedTitle); - }); - - describe('tracking', () => { - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('tracks pipeline id click', () => { - createComponent(); - - findPipelineUrlLink().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_pipeline_id', { - label: TRACKING_CATEGORIES.table, - }); - }); - - it('tracks merge request ref click', () => { - createComponent(); - - findRefName().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_mr_ref', { - label: TRACKING_CATEGORIES.table, - }); - }); - - it('tracks commit ref name click', () => { - createComponent(mockPipelineBranch()); - - findCommitRefName().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_name', { - label: TRACKING_CATEGORIES.table, - }); - }); - - it('tracks commit title click', () => { - createComponent(merge(mockPipelineBranch(), { pipeline: { name: null } })); - - findCommitTitle(findCommitTitleContainer()).vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_title', { - label: TRACKING_CATEGORIES.table, - }); - }); - - it('tracks commit short sha click', () => { - createComponent(mockPipelineBranch()); - - findCommitShortSha().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_sha', { - label: TRACKING_CATEGORIES.table, - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js deleted file mode 100644 index 1abc2887682..00000000000 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ /dev/null @@ -1,64 +0,0 @@ -import { - GlDisclosureDropdown, - GlDisclosureDropdownItem, - GlDisclosureDropdownGroup, - GlSprintf, -} from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; - -describe('Pipelines Artifacts dropdown', () => { - let wrapper; - - const artifacts = [ - { - name: 'job my-artifact', - path: '/download/path', - }, - { - name: 'job-2 my-artifact-2', - path: '/download/path-two', - }, - ]; - const pipelineId = 108; - - const createComponent = ({ mockArtifacts = artifacts } = {}) => { - wrapper = shallowMount(PipelineArtifacts, { - propsData: { - pipelineId, - artifacts: mockArtifacts, - }, - stubs: { - GlSprintf, - GlDisclosureDropdown, - GlDisclosureDropdownItem, - GlDisclosureDropdownGroup, - }, - }); - }; - - const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findFirstGlDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); - - it('should render a dropdown with all the provided artifacts', () => { - createComponent(); - - const [{ items }] = findGlDropdown().props('items'); - expect(items).toHaveLength(artifacts.length); - }); - - it('should render a link with the provided path', () => { - createComponent(); - - expect(findFirstGlDropdownItem().props('item').href).toBe(artifacts[0].path); - expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name); - }); - - describe('with no artifacts', () => { - it('should not render the dropdown', () => { - createComponent({ mockArtifacts: [] }); - - expect(findGlDropdown().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipelines_manual_actions_spec.js b/spec/frontend/pipelines/pipelines_manual_actions_spec.js deleted file mode 100644 index 82cab88c9eb..00000000000 --- a/spec/frontend/pipelines/pipelines_manual_actions_spec.js +++ /dev/null @@ -1,216 +0,0 @@ -import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import mockPipelineActionsQueryResponse from 'test_fixtures/graphql/pipelines/get_pipeline_actions.query.graphql.json'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; -import getPipelineActionsQuery from '~/pipelines/graphql/queries/get_pipeline_actions.query.graphql'; -import { TRACKING_CATEGORIES } from '~/pipelines/constants'; -import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; - -Vue.use(VueApollo); - -jest.mock('~/alert'); -jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); - -describe('Pipeline manual actions', () => { - let wrapper; - let mock; - - const queryHandler = jest.fn().mockResolvedValue(mockPipelineActionsQueryResponse); - const { - data: { - project: { - pipeline: { - jobs: { nodes }, - }, - }, - }, - } = mockPipelineActionsQueryResponse; - - const mockPath = nodes[2].playPath; - - const createComponent = (limit = 50) => { - wrapper = shallowMountExtended(PipelinesManualActions, { - provide: { - fullPath: 'root/ci-project', - manualActionsLimit: limit, - }, - propsData: { - iid: 100, - }, - stubs: { - GlDropdown, - }, - apolloProvider: createMockApollo([[getPipelineActionsQuery, queryHandler]]), - }); - }; - - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findLimitMessage = () => wrapper.findByTestId('limit-reached-msg'); - - it('skips calling query on mount', () => { - createComponent(); - - expect(queryHandler).not.toHaveBeenCalled(); - }); - - describe('loading', () => { - beforeEach(() => { - createComponent(); - - findDropdown().vm.$emit('shown'); - }); - - it('display loading state while actions are being fetched', () => { - expect(findAllDropdownItems().at(0).text()).toBe('Loading...'); - expect(findLoadingIcon().exists()).toBe(true); - expect(findAllDropdownItems()).toHaveLength(1); - }); - }); - - describe('loaded', () => { - beforeEach(async () => { - mock = new MockAdapter(axios); - - createComponent(); - - findDropdown().vm.$emit('shown'); - - await waitForPromises(); - }); - - afterEach(() => { - mock.restore(); - confirmAction.mockReset(); - }); - - it('displays dropdown with the provided actions', () => { - expect(findAllDropdownItems()).toHaveLength(3); - }); - - it("displays a disabled action when it's not playable", () => { - expect(findAllDropdownItems().at(0).attributes('disabled')).toBeDefined(); - }); - - describe('on action click', () => { - it('makes a request and toggles the loading state', async () => { - mock.onPost(mockPath).reply(HTTP_STATUS_OK); - - findAllDropdownItems().at(1).vm.$emit('click'); - - await nextTick(); - - expect(findDropdown().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findDropdown().props('loading')).toBe(false); - }); - - it('makes a failed request and toggles the loading state', async () => { - mock.onPost(mockPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - - findAllDropdownItems().at(1).vm.$emit('click'); - - await nextTick(); - - expect(findDropdown().props('loading')).toBe(true); - - await waitForPromises(); - - expect(findDropdown().props('loading')).toBe(false); - expect(createAlert).toHaveBeenCalledTimes(1); - }); - }); - - describe('tracking', () => { - afterEach(() => { - unmockTracking(); - }); - - it('tracks manual actions click', () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - findDropdown().vm.$emit('shown'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', { - label: TRACKING_CATEGORIES.table, - }); - }); - }); - - describe('scheduled jobs', () => { - beforeEach(() => { - jest - .spyOn(Date, 'now') - .mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); - }); - - it('makes post request after confirming', async () => { - mock.onPost(mockPath).reply(HTTP_STATUS_OK); - - confirmAction.mockResolvedValueOnce(true); - - findAllDropdownItems().at(2).vm.$emit('click'); - - expect(confirmAction).toHaveBeenCalled(); - - await waitForPromises(); - - expect(mock.history.post).toHaveLength(1); - }); - - it('does not make post request if confirmation is cancelled', async () => { - mock.onPost(mockPath).reply(HTTP_STATUS_OK); - - confirmAction.mockResolvedValueOnce(false); - - findAllDropdownItems().at(2).vm.$emit('click'); - - expect(confirmAction).toHaveBeenCalled(); - - await waitForPromises(); - - expect(mock.history.post).toHaveLength(0); - }); - - it('displays the remaining time in the dropdown', () => { - expect(findAllCountdowns().at(0).props('endDateString')).toBe(nodes[2].scheduledAt); - }); - }); - }); - - describe('limit message', () => { - it('limit message does not show', async () => { - createComponent(); - - findDropdown().vm.$emit('shown'); - - await waitForPromises(); - - expect(findLimitMessage().exists()).toBe(false); - }); - - it('limit message does show', async () => { - createComponent(3); - - findDropdown().vm.$emit('shown'); - - await waitForPromises(); - - expect(findLimitMessage().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js deleted file mode 100644 index cc85d6d99e0..00000000000 --- a/spec/frontend/pipelines/pipelines_spec.js +++ /dev/null @@ -1,850 +0,0 @@ -import '~/commons'; -import { - GlButton, - GlEmptyState, - GlFilteredSearch, - GlLoadingIcon, - GlPagination, - GlCollapsibleListbox, -} from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { chunk } from 'lodash'; -import { nextTick } from 'vue'; -import mockPipelinesResponse from 'test_fixtures/pipelines/pipelines.json'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import { TEST_HOST } from 'helpers/test_constants'; -import { mockTracking } from 'helpers/tracking_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import Api from '~/api'; -import { createAlert, VARIANT_WARNING } from '~/alert'; -import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; -import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; -import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; -import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; -import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/pipelines/constants'; -import Store from '~/pipelines/stores/pipelines_store'; -import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { - setIdTypePreferenceMutationResponse, - setIdTypePreferenceMutationResponseWithErrors, -} from 'jest/issues/list/mock_data'; - -import { stageReply, users, mockSearch, branches } from './mock_data'; - -jest.mock('@sentry/browser'); -jest.mock('~/alert'); - -const mockProjectPath = 'twitter/flight'; -const mockProjectId = '21'; -const mockDefaultBranchName = 'main'; -const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`; -const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id); -const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( - (p) => p.details.stages && p.details.stages.length, -); - -describe('Pipelines', () => { - let wrapper; - let mockApollo; - let mock; - let trackingSpy; - - const paths = { - emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', - errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', - noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', - ciLintPath: '/ci/lint', - resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, - newPipelinePath: `${mockProjectPath}/pipelines/new`, - - ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`, - }; - - const noPermissions = { - emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', - errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', - noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', - }; - - const defaultProps = { - hasGitlabCi: true, - canCreatePipeline: true, - ...paths, - }; - - const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - const findNavigationTabs = () => wrapper.findComponent(NavigationTabs); - const findNavigationControls = () => wrapper.findComponent(NavigationControls); - const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); - const findTablePagination = () => wrapper.findComponent(TablePagination); - const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox); - - const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); - const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box'); - const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); - const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); - const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); - const findStagesDropdownToggle = () => - wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'); - const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); - - const createComponent = (props = defaultProps) => { - const { mutationMock, ...restProps } = props; - mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]); - - wrapper = extendedWrapper( - mount(PipelinesComponent, { - provide: { - pipelineEditorPath: '', - suggestedCiTemplates: [], - ciRunnerSettingsPath: paths.ciRunnerSettingsPath, - anyRunnersAvailable: true, - }, - propsData: { - store: new Store(), - projectId: mockProjectId, - defaultBranchName: mockDefaultBranchName, - endpoint: mockPipelinesEndpoint, - params: {}, - ...restProps, - }, - apolloProvider: mockApollo, - }), - ); - }; - - beforeEach(() => { - setWindowLocation(TEST_HOST); - }); - - beforeEach(() => { - mock = new MockAdapter(axios); - - jest.spyOn(window.history, 'pushState'); - jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); - jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); - }); - - afterEach(() => { - mock.reset(); - mockApollo = null; - window.history.pushState.mockReset(); - }); - - describe('when pipelines are not yet loaded', () => { - beforeEach(async () => { - createComponent(); - await nextTick(); - }); - - it('shows loading state when the app is loading', () => { - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - - it('does not display tabs when the first request has not yet been made', () => { - expect(findNavigationTabs().exists()).toBe(false); - }); - - it('does not display buttons', () => { - expect(findNavigationControls().exists()).toBe(false); - }); - }); - - describe('when there are pipelines in the project', () => { - beforeEach(() => { - mock - .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) - .reply(HTTP_STATUS_OK, mockPipelinesResponse); - }); - - describe('when user has no permissions', () => { - beforeEach(async () => { - createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); - await waitForPromises(); - }); - - it('renders "All" tab with count different from "0"', () => { - expect(findTab('all').text()).toMatchInterpolatedText('All 3'); - }); - - it('does not render buttons', () => { - expect(findNavigationControls().exists()).toBe(false); - - expect(findRunPipelineButton().exists()).toBe(false); - expect(findCiLintButton().exists()).toBe(false); - expect(findCleanCacheButton().exists()).toBe(false); - }); - - it('renders pipelines in a table', () => { - expect(findPipelinesTable().exists()).toBe(true); - - expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); - expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); - expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); - }); - }); - - describe('when user has permissions', () => { - beforeEach(async () => { - createComponent(); - await waitForPromises(); - }); - - it('should set up navigation tabs', () => { - expect(findNavigationTabs().props('tabs')).toEqual([ - { name: 'All', scope: 'all', count: '3', isActive: true }, - { name: 'Finished', scope: 'finished', count: undefined, isActive: false }, - { name: 'Branches', scope: 'branches', isActive: false }, - { name: 'Tags', scope: 'tags', isActive: false }, - ]); - }); - - it('renders "All" tab with count different from "0"', () => { - expect(findTab('all').text()).toMatchInterpolatedText('All 3'); - }); - - it('should render other navigation tabs', () => { - expect(findTab('finished').text()).toBe('Finished'); - expect(findTab('branches').text()).toBe('Branches'); - expect(findTab('tags').text()).toBe('Tags'); - }); - - it('shows navigation controls', () => { - expect(findNavigationControls().exists()).toBe(true); - }); - - it('renders Run pipeline link', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); - }); - - it('renders CI lint link', () => { - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); - }); - - it('renders Clear runner cache button', () => { - expect(findCleanCacheButton().text()).toBe('Clear runner caches'); - }); - - it('renders pipelines in a table', () => { - expect(findPipelinesTable().exists()).toBe(true); - - expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); - expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); - expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); - }); - - describe('when user goes to a tab', () => { - const goToTab = (tab) => { - findNavigationTabs().vm.$emit('onChangeTab', tab); - }; - - describe('when the scope in the tab has pipelines', () => { - const mockFinishedPipeline = mockPipelinesResponse.pipelines[0]; - - beforeEach(async () => { - mock - .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) - .reply(HTTP_STATUS_OK, { - pipelines: [mockFinishedPipeline], - count: mockPipelinesResponse.count, - }); - - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - goToTab('finished'); - - await waitForPromises(); - }); - - it('should filter pipelines', () => { - expect(findPipelinesTable().exists()).toBe(true); - - expect(findPipelineUrlLinks()).toHaveLength(1); - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFinishedPipeline.id}`); - }); - - it('should update browser bar', () => { - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - `${window.location.pathname}?scope=finished&page=1`, - ); - }); - - it.each(['all', 'finished', 'branches', 'tags'])('tracks %p tab click', async (scope) => { - goToTab(scope); - - await waitForPromises(); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filter_tabs', { - label: TRACKING_CATEGORIES.tabs, - property: scope, - }); - }); - }); - - describe('when the scope in the tab is empty', () => { - beforeEach(async () => { - mock - .onGet(mockPipelinesEndpoint, { params: { scope: 'branches', page: '1' } }) - .reply(HTTP_STATUS_OK, { - pipelines: [], - count: mockPipelinesResponse.count, - }); - - goToTab('branches'); - - await waitForPromises(); - }); - - it('should filter pipelines', () => { - expect(findEmptyState().text()).toBe('There are currently no pipelines.'); - }); - - it('should update browser bar', () => { - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - `${window.location.pathname}?scope=branches&page=1`, - ); - }); - }); - }); - - describe('when user triggers a filtered search', () => { - const mockFilteredPipeline = mockPipelinesResponse.pipelines[1]; - - let expectedParams; - - beforeEach(async () => { - expectedParams = { - page: '1', - scope: 'all', - username: 'root', - ref: 'main', - status: 'pending', - }; - - mock - .onGet(mockPipelinesEndpoint, { - params: expectedParams, - }) - .replyOnce(HTTP_STATUS_OK, { - pipelines: [mockFilteredPipeline], - count: mockPipelinesResponse.count, - }); - - findFilteredSearch().vm.$emit('submit', mockSearch); - - await waitForPromises(); - }); - - it('requests data with query params on filter submit', () => { - expect(mock.history.get[1].params).toEqual(expectedParams); - }); - - it('renders filtered pipelines', () => { - expect(findPipelineUrlLinks()).toHaveLength(1); - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); - }); - - it('should update browser bar', () => { - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - `${window.location.pathname}?page=1&scope=all&username=root&ref=main&status=pending`, - ); - }); - }); - - describe('when user changes Show Pipeline ID to Show Pipeline IID', () => { - const mockFilteredPipeline = mockPipelinesResponse.pipelines[0]; - - beforeEach(() => { - gon.current_user_id = 1; - }); - - it('should change the text to Show Pipeline IID', async () => { - expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); - - await waitForPromises(); - - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.iid}`); - }); - - it('calls mutation to save idType preference', () => { - const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); - createComponent({ ...defaultProps, mutationMock }); - - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); - - expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } }); - }); - - it('captures error when mutation response has errors', async () => { - const mutationMock = jest - .fn() - .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); - createComponent({ ...defaultProps, mutationMock }); - - findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); - await waitForPromises(); - - expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); - }); - }); - - describe('when user triggers a filtered search with raw text', () => { - beforeEach(async () => { - findFilteredSearch().vm.$emit('submit', ['rawText']); - - await waitForPromises(); - }); - - it('requests data with query params on filter submit', () => { - expect(mock.history.get[1].params).toEqual({ page: '1', scope: 'all' }); - }); - - it('displays a warning message if raw text search is used', () => { - expect(createAlert).toHaveBeenCalledTimes(1); - expect(createAlert).toHaveBeenCalledWith({ - message: RAW_TEXT_WARNING, - variant: VARIANT_WARNING, - }); - }); - - it('should update browser bar', () => { - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - `${window.location.pathname}?page=1&scope=all`, - ); - }); - }); - }); - }); - - describe('when there are multiple pages of pipelines', () => { - const mockPageSize = 2; - const mockPageHeaders = ({ page = 1 } = {}) => { - return { - 'X-PER-PAGE': `${mockPageSize}`, - 'X-PREV-PAGE': `${page - 1}`, - 'X-PAGE': `${page}`, - 'X-NEXT-PAGE': `${page + 1}`, - }; - }; - const [firstPage, secondPage] = chunk(mockPipelinesResponse.pipelines, mockPageSize); - - const goToPage = (page) => { - findTablePagination().findComponent(GlPagination).vm.$emit('input', page); - }; - - beforeEach(async () => { - mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }).reply( - HTTP_STATUS_OK, - { - pipelines: firstPage, - count: mockPipelinesResponse.count, - }, - mockPageHeaders({ page: 1 }), - ); - mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '2' } }).reply( - HTTP_STATUS_OK, - { - pipelines: secondPage, - count: mockPipelinesResponse.count, - }, - mockPageHeaders({ page: 2 }), - ); - - createComponent(); - - await waitForPromises(); - }); - - it('shows the first page of pipelines', () => { - expect(findPipelineUrlLinks()).toHaveLength(firstPage.length); - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${firstPage[0].id}`); - expect(findPipelineUrlLinks().at(1).text()).toBe(`#${firstPage[1].id}`); - }); - - it('should not update browser bar', () => { - expect(window.history.pushState).not.toHaveBeenCalled(); - }); - - describe('when user goes to next page', () => { - beforeEach(async () => { - goToPage(2); - await waitForPromises(); - }); - - it('should update page and keep scope the same scope', () => { - expect(findPipelineUrlLinks()).toHaveLength(secondPage.length); - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${secondPage[0].id}`); - }); - - it('should update browser bar', () => { - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - `${window.location.pathname}?page=2&scope=all`, - ); - }); - - it('should reset page to 1 when filtering pipelines', () => { - expect(window.history.pushState).toHaveBeenCalledTimes(1); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - `${window.location.pathname}?page=2&scope=all`, - ); - - findFilteredSearch().vm.$emit('submit', [ - { type: 'status', value: { data: 'success', operator: '=' } }, - ]); - - expect(window.history.pushState).toHaveBeenCalledTimes(2); - expect(window.history.pushState).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - `${window.location.pathname}?page=1&scope=all&status=success`, - ); - }); - }); - }); - - describe('when pipelines can be polled', () => { - beforeEach(() => { - const emptyResponse = { - pipelines: [], - count: { all: '0' }, - }; - - // Mock no pipelines in the first attempt - mock - .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) - .replyOnce(HTTP_STATUS_OK, emptyResponse, { - 'POLL-INTERVAL': 100, - }); - // Mock pipelines in the next attempt - mock - .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) - .reply(HTTP_STATUS_OK, mockPipelinesResponse, { - 'POLL-INTERVAL': 100, - }); - }); - - describe('data is loaded for the first time', () => { - beforeEach(async () => { - createComponent(); - await waitForPromises(); - }); - - it('shows tabs', () => { - expect(findNavigationTabs().exists()).toBe(true); - }); - - it('should update page and keep scope the same scope', () => { - expect(findPipelineUrlLinks()).toHaveLength(0); - }); - - describe('data is loaded for a second time', () => { - beforeEach(async () => { - jest.runOnlyPendingTimers(); - await waitForPromises(); - }); - - it('shows tabs', () => { - expect(findNavigationTabs().exists()).toBe(true); - }); - - it('is loading after a time', () => { - expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); - expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); - expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); - expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); - }); - }); - }); - }); - - describe('when no pipelines exist', () => { - beforeEach(() => { - mock - .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) - .reply(HTTP_STATUS_OK, { - pipelines: [], - count: { all: '0' }, - }); - }); - - describe('when CI is enabled and user has permissions', () => { - beforeEach(async () => { - createComponent(); - await waitForPromises(); - }); - - it('renders tab with count of "0"', () => { - expect(findNavigationTabs().exists()).toBe(true); - expect(findTab('all').text()).toMatchInterpolatedText('All 0'); - }); - - it('renders Run pipeline link', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); - }); - - it('renders CI lint link', () => { - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); - }); - - it('renders Clear runner cache button', () => { - expect(findCleanCacheButton().text()).toBe('Clear runner caches'); - }); - - it('renders empty state', () => { - expect(findEmptyState().text()).toBe('There are currently no pipelines.'); - }); - - it('renders filtered search', () => { - expect(findFilteredSearch().exists()).toBe(true); - }); - - it('renders the pipeline key collapsible box', () => { - expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); - }); - - it('renders tab empty state finished scope', async () => { - mock - .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) - .reply(HTTP_STATUS_OK, { - pipelines: [], - count: { all: '0' }, - }); - - findNavigationTabs().vm.$emit('onChangeTab', 'finished'); - - await waitForPromises(); - - expect(findEmptyState().text()).toBe('There are currently no finished pipelines.'); - }); - }); - - describe('when CI is not enabled and user has permissions', () => { - beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); - await waitForPromises(); - }); - - it('renders the CI/CD templates', () => { - expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true); - }); - - it('does not render filtered search', () => { - expect(findFilteredSearch().exists()).toBe(false); - }); - - it('does not render the pipeline key dropdown', () => { - expect(findPipelineKeyCollapsibleBox().exists()).toBe(false); - }); - - it('does not render tabs nor buttons', () => { - expect(findNavigationTabs().exists()).toBe(false); - expect(findTab('all').exists()).toBe(false); - expect(findRunPipelineButton().exists()).toBe(false); - expect(findCiLintButton().exists()).toBe(false); - expect(findCleanCacheButton().exists()).toBe(false); - }); - }); - - describe('when CI is not enabled and user has no permissions', () => { - beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); - await waitForPromises(); - }); - - it('renders empty state without button to set CI', () => { - expect(findEmptyState().text()).toBe( - 'This project is not currently set up to run pipelines.', - ); - - expect(findEmptyState().findComponent(GlButton).exists()).toBe(false); - }); - - it('does not render tabs or buttons', () => { - expect(findTab('all').exists()).toBe(false); - expect(findRunPipelineButton().exists()).toBe(false); - expect(findCiLintButton().exists()).toBe(false); - expect(findCleanCacheButton().exists()).toBe(false); - }); - }); - - describe('when CI is enabled and user has no permissions', () => { - beforeEach(() => { - createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); - - return waitForPromises(); - }); - - it('renders tab with count of "0"', () => { - expect(findTab('all').text()).toMatchInterpolatedText('All 0'); - }); - - it('does not render buttons', () => { - expect(findRunPipelineButton().exists()).toBe(false); - expect(findCiLintButton().exists()).toBe(false); - expect(findCleanCacheButton().exists()).toBe(false); - }); - - it('renders empty state', () => { - expect(findEmptyState().text()).toBe('There are currently no pipelines.'); - }); - }); - }); - - describe('when a pipeline with stages exists', () => { - describe('updates results when a staged is clicked', () => { - let stopMock; - let restartMock; - let cancelMock; - - beforeEach(() => { - mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply( - HTTP_STATUS_OK, - { - pipelines: [mockPipelineWithStages], - count: { all: '1' }, - }, - { - 'POLL-INTERVAL': 100, - }, - ); - - mock - .onGet(mockPipelineWithStages.details.stages[0].dropdown_path) - .reply(HTTP_STATUS_OK, stageReply); - - createComponent(); - - stopMock = jest.spyOn(window, 'clearTimeout'); - restartMock = jest.spyOn(axios, 'get'); - }); - - describe('when a request is being made', () => { - beforeEach(async () => { - mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_OK, mockPipelinesResponse); - - await waitForPromises(); - }); - - it('stops polling, cancels the request, & restarts polling', async () => { - // Mock init a polling cycle - wrapper.vm.poll.options.notificationCallback(true); - - await findStagesDropdownToggle().trigger('click'); - jest.runOnlyPendingTimers(); - - // cancelMock is getting overwritten in pipelines_service.js#L29 - // so we have to spy on it again here - cancelMock = jest.spyOn(axios.CancelToken, 'source'); - - await waitForPromises(); - - expect(cancelMock).toHaveBeenCalled(); - expect(stopMock).toHaveBeenCalled(); - expect(restartMock).toHaveBeenCalledWith( - `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`, - ); - }); - - it('stops polling & restarts polling', async () => { - await findStagesDropdownToggle().trigger('click'); - jest.runOnlyPendingTimers(); - await waitForPromises(); - - expect(cancelMock).not.toHaveBeenCalled(); - expect(stopMock).toHaveBeenCalled(); - expect(restartMock).toHaveBeenCalledWith( - `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`, - ); - }); - }); - }); - }); - - describe('when pipelines cannot be loaded', () => { - beforeEach(() => { - mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, {}); - }); - - describe('when user has no permissions', () => { - beforeEach(async () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); - - await waitForPromises(); - }); - - it('renders tabs', () => { - expect(findNavigationTabs().exists()).toBe(true); - expect(findTab('all').text()).toBe('All'); - }); - - it('does not render buttons', () => { - expect(findRunPipelineButton().exists()).toBe(false); - expect(findCiLintButton().exists()).toBe(false); - expect(findCleanCacheButton().exists()).toBe(false); - }); - - it('shows error state', () => { - expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.'); - expect(findEmptyState().props('description')).toBe( - 'Try again in a few moments or contact your support team.', - ); - }); - }); - - describe('when user has permissions', () => { - beforeEach(async () => { - createComponent(); - - await waitForPromises(); - }); - - it('renders tabs', () => { - expect(findTab('all').text()).toBe('All'); - }); - - it('renders buttons', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); - - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); - expect(findCleanCacheButton().text()).toBe('Clear runner caches'); - }); - - it('shows error state', () => { - expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.'); - expect(findEmptyState().props('description')).toBe( - 'Try again in a few moments or contact your support team.', - ); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipelines_store_spec.js b/spec/frontend/pipelines/pipelines_store_spec.js deleted file mode 100644 index f374ecd0c0a..00000000000 --- a/spec/frontend/pipelines/pipelines_store_spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import PipelineStore from '~/pipelines/stores/pipelines_store'; - -describe('Pipelines Store', () => { - let store; - - beforeEach(() => { - store = new PipelineStore(); - }); - - it('should be initialized with an empty state', () => { - expect(store.state.pipelines).toEqual([]); - expect(store.state.count).toEqual({}); - expect(store.state.pageInfo).toEqual({}); - }); - - describe('storePipelines', () => { - it('should use the default parameter if none is provided', () => { - store.storePipelines(); - - expect(store.state.pipelines).toEqual([]); - }); - - it('should store the provided array', () => { - const array = [ - { id: 1, status: 'running' }, - { id: 2, status: 'success' }, - ]; - store.storePipelines(array); - - expect(store.state.pipelines).toEqual(array); - }); - }); - - describe('storeCount', () => { - it('should use the default parameter if none is provided', () => { - store.storeCount(); - - expect(store.state.count).toEqual({}); - }); - - it('should store the provided count', () => { - const count = { all: 20, finished: 10 }; - store.storeCount(count); - - expect(store.state.count).toEqual(count); - }); - }); - - describe('storePagination', () => { - it('should use the default parameter if none is provided', () => { - store.storePagination(); - - expect(store.state.pageInfo).toEqual({}); - }); - - it('should store pagination information normalized and parsed', () => { - const pagination = { - 'X-nExt-pAge': '2', - 'X-page': '1', - 'X-Per-Page': '1', - 'X-Prev-Page': '2', - 'X-TOTAL': '37', - 'X-Total-Pages': '2', - }; - - const expectedResult = { - perPage: 1, - page: 1, - total: 37, - totalPages: 2, - nextPage: 2, - previousPage: 2, - }; - - store.storePagination(pagination); - - expect(store.state.pageInfo).toEqual(expectedResult); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js deleted file mode 100644 index 950a6b21e16..00000000000 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ /dev/null @@ -1,280 +0,0 @@ -import '~/commons'; -import { GlTableLite } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import fixture from 'test_fixtures/pipelines/pipelines.json'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; -import PipelineFailedJobsWidget from '~/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue'; -import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue'; -import PipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue'; -import PipelineUrl from '~/pipelines/components/pipelines_list/pipeline_url.vue'; -import PipelinesTable from '~/pipelines/components/pipelines_list/pipelines_table.vue'; -import PipelinesTimeago from '~/pipelines/components/pipelines_list/time_ago.vue'; -import { - PipelineKeyOptions, - BUTTON_TOOLTIP_RETRY, - BUTTON_TOOLTIP_CANCEL, - TRACKING_CATEGORIES, -} from '~/pipelines/constants'; - -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; - -jest.mock('~/pipelines/event_hub'); - -describe('Pipelines Table', () => { - let pipeline; - let wrapper; - let trackingSpy; - - const defaultProvide = { - glFeatures: {}, - withFailedJobsDetails: false, - }; - - const provideWithDetails = { - glFeatures: { - ciJobFailuresInMr: true, - }, - withFailedJobsDetails: true, - }; - - const defaultProps = { - pipelines: [], - viewType: 'root', - pipelineKeyOption: PipelineKeyOptions[0], - }; - - const createMockPipeline = () => { - // Clone fixture as it could be modified by tests - const { pipelines } = JSON.parse(JSON.stringify(fixture)); - return pipelines.find((p) => p.user !== null && p.commit !== null); - }; - - const createComponent = (props = {}, provide = {}) => { - wrapper = extendedWrapper( - mount(PipelinesTable, { - propsData: { - ...defaultProps, - ...props, - }, - provide: { - ...defaultProvide, - ...provide, - }, - stubs: ['PipelineFailedJobsWidget'], - }), - ); - }; - - const findGlTableLite = () => wrapper.findComponent(GlTableLite); - const findCiBadgeLink = () => wrapper.findComponent(CiBadgeLink); - const findPipelineInfo = () => wrapper.findComponent(PipelineUrl); - const findTriggerer = () => wrapper.findComponent(PipelineTriggerer); - const findLegacyPipelineMiniGraph = () => wrapper.findComponent(LegacyPipelineMiniGraph); - const findTimeAgo = () => wrapper.findComponent(PipelinesTimeago); - const findActions = () => wrapper.findComponent(PipelineOperations); - - const findPipelineFailureWidget = () => wrapper.findComponent(PipelineFailedJobsWidget); - const findTableRows = () => wrapper.findAllByTestId('pipeline-table-row'); - const findStatusTh = () => wrapper.findByTestId('status-th'); - const findPipelineTh = () => wrapper.findByTestId('pipeline-th'); - const findStagesTh = () => wrapper.findByTestId('stages-th'); - const findActionsTh = () => wrapper.findByTestId('actions-th'); - const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); - const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); - - beforeEach(() => { - pipeline = createMockPipeline(); - }); - - describe('Pipelines Table', () => { - beforeEach(() => { - createComponent({ pipelines: [pipeline], viewType: 'root' }); - }); - - it('displays table', () => { - expect(findGlTableLite().exists()).toBe(true); - }); - - it('should render table head with correct columns', () => { - expect(findStatusTh().text()).toBe('Status'); - expect(findPipelineTh().text()).toBe('Pipeline'); - expect(findStagesTh().text()).toBe('Stages'); - expect(findActionsTh().text()).toBe('Actions'); - }); - - it('should display a table row', () => { - expect(findTableRows()).toHaveLength(1); - }); - - describe('status cell', () => { - it('should render a status badge', () => { - expect(findCiBadgeLink().exists()).toBe(true); - }); - }); - - describe('pipeline cell', () => { - it('should render pipeline information', () => { - expect(findPipelineInfo().exists()).toBe(true); - }); - - it('should display the pipeline id', () => { - expect(findPipelineInfo().text()).toContain(`#${pipeline.id}`); - }); - }); - - describe('stages cell', () => { - it('should render pipeline mini graph', () => { - expect(findLegacyPipelineMiniGraph().exists()).toBe(true); - }); - - it('should render the right number of stages', () => { - const stagesLength = pipeline.details.stages.length; - expect(findLegacyPipelineMiniGraph().props('stages').length).toBe(stagesLength); - }); - - it('should render the latest downstream pipelines only', () => { - // component receives two downstream pipelines. one of them is already outdated - // because we retried the trigger job, so the mini pipeline graph will only - // render the newly created downstream pipeline instead - expect(pipeline.triggered).toHaveLength(2); - expect(findLegacyPipelineMiniGraph().props('downstreamPipelines')).toHaveLength(1); - }); - - describe('when pipeline does not have stages', () => { - beforeEach(() => { - pipeline = createMockPipeline(); - pipeline.details.stages = []; - - createComponent({ pipelines: [pipeline] }); - }); - - it('stages are not rendered', () => { - expect(findLegacyPipelineMiniGraph().props('stages')).toHaveLength(0); - }); - }); - }); - - describe('duration cell', () => { - it('should render duration information', () => { - expect(findTimeAgo().exists()).toBe(true); - }); - }); - - describe('operations cell', () => { - it('should render pipeline operations', () => { - expect(findActions().exists()).toBe(true); - }); - - it('should render retry action tooltip', () => { - expect(findRetryBtn().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); - }); - - it('should render cancel action tooltip', () => { - expect(findCancelBtn().attributes('title')).toBe(BUTTON_TOOLTIP_CANCEL); - }); - }); - - describe('triggerer cell', () => { - it('should render the pipeline triggerer', () => { - expect(findTriggerer().exists()).toBe(true); - }); - }); - - describe('failed jobs details', () => { - describe('row', () => { - describe('when the FF is disabled', () => { - beforeEach(() => { - createComponent({ pipelines: [pipeline] }); - }); - - it('does not render', () => { - expect(findTableRows()).toHaveLength(1); - expect(findPipelineFailureWidget().exists()).toBe(false); - }); - }); - - describe('when the FF is enabled', () => { - describe('and `withFailedJobsDetails` value is provided', () => { - beforeEach(() => { - createComponent({ pipelines: [pipeline] }, provideWithDetails); - }); - - it('renders', () => { - expect(findTableRows()).toHaveLength(2); - expect(findPipelineFailureWidget().exists()).toBe(true); - }); - - it('passes the expected props', () => { - expect(findPipelineFailureWidget().props()).toStrictEqual({ - failedJobsCount: pipeline.failed_builds.length, - isPipelineActive: pipeline.active, - pipelineIid: pipeline.iid, - pipelinePath: pipeline.path, - // Make sure the forward slash was removed - projectPath: 'frontend-fixtures/pipelines-project', - }); - }); - }); - - describe('and `withFailedJobsDetails` value is not provided', () => { - beforeEach(() => { - createComponent( - { pipelines: [pipeline] }, - { glFeatures: { ciJobFailuresInMr: true } }, - ); - }); - - it('does not render', () => { - expect(findTableRows()).toHaveLength(1); - expect(findPipelineFailureWidget().exists()).toBe(false); - }); - }); - }); - }); - }); - - describe('tracking', () => { - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('tracks status badge click', () => { - findCiBadgeLink().vm.$emit('ciStatusBadgeClick'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_ci_status_badge', { - label: TRACKING_CATEGORIES.table, - }); - }); - - it('tracks retry pipeline button click', () => { - findRetryBtn().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_retry_button', { - label: TRACKING_CATEGORIES.table, - }); - }); - - it('tracks cancel pipeline button click', () => { - findCancelBtn().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_cancel_button', { - label: TRACKING_CATEGORIES.table, - }); - }); - - it('tracks pipeline mini graph stage click', () => { - findLegacyPipelineMiniGraph().vm.$emit('miniGraphStageClick'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_minigraph', { - label: TRACKING_CATEGORIES.table, - }); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/empty_state_spec.js b/spec/frontend/pipelines/test_reports/empty_state_spec.js deleted file mode 100644 index ee0f8a90a11..00000000000 --- a/spec/frontend/pipelines/test_reports/empty_state_spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import EmptyState, { i18n } from '~/pipelines/components/test_reports/empty_state.vue'; - -describe('Test report empty state', () => { - let wrapper; - - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - - const createComponent = ({ hasTestReport = true } = {}) => { - wrapper = shallowMount(EmptyState, { - provide: { - emptyStateImagePath: '/image/path', - hasTestReport, - }, - stubs: { - GlEmptyState, - }, - }); - }; - - describe('when pipeline has a test report', () => { - it('should render empty test report message', () => { - createComponent(); - - expect(findEmptyState().props()).toMatchObject({ - primaryButtonText: i18n.noTestsButton, - description: i18n.noTestsDescription, - title: i18n.noTestsTitle, - }); - }); - }); - - describe('when pipeline does not have a test report', () => { - it('should render no test report message', () => { - createComponent({ hasTestReport: false }); - - expect(findEmptyState().props()).toMatchObject({ - primaryButtonText: i18n.noReportsButton, - description: i18n.noReportsDescription, - title: i18n.noReportsTitle, - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js deleted file mode 100644 index c3ca1429842..00000000000 --- a/spec/frontend/pipelines/test_reports/mock_data.js +++ /dev/null @@ -1,31 +0,0 @@ -import { TestStatus } from '~/pipelines/constants'; - -export default [ - { - classname: 'spec.test_spec', - file: 'spec/trace_spec.rb', - execution_time: 0, - name: 'Test#skipped text', - stack_trace: null, - status: TestStatus.SKIPPED, - system_output: null, - }, - { - classname: 'spec.test_spec', - file: 'spec/trace_spec.rb', - execution_time: 0, - name: 'Test#error text', - stack_trace: null, - status: TestStatus.ERROR, - system_output: null, - }, - { - classname: 'spec.test_spec', - file: 'spec/trace_spec.rb', - execution_time: 0, - name: 'Test#unknown text', - stack_trace: null, - status: TestStatus.UNKNOWN, - system_output: null, - }, -]; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js deleted file mode 100644 index e05d2151f0a..00000000000 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ /dev/null @@ -1,149 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import testReports from 'test_fixtures/pipelines/test_report.json'; -import { TEST_HOST } from 'helpers/test_constants'; -import testAction from 'helpers/vuex_action_helper'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import * as actions from '~/pipelines/stores/test_reports/actions'; -import * as types from '~/pipelines/stores/test_reports/mutation_types'; - -jest.mock('~/alert'); - -describe('Actions TestReports Store', () => { - let mock; - let state; - - const summary = { total_count: 1 }; - - const suiteEndpoint = `${TEST_HOST}/tests/suite.json`; - const summaryEndpoint = `${TEST_HOST}/test_reports/summary.json`; - const defaultState = { - suiteEndpoint, - summaryEndpoint, - testReports: {}, - selectedSuite: null, - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - state = { ...defaultState }; - }); - - afterEach(() => { - mock.restore(); - }); - - describe('fetch report summary', () => { - beforeEach(() => { - mock.onGet(summaryEndpoint).replyOnce(HTTP_STATUS_OK, summary, {}); - }); - - it('sets testReports and shows tests', () => { - return testAction( - actions.fetchSummary, - null, - state, - [{ type: types.SET_SUMMARY, payload: summary }], - [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - ); - }); - - it('should create alert on API error', async () => { - await testAction( - actions.fetchSummary, - null, - { summaryEndpoint: null }, - [], - [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - ); - expect(createAlert).toHaveBeenCalled(); - }); - }); - - describe('fetch test suite', () => { - beforeEach(() => { - const buildIds = [1]; - testReports.test_suites[0].build_ids = buildIds; - mock - .onGet(suiteEndpoint, { params: { build_ids: buildIds } }) - .replyOnce(HTTP_STATUS_OK, testReports.test_suites[0], {}); - }); - - it('sets test suite and shows tests', () => { - const suite = testReports.test_suites[0]; - const index = 0; - - return testAction( - actions.fetchTestSuite, - index, - { ...state, testReports }, - [{ type: types.SET_SUITE, payload: { suite, index } }], - [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - ); - }); - - it('should call SET_SUITE_ERROR on error', () => { - const index = 0; - - return testAction( - actions.fetchTestSuite, - index, - { ...state, testReports, suiteEndpoint: null }, - [{ type: types.SET_SUITE_ERROR, payload: expect.any(Error) }], - [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - ); - }); - - describe('when we already have the suite data', () => { - it('should not fetch suite', () => { - const index = 0; - testReports.test_suites[0].hasFullSuite = true; - - return testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], []); - }); - }); - }); - - describe('set selected suite index', () => { - it('sets selectedSuiteIndex', () => { - const selectedSuiteIndex = 0; - - return testAction( - actions.setSelectedSuiteIndex, - selectedSuiteIndex, - { ...state, hasFullReport: true }, - [{ type: types.SET_SELECTED_SUITE_INDEX, payload: selectedSuiteIndex }], - [], - ); - }); - }); - - describe('remove selected suite index', () => { - it('sets selectedSuiteIndex to null', () => { - return testAction( - actions.removeSelectedSuiteIndex, - {}, - state, - [{ type: types.SET_SELECTED_SUITE_INDEX, payload: null }], - [], - ); - }); - }); - - describe('toggles loading', () => { - it('sets isLoading to true', () => { - return testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], []); - }); - - it('toggles isLoading to false', () => { - return testAction( - actions.toggleLoading, - {}, - { ...state, isLoading: true }, - [{ type: types.TOGGLE_LOADING }], - [], - ); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js deleted file mode 100644 index 70e3a01dbf1..00000000000 --- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js +++ /dev/null @@ -1,171 +0,0 @@ -import testReports from 'test_fixtures/pipelines/test_report.json'; -import * as getters from '~/pipelines/stores/test_reports/getters'; -import { - iconForTestStatus, - formatFilePath, - formattedTime, -} from '~/pipelines/stores/test_reports/utils'; - -describe('Getters TestReports Store', () => { - let state; - - const defaultState = { - blobPath: '/test/blob/path', - testReports, - selectedSuiteIndex: 0, - pageInfo: { - page: 1, - perPage: 2, - }, - }; - - const emptyState = { - blobPath: '', - testReports: {}, - selectedSuite: null, - pageInfo: { - page: 1, - perPage: 2, - }, - }; - - beforeEach(() => { - state = { - testReports, - }; - }); - - const setupState = (testState = defaultState) => { - state = testState; - }; - - describe('getTestSuites', () => { - it('should return the test suites', () => { - setupState(); - - const suites = getters.getTestSuites(state); - const expected = testReports.test_suites.map((x) => ({ - ...x, - formattedTime: formattedTime(x.total_time), - })); - - expect(suites).toEqual(expected); - }); - - it('should return an empty array when testReports is empty', () => { - setupState(emptyState); - - expect(getters.getTestSuites(state)).toEqual([]); - }); - }); - - describe('getSelectedSuite', () => { - it('should return the selected suite', () => { - setupState(); - - const selectedSuite = getters.getSelectedSuite(state); - const expected = testReports.test_suites[state.selectedSuiteIndex]; - - expect(selectedSuite).toEqual(expected); - }); - }); - - describe('getSuiteTests', () => { - it('should return the current page of test cases inside the suite', () => { - setupState(); - - const cases = getters.getSuiteTests(state); - const expected = testReports.test_suites[0].test_cases - .map((x) => ({ - ...x, - filePath: `${state.blobPath}/${formatFilePath(x.file)}`, - formattedTime: formattedTime(x.execution_time), - icon: iconForTestStatus(x.status), - })) - .slice(0, state.pageInfo.perPage); - - expect(cases).toEqual(expected); - }); - - it('should return an empty array when testReports is empty', () => { - setupState(emptyState); - - expect(getters.getSuiteTests(state)).toEqual([]); - }); - - describe('when a test case classname property is null', () => { - it('should return an empty string value for the classname property', () => { - const testCases = testReports.test_suites[0].test_cases; - setupState({ - ...defaultState, - testReports: { - ...testReports, - test_suites: [ - { - test_cases: testCases.map((testCase) => ({ - ...testCase, - classname: null, - })), - }, - ], - }, - }); - - const expected = testCases - .map((x) => ({ - ...x, - classname: '', - filePath: `${state.blobPath}/${formatFilePath(x.file)}`, - formattedTime: formattedTime(x.execution_time), - icon: iconForTestStatus(x.status), - })) - .slice(0, state.pageInfo.perPage); - - expect(getters.getSuiteTests(state)).toEqual(expected); - }); - }); - - describe('when a test case name property is null', () => { - it('should return an empty string value for the name property', () => { - const testCases = testReports.test_suites[0].test_cases; - setupState({ - ...defaultState, - testReports: { - ...testReports, - test_suites: [ - { - test_cases: testCases.map((testCase) => ({ - ...testCase, - name: null, - })), - }, - ], - }, - }); - - const expected = testCases - .map((x) => ({ - ...x, - name: '', - filePath: `${state.blobPath}/${formatFilePath(x.file)}`, - formattedTime: formattedTime(x.execution_time), - icon: iconForTestStatus(x.status), - })) - .slice(0, state.pageInfo.perPage); - - expect(getters.getSuiteTests(state)).toEqual(expected); - }); - }); - }); - - describe('getSuiteTestCount', () => { - it('should return the total number of test cases', () => { - setupState(); - - const testCount = getters.getSuiteTestCount(state); - const expected = testReports.test_suites[0].test_cases.length; - - expect(testCount).toEqual(expected); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js deleted file mode 100644 index 685ac6ea3e5..00000000000 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ /dev/null @@ -1,114 +0,0 @@ -import testReports from 'test_fixtures/pipelines/test_report.json'; -import * as types from '~/pipelines/stores/test_reports/mutation_types'; -import mutations from '~/pipelines/stores/test_reports/mutations'; -import { createAlert } from '~/alert'; - -jest.mock('~/alert'); - -describe('Mutations TestReports Store', () => { - let mockState; - - const defaultState = { - endpoint: '', - testReports: {}, - selectedSuite: null, - isLoading: false, - pageInfo: { - page: 1, - perPage: 2, - }, - }; - - beforeEach(() => { - mockState = { ...defaultState }; - }); - - describe('set page', () => { - it('should set the current page to display', () => { - const pageToDisplay = 3; - mutations[types.SET_PAGE](mockState, pageToDisplay); - - expect(mockState.pageInfo.page).toEqual(pageToDisplay); - }); - }); - - describe('set suite', () => { - it('should set the suite at the given index', () => { - mockState.testReports = testReports; - const suite = { name: 'test_suite' }; - const index = 0; - const expectedState = { ...mockState }; - expectedState.testReports.test_suites[index] = { suite, hasFullSuite: true }; - mutations[types.SET_SUITE](mockState, { suite, index }); - - expect(mockState.testReports.test_suites[index]).toEqual( - expectedState.testReports.test_suites[index], - ); - }); - }); - - describe('set suite error', () => { - it('should set the error message in state if provided', () => { - const message = 'Test report artifacts not found'; - - mutations[types.SET_SUITE_ERROR](mockState, { - response: { data: { errors: message } }, - }); - - expect(mockState.errorMessage).toBe(message); - }); - - it('should show an alert otherwise', () => { - mutations[types.SET_SUITE_ERROR](mockState, {}); - - expect(createAlert).toHaveBeenCalled(); - }); - }); - - describe('set selected suite index', () => { - it('should set selectedSuiteIndex', () => { - const selectedSuiteIndex = 0; - mutations[types.SET_SELECTED_SUITE_INDEX](mockState, selectedSuiteIndex); - - expect(mockState.selectedSuiteIndex).toEqual(selectedSuiteIndex); - }); - }); - - describe('set summary', () => { - it('should set summary', () => { - const summary = { - total: { time: 0, count: 10, success: 1, failed: 2, skipped: 3, error: 4 }, - }; - const expectedSummary = { - ...summary, - total_time: 0, - total_count: 10, - success_count: 1, - failed_count: 2, - skipped_count: 3, - error_count: 4, - }; - mutations[types.SET_SUMMARY](mockState, summary); - - expect(mockState.testReports).toEqual(expectedSummary); - }); - }); - - describe('toggle loading', () => { - it('should set to true', () => { - const expectedState = { ...mockState, isLoading: true }; - mutations[types.TOGGLE_LOADING](mockState); - - expect(mockState.isLoading).toEqual(expectedState.isLoading); - }); - - it('should toggle back to false', () => { - const expectedState = { ...mockState, isLoading: false }; - mockState.isLoading = true; - - mutations[types.TOGGLE_LOADING](mockState); - - expect(mockState.isLoading).toEqual(expectedState.isLoading); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/stores/utils_spec.js b/spec/frontend/pipelines/test_reports/stores/utils_spec.js deleted file mode 100644 index 703fe69026c..00000000000 --- a/spec/frontend/pipelines/test_reports/stores/utils_spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import { formatFilePath, formattedTime } from '~/pipelines/stores/test_reports/utils'; - -describe('Test reports utils', () => { - 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(formatFilePath(file)).toBe(expected); - }); - }); - - describe('formattedTime', () => { - describe('when time is smaller than a second', () => { - it('should return time in milliseconds fixed to 2 decimals', () => { - const result = formattedTime(0.4815162342); - expect(result).toBe('481.52ms'); - }); - }); - - describe('when time is equal to a second', () => { - it('should return time in seconds fixed to 2 decimals', () => { - const result = formattedTime(1); - expect(result).toBe('1.00s'); - }); - }); - - describe('when time is greater than a second', () => { - it('should return time in seconds fixed to 2 decimals', () => { - const result = formattedTime(4.815162342); - expect(result).toBe('4.82s'); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js deleted file mode 100644 index f8663408817..00000000000 --- a/spec/frontend/pipelines/test_reports/test_case_details_spec.js +++ /dev/null @@ -1,149 +0,0 @@ -import { GlModal, GlLink } from '@gitlab/ui'; -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; - const defaultTestCase = { - classname: 'spec.test_spec', - name: 'Test#something cool', - file: '~/index.js', - filePath: '/src/javascripts/index.js', - formattedTime: '10.04ms', - recent_failures: { - count: 2, - base_branch: 'main', - }, - 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'); - const findFileLink = () => wrapper.findComponent(GlLink); - const findDuration = () => wrapper.findByTestId('test-case-duration'); - const findRecentFailures = () => wrapper.findByTestId('test-case-recent-failures'); - const findAttachmentUrl = () => wrapper.findByTestId('test-case-attachment-url'); - const findSystemOutput = () => wrapper.findByTestId('test-case-trace'); - - const createComponent = (testCase = {}) => { - wrapper = extendedWrapper( - shallowMount(TestCaseDetails, { - propsData: { - modalId: 'my-modal', - testCase: { - ...defaultTestCase, - ...testCase, - }, - }, - stubs: { CodeBlock, GlModal }, - }), - ); - }; - - describe('required details', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the test case classname as modal title', () => { - expect(findModal().props('title')).toBe(defaultTestCase.classname); - }); - - it('renders the test case name', () => { - expect(findName().text()).toBe(defaultTestCase.name); - }); - - it('renders the test case file', () => { - expect(findFile().text()).toBe(defaultTestCase.file); - 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); - }); - }); - - describe('when test case has execution time instead of formatted time', () => { - beforeEach(() => { - createComponent({ ...defaultTestCase, formattedTime: null, execution_time: 17 }); - }); - - it('renders the test case duration', () => { - expect(findDuration().text()).toBe('17 s'); - }); - }); - - describe('when test case has recent failures', () => { - describe('has only 1 recent failure', () => { - it('renders the recent failure', () => { - createComponent({ recent_failures: { ...defaultTestCase.recent_failures, count: 1 } }); - - expect(findRecentFailures().text()).toContain( - `Failed 1 time in ${defaultTestCase.recent_failures.base_branch} in the last 14 days`, - ); - }); - }); - - describe('has more than 1 recent failure', () => { - it('renders the recent failures', () => { - createComponent(); - - expect(findRecentFailures().text()).toContain( - `Failed ${defaultTestCase.recent_failures.count} times in ${defaultTestCase.recent_failures.base_branch} in the last 14 days`, - ); - }); - }); - }); - - describe('when test case does not have recent failures', () => { - it('does not render the recent failures', () => { - createComponent({ recent_failures: null }); - - expect(findRecentFailures().exists()).toBe(false); - }); - }); - - describe('when test case has attachment URL', () => { - it('renders the attachment URL as a link', () => { - const expectedUrl = '/my/path.jpg'; - createComponent({ attachment_url: expectedUrl }); - const attachmentUrl = findAttachmentUrl(); - - expect(attachmentUrl.exists()).toBe(true); - expect(attachmentUrl.attributes('href')).toBe(expectedUrl); - }); - }); - - describe('when test case does not have attachment URL', () => { - it('does not render the attachment URL', () => { - createComponent({ attachment_url: null }); - - expect(findAttachmentUrl().exists()).toBe(false); - }); - }); - - describe('when test case has system output', () => { - it('renders the test case system output', () => { - createComponent(); - - expect(findSystemOutput().text()).toContain(defaultTestCase.system_output); - }); - }); - - describe('when test case does not have system output', () => { - it('does not render the test case system output', () => { - createComponent({ system_output: null }); - - expect(findSystemOutput().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js deleted file mode 100644 index de16f496eff..00000000000 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ /dev/null @@ -1,125 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import testReports from 'test_fixtures/pipelines/test_report.json'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import EmptyState from '~/pipelines/components/test_reports/empty_state.vue'; -import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; -import TestSummary from '~/pipelines/components/test_reports/test_summary.vue'; -import TestSummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; -import * as getters from '~/pipelines/stores/test_reports/getters'; - -Vue.use(Vuex); - -describe('Test reports app', () => { - let wrapper; - let store; - - const loadingSpinner = () => wrapper.findComponent(GlLoadingIcon); - const testsDetail = () => wrapper.findByTestId('tests-detail'); - const emptyState = () => wrapper.findComponent(EmptyState); - const testSummary = () => wrapper.findComponent(TestSummary); - const testSummaryTable = () => wrapper.findComponent(TestSummaryTable); - - const actionSpies = { - fetchTestSuite: jest.fn(), - fetchSummary: jest.fn(), - setSelectedSuiteIndex: jest.fn(), - removeSelectedSuiteIndex: jest.fn(), - }; - - const createComponent = ({ state = {} } = {}) => { - store = new Vuex.Store({ - modules: { - testReports: { - namespaced: true, - state: { - isLoading: false, - selectedSuiteIndex: null, - testReports, - ...state, - }, - actions: actionSpies, - getters, - }, - }, - }); - - jest.spyOn(store, 'registerModule').mockReturnValue(null); - - wrapper = extendedWrapper( - shallowMount(TestReports, { - provide: { - blobPath: '/blob/path', - summaryEndpoint: '/summary.json', - suiteEndpoint: '/suite.json', - }, - store, - }), - ); - }; - - describe('when component is created', () => { - it('should call fetchSummary when pipeline has test report', () => { - createComponent(); - - expect(actionSpies.fetchSummary).toHaveBeenCalled(); - }); - }); - - describe('when loading', () => { - beforeEach(() => createComponent({ state: { isLoading: true } })); - - it('shows the loading spinner', () => { - expect(emptyState().exists()).toBe(false); - expect(testsDetail().exists()).toBe(false); - expect(loadingSpinner().exists()).toBe(true); - }); - }); - - describe('when the api returns no data', () => { - it('displays empty state component', () => { - createComponent({ state: { testReports: {} } }); - - expect(emptyState().exists()).toBe(true); - }); - }); - - describe('when the api returns data', () => { - beforeEach(() => createComponent()); - - it('sets testReports and shows tests', () => { - expect(wrapper.vm.testReports).toEqual(expect.any(Object)); - expect(wrapper.vm.showTests).toBe(true); - }); - - it('shows tests details', () => { - expect(testsDetail().exists()).toBe(true); - }); - }); - - describe('when a suite is clicked', () => { - beforeEach(() => { - createComponent({ state: { hasFullReport: true } }); - testSummaryTable().vm.$emit('row-click', 0); - }); - - it('should call setSelectedSuiteIndex and fetchTestSuite', () => { - expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); - expect(actionSpies.fetchTestSuite).toHaveBeenCalled(); - }); - }); - - describe('when clicking back to summary', () => { - beforeEach(() => { - createComponent({ state: { selectedSuiteIndex: 0 } }); - testSummary().vm.$emit('on-back-click'); - }); - - it('should call removeSelectedSuiteIndex', () => { - expect(actionSpies.removeSelectedSuiteIndex).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js deleted file mode 100644 index 08b430fa703..00000000000 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ /dev/null @@ -1,169 +0,0 @@ -import { GlButton, GlFriendlyWrap, GlLink, GlPagination, GlEmptyState } from '@gitlab/ui'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import testReports from 'test_fixtures/pipelines/test_report.json'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import SuiteTable, { i18n } from '~/pipelines/components/test_reports/test_suite_table.vue'; -import { TestStatus } from '~/pipelines/constants'; -import * as getters from '~/pipelines/stores/test_reports/getters'; -import { formatFilePath } from '~/pipelines/stores/test_reports/utils'; -import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from '~/pipelines/stores/test_reports/constants'; -import skippedTestCases from './mock_data'; - -Vue.use(Vuex); - -describe('Test reports suite table', () => { - let wrapper; - let store; - - const { - test_suites: [testSuite], - } = testReports; - - testSuite.test_cases = [...testSuite.test_cases, ...skippedTestCases]; - const testCases = testSuite.test_cases; - const blobPath = '/test/blob/path'; - - const noCasesMessage = () => wrapper.findByTestId('no-test-cases'); - const artifactsExpiredMessage = () => wrapper.findByTestId('artifacts-expired'); - const artifactsExpiredEmptyState = () => wrapper.findComponent(GlEmptyState); - const allCaseRows = () => wrapper.findAllByTestId('test-case-row'); - const findCaseRowAtIndex = (index) => wrapper.findAllByTestId('test-case-row').at(index); - const findLinkForRow = (row) => row.findComponent(GlLink); - const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); - - const createComponent = ({ suite = testSuite, perPage = 20, errorMessage } = {}) => { - store = new Vuex.Store({ - modules: { - testReports: { - namespaced: true, - state: { - blobPath, - testReports: { - test_suites: [suite], - }, - selectedSuiteIndex: 0, - pageInfo: { - page: 1, - perPage, - }, - errorMessage, - }, - getters, - }, - }, - }); - - wrapper = shallowMountExtended(SuiteTable, { - provide: { - blobPath: '/blob/path', - summaryEndpoint: '/summary.json', - suiteEndpoint: '/suite.json', - }, - store, - stubs: { GlFriendlyWrap }, - }); - }; - - it('should render a message when there are no test cases', () => { - createComponent({ suite: [] }); - - expect(noCasesMessage().exists()).toBe(true); - expect(artifactsExpiredMessage().exists()).toBe(false); - }); - - it('should render an empty state when artifacts have expired', () => { - createComponent({ suite: [], errorMessage: ARTIFACTS_EXPIRED_ERROR_MESSAGE }); - const emptyState = artifactsExpiredEmptyState(); - - expect(noCasesMessage().exists()).toBe(false); - expect(artifactsExpiredMessage().exists()).toBe(true); - - expect(emptyState.exists()).toBe(true); - expect(emptyState.props('title')).toBe(i18n.expiredArtifactsTitle); - }); - - describe('when a test suite is supplied', () => { - beforeEach(() => createComponent()); - - it('renders the correct number of rows', () => { - expect(allCaseRows()).toHaveLength(testCases.length); - }); - - it.each([ - TestStatus.ERROR, - TestStatus.FAILED, - TestStatus.SKIPPED, - TestStatus.SUCCESS, - 'unknown', - ])('renders the correct icon for test case with %s status', (status) => { - const test = testCases.findIndex((x) => x.status === status); - const row = findCaseRowAtIndex(test); - - expect(findIconForRow(row, status).exists()).toBe(true); - }); - - it('renders the file name for the test with a copy button', () => { - const { file } = testCases[0]; - const relativeFile = formatFilePath(file); - const filePath = `${blobPath}/${relativeFile}`; - const row = findCaseRowAtIndex(0); - const fileLink = findLinkForRow(row); - const button = row.findComponent(GlButton); - - expect(fileLink.attributes('href')).toBe(filePath); - expect(row.text()).toContain(file); - expect(button.exists()).toBe(true); - expect(button.attributes('data-clipboard-text')).toBe(file); - }); - }); - - describe('when a test suite has more test cases than the pagination size', () => { - const perPage = 2; - - beforeEach(() => { - createComponent({ testSuite, perPage }); - }); - - it('renders one page of test cases', () => { - expect(allCaseRows().length).toBe(perPage); - }); - - it('renders a pagination component', () => { - expect(wrapper.findComponent(GlPagination).exists()).toBe(true); - }); - }); - - describe('when a test case classname property is null', () => { - it('still renders all test cases', () => { - createComponent({ - testSuite: { - ...testSuite, - test_cases: testSuite.test_cases.map((testCase) => ({ - ...testCase, - classname: null, - })), - }, - }); - - expect(allCaseRows()).toHaveLength(testCases.length); - }); - }); - - describe('when a test case name property is null', () => { - it('still renders all test cases', () => { - createComponent({ - testSuite: { - ...testSuite, - test_cases: testSuite.test_cases.map((testCase) => ({ - ...testCase, - name: null, - })), - }, - }); - - expect(allCaseRows()).toHaveLength(testCases.length); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js deleted file mode 100644 index 7eed6671fb9..00000000000 --- a/spec/frontend/pipelines/test_reports/test_summary_spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { mount } from '@vue/test-utils'; -import testReports from 'test_fixtures/pipelines/test_report.json'; -import Summary from '~/pipelines/components/test_reports/test_summary.vue'; -import { formattedTime } from '~/pipelines/stores/test_reports/utils'; - -describe('Test reports summary', () => { - let wrapper; - - const { - test_suites: [testSuite], - } = testReports; - - const backButton = () => wrapper.find('.js-back-button'); - const totalTests = () => wrapper.find('.js-total-tests'); - const failedTests = () => wrapper.find('.js-failed-tests'); - const erroredTests = () => wrapper.find('.js-errored-tests'); - const successRate = () => wrapper.find('.js-success-rate'); - const duration = () => wrapper.find('.js-duration'); - - const defaultProps = { - report: testSuite, - showBack: false, - }; - - const createComponent = (props) => { - wrapper = mount(Summary, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - describe('should not render', () => { - beforeEach(() => { - createComponent(); - }); - - it('a back button by default', () => { - expect(backButton().exists()).toBe(false); - }); - }); - - describe('should render', () => { - beforeEach(() => { - createComponent(); - }); - - it('a back button and emit on-back-click event', () => { - createComponent({ - showBack: true, - }); - - expect(backButton().exists()).toBe(true); - }); - }); - - describe('when a report is supplied', () => { - beforeEach(() => { - createComponent(); - }); - - it('displays the correct total', () => { - expect(totalTests().text()).toBe('4 tests'); - }); - - it('displays the correct failure count', () => { - expect(failedTests().text()).toBe('2 failures'); - }); - - it('displays the correct error count', () => { - expect(erroredTests().text()).toBe('0 errors'); - }); - - it('calculates and displays percentages correctly', () => { - expect(successRate().text()).toBe('50% success rate'); - }); - - it('displays the correctly formatted duration', () => { - expect(duration().text()).toBe(formattedTime(testSuite.total_time)); - }); - }); - - describe('success percentage calculation', () => { - it.each` - name | successCount | totalCount | skippedCount | result - ${'displays 0 when there are no tests'} | ${0} | ${0} | ${0} | ${'0'} - ${'displays whole number when possible'} | ${10} | ${50} | ${0} | ${'20'} - ${'excludes skipped tests from total'} | ${10} | ${50} | ${5} | ${'22.22'} - ${'rounds to 0.01'} | ${1} | ${16604} | ${0} | ${'0.01'} - ${'correctly rounds to 50'} | ${8302} | ${16604} | ${0} | ${'50'} - ${'rounds down for large close numbers'} | ${16603} | ${16604} | ${0} | ${'99.99'} - ${'correctly displays 100'} | ${16604} | ${16604} | ${0} | ${'100'} - `('$name', ({ successCount, totalCount, skippedCount, result }) => { - createComponent({ - report: { - success_count: successCount, - skipped_count: skippedCount, - total_count: totalCount, - }, - }); - - expect(successRate().text()).toBe(`${result}% success rate`); - }); - }); -}); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js deleted file mode 100644 index a45946d5a03..00000000000 --- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js +++ /dev/null @@ -1,100 +0,0 @@ -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -// eslint-disable-next-line no-restricted-imports -import Vuex from 'vuex'; -import testReports from 'test_fixtures/pipelines/test_report.json'; -import SummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; -import * as getters from '~/pipelines/stores/test_reports/getters'; - -Vue.use(Vuex); - -describe('Test reports summary table', () => { - let wrapper; - let store; - - const allSuitesRows = () => wrapper.findAll('.js-suite-row'); - const noSuitesToShow = () => wrapper.find('.js-no-tests-suites'); - - const defaultProps = { - testReports, - }; - - const createComponent = (reports = null) => { - store = new Vuex.Store({ - modules: { - testReports: { - namespaced: true, - state: { - testReports: reports || testReports, - }, - getters, - }, - }, - }); - - wrapper = mount(SummaryTable, { - provide: { - blobPath: '/blob/path', - summaryEndpoint: '/summary.json', - suiteEndpoint: '/suite.json', - }, - propsData: defaultProps, - store, - }); - }; - - describe('when test reports are supplied', () => { - beforeEach(() => createComponent()); - const findErrorIcon = () => wrapper.findComponent({ ref: 'suiteErrorIcon' }); - - it('renders the correct number of rows', () => { - expect(noSuitesToShow().exists()).toBe(false); - expect(allSuitesRows().length).toBe(testReports.test_suites.length); - }); - - describe('when there is a suite error', () => { - beforeEach(() => { - createComponent({ - test_suites: [ - { - ...testReports.test_suites[0], - suite_error: 'Suite Error', - }, - ], - }); - }); - - it('renders error icon', () => { - expect(findErrorIcon().exists()).toBe(true); - expect(findErrorIcon().attributes('title')).toEqual('Suite Error'); - }); - }); - - describe('when there is not a suite error', () => { - beforeEach(() => { - createComponent({ - test_suites: [ - { - ...testReports.test_suites[0], - suite_error: null, - }, - ], - }); - }); - - it('does not render error icon', () => { - expect(findErrorIcon().exists()).toBe(false); - }); - }); - }); - - describe('when there are no test suites', () => { - beforeEach(() => { - createComponent({ test_suites: [] }); - }); - - it('displays the no suites to show message', () => { - expect(noSuitesToShow().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js deleted file mode 100644 index d2aa340a980..00000000000 --- a/spec/frontend/pipelines/time_ago_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import { GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue'; - -describe('Timeago component', () => { - let wrapper; - - const defaultProps = { duration: 0, finished_at: '' }; - - const createComponent = (props = defaultProps, extraProps) => { - wrapper = extendedWrapper( - shallowMount(TimeAgo, { - propsData: { - pipeline: { - details: { - ...props, - }, - }, - ...extraProps, - }, - data() { - return { - iconTimerSvg: ``, - }; - }, - }), - ); - }; - - const duration = () => wrapper.find('.duration'); - const finishedAt = () => wrapper.find('.finished-at'); - const findCalendarIcon = () => wrapper.findByTestId('calendar-icon'); - - describe('with duration', () => { - beforeEach(() => { - createComponent({ duration: 10, finished_at: '' }); - }); - - it('should render duration and timer svg', () => { - const icon = duration().findComponent(GlIcon); - - expect(duration().exists()).toBe(true); - expect(icon.props('name')).toBe('timer'); - }); - }); - - describe('without duration', () => { - beforeEach(() => { - createComponent(); - }); - - it('should not render duration and timer svg', () => { - expect(duration().exists()).toBe(false); - }); - }); - - describe('with finishedTime', () => { - it('should render time', () => { - createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); - - const time = finishedAt().find('time'); - - expect(finishedAt().exists()).toBe(true); - expect(time.exists()).toBe(true); - }); - - it('should display calendar icon', () => { - createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); - - expect(findCalendarIcon().exists()).toBe(true); - }); - }); - - describe('without finishedTime', () => { - beforeEach(() => { - createComponent(); - }); - - it('should not render time and calendar icon', () => { - expect(finishedAt().exists()).toBe(false); - expect(findCalendarIcon().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js deleted file mode 100644 index d518519a424..00000000000 --- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js +++ /dev/null @@ -1,142 +0,0 @@ -import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; -import { nextTick } from 'vue'; -import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; -import Api from '~/api'; -import PipelineBranchNameToken from '~/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue'; -import { branches, mockBranchesAfterMap } from '../mock_data'; - -describe('Pipeline Branch Name Token', () => { - let wrapper; - - const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); - const findAllFilteredSearchSuggestions = () => - wrapper.findAllComponents(GlFilteredSearchSuggestion); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const getBranchSuggestions = () => - findAllFilteredSearchSuggestions().wrappers.map((w) => w.text()); - - const stubs = { - GlFilteredSearchToken: { - template: `
    `, - }, - }; - - const defaultProps = { - config: { - type: 'ref', - icon: 'branch', - title: 'Branch name', - unique: true, - projectId: '21', - defaultBranchName: null, - disabled: false, - }, - value: { - data: '', - }, - cursorPosition: 'start', - }; - - const optionsWithDefaultBranchName = (options) => { - return { - propsData: { - ...defaultProps, - config: { - ...defaultProps.config, - defaultBranchName: 'main', - }, - }, - ...options, - }; - }; - - const createComponent = (options, data) => { - wrapper = shallowMount(PipelineBranchNameToken, { - propsData: { - ...defaultProps, - }, - data() { - return { - ...data, - }; - }, - ...options, - }); - }; - - beforeEach(() => { - jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); - - createComponent(); - }); - - it('passes config correctly', () => { - expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); - }); - - it('fetches and sets project branches', () => { - expect(Api.branches).toHaveBeenCalled(); - - expect(wrapper.vm.branches).toEqual(mockBranchesAfterMap); - expect(findLoadingIcon().exists()).toBe(false); - }); - - describe('displays loading icon correctly', () => { - it('shows loading icon', () => { - createComponent({ stubs }, { loading: true }); - - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('does not show loading icon', () => { - createComponent({ stubs }, { loading: false }); - - expect(findLoadingIcon().exists()).toBe(false); - }); - }); - - describe('shows branches correctly', () => { - it('renders all branches', () => { - createComponent({ stubs }, { branches, loading: false }); - - expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length); - }); - - it('renders only the branch searched for', () => { - const mockBranches = ['main']; - createComponent({ stubs }, { branches: mockBranches, loading: false }); - - expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length); - }); - - it('shows the default branch first if no branch was searched for', async () => { - const mockBranches = [{ name: 'branch-1' }]; - jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); - - createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false }); - await nextTick(); - expect(getBranchSuggestions()).toEqual(['main', 'branch-1']); - }); - - it('does not show the default branch if a search term was provided', async () => { - const mockBranches = [{ name: 'branch-1' }]; - jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); - - createComponent(optionsWithDefaultBranchName(), { loading: false }); - - findFilteredSearchToken().vm.$emit('input', { data: 'branch-1' }); - await waitForPromises(); - expect(getBranchSuggestions()).toEqual(['branch-1']); - }); - - it('shows the default branch only once if it appears in the results', async () => { - const mockBranches = [{ name: 'main' }]; - jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); - - createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false }); - await nextTick(); - expect(getBranchSuggestions()).toEqual(['main']); - }); - }); -}); diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js deleted file mode 100644 index 60abb63a7e0..00000000000 --- a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js +++ /dev/null @@ -1,53 +0,0 @@ -import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { PIPELINE_SOURCES } from 'ee_else_ce/pipelines/components/pipelines_list/tokens/constants'; -import { stubComponent } from 'helpers/stub_component'; -import PipelineSourceToken from '~/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue'; - -describe('Pipeline Source Token', () => { - let wrapper; - - const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); - const findAllFilteredSearchSuggestions = () => - wrapper.findAllComponents(GlFilteredSearchSuggestion); - - const defaultProps = { - config: { - type: 'source', - icon: 'trigger-source', - title: 'Source', - unique: true, - }, - value: { - data: '', - }, - cursorPosition: 'start', - }; - - const createComponent = () => { - wrapper = shallowMount(PipelineSourceToken, { - propsData: { - ...defaultProps, - }, - stubs: { - GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { - template: `
    `, - }), - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('passes config correctly', () => { - expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); - }); - - describe('shows sources correctly', () => { - it('renders all pipeline sources available', () => { - expect(findAllFilteredSearchSuggestions()).toHaveLength(PIPELINE_SOURCES.length); - }); - }); -}); diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js deleted file mode 100644 index cf4ccb5ce43..00000000000 --- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { stubComponent } from 'helpers/stub_component'; -import PipelineStatusToken from '~/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue'; -import { - TOKEN_TITLE_STATUS, - TOKEN_TYPE_STATUS, -} from '~/vue_shared/components/filtered_search_bar/constants'; - -describe('Pipeline Status Token', () => { - let wrapper; - - const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); - const findAllFilteredSearchSuggestions = () => - wrapper.findAllComponents(GlFilteredSearchSuggestion); - const findAllGlIcons = () => wrapper.findAllComponents(GlIcon); - - const defaultProps = { - config: { - type: TOKEN_TYPE_STATUS, - icon: 'status', - title: TOKEN_TITLE_STATUS, - unique: true, - }, - value: { - data: '', - }, - cursorPosition: 'start', - }; - - const createComponent = () => { - wrapper = shallowMount(PipelineStatusToken, { - propsData: { - ...defaultProps, - }, - stubs: { - GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { - template: `
    `, - }), - }, - }); - }; - - beforeEach(() => { - createComponent(); - }); - - it('passes config correctly', () => { - expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); - }); - - describe('shows statuses correctly', () => { - it('renders all pipeline statuses available', () => { - expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.statuses.length); - expect(findAllGlIcons()).toHaveLength(wrapper.vm.statuses.length); - }); - }); -}); diff --git a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js deleted file mode 100644 index 88c88d8f16f..00000000000 --- a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js +++ /dev/null @@ -1,95 +0,0 @@ -import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Api from '~/api'; -import PipelineTagNameToken from '~/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue'; -import { tags, mockTagsAfterMap } from '../mock_data'; - -describe('Pipeline Branch Name Token', () => { - let wrapper; - - const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); - const findAllFilteredSearchSuggestions = () => - wrapper.findAllComponents(GlFilteredSearchSuggestion); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - - const stubs = { - GlFilteredSearchToken: { - template: `
    `, - }, - }; - - const defaultProps = { - config: { - type: 'tag', - icon: 'tag', - title: 'Tag name', - unique: true, - projectId: '21', - disabled: false, - }, - value: { - data: '', - }, - cursorPosition: 'start', - }; - - const createComponent = (options, data) => { - wrapper = shallowMount(PipelineTagNameToken, { - propsData: { - ...defaultProps, - }, - data() { - return { - ...data, - }; - }, - ...options, - }); - }; - - beforeEach(() => { - jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); - - createComponent(); - }); - - it('passes config correctly', () => { - expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); - }); - - it('fetches and sets project tags', () => { - expect(Api.tags).toHaveBeenCalled(); - - expect(wrapper.vm.tags).toEqual(mockTagsAfterMap); - expect(findLoadingIcon().exists()).toBe(false); - }); - - describe('displays loading icon correctly', () => { - it('shows loading icon', () => { - createComponent({ stubs }, { loading: true }); - - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('does not show loading icon', () => { - createComponent({ stubs }, { loading: false }); - - expect(findLoadingIcon().exists()).toBe(false); - }); - }); - - describe('shows tags correctly', () => { - it('renders all tags', () => { - createComponent({ stubs }, { tags, loading: false }); - - expect(findAllFilteredSearchSuggestions()).toHaveLength(tags.length); - }); - - it('renders only the tag searched for', () => { - const mockTags = ['main-tag']; - createComponent({ stubs }, { tags: mockTags, loading: false }); - - expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length); - }); - }); -}); diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js deleted file mode 100644 index e9ec684a350..00000000000 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ /dev/null @@ -1,99 +0,0 @@ -import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { stubComponent } from 'helpers/stub_component'; -import Api from '~/api'; -import PipelineTriggerAuthorToken from '~/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue'; -import { users } from '../mock_data'; - -describe('Pipeline Trigger Author Token', () => { - let wrapper; - - const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); - const findAllFilteredSearchSuggestions = () => - wrapper.findAllComponents(GlFilteredSearchSuggestion); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - - const defaultProps = { - config: { - type: 'username', - icon: 'user', - title: 'Trigger author', - dataType: 'username', - unique: true, - triggerAuthors: users, - }, - value: { - data: '', - }, - cursorPosition: 'start', - }; - - const createComponent = (data) => { - wrapper = shallowMount(PipelineTriggerAuthorToken, { - propsData: { - ...defaultProps, - }, - data() { - return { - ...data, - }; - }, - stubs: { - GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { - template: `
    `, - }), - }, - }); - }; - - beforeEach(() => { - jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); - - createComponent(); - }); - - it('passes config correctly', () => { - expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); - }); - - it('fetches and sets project users', () => { - expect(Api.projectUsers).toHaveBeenCalled(); - - expect(wrapper.vm.users).toEqual(users); - expect(findLoadingIcon().exists()).toBe(false); - }); - - describe('displays loading icon correctly', () => { - it('shows loading icon', () => { - createComponent({ loading: true }); - - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('does not show loading icon', () => { - createComponent({ loading: false }); - - expect(findLoadingIcon().exists()).toBe(false); - }); - }); - - describe('shows trigger authors correctly', () => { - beforeEach(() => {}); - - it('renders all trigger authors', () => { - createComponent({ users, loading: false }); - - // should have length of all users plus the static 'Any' option - expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1); - }); - - it('renders only the trigger author searched for', () => { - createComponent({ - users: [{ name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }], - loading: false, - }); - - expect(findAllFilteredSearchSuggestions()).toHaveLength(2); - }); - }); -}); diff --git a/spec/frontend/pipelines/unwrapping_utils_spec.js b/spec/frontend/pipelines/unwrapping_utils_spec.js deleted file mode 100644 index a6ce7d4049f..00000000000 --- a/spec/frontend/pipelines/unwrapping_utils_spec.js +++ /dev/null @@ -1,127 +0,0 @@ -import { - unwrapGroups, - unwrapNodesWithName, - unwrapStagesWithNeeds, -} from '~/pipelines/components/unwrapping_utils'; - -const groupsArray = [ - { - name: 'build_a', - size: 1, - status: { - label: 'passed', - group: 'success', - icon: 'status_success', - }, - }, - { - name: 'bob_the_build', - size: 1, - status: { - label: 'passed', - group: 'success', - icon: 'status_success', - }, - }, -]; - -const basicStageInfo = { - name: 'center_stage', - status: { - action: null, - }, -}; - -const stagesAndGroups = [ - { - ...basicStageInfo, - groups: { - nodes: groupsArray, - }, - }, -]; - -const needArray = [ - { - name: 'build_b', - }, -]; - -const elephantArray = [ - { - name: 'build_b', - elephant: 'gray', - }, -]; - -const baseJobs = { - name: 'test_d', - status: { - icon: 'status_success', - tooltip: null, - hasDetails: true, - detailsPath: '/root/abcd-dag/-/pipelines/162', - group: 'success', - action: null, - }, -}; - -const jobArrayWithNeeds = [ - { - ...baseJobs, - needs: { - nodes: needArray, - }, - }, -]; - -const jobArrayWithElephant = [ - { - ...baseJobs, - needs: { - nodes: elephantArray, - }, - }, -]; - -const completeMock = [ - { - ...basicStageInfo, - groups: { - nodes: groupsArray.map((group) => ({ ...group, jobs: { nodes: jobArrayWithNeeds } })), - }, - }, -]; - -describe('Shared pipeline unwrapping utils', () => { - describe('unwrapGroups', () => { - it('takes stages without nodes and returns the unwrapped groups', () => { - expect(unwrapGroups(stagesAndGroups)[0].node.groups).toEqual(groupsArray); - }); - - it('keeps other stage properties intact', () => { - expect(unwrapGroups(stagesAndGroups)[0].node).toMatchObject(basicStageInfo); - }); - }); - - describe('unwrapNodesWithName', () => { - it('works with no field argument', () => { - expect(unwrapNodesWithName(jobArrayWithNeeds, 'needs')[0].needs).toEqual([needArray[0].name]); - }); - - it('works with custom field argument', () => { - expect(unwrapNodesWithName(jobArrayWithElephant, 'needs', 'elephant')[0].needs).toEqual([ - elephantArray[0].elephant, - ]); - }); - }); - - describe('unwrapStagesWithNeeds', () => { - it('removes nodes from groups, jobs, and needs', () => { - const firstProcessedGroup = unwrapStagesWithNeeds(completeMock)[0].groups[0]; - expect(firstProcessedGroup).toMatchObject(groupsArray[0]); - expect(firstProcessedGroup.jobs[0]).toMatchObject(baseJobs); - expect(firstProcessedGroup.jobs[0].needs[0]).toBe(needArray[0].name); - }); - }); -}); diff --git a/spec/frontend/pipelines/utils_spec.js b/spec/frontend/pipelines/utils_spec.js deleted file mode 100644 index 286d79edc6c..00000000000 --- a/spec/frontend/pipelines/utils_spec.js +++ /dev/null @@ -1,191 +0,0 @@ -import mockPipelineResponse from 'test_fixtures/pipelines/pipeline_details.json'; -import { createSankey } from '~/pipelines/components/dag/drawing_utils'; -import { - makeLinksFromNodes, - filterByAncestors, - generateColumnsFromLayersListBare, - keepLatestDownstreamPipelines, - listByLayers, - parseData, - removeOrphanNodes, - getMaxNodes, -} from '~/pipelines/components/parsing_utils'; -import { createNodeDict } from '~/pipelines/utils'; - -import { mockDownstreamPipelinesRest } from '../vue_merge_request_widget/mock_data'; -import { mockDownstreamPipelinesGraphql } from '../commit/mock_data'; -import { mockParsedGraphQLNodes, missingJob } from './components/dag/mock_data'; -import { generateResponse } from './graph/mock_data'; - -describe('DAG visualization parsing utilities', () => { - const nodeDict = createNodeDict(mockParsedGraphQLNodes); - const unfilteredLinks = makeLinksFromNodes(mockParsedGraphQLNodes, nodeDict); - const parsed = parseData(mockParsedGraphQLNodes); - - describe('makeLinksFromNodes', () => { - it('returns the expected link structure', () => { - expect(unfilteredLinks[0]).toHaveProperty('source', 'build_a'); - expect(unfilteredLinks[0]).toHaveProperty('target', 'test_a'); - expect(unfilteredLinks[0]).toHaveProperty('value', 10); - }); - - it('does not generate a link for non-existing jobs', () => { - const sources = unfilteredLinks.map(({ source }) => source); - - expect(sources.includes(missingJob)).toBe(false); - }); - }); - - describe('filterByAncestors', () => { - const allLinks = [ - { source: 'job1', target: 'job4' }, - { source: 'job1', target: 'job2' }, - { source: 'job2', target: 'job4' }, - ]; - - const dedupedLinks = [ - { source: 'job1', target: 'job2' }, - { source: 'job2', target: 'job4' }, - ]; - - const nodeLookup = { - job1: { - name: 'job1', - }, - job2: { - name: 'job2', - needs: ['job1'], - }, - job4: { - name: 'job4', - needs: ['job1', 'job2'], - category: 'build', - }, - }; - - it('dedupes links', () => { - expect(filterByAncestors(allLinks, nodeLookup)).toMatchObject(dedupedLinks); - }); - }); - - describe('parseData parent function', () => { - it('returns an object containing a list of nodes and links', () => { - // an array of nodes exist and the values are defined - expect(parsed).toHaveProperty('nodes'); - expect(Array.isArray(parsed.nodes)).toBe(true); - expect(parsed.nodes.filter(Boolean)).not.toHaveLength(0); - - // an array of links exist and the values are defined - expect(parsed).toHaveProperty('links'); - expect(Array.isArray(parsed.links)).toBe(true); - expect(parsed.links.filter(Boolean)).not.toHaveLength(0); - }); - }); - - describe('removeOrphanNodes', () => { - it('removes sankey nodes that have no needs and are not needed', () => { - const layoutSettings = { - width: 200, - height: 200, - nodeWidth: 10, - nodePadding: 20, - paddingForLabels: 100, - }; - - const sankeyLayout = createSankey(layoutSettings)(parsed); - const cleanedNodes = removeOrphanNodes(sankeyLayout.nodes); - /* - These lengths are determined by the mock data. - If the data changes, the numbers may also change. - */ - expect(parsed.nodes).toHaveLength(mockParsedGraphQLNodes.length); - expect(cleanedNodes).toHaveLength(12); - }); - }); - - describe('getMaxNodes', () => { - it('returns the number of nodes in the most populous generation', () => { - const layerNodes = [ - { layer: 0 }, - { layer: 0 }, - { layer: 1 }, - { layer: 1 }, - { layer: 0 }, - { layer: 3 }, - { layer: 2 }, - { layer: 4 }, - { layer: 1 }, - { layer: 3 }, - { layer: 4 }, - ]; - expect(getMaxNodes(layerNodes)).toBe(3); - }); - }); - - describe('generateColumnsFromLayersList', () => { - const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo'); - const { pipelineLayers } = listByLayers(pipeline); - const columns = generateColumnsFromLayersListBare(pipeline, pipelineLayers); - - it('returns stage-like objects with default name, id, and status', () => { - columns.forEach((col, idx) => { - expect(col).toMatchObject({ - name: '', - status: { action: null }, - id: `layer-${idx}`, - }); - }); - }); - - it('creates groups that match the list created in listByLayers', () => { - columns.forEach((col, idx) => { - const groupNames = col.groups.map(({ name }) => name); - expect(groupNames).toEqual(pipelineLayers[idx]); - }); - }); - - it('looks up the correct group object', () => { - columns.forEach((col) => { - col.groups.forEach((group) => { - const groupStage = pipeline.stages.find((el) => el.name === group.stageName); - const groupObject = groupStage.groups.find((el) => el.name === group.name); - expect(group).toBe(groupObject); - }); - }); - }); - }); -}); - -describe('linked pipeline utilities', () => { - describe('keepLatestDownstreamPipelines', () => { - it('filters data from GraphQL', () => { - const downstream = mockDownstreamPipelinesGraphql().nodes; - const latestDownstream = keepLatestDownstreamPipelines(downstream); - - expect(downstream).toHaveLength(3); - expect(latestDownstream).toHaveLength(1); - }); - - it('filters data from REST', () => { - const downstream = mockDownstreamPipelinesRest(); - const latestDownstream = keepLatestDownstreamPipelines(downstream); - - expect(downstream).toHaveLength(2); - expect(latestDownstream).toHaveLength(1); - }); - - it('returns downstream pipelines if sourceJob.retried is null', () => { - const downstream = mockDownstreamPipelinesGraphql({ includeSourceJobRetried: false }).nodes; - const latestDownstream = keepLatestDownstreamPipelines(downstream); - - expect(latestDownstream).toHaveLength(downstream.length); - }); - - it('returns downstream pipelines if source_job.retried is null', () => { - const downstream = mockDownstreamPipelinesRest({ includeSourceJobRetried: false }); - const latestDownstream = keepLatestDownstreamPipelines(downstream); - - expect(latestDownstream).toHaveLength(downstream.length); - }); - }); -}); diff --git a/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap index f675b6cf15c..7d5e0cccb38 100644 --- a/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap +++ b/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap @@ -7,7 +7,6 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` - @@ -16,71 +15,66 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` class="line_holder parallel" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - `; @@ -71,10 +61,10 @@ exports[`Repository table row component renders table row 1`] = ` class="tree-item" > - - `; @@ -137,10 +117,10 @@ exports[`Repository table row component renders table row for path with special class="tree-item" > - - `; diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index e20849d1085..c60b6ace598 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -73,17 +73,6 @@ export const projectMock = { }, }; -export const userInfoMock = { - currentUser: { - id: '123', - gitpodEnabled: true, - preferencesGitpodPath: '/-/profile/preferences#user_gitpod_enabled', - profileEnableGitpodPath: '/-/profile?user%5Bgitpod_enabled%5D=true', - }, -}; - -export const applicationInfoMock = { gitpodEnabled: true }; - export const propsMock = { path: 'some_file.js', projectPath: 'some/path' }; export const refMock = 'default-ref'; diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index a063f20aca6..7bddc4b1c48 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -194,7 +194,7 @@ export const MOCK_DATA_FOR_NAVIGATION_ACTION_MUTATION = { label: 'Projects', scope: 'projects', link: '/search?scope=projects&search=et', - count_link: '/search/count?scope=projects&search=et', + count_link: null, }, }; diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js index a4559c2dc34..8e23f9c1680 100644 --- a/spec/frontend/search/sidebar/components/app_spec.js +++ b/spec/frontend/search/sidebar/components/app_spec.js @@ -2,14 +2,26 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; +import { + SEARCH_TYPE_ZOEKT, + SEARCH_TYPE_ADVANCED, + SEARCH_TYPE_BASIC, +} from '~/search/sidebar/constants'; import { MOCK_QUERY } from 'jest/search/mock_data'; +import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; import GlobalSearchSidebar from '~/search/sidebar/components/app.vue'; import IssuesFilters from '~/search/sidebar/components/issues_filters.vue'; import MergeRequestsFilters from '~/search/sidebar/components/merge_requests_filters.vue'; import BlobsFilters from '~/search/sidebar/components/blobs_filters.vue'; import ProjectsFilters from '~/search/sidebar/components/projects_filters.vue'; +import NotesFilters from '~/search/sidebar/components/notes_filters.vue'; +import CommitsFilters from '~/search/sidebar/components/commits_filters.vue'; import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; +import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; + +jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager'); Vue.use(Vuex); @@ -20,7 +32,7 @@ describe('GlobalSearchSidebar', () => { currentScope: jest.fn(() => 'issues'), }; - const createComponent = (initialState = {}, ff = false) => { + const createComponent = (initialState = {}) => { const store = new Vuex.Store({ state: { urlQuery: MOCK_QUERY, @@ -33,7 +45,8 @@ describe('GlobalSearchSidebar', () => { store, provide: { glFeatures: { - searchProjectsHideArchived: ff, + searchNotesHideArchivedProjects: true, + searchCommitsHideArchivedProjects: true, }, }, }); @@ -44,69 +57,111 @@ describe('GlobalSearchSidebar', () => { const findMergeRequestsFilters = () => wrapper.findComponent(MergeRequestsFilters); const findBlobsFilters = () => wrapper.findComponent(BlobsFilters); const findProjectsFilters = () => wrapper.findComponent(ProjectsFilters); + const findNotesFilters = () => wrapper.findComponent(NotesFilters); + const findCommitsFilters = () => wrapper.findComponent(CommitsFilters); const findScopeLegacyNavigation = () => wrapper.findComponent(ScopeLegacyNavigation); + const findSmallScreenDrawerNavigation = () => wrapper.findComponent(SmallScreenDrawerNavigation); const findScopeSidebarNavigation = () => wrapper.findComponent(ScopeSidebarNavigation); + const findDomElementListener = () => wrapper.findComponent(DomElementListener); describe('renders properly', () => { describe('always', () => { beforeEach(() => { createComponent(); }); + it(`shows section`, () => { expect(findSidebarSection().exists()).toBe(true); }); }); describe.each` - scope | filter - ${'issues'} | ${findIssuesFilters} - ${'merge_requests'} | ${findMergeRequestsFilters} - ${'blobs'} | ${findBlobsFilters} - `('with sidebar $scope scope:', ({ scope, filter }) => { + scope | filter | searchType | isShown + ${'issues'} | ${findIssuesFilters} | ${SEARCH_TYPE_BASIC} | ${true} + ${'merge_requests'} | ${findMergeRequestsFilters} | ${SEARCH_TYPE_BASIC} | ${true} + ${'projects'} | ${findProjectsFilters} | ${SEARCH_TYPE_BASIC} | ${true} + ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_BASIC} | ${false} + ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} + ${'blobs'} | ${findBlobsFilters} | ${SEARCH_TYPE_ZOEKT} | ${false} + ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_BASIC} | ${false} + ${'notes'} | ${findNotesFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} + ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_BASIC} | ${false} + ${'commits'} | ${findCommitsFilters} | ${SEARCH_TYPE_ADVANCED} | ${true} + `('with sidebar $scope scope:', ({ scope, filter, searchType, isShown }) => { beforeEach(() => { getterSpies.currentScope = jest.fn(() => scope); - createComponent({ urlQuery: { scope } }); + createComponent({ urlQuery: { scope }, searchType }); }); - it(`shows filter ${filter.name.replace('find', '')}`, () => { - expect(filter().exists()).toBe(true); + it(`renders correctly filter ${filter.name.replace( + 'find', + '', + )} when search_type ${searchType}`, () => { + expect(filter().exists()).toBe(isShown); }); }); - describe.each` - featureFlag - ${false} - ${true} - `('with sidebar $scope scope:', ({ featureFlag }) => { + describe('filters for blobs will not load if zoekt is enabled', () => { + beforeEach(() => { + createComponent({ urlQuery: { scope: 'blobs' }, searchType: SEARCH_TYPE_ZOEKT }); + }); + + it("doesn't render blobs filters", () => { + expect(findBlobsFilters().exists()).toBe(false); + }); + }); + + describe('with sidebar scope: projects', () => { beforeEach(() => { getterSpies.currentScope = jest.fn(() => 'projects'); - createComponent({ urlQuery: { scope: 'projects' } }, featureFlag); + createComponent({ urlQuery: { scope: 'projects' } }); }); - it(`shows filter ProjectsFilters}`, () => { - expect(findProjectsFilters().exists()).toBe(featureFlag); + it(`shows filter ProjectsFilters`, () => { + expect(findProjectsFilters().exists()).toBe(true); }); }); describe.each` currentScope | sidebarNavShown | legacyNavShown ${'issues'} | ${false} | ${true} - ${''} | ${false} | ${false} + ${'test'} | ${false} | ${true} ${'issues'} | ${true} | ${false} - ${''} | ${true} | ${false} - `('renders navigation', ({ currentScope, sidebarNavShown, legacyNavShown }) => { - beforeEach(() => { - getterSpies.currentScope = jest.fn(() => currentScope); - createComponent({ useSidebarNavigation: sidebarNavShown }); - }); + ${'test'} | ${true} | ${false} + `( + 'renders navigation for scope $currentScope', + ({ currentScope, sidebarNavShown, legacyNavShown }) => { + beforeEach(() => { + getterSpies.currentScope = jest.fn(() => currentScope); + createComponent({ useSidebarNavigation: sidebarNavShown }); + }); - it(`${!legacyNavShown ? 'hides' : 'shows'} the legacy navigation`, () => { - expect(findScopeLegacyNavigation().exists()).toBe(legacyNavShown); - }); + it(`renders navigation correctly with legacyNavShown ${legacyNavShown}`, () => { + expect(findScopeLegacyNavigation().exists()).toBe(legacyNavShown); + expect(findSmallScreenDrawerNavigation().exists()).toBe(legacyNavShown); + }); - it(`${!sidebarNavShown ? 'hides' : 'shows'} the sidebar navigation`, () => { - expect(findScopeSidebarNavigation().exists()).toBe(sidebarNavShown); - }); + it(`renders navigation correctly with sidebarNavShown ${sidebarNavShown}`, () => { + expect(findScopeSidebarNavigation().exists()).toBe(sidebarNavShown); + }); + }, + ); + }); + + describe('when useSidebarNavigation=true', () => { + beforeEach(() => { + createComponent({ useSidebarNavigation: true }); + }); + + it('toggles super sidebar when button is clicked', () => { + const elListener = findDomElementListener(); + + expect(toggleSuperSidebarCollapsed).not.toHaveBeenCalled(); + + elListener.vm.$emit('click'); + + expect(toggleSuperSidebarCollapsed).toHaveBeenCalledTimes(1); + expect(elListener.props('selector')).toBe('#js-open-mobile-filters'); }); }); }); diff --git a/spec/frontend/search/sidebar/components/blobs_filters_spec.js b/spec/frontend/search/sidebar/components/blobs_filters_spec.js index ff93e6f32e4..729fae44c19 100644 --- a/spec/frontend/search/sidebar/components/blobs_filters_spec.js +++ b/spec/frontend/search/sidebar/components/blobs_filters_spec.js @@ -1,28 +1,93 @@ import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import { MOCK_QUERY } from 'jest/search/mock_data'; import BlobsFilters from '~/search/sidebar/components/blobs_filters.vue'; import LanguageFilter from '~/search/sidebar/components/language_filter/index.vue'; -import FiltersTemplate from '~/search/sidebar/components/filters_template.vue'; +import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; +import { SEARCH_TYPE_ADVANCED } from '~/search/sidebar/constants'; + +Vue.use(Vuex); describe('GlobalSearch BlobsFilters', () => { let wrapper; - const findLanguageFilter = () => wrapper.findComponent(LanguageFilter); - const findFiltersTemplate = () => wrapper.findComponent(FiltersTemplate); + const defaultGetters = { + currentScope: () => 'blobs', + }; - const createComponent = () => { - wrapper = shallowMount(BlobsFilters); + const createComponent = ({ initialState = {}, searchBlobsHideArchivedProjects = true } = {}) => { + const store = new Vuex.Store({ + state: { + urlQuery: MOCK_QUERY, + useSidebarNavigation: false, + searchType: SEARCH_TYPE_ADVANCED, + ...initialState, + }, + getters: defaultGetters, + }); + + wrapper = shallowMount(BlobsFilters, { + store, + provide: { + glFeatures: { + searchBlobsHideArchivedProjects, + }, + }, + }); }; - describe('Renders correctly', () => { + const findLanguageFilter = () => wrapper.findComponent(LanguageFilter); + const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); + const findDividers = () => wrapper.findAll('hr'); + + describe.each` + description | searchBlobsHideArchivedProjects + ${'Renders correctly with Archived Filter enabled'} | ${true} + ${'Renders correctly with Archived Filter disabled'} | ${false} + `('$description', ({ searchBlobsHideArchivedProjects }) => { + beforeEach(() => { + createComponent({ + searchBlobsHideArchivedProjects, + }); + }); + + it('renders LanguageFilter', () => { + expect(findLanguageFilter().exists()).toBe(true); + }); + + it(`renders correctly ArchivedFilter when searchBlobsHideArchivedProjects is ${searchBlobsHideArchivedProjects}`, () => { + expect(findArchivedFilter().exists()).toBe(searchBlobsHideArchivedProjects); + }); + + it('renders divider correctly', () => { + const dividersCount = searchBlobsHideArchivedProjects ? 1 : 0; + expect(findDividers()).toHaveLength(dividersCount); + }); + }); + + describe('Renders correctly in new nav', () => { beforeEach(() => { - createComponent(); + createComponent({ + initialState: { + searchType: SEARCH_TYPE_ADVANCED, + useSidebarNavigation: true, + }, + searchBlobsHideArchivedProjects: true, + }); }); - it('renders FiltersTemplate', () => { + + it('renders correctly LanguageFilter', () => { expect(findLanguageFilter().exists()).toBe(true); }); - it('renders ConfidentialityFilter', () => { - expect(findFiltersTemplate().exists()).toBe(true); + it('renders correctly ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); + }); + + it("doesn't render dividers", () => { + expect(findDividers()).toHaveLength(0); }); }); }); diff --git a/spec/frontend/search/sidebar/components/commits_filters_spec.js b/spec/frontend/search/sidebar/components/commits_filters_spec.js new file mode 100644 index 00000000000..cb47c6833ef --- /dev/null +++ b/spec/frontend/search/sidebar/components/commits_filters_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import CommitsFilters from '~/search/sidebar/components/projects_filters.vue'; +import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; +import FiltersTemplate from '~/search/sidebar/components/filters_template.vue'; + +describe('GlobalSearch CommitsFilters', () => { + let wrapper; + + const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); + const findFiltersTemplate = () => wrapper.findComponent(FiltersTemplate); + + const createComponent = () => { + wrapper = shallowMount(CommitsFilters); + }; + + describe('Renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); + }); + + it('renders FiltersTemplate', () => { + expect(findFiltersTemplate().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/issues_filters_spec.js b/spec/frontend/search/sidebar/components/issues_filters_spec.js index 84c4258cbdb..39d10cbb8b4 100644 --- a/spec/frontend/search/sidebar/components/issues_filters_spec.js +++ b/spec/frontend/search/sidebar/components/issues_filters_spec.js @@ -7,6 +7,8 @@ import IssuesFilters from '~/search/sidebar/components/issues_filters.vue'; import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter/index.vue'; import StatusFilter from '~/search/sidebar/components/status_filter/index.vue'; import LabelFilter from '~/search/sidebar/components/label_filter/index.vue'; +import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; +import { SEARCH_TYPE_ADVANCED, SEARCH_TYPE_BASIC } from '~/search/sidebar/constants'; Vue.use(Vuex); @@ -17,10 +19,16 @@ describe('GlobalSearch IssuesFilters', () => { currentScope: () => 'issues', }; - const createComponent = (initialState, ff = true) => { + const createComponent = ({ + initialState = {}, + searchIssueLabelAggregation = true, + searchIssuesHideArchivedProjects = true, + } = {}) => { const store = new Vuex.Store({ state: { urlQuery: MOCK_QUERY, + useSidebarNavigation: false, + searchType: SEARCH_TYPE_ADVANCED, ...initialState, }, getters: defaultGetters, @@ -30,7 +38,8 @@ describe('GlobalSearch IssuesFilters', () => { store, provide: { glFeatures: { - searchIssueLabelAggregation: ff, + searchIssueLabelAggregation, + searchIssuesHideArchivedProjects, }, }, }); @@ -39,12 +48,23 @@ describe('GlobalSearch IssuesFilters', () => { const findStatusFilter = () => wrapper.findComponent(StatusFilter); const findConfidentialityFilter = () => wrapper.findComponent(ConfidentialityFilter); const findLabelFilter = () => wrapper.findComponent(LabelFilter); + const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); const findDividers = () => wrapper.findAll('hr'); - describe('Renders correctly with FF enabled', () => { + describe.each` + description | searchIssueLabelAggregation | searchIssuesHideArchivedProjects + ${'Renders correctly with Label Filter disabled'} | ${false} | ${true} + ${'Renders correctly with Archived Filter disabled'} | ${true} | ${false} + ${'Renders correctly with Archived Filter and Label Filter disabled'} | ${false} | ${false} + ${'Renders correctly with Archived Filter and Label Filter enabled'} | ${true} | ${true} + `('$description', ({ searchIssueLabelAggregation, searchIssuesHideArchivedProjects }) => { beforeEach(() => { - createComponent({ urlQuery: MOCK_QUERY }); + createComponent({ + searchIssueLabelAggregation, + searchIssuesHideArchivedProjects, + }); }); + it('renders StatusFilter', () => { expect(findStatusFilter().exists()).toBe(true); }); @@ -53,18 +73,30 @@ describe('GlobalSearch IssuesFilters', () => { expect(findConfidentialityFilter().exists()).toBe(true); }); - it('renders LabelFilter', () => { - expect(findLabelFilter().exists()).toBe(true); + it(`renders correctly LabelFilter when searchIssueLabelAggregation is ${searchIssueLabelAggregation}`, () => { + expect(findLabelFilter().exists()).toBe(searchIssueLabelAggregation); }); - it('renders dividers correctly', () => { - expect(findDividers()).toHaveLength(2); + it(`renders correctly ArchivedFilter when searchIssuesHideArchivedProjects is ${searchIssuesHideArchivedProjects}`, () => { + expect(findArchivedFilter().exists()).toBe(searchIssuesHideArchivedProjects); + }); + + it('renders divider correctly', () => { + // one divider can't be disabled + let dividersCount = 1; + if (searchIssueLabelAggregation) { + dividersCount += 1; + } + if (searchIssuesHideArchivedProjects) { + dividersCount += 1; + } + expect(findDividers()).toHaveLength(dividersCount); }); }); - describe('Renders correctly with FF disabled', () => { + describe('Renders correctly with basic search', () => { beforeEach(() => { - createComponent({ urlQuery: MOCK_QUERY }, false); + createComponent({ initialState: { searchType: SEARCH_TYPE_BASIC } }); }); it('renders StatusFilter', () => { expect(findStatusFilter().exists()).toBe(true); @@ -78,15 +110,51 @@ describe('GlobalSearch IssuesFilters', () => { expect(findLabelFilter().exists()).toBe(false); }); - it('renders divider correctly', () => { + it("doesn't render ArchivedFilter", () => { + expect(findArchivedFilter().exists()).toBe(false); + }); + + it('renders 1 divider', () => { expect(findDividers()).toHaveLength(1); }); }); + describe('Renders correctly in new nav', () => { + beforeEach(() => { + createComponent({ + initialState: { + searchType: SEARCH_TYPE_ADVANCED, + useSidebarNavigation: true, + }, + searchIssueLabelAggregation: true, + searchIssuesHideArchivedProjects: true, + }); + }); + it('renders StatusFilter', () => { + expect(findStatusFilter().exists()).toBe(true); + }); + + it('renders ConfidentialityFilter', () => { + expect(findConfidentialityFilter().exists()).toBe(true); + }); + + it('renders LabelFilter', () => { + expect(findLabelFilter().exists()).toBe(true); + }); + + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); + }); + + it("doesn't render dividers", () => { + expect(findDividers()).toHaveLength(0); + }); + }); + describe('Renders correctly with wrong scope', () => { beforeEach(() => { - defaultGetters.currentScope = () => 'blobs'; - createComponent({ urlQuery: MOCK_QUERY }); + defaultGetters.currentScope = () => 'test'; + createComponent(); }); it("doesn't render StatusFilter", () => { expect(findStatusFilter().exists()).toBe(false); @@ -100,6 +168,10 @@ describe('GlobalSearch IssuesFilters', () => { expect(findLabelFilter().exists()).toBe(false); }); + it("doesn't render ArchivedFilter", () => { + expect(findArchivedFilter().exists()).toBe(false); + }); + it("doesn't render dividers", () => { expect(findDividers()).toHaveLength(0); }); diff --git a/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js b/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js index 0932f8e47d2..b50f348be69 100644 --- a/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js +++ b/spec/frontend/search/sidebar/components/merge_requests_filters_spec.js @@ -1,28 +1,131 @@ import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import { MOCK_QUERY } from 'jest/search/mock_data'; import MergeRequestsFilters from '~/search/sidebar/components/merge_requests_filters.vue'; import StatusFilter from '~/search/sidebar/components/status_filter/index.vue'; -import FiltersTemplate from '~/search/sidebar/components/filters_template.vue'; +import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; +import { SEARCH_TYPE_ADVANCED, SEARCH_TYPE_BASIC } from '~/search/sidebar/constants'; + +Vue.use(Vuex); describe('GlobalSearch MergeRequestsFilters', () => { let wrapper; - const findStatusFilter = () => wrapper.findComponent(StatusFilter); - const findFiltersTemplate = () => wrapper.findComponent(FiltersTemplate); + const defaultGetters = { + currentScope: () => 'merge_requests', + }; - const createComponent = () => { - wrapper = shallowMount(MergeRequestsFilters); + const createComponent = ({ + initialState = {}, + searchMergeRequestsHideArchivedProjects = true, + } = {}) => { + const store = new Vuex.Store({ + state: { + urlQuery: MOCK_QUERY, + useSidebarNavigation: false, + searchType: SEARCH_TYPE_ADVANCED, + ...initialState, + }, + getters: defaultGetters, + }); + + wrapper = shallowMount(MergeRequestsFilters, { + store, + provide: { + glFeatures: { + searchMergeRequestsHideArchivedProjects, + }, + }, + }); }; - describe('Renders correctly', () => { + const findStatusFilter = () => wrapper.findComponent(StatusFilter); + const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); + const findDividers = () => wrapper.findAll('hr'); + + describe.each` + description | searchMergeRequestsHideArchivedProjects + ${'Renders correctly with Archived Filter disabled'} | ${false} + ${'Renders correctly with Archived Filter enabled'} | ${true} + `('$description', ({ searchMergeRequestsHideArchivedProjects }) => { beforeEach(() => { - createComponent(); + createComponent({ + searchMergeRequestsHideArchivedProjects, + }); + }); + + it('renders StatusFilter', () => { + expect(findStatusFilter().exists()).toBe(true); + }); + + it(`renders correctly ArchivedFilter when searchMergeRequestsHideArchivedProjects is ${searchMergeRequestsHideArchivedProjects}`, () => { + expect(findArchivedFilter().exists()).toBe(searchMergeRequestsHideArchivedProjects); }); - it('renders ConfidentialityFilter', () => { + + it('renders divider correctly', () => { + const dividersCount = searchMergeRequestsHideArchivedProjects ? 1 : 0; + expect(findDividers()).toHaveLength(dividersCount); + }); + }); + + describe('Renders correctly with basic search', () => { + beforeEach(() => { + createComponent({ initialState: { searchType: SEARCH_TYPE_BASIC } }); + }); + + it('renders StatusFilter', () => { expect(findStatusFilter().exists()).toBe(true); }); - it('renders FiltersTemplate', () => { - expect(findFiltersTemplate().exists()).toBe(true); + it("doesn't render ArchivedFilter", () => { + expect(findArchivedFilter().exists()).toBe(false); + }); + + it('renders 1 divider', () => { + expect(findDividers()).toHaveLength(0); + }); + }); + + describe('Renders correctly in new nav', () => { + beforeEach(() => { + createComponent({ + initialState: { + searchType: SEARCH_TYPE_ADVANCED, + useSidebarNavigation: true, + }, + searchMergeRequestsHideArchivedProjects: true, + }); + }); + it('renders StatusFilter', () => { + expect(findStatusFilter().exists()).toBe(true); + }); + + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); + }); + + it("doesn't render divider", () => { + expect(findDividers()).toHaveLength(0); + }); + }); + + describe('Renders correctly with wrong scope', () => { + beforeEach(() => { + defaultGetters.currentScope = () => 'test'; + createComponent(); + }); + it("doesn't render StatusFilter", () => { + expect(findStatusFilter().exists()).toBe(false); + }); + + it("doesn't render ArchivedFilter", () => { + expect(findArchivedFilter().exists()).toBe(false); + }); + + it("doesn't render dividers", () => { + expect(findDividers()).toHaveLength(0); }); }); }); diff --git a/spec/frontend/search/sidebar/components/notes_filters_spec.js b/spec/frontend/search/sidebar/components/notes_filters_spec.js new file mode 100644 index 00000000000..2fb8e731ef5 --- /dev/null +++ b/spec/frontend/search/sidebar/components/notes_filters_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import NotesFilters from '~/search/sidebar/components/projects_filters.vue'; +import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; +import FiltersTemplate from '~/search/sidebar/components/filters_template.vue'; + +describe('GlobalSearch ProjectsFilters', () => { + let wrapper; + + const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); + const findFiltersTemplate = () => wrapper.findComponent(FiltersTemplate); + + const createComponent = () => { + wrapper = shallowMount(NotesFilters); + }; + + describe('Renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); + }); + + it('renders FiltersTemplate', () => { + expect(findFiltersTemplate().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/projects_filters_spec.js b/spec/frontend/search/sidebar/components/projects_filters_spec.js new file mode 100644 index 00000000000..930b7263ea4 --- /dev/null +++ b/spec/frontend/search/sidebar/components/projects_filters_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import ProjectsFilters from '~/search/sidebar/components/projects_filters.vue'; +import ArchivedFilter from '~/search/sidebar/components/archived_filter/index.vue'; +import FiltersTemplate from '~/search/sidebar/components/filters_template.vue'; + +describe('GlobalSearch ProjectsFilters', () => { + let wrapper; + + const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); + const findFiltersTemplate = () => wrapper.findComponent(FiltersTemplate); + + const createComponent = () => { + wrapper = shallowMount(ProjectsFilters); + }; + + describe('Renders correctly', () => { + beforeEach(() => { + createComponent(); + }); + it('renders ArchivedFilter', () => { + expect(findArchivedFilter().exists()).toBe(true); + }); + + it('renders FiltersTemplate', () => { + expect(findFiltersTemplate().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/search/sidebar/components/projects_filters_specs.js b/spec/frontend/search/sidebar/components/projects_filters_specs.js deleted file mode 100644 index 15e3254e289..00000000000 --- a/spec/frontend/search/sidebar/components/projects_filters_specs.js +++ /dev/null @@ -1,28 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import ProjectsFilters from '~/search/sidebar/components/projects_filters.vue'; -import ArchivedFilter from '~/search/sidebar/components/language_filter/index.vue'; -import FiltersTemplate from '~/search/sidebar/components/filters_template.vue'; - -describe('GlobalSearch ProjectsFilters', () => { - let wrapper; - - const findArchivedFilter = () => wrapper.findComponent(ArchivedFilter); - const findFiltersTemplate = () => wrapper.findComponent(FiltersTemplate); - - const createComponent = () => { - wrapper = shallowMount(ProjectsFilters); - }; - - describe('Renders correctly', () => { - beforeEach(() => { - createComponent(); - }); - it('renders ArchivedFilter', () => { - expect(findArchivedFilter().exists()).toBe(true); - }); - - it('renders FiltersTemplate', () => { - expect(findFiltersTemplate().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js b/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js new file mode 100644 index 00000000000..5ab4afba7f0 --- /dev/null +++ b/spec/frontend/search/sidebar/components/small_screen_drawer_navigation_spec.js @@ -0,0 +1,68 @@ +import { nextTick } from 'vue'; +import { GlDrawer } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; + +describe('ScopeLegacyNavigation', () => { + let wrapper; + let closeSpy; + let toggleSpy; + + const createComponent = () => { + wrapper = shallowMountExtended(SmallScreenDrawerNavigation, { + slots: { + default: '
    test
    ', + }, + }); + }; + + const findGlDrawer = () => wrapper.findComponent(GlDrawer); + const findTitle = () => wrapper.findComponent('h2'); + const findSlot = () => wrapper.findByTestId('default-slot-content'); + const findDomElementListener = () => wrapper.findComponent(DomElementListener); + + describe('small screen navigation', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders drawer', () => { + expect(findGlDrawer().exists()).toBe(true); + expect(findGlDrawer().attributes('zindex')).toBe(DRAWER_Z_INDEX.toString()); + expect(findGlDrawer().attributes('headerheight')).toBe('0'); + }); + + it('renders title', () => { + expect(findTitle().exists()).toBe(true); + }); + + it('renders slots', () => { + expect(findSlot().exists()).toBe(true); + }); + }); + + describe('actions', () => { + beforeEach(() => { + closeSpy = jest.spyOn(SmallScreenDrawerNavigation.methods, 'closeSmallScreenFilters'); + toggleSpy = jest.spyOn(SmallScreenDrawerNavigation.methods, 'toggleSmallScreenFilters'); + createComponent(); + }); + + it('calls onClose', () => { + findGlDrawer().vm.$emit('close'); + expect(closeSpy).toHaveBeenCalled(); + }); + + it('calls toggleSmallScreenFilters', async () => { + expect(findGlDrawer().props('open')).toBe(false); + + findDomElementListener().vm.$emit('click'); + await nextTick(); + + expect(toggleSpy).toHaveBeenCalled(); + expect(findGlDrawer().props('open')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index cc9c555b6c7..889260fc478 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { mapValues } from 'lodash'; import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; import { createAlert } from '~/alert'; @@ -312,6 +313,21 @@ describe('Global Search Store Actions', () => { }); }); + describe('fetchSidebarCount with no count_link', () => { + beforeEach(() => { + state.navigation = mapValues(MOCK_NAVIGATION_DATA, (navItem) => ({ + ...navItem, + count_link: null, + })); + }); + + it('should not request anything', async () => { + await testAction({ action: actions.fetchSidebarCount, state, expectedMutations: [] }); + + expect(mock.history.get.length).toBe(0); + }); + }); + describe.each` action | axiosMock | type | expectedMutations | errorLogs ${actions.fetchAllAggregation} | ${{ method: 'onGet', code: HTTP_STATUS_OK }} | ${'success'} | ${MOCK_RECEIVE_AGGREGATIONS_SUCCESS_MUTATION} | ${0} diff --git a/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js b/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js new file mode 100644 index 00000000000..84a468e4dd8 --- /dev/null +++ b/spec/frontend/security_configuration/components/continuous_vulnerability_scan_spec.js @@ -0,0 +1,124 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBadge, GlToggle } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import ProjectSetContinuousVulnerabilityScanning from '~/security_configuration/graphql/project_set_continuous_vulnerability_scanning.graphql'; +import ContinuousVulnerabilityScan from '~/security_configuration/components/continuous_vulnerability_scan.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +Vue.use(VueApollo); + +const setCVSMockResponse = { + data: { + projectSetContinuousVulnerabilityScanning: { + continuousVulnerabilityScanningEnabled: true, + errors: [], + }, + }, +}; + +const defaultProvide = { + continuousVulnerabilityScansEnabled: true, + projectFullPath: 'project/full/path', +}; + +describe('ContinuousVulnerabilityScan', () => { + let wrapper; + let apolloProvider; + let requestHandlers; + + const createComponent = (options) => { + requestHandlers = { + setCVSMutationHandler: jest.fn().mockResolvedValue(setCVSMockResponse), + }; + + apolloProvider = createMockApollo([ + [ProjectSetContinuousVulnerabilityScanning, requestHandlers.setCVSMutationHandler], + ]); + + wrapper = shallowMount(ContinuousVulnerabilityScan, { + propsData: { + feature: { + available: true, + configured: true, + }, + }, + provide: { + glFeatures: { + dependencyScanningOnAdvisoryIngestion: true, + }, + ...defaultProvide, + }, + apolloProvider, + ...options, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + apolloProvider = null; + }); + + const findBadge = () => wrapper.findComponent(GlBadge); + const findToggle = () => wrapper.findComponent(GlToggle); + + it('renders the component', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('renders the correct title', () => { + expect(wrapper.text()).toContain('Continuous Vulnerability Scan'); + }); + + it('renders the badge and toggle component with correct values', () => { + expect(findBadge().exists()).toBe(true); + expect(findBadge().text()).toBe('Experiment'); + + expect(findToggle().exists()).toBe(true); + expect(findToggle().props('value')).toBe(defaultProvide.continuousVulnerabilityScansEnabled); + }); + + it('should disable toggle when feature is not configured', () => { + createComponent({ + propsData: { + feature: { + available: true, + configured: false, + }, + }, + }); + expect(findToggle().props('disabled')).toBe(true); + }); + + it('calls mutation on toggle change with correct payload', () => { + findToggle().vm.$emit('change', true); + + expect(requestHandlers.setCVSMutationHandler).toHaveBeenCalledWith({ + input: { + projectPath: 'project/full/path', + enable: true, + }, + }); + }); + + describe('when feature flag is disabled', () => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + dependencyScanningOnAdvisoryIngestion: false, + }, + ...defaultProvide, + }, + }); + }); + + it('should not render toggle and badge', () => { + expect(findToggle().exists()).toBe(false); + expect(findBadge().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index 983a66a7fd3..c715d01dd58 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -1,5 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import Vue from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { securityFeatures } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; @@ -13,6 +14,10 @@ import { import { manageViaMRErrorMessage } from '../constants'; import { makeFeature } from './utils'; +const MockComponent = Vue.component('MockComponent', { + render: (createElement) => createElement('span'), +}); + describe('FeatureCard component', () => { let feature; let wrapper; @@ -389,4 +394,17 @@ describe('FeatureCard component', () => { }); }); }); + + describe('when a slot component is passed', () => { + beforeEach(() => { + feature = makeFeature({ + slotComponent: MockComponent, + }); + createComponent({ feature }); + }); + + it('renders the component properly', () => { + expect(wrapper.findComponent(MockComponent).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js index 6e731e45da2..ea04e9e7993 100644 --- a/spec/frontend/security_configuration/utils_spec.js +++ b/spec/frontend/security_configuration/utils_spec.js @@ -34,6 +34,33 @@ describe('augmentFeatures', () => { }, ]; + const mockSecurityFeaturesDast = [ + { + name: 'DAST', + type: 'dast', + }, + ]; + + const mockValidCustomFeatureWithOnDemandAvailableFalse = [ + { + name: 'DAST', + type: 'dast', + customField: 'customvalue', + onDemandAvailable: false, + badge: {}, + }, + ]; + + const mockValidCustomFeatureWithOnDemandAvailableTrue = [ + { + name: 'DAST', + type: 'dast', + customField: 'customvalue', + onDemandAvailable: true, + badge: {}, + }, + ]; + const mockValidCustomFeatureSnakeCase = [ { name: 'SAST', @@ -54,6 +81,29 @@ describe('augmentFeatures', () => { augmentedSecurityFeatures: mockValidCustomFeature, }; + const expectedOutputCustomFeatureWithOnDemandAvailableFalse = { + augmentedSecurityFeatures: [ + { + name: 'DAST', + type: 'dast', + customField: 'customvalue', + onDemandAvailable: false, + }, + ], + }; + + const expectedOutputCustomFeatureWithOnDemandAvailableTrue = { + augmentedSecurityFeatures: [ + { + name: 'DAST', + type: 'dast', + customField: 'customvalue', + onDemandAvailable: true, + badge: {}, + }, + ], + }; + describe('returns an object with augmentedSecurityFeatures when', () => { it('given an empty array', () => { expect(augmentFeatures(mockSecurityFeatures, [])).toEqual(expectedOutputDefault); @@ -85,6 +135,20 @@ describe('augmentFeatures', () => { ); }); }); + + describe('follows onDemandAvailable', () => { + it('deletes badge when false', () => { + expect( + augmentFeatures(mockSecurityFeaturesDast, mockValidCustomFeatureWithOnDemandAvailableFalse), + ).toEqual(expectedOutputCustomFeatureWithOnDemandAvailableFalse); + }); + + it('keeps badge when true', () => { + expect( + augmentFeatures(mockSecurityFeaturesDast, mockValidCustomFeatureWithOnDemandAvailableTrue), + ).toEqual(expectedOutputCustomFeatureWithOnDemandAvailableTrue); + }); + }); }); describe('translateScannerNames', () => { diff --git a/spec/frontend/sentry/index_spec.js b/spec/frontend/sentry/index_spec.js deleted file mode 100644 index 3130e01cc9e..00000000000 --- a/spec/frontend/sentry/index_spec.js +++ /dev/null @@ -1,104 +0,0 @@ -import index from '~/sentry/index'; - -import LegacySentryConfig from '~/sentry/legacy_sentry_config'; -import SentryConfig from '~/sentry/sentry_config'; - -describe('Sentry init', () => { - const version = '1.0.0'; - const dsn = 'https://123@sentry.gitlab.test/123'; - const environment = 'test'; - const currentUserId = '1'; - const gitlabUrl = 'gitlabUrl'; - const revision = 'revision'; - const featureCategory = 'my_feature_category'; - - beforeEach(() => { - window.gon = { - version, - sentry_dsn: dsn, - sentry_environment: environment, - current_user_id: currentUserId, - gitlab_url: gitlabUrl, - revision, - feature_category: featureCategory, - }; - - jest.spyOn(LegacySentryConfig, 'init').mockImplementation(); - jest.spyOn(SentryConfig, 'init').mockImplementation(); - }); - - it('exports new version of Sentry in the global object', () => { - // eslint-disable-next-line no-underscore-dangle - expect(window._Sentry.SDK_VERSION).not.toMatch(/^5\./); - }); - - describe('when called', () => { - beforeEach(() => { - index(); - }); - - it('configures sentry', () => { - expect(SentryConfig.init).toHaveBeenCalledTimes(1); - expect(SentryConfig.init).toHaveBeenCalledWith({ - dsn, - currentUserId, - allowUrls: [gitlabUrl, 'webpack-internal://'], - environment, - release: version, - tags: { - revision, - feature_category: featureCategory, - }, - }); - }); - - it('does not configure legacy sentry', () => { - expect(LegacySentryConfig.init).not.toHaveBeenCalled(); - }); - }); - - describe('with "data-page" attr in body', () => { - const mockPage = 'projects:show'; - - beforeEach(() => { - document.body.dataset.page = mockPage; - - index(); - }); - - afterEach(() => { - delete document.body.dataset.page; - }); - - it('configures sentry with a "page" tag', () => { - expect(SentryConfig.init).toHaveBeenCalledTimes(1); - expect(SentryConfig.init).toHaveBeenCalledWith( - expect.objectContaining({ - tags: { - revision, - page: mockPage, - feature_category: featureCategory, - }, - }), - ); - }); - }); - - describe('with no tags configuration', () => { - beforeEach(() => { - window.gon.revision = undefined; - window.gon.feature_category = undefined; - - index(); - }); - - it('configures sentry with no tags', () => { - expect(SentryConfig.init).toHaveBeenCalledTimes(1); - expect(SentryConfig.init).toHaveBeenCalledWith( - expect.objectContaining({ - tags: {}, - }), - ); - }); - }); -}); diff --git a/spec/frontend/sentry/init_sentry_spec.js b/spec/frontend/sentry/init_sentry_spec.js new file mode 100644 index 00000000000..e31068b935b --- /dev/null +++ b/spec/frontend/sentry/init_sentry_spec.js @@ -0,0 +1,177 @@ +import { + BrowserClient, + defaultStackParser, + makeFetchTransport, + defaultIntegrations, + + // exports + captureException, + captureMessage, + withScope, + SDK_VERSION, +} from 'sentrybrowser'; +import * as Sentry from 'sentrybrowser'; + +import { initSentry } from '~/sentry/init_sentry'; + +const mockDsn = 'https://123@sentry.gitlab.test/123'; +const mockEnvironment = 'development'; +const mockCurrentUserId = 1; +const mockGitlabUrl = 'https://gitlab.com'; +const mockVersion = '1.0.0'; +const mockRevision = '00112233'; +const mockFeatureCategory = 'my_feature_category'; +const mockPage = 'index:page'; +const mockSentryClientsideTracesSampleRate = 0.1; + +jest.mock('sentrybrowser', () => { + return { + ...jest.createMockFromModule('sentrybrowser'), + + // unmock actual configuration options + defaultStackParser: jest.requireActual('sentrybrowser').defaultStackParser, + makeFetchTransport: jest.requireActual('sentrybrowser').makeFetchTransport, + defaultIntegrations: jest.requireActual('sentrybrowser').defaultIntegrations, + }; +}); + +describe('SentryConfig', () => { + let mockBindClient; + let mockSetTags; + let mockSetUser; + let mockBrowserClient; + let mockStartSession; + let mockCaptureSession; + + beforeEach(() => { + window.gon = { + sentry_dsn: mockDsn, + sentry_environment: mockEnvironment, + current_user_id: mockCurrentUserId, + gitlab_url: mockGitlabUrl, + version: mockVersion, + revision: mockRevision, + feature_category: mockFeatureCategory, + sentry_clientside_traces_sample_rate: mockSentryClientsideTracesSampleRate, + }; + + document.body.dataset.page = mockPage; + + mockBindClient = jest.fn(); + mockSetTags = jest.fn(); + mockSetUser = jest.fn(); + mockStartSession = jest.fn(); + mockCaptureSession = jest.fn(); + mockBrowserClient = jest.spyOn(Sentry, 'BrowserClient'); + + jest.spyOn(Sentry, 'getCurrentHub').mockReturnValue({ + bindClient: mockBindClient, + setTags: mockSetTags, + setUser: mockSetUser, + startSession: mockStartSession, + captureSession: mockCaptureSession, + }); + }); + + afterEach(() => { + // eslint-disable-next-line no-underscore-dangle + window._Sentry = undefined; + }); + + describe('initSentry', () => { + describe('when sentry is initialized', () => { + beforeEach(() => { + initSentry(); + }); + + it('creates BrowserClient with gon values and configuration', () => { + expect(mockBrowserClient).toHaveBeenCalledWith( + expect.objectContaining({ + dsn: mockDsn, + release: mockVersion, + allowUrls: [mockGitlabUrl, 'webpack-internal://'], + environment: mockEnvironment, + tracesSampleRate: mockSentryClientsideTracesSampleRate, + tracePropagationTargets: [/^\//], + + transport: makeFetchTransport, + stackParser: defaultStackParser, + integrations: defaultIntegrations, + }), + ); + }); + + it('binds the BrowserClient to the hub', () => { + expect(mockBindClient).toHaveBeenCalledTimes(1); + expect(mockBindClient).toHaveBeenCalledWith(expect.any(BrowserClient)); + }); + + it('calls Sentry.setTags with gon values', () => { + expect(mockSetTags).toHaveBeenCalledTimes(1); + expect(mockSetTags).toHaveBeenCalledWith({ + page: mockPage, + revision: mockRevision, + feature_category: mockFeatureCategory, + }); + }); + + it('calls Sentry.setUser with gon values', () => { + expect(mockSetUser).toHaveBeenCalledTimes(1); + expect(mockSetUser).toHaveBeenCalledWith({ + id: mockCurrentUserId, + }); + }); + + it('sets global sentry', () => { + // eslint-disable-next-line no-underscore-dangle + expect(window._Sentry).toEqual({ + captureException, + captureMessage, + withScope, + SDK_VERSION, + }); + }); + }); + + describe('when user is not logged in', () => { + beforeEach(() => { + window.gon.current_user_id = undefined; + initSentry(); + }); + + it('does not call Sentry.setUser', () => { + expect(mockSetUser).not.toHaveBeenCalled(); + }); + }); + + describe('when gon is not defined', () => { + beforeEach(() => { + window.gon = undefined; + initSentry(); + }); + + it('Sentry.init is not called', () => { + expect(mockBrowserClient).not.toHaveBeenCalled(); + expect(mockBindClient).not.toHaveBeenCalled(); + + // eslint-disable-next-line no-underscore-dangle + expect(window._Sentry).toBe(undefined); + }); + }); + + describe('when dsn is not configured', () => { + beforeEach(() => { + window.gon.sentry_dsn = undefined; + initSentry(); + }); + + it('Sentry.init is not called', () => { + expect(mockBrowserClient).not.toHaveBeenCalled(); + expect(mockBindClient).not.toHaveBeenCalled(); + + // eslint-disable-next-line no-underscore-dangle + expect(window._Sentry).toBe(undefined); + }); + }); + }); +}); diff --git a/spec/frontend/sentry/legacy_index_spec.js b/spec/frontend/sentry/legacy_index_spec.js index 493b4dfde67..fad1760ffc5 100644 --- a/spec/frontend/sentry/legacy_index_spec.js +++ b/spec/frontend/sentry/legacy_index_spec.js @@ -1,7 +1,6 @@ import index from '~/sentry/legacy_index'; import LegacySentryConfig from '~/sentry/legacy_sentry_config'; -import SentryConfig from '~/sentry/sentry_config'; describe('Sentry init', () => { const dsn = 'https://123@sentry.gitlab.test/123'; @@ -22,7 +21,6 @@ describe('Sentry init', () => { }; jest.spyOn(LegacySentryConfig, 'init').mockImplementation(); - jest.spyOn(SentryConfig, 'init').mockImplementation(); }); it('exports legacy version of Sentry in the global object', () => { @@ -49,9 +47,5 @@ describe('Sentry init', () => { }, }); }); - - it('does not configure new sentry', () => { - expect(SentryConfig.init).not.toHaveBeenCalled(); - }); }); }); diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js deleted file mode 100644 index 34c5221ef0d..00000000000 --- a/spec/frontend/sentry/sentry_config_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import * as Sentry from 'sentrybrowser7'; - -import SentryConfig from '~/sentry/sentry_config'; - -describe('SentryConfig', () => { - describe('init', () => { - const options = { - currentUserId: 1, - }; - - beforeEach(() => { - jest.spyOn(SentryConfig, 'configure'); - jest.spyOn(SentryConfig, 'setUser'); - - SentryConfig.init(options); - }); - - it('should set the options property', () => { - expect(SentryConfig.options).toEqual(options); - }); - - it('should call the configure method', () => { - expect(SentryConfig.configure).toHaveBeenCalled(); - }); - - it('should call setUser', () => { - expect(SentryConfig.setUser).toHaveBeenCalled(); - }); - - it('should not call setUser if there is no current user ID', () => { - SentryConfig.setUser.mockClear(); - SentryConfig.init({ currentUserId: undefined }); - - expect(SentryConfig.setUser).not.toHaveBeenCalled(); - }); - }); - - describe('configure', () => { - const sentryConfig = {}; - const options = { - dsn: 'https://123@sentry.gitlab.test/123', - allowUrls: ['//gitlabUrl', 'webpack-internal://'], - environment: 'test', - release: 'revision', - tags: { - revision: 'revision', - feature_category: 'my_feature_category', - }, - }; - - beforeEach(() => { - jest.spyOn(Sentry, 'init').mockImplementation(); - jest.spyOn(Sentry, 'setTags').mockImplementation(); - - sentryConfig.options = options; - - SentryConfig.configure.call(sentryConfig); - }); - - it('should call Sentry.init', () => { - expect(Sentry.init).toHaveBeenCalledWith({ - dsn: options.dsn, - release: options.release, - allowUrls: options.allowUrls, - environment: options.environment, - }); - }); - - it('should call Sentry.setTags', () => { - expect(Sentry.setTags).toHaveBeenCalledWith(options.tags); - }); - - it('should set environment from options', () => { - sentryConfig.options.environment = 'development'; - - SentryConfig.configure.call(sentryConfig); - - expect(Sentry.init).toHaveBeenCalledWith({ - dsn: options.dsn, - release: options.release, - allowUrls: options.allowUrls, - environment: 'development', - }); - }); - }); - - describe('setUser', () => { - let sentryConfig; - - beforeEach(() => { - sentryConfig = { options: { currentUserId: 1 } }; - jest.spyOn(Sentry, 'setUser'); - - SentryConfig.setUser.call(sentryConfig); - }); - - it('should call .setUser', () => { - expect(Sentry.setUser).toHaveBeenCalledWith({ - id: sentryConfig.options.currentUserId, - }); - }); - }); -}); diff --git a/spec/frontend/service_desk/components/empty_state_with_any_issues_spec.js b/spec/frontend/service_desk/components/empty_state_with_any_issues_spec.js deleted file mode 100644 index ce8a78767d4..00000000000 --- a/spec/frontend/service_desk/components/empty_state_with_any_issues_spec.js +++ /dev/null @@ -1,74 +0,0 @@ -import { GlEmptyState } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import EmptyStateWithAnyIssues from '~/service_desk/components/empty_state_with_any_issues.vue'; -import { - noSearchResultsTitle, - noSearchResultsDescription, - infoBannerUserNote, - noOpenIssuesTitle, - noClosedIssuesTitle, -} from '~/service_desk/constants'; - -describe('EmptyStateWithAnyIssues component', () => { - let wrapper; - - const defaultProvide = { - emptyStateSvgPath: 'empty/state/svg/path', - newIssuePath: 'new/issue/path', - showNewIssueLink: false, - }; - - const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); - - const mountComponent = (props = {}) => { - wrapper = shallowMount(EmptyStateWithAnyIssues, { - propsData: { - hasSearch: true, - isOpenTab: true, - ...props, - }, - provide: defaultProvide, - }); - }; - - describe('when there is a search (with no results)', () => { - beforeEach(() => { - mountComponent(); - }); - - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - description: noSearchResultsDescription, - title: noSearchResultsTitle, - svgPath: defaultProvide.emptyStateSvgPath, - }); - }); - }); - - describe('when "Open" tab is active', () => { - beforeEach(() => { - mountComponent({ hasSearch: false }); - }); - - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - description: infoBannerUserNote, - title: noOpenIssuesTitle, - svgPath: defaultProvide.emptyStateSvgPath, - }); - }); - }); - - describe('when "Closed" tab is active', () => { - beforeEach(() => { - mountComponent({ hasSearch: false, isClosedTab: true, isOpenTab: false }); - }); - - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - title: noClosedIssuesTitle, - svgPath: defaultProvide.emptyStateSvgPath, - }); - }); - }); -}); diff --git a/spec/frontend/service_desk/components/empty_state_without_any_issues_spec.js b/spec/frontend/service_desk/components/empty_state_without_any_issues_spec.js deleted file mode 100644 index c67f9588ed4..00000000000 --- a/spec/frontend/service_desk/components/empty_state_without_any_issues_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import { GlEmptyState, GlLink } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import EmptyStateWithoutAnyIssues from '~/service_desk/components/empty_state_without_any_issues.vue'; -import { infoBannerTitle, noIssuesSignedOutButtonText, learnMore } from '~/service_desk/constants'; - -describe('EmptyStateWithoutAnyIssues component', () => { - let wrapper; - - const defaultProvide = { - emptyStateSvgPath: 'empty/state/svg/path', - isSignedIn: true, - signInPath: 'sign/in/path', - canAdminIssues: true, - isServiceDeskEnabled: true, - serviceDeskEmailAddress: 'email@address.com', - serviceDeskHelpPath: 'service/desk/help/path', - }; - - const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); - const findGlLink = () => wrapper.findComponent(GlLink); - const findIssuesHelpPageLink = () => wrapper.findByRole('link', { name: learnMore }); - - const mountComponent = ({ provide = {} } = {}) => { - wrapper = mountExtended(EmptyStateWithoutAnyIssues, { - provide: { - ...defaultProvide, - ...provide, - }, - }); - }; - - describe('when signed in', () => { - beforeEach(() => { - mountComponent(); - }); - - it('renders empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - title: infoBannerTitle, - svgPath: defaultProvide.emptyStateSvgPath, - contentClass: 'gl-max-w-80!', - }); - }); - - it('renders description with service desk docs link', () => { - expect(findIssuesHelpPageLink().attributes('href')).toBe(defaultProvide.serviceDeskHelpPath); - }); - - it('renders email address, when user can admin issues and service desk is enabled', () => { - expect(wrapper.text()).toContain(wrapper.vm.serviceDeskEmailAddress); - }); - - it('does not render email address, when user can not admin issues', () => { - mountComponent({ provide: { canAdminIssues: false } }); - - expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); - }); - - it('does not render email address, when service desk is not setup', () => { - mountComponent({ provide: { isServiceDeskEnabled: false } }); - - expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); - }); - }); - - describe('when signed out', () => { - beforeEach(() => { - mountComponent({ provide: { isSignedIn: false } }); - }); - - it('renders empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - title: infoBannerTitle, - svgPath: defaultProvide.emptyStateSvgPath, - primaryButtonText: noIssuesSignedOutButtonText, - primaryButtonLink: defaultProvide.signInPath, - contentClass: 'gl-max-w-80!', - }); - }); - - it('renders service desk docs link', () => { - expect(findGlLink().attributes('href')).toBe(defaultProvide.serviceDeskHelpPath); - expect(findGlLink().text()).toBe(learnMore); - }); - }); -}); diff --git a/spec/frontend/service_desk/components/info_banner_spec.js b/spec/frontend/service_desk/components/info_banner_spec.js deleted file mode 100644 index 7487d5d8b64..00000000000 --- a/spec/frontend/service_desk/components/info_banner_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlLink, GlButton } from '@gitlab/ui'; -import InfoBanner from '~/service_desk/components/info_banner.vue'; -import { infoBannerAdminNote, enableServiceDesk } from '~/service_desk/constants'; - -describe('InfoBanner', () => { - let wrapper; - - const defaultProvide = { - serviceDeskCalloutSvgPath: 'callout.svg', - serviceDeskEmailAddress: 'sd@gmail.com', - canAdminIssues: true, - canEditProjectSettings: true, - serviceDeskSettingsPath: 'path/to/project/settings', - serviceDeskHelpPath: 'path/to/documentation', - isServiceDeskEnabled: true, - }; - - const findEnableSDButton = () => wrapper.findComponent(GlButton); - - const mountComponent = (provide) => { - return shallowMount(InfoBanner, { - provide: { - ...defaultProvide, - ...provide, - }, - stubs: { - GlLink, - GlButton, - }, - }); - }; - - beforeEach(() => { - wrapper = mountComponent(); - }); - - describe('Service Desk email address', () => { - it('renders when user can admin issues and service desk is enabled', () => { - expect(wrapper.text()).toContain(infoBannerAdminNote); - expect(wrapper.text()).toContain(wrapper.vm.serviceDeskEmailAddress); - }); - - it('does not render, when user can not admin issues', () => { - wrapper = mountComponent({ canAdminIssues: false }); - - expect(wrapper.text()).not.toContain(infoBannerAdminNote); - expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); - }); - - it('does not render, when service desk is not setup', () => { - wrapper = mountComponent({ isServiceDeskEnabled: false }); - - expect(wrapper.text()).not.toContain(infoBannerAdminNote); - expect(wrapper.text()).not.toContain(wrapper.vm.serviceDeskEmailAddress); - }); - }); - - describe('Link to Service Desk settings', () => { - it('renders when user can edit settings and service desk is not enabled', () => { - wrapper = mountComponent({ isServiceDeskEnabled: false }); - - expect(wrapper.text()).toContain(enableServiceDesk); - expect(findEnableSDButton().exists()).toBe(true); - }); - - it('does not render when service desk is enabled', () => { - wrapper = mountComponent(); - - expect(wrapper.text()).not.toContain(enableServiceDesk); - expect(findEnableSDButton().exists()).toBe(false); - }); - - it('does not render when user cannot edit settings', () => { - wrapper = mountComponent({ canEditProjectSettings: false }); - - expect(wrapper.text()).not.toContain(enableServiceDesk); - expect(findEnableSDButton().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/service_desk/components/service_desk_list_app_spec.js b/spec/frontend/service_desk/components/service_desk_list_app_spec.js deleted file mode 100644 index bdb6a48895e..00000000000 --- a/spec/frontend/service_desk/components/service_desk_list_app_spec.js +++ /dev/null @@ -1,376 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import { cloneDeep } from 'lodash'; -import VueRouter from 'vue-router'; -import * as Sentry from '@sentry/browser'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import { TEST_HOST } from 'helpers/test_constants'; -import waitForPromises from 'helpers/wait_for_promises'; -import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; -import { issuableListTabs } from '~/vue_shared/issuable/list/constants'; -import { TYPENAME_USER } from '~/graphql_shared/constants'; -import { convertToGraphQLId } from '~/graphql_shared/utils'; -import { STATUS_CLOSED, STATUS_OPEN, STATUS_ALL } from '~/service_desk/constants'; -import getServiceDeskIssuesQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues.query.graphql'; -import getServiceDeskIssuesCountsQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues_counts.query.graphql'; -import ServiceDeskListApp from '~/service_desk/components/service_desk_list_app.vue'; -import InfoBanner from '~/service_desk/components/info_banner.vue'; -import EmptyStateWithAnyIssues from '~/service_desk/components/empty_state_with_any_issues.vue'; -import EmptyStateWithoutAnyIssues from '~/service_desk/components/empty_state_without_any_issues.vue'; - -import { - TOKEN_TYPE_ASSIGNEE, - TOKEN_TYPE_AUTHOR, - TOKEN_TYPE_CONFIDENTIAL, - TOKEN_TYPE_LABEL, - TOKEN_TYPE_MILESTONE, - TOKEN_TYPE_MY_REACTION, - TOKEN_TYPE_RELEASE, - TOKEN_TYPE_SEARCH_WITHIN, -} from '~/vue_shared/components/filtered_search_bar/constants'; -import { - getServiceDeskIssuesQueryResponse, - getServiceDeskIssuesQueryEmptyResponse, - getServiceDeskIssuesCountsQueryResponse, - filteredTokens, - urlParams, - locationSearch, -} from '../mock_data'; - -jest.mock('@sentry/browser'); - -describe('CE ServiceDeskListApp', () => { - let wrapper; - let router; - - Vue.use(VueApollo); - Vue.use(VueRouter); - - const defaultProvide = { - releasesPath: 'releases/path', - autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', - hasIterationsFeature: true, - hasIssueWeightsFeature: true, - hasIssuableHealthStatusFeature: true, - groupPath: 'group/path', - emptyStateSvgPath: 'empty-state.svg', - isProject: true, - isSignedIn: true, - fullPath: 'path/to/project', - isServiceDeskSupported: true, - hasAnyIssues: true, - initialSort: '', - issuablesLoading: false, - }; - - let defaultQueryResponse = getServiceDeskIssuesQueryResponse; - if (IS_EE) { - defaultQueryResponse = cloneDeep(getServiceDeskIssuesQueryResponse); - defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null; - defaultQueryResponse.data.project.issues.nodes[0].weight = 5; - } - - const mockServiceDeskIssuesQueryResponseHandler = jest - .fn() - .mockResolvedValue(defaultQueryResponse); - const mockServiceDeskIssuesQueryEmptyResponseHandler = jest - .fn() - .mockResolvedValue(getServiceDeskIssuesQueryEmptyResponse); - const mockServiceDeskIssuesCountsQueryResponseHandler = jest - .fn() - .mockResolvedValue(getServiceDeskIssuesCountsQueryResponse); - - const findIssuableList = () => wrapper.findComponent(IssuableList); - const findInfoBanner = () => wrapper.findComponent(InfoBanner); - const findLabelsToken = () => - findIssuableList() - .props('searchTokens') - .find((token) => token.type === TOKEN_TYPE_LABEL); - - const createComponent = ({ - provide = {}, - serviceDeskIssuesQueryResponseHandler = mockServiceDeskIssuesQueryResponseHandler, - serviceDeskIssuesCountsQueryResponseHandler = mockServiceDeskIssuesCountsQueryResponseHandler, - } = {}) => { - const requestHandlers = [ - [getServiceDeskIssuesQuery, serviceDeskIssuesQueryResponseHandler], - [getServiceDeskIssuesCountsQuery, serviceDeskIssuesCountsQueryResponseHandler], - ]; - - router = new VueRouter({ mode: 'history' }); - - return shallowMount(ServiceDeskListApp, { - apolloProvider: createMockApollo( - requestHandlers, - {}, - { - typePolicies: { - Query: { - fields: { - project: { - merge: true, - }, - }, - }, - }, - }, - ), - router, - provide: { - ...defaultProvide, - ...provide, - }, - }); - }; - - beforeEach(() => { - setWindowLocation(TEST_HOST); - wrapper = createComponent(); - return waitForPromises(); - }); - - it('renders the issuable list with skeletons while fetching service desk issues', async () => { - wrapper = createComponent(); - await nextTick(); - - expect(findIssuableList().props('issuablesLoading')).toBe(true); - - await waitForPromises(); - - expect(findIssuableList().props('issuablesLoading')).toBe(false); - }); - - it('fetches service desk issues and renders them in the issuable list', () => { - expect(findIssuableList().props()).toMatchObject({ - namespace: 'service-desk', - recentSearchesStorageKey: 'service-desk-issues', - issuables: defaultQueryResponse.data.project.issues.nodes, - tabs: issuableListTabs, - currentTab: STATUS_OPEN, - tabCounts: { - opened: 1, - closed: 1, - all: 1, - }, - }); - }); - - describe('InfoBanner', () => { - it('renders when Service Desk is supported and has any number of issues', () => { - expect(findInfoBanner().exists()).toBe(true); - }); - - it('does not render when Service Desk is not supported and has any number of issues', () => { - wrapper = createComponent({ provide: { isServiceDeskSupported: false } }); - - expect(findInfoBanner().exists()).toBe(false); - }); - - it('does not render, when there are no issues', () => { - wrapper = createComponent({ - serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler, - }); - - expect(findInfoBanner().exists()).toBe(false); - }); - }); - - describe('Empty states', () => { - describe('when there are issues', () => { - it('shows EmptyStateWithAnyIssues component', () => { - setWindowLocation(locationSearch); - wrapper = createComponent({ - serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler, - }); - - expect(wrapper.findComponent(EmptyStateWithAnyIssues).props()).toEqual({ - hasSearch: true, - isOpenTab: true, - }); - }); - }); - - describe('when there are no issues', () => { - it('shows EmptyStateWithoutAnyIssues component', () => { - wrapper = createComponent({ - provide: { hasAnyIssues: false }, - serviceDeskIssuesQueryResponseHandler: mockServiceDeskIssuesQueryEmptyResponseHandler, - }); - - expect(wrapper.findComponent(EmptyStateWithoutAnyIssues).exists()).toBe(true); - }); - }); - }); - - describe('Initial url params', () => { - describe('search', () => { - it('is set from the url params', () => { - setWindowLocation(locationSearch); - wrapper = createComponent(); - - expect(router.history.current.query).toMatchObject({ search: 'find issues' }); - }); - }); - - describe('state', () => { - it('is set from the url params', async () => { - const initialState = STATUS_ALL; - setWindowLocation(`?state=${initialState}`); - wrapper = createComponent(); - await waitForPromises(); - - expect(findIssuableList().props('currentTab')).toBe(initialState); - }); - }); - - describe('filter tokens', () => { - it('are set from the url params', () => { - setWindowLocation(locationSearch); - wrapper = createComponent(); - - expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); - }); - }); - }); - - describe('Tokens', () => { - const mockCurrentUser = { - id: 1, - name: 'Administrator', - username: 'root', - avatar_url: 'avatar/url', - }; - - describe('when user is signed out', () => { - beforeEach(() => { - wrapper = createComponent({ provide: { isSignedIn: false } }); - return waitForPromises(); - }); - - it('does not render My-Reaction or Confidential tokens', () => { - expect(findIssuableList().props('searchTokens')).not.toMatchObject([ - { type: TOKEN_TYPE_AUTHOR, preloadedUsers: [mockCurrentUser] }, - { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers: [mockCurrentUser] }, - { type: TOKEN_TYPE_MY_REACTION }, - { type: TOKEN_TYPE_CONFIDENTIAL }, - ]); - }); - }); - - describe('when all tokens are available', () => { - beforeEach(() => { - window.gon = { - current_user_id: mockCurrentUser.id, - current_user_fullname: mockCurrentUser.name, - current_username: mockCurrentUser.username, - current_user_avatar_url: mockCurrentUser.avatar_url, - }; - - wrapper = createComponent(); - return waitForPromises(); - }); - - it('renders all tokens alphabetically', () => { - const preloadedUsers = [ - { ...mockCurrentUser, id: convertToGraphQLId(TYPENAME_USER, mockCurrentUser.id) }, - ]; - - expect(findIssuableList().props('searchTokens')).toMatchObject([ - { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, - { type: TOKEN_TYPE_CONFIDENTIAL }, - { type: TOKEN_TYPE_LABEL }, - { type: TOKEN_TYPE_MILESTONE }, - { type: TOKEN_TYPE_MY_REACTION }, - { type: TOKEN_TYPE_RELEASE }, - { type: TOKEN_TYPE_SEARCH_WITHIN }, - ]); - }); - }); - }); - - describe('Events', () => { - describe('when "click-tab" event is emitted by IssuableList', () => { - beforeEach(async () => { - wrapper = createComponent(); - router.push = jest.fn(); - await waitForPromises(); - - findIssuableList().vm.$emit('click-tab', STATUS_CLOSED); - }); - - it('updates ui to the new tab', () => { - expect(findIssuableList().props('currentTab')).toBe(STATUS_CLOSED); - }); - - it('updates url to the new tab', () => { - expect(router.push).toHaveBeenCalledWith({ - query: expect.objectContaining({ state: STATUS_CLOSED }), - }); - }); - }); - - describe('when "filter" event is emitted by IssuableList', () => { - it('updates IssuableList with url params', async () => { - wrapper = createComponent(); - router.push = jest.fn(); - await waitForPromises(); - - findIssuableList().vm.$emit('filter', filteredTokens); - await nextTick(); - - expect(router.push).toHaveBeenCalledWith({ - query: expect.objectContaining(urlParams), - }); - }); - }); - }); - - describe('Errors', () => { - describe.each` - error | responseHandler - ${'fetching issues'} | ${'serviceDeskIssuesQueryResponseHandler'} - ${'fetching issue counts'} | ${'serviceDeskIssuesCountsQueryResponseHandler'} - `('when there is an error $error', ({ responseHandler }) => { - beforeEach(() => { - wrapper = createComponent({ - [responseHandler]: jest.fn().mockRejectedValue(new Error('ERROR')), - }); - return waitForPromises(); - }); - - it('shows an error message', () => { - expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR')); - }); - }); - }); - - describe('When providing token for labels', () => { - it('passes function to fetchLatestLabels property if frontend caching is enabled', async () => { - wrapper = createComponent({ - provide: { - glFeatures: { - frontendCaching: true, - }, - }, - }); - await waitForPromises(); - - expect(typeof findLabelsToken().fetchLatestLabels).toBe('function'); - }); - - it('passes null to fetchLatestLabels property if frontend caching is disabled', async () => { - wrapper = createComponent({ - provide: { - glFeatures: { - frontendCaching: false, - }, - }, - }); - await waitForPromises(); - - expect(findLabelsToken().fetchLatestLabels).toBe(null); - }); - }); -}); diff --git a/spec/frontend/service_desk/mock_data.js b/spec/frontend/service_desk/mock_data.js deleted file mode 100644 index dc875cb5c1e..00000000000 --- a/spec/frontend/service_desk/mock_data.js +++ /dev/null @@ -1,236 +0,0 @@ -import { - FILTERED_SEARCH_TERM, - OPERATOR_IS, - OPERATOR_NOT, - OPERATOR_OR, - TOKEN_TYPE_ASSIGNEE, - TOKEN_TYPE_CONFIDENTIAL, - TOKEN_TYPE_EPIC, - TOKEN_TYPE_ITERATION, - TOKEN_TYPE_LABEL, - TOKEN_TYPE_MILESTONE, - TOKEN_TYPE_MY_REACTION, - TOKEN_TYPE_RELEASE, - TOKEN_TYPE_WEIGHT, - TOKEN_TYPE_HEALTH, -} from '~/vue_shared/components/filtered_search_bar/constants'; - -export const getServiceDeskIssuesQueryResponse = { - data: { - project: { - id: '1', - __typename: 'Project', - issues: { - __persist: true, - pageInfo: { - __typename: 'PageInfo', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'startcursor', - endCursor: 'endcursor', - }, - nodes: [ - { - __persist: true, - __typename: 'Issue', - id: 'gid://gitlab/Issue/123456', - iid: '789', - confidential: false, - createdAt: '2021-05-22T04:08:01Z', - downvotes: 2, - dueDate: '2021-05-29', - hidden: false, - humanTimeEstimate: null, - mergeRequestsCount: false, - moved: false, - state: 'opened', - title: 'Issue title', - updatedAt: '2021-05-22T04:08:01Z', - closedAt: null, - upvotes: 3, - userDiscussionsCount: 4, - webPath: 'project/-/issues/789', - webUrl: 'project/-/issues/789', - type: 'issue', - assignees: { - nodes: [ - { - __persist: true, - __typename: 'UserCore', - id: 'gid://gitlab/User/234', - avatarUrl: 'avatar/url', - name: 'Marge Simpson', - username: 'msimpson', - webUrl: 'url/msimpson', - }, - ], - }, - author: { - __persist: true, - __typename: 'UserCore', - id: 'gid://gitlab/User/456', - avatarUrl: 'avatar/url', - name: 'GitLab Support Bot', - username: 'support-bot', - webUrl: 'url/hsimpson', - }, - labels: { - nodes: [ - { - __persist: true, - id: 'gid://gitlab/ProjectLabel/456', - color: '#333', - title: 'Label title', - description: 'Label description', - }, - ], - }, - milestone: null, - taskCompletionStatus: { - completedCount: 1, - count: 2, - }, - }, - ], - }, - }, - }, -}; - -export const getServiceDeskIssuesQueryEmptyResponse = { - data: { - project: { - id: '1', - __typename: 'Project', - issues: { - __persist: true, - pageInfo: { - __typename: 'PageInfo', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'startcursor', - endCursor: 'endcursor', - }, - nodes: [], - }, - }, - }, -}; - -export const getServiceDeskIssuesCountsQueryResponse = { - data: { - project: { - id: '1', - openedIssues: { - count: 1, - }, - closedIssues: { - count: 1, - }, - allIssues: { - count: 1, - }, - }, - }, -}; - -export const filteredTokens = [ - { type: FILTERED_SEARCH_TERM, value: { data: 'find issues', operator: 'undefined' } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lisa', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: '5', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } }, - { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 3', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 4', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 30', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_LABEL, value: { data: 'cartoon', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_LABEL, value: { data: 'tv', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_LABEL, value: { data: 'live action', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_LABEL, value: { data: 'drama', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_LABEL, value: { data: 'comedy', operator: OPERATOR_OR } }, - { type: TOKEN_TYPE_LABEL, value: { data: 'sitcom', operator: OPERATOR_OR } }, - { type: TOKEN_TYPE_RELEASE, value: { data: 'v3', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_RELEASE, value: { data: 'v4', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_RELEASE, value: { data: 'v20', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_RELEASE, value: { data: 'v30', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsup', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsdown', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_CONFIDENTIAL, value: { data: 'yes', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ITERATION, value: { data: '4', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ITERATION, value: { data: '12', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ITERATION, value: { data: '20', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_ITERATION, value: { data: '42', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_EPIC, value: { data: '12', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_EPIC, value: { data: '34', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_WEIGHT, value: { data: '1', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_WEIGHT, value: { data: '3', operator: OPERATOR_NOT } }, - { type: TOKEN_TYPE_HEALTH, value: { data: 'atRisk', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_HEALTH, value: { data: 'onTrack', operator: OPERATOR_NOT } }, -]; - -export const urlParams = { - search: 'find issues', - 'assignee_username[]': ['bart', 'lisa', '5'], - 'not[assignee_username][]': ['patty', 'selma'], - 'or[assignee_username][]': ['carl', 'lenny'], - milestone_title: ['season 3', 'season 4'], - 'not[milestone_title]': ['season 20', 'season 30'], - 'label_name[]': ['cartoon', 'tv'], - 'not[label_name][]': ['live action', 'drama'], - 'or[label_name][]': ['comedy', 'sitcom'], - release_tag: ['v3', 'v4'], - 'not[release_tag]': ['v20', 'v30'], - my_reaction_emoji: 'thumbsup', - 'not[my_reaction_emoji]': 'thumbsdown', - confidential: 'yes', - iteration_id: ['4', '12'], - 'not[iteration_id]': ['20', '42'], - epic_id: '12', - 'not[epic_id]': '34', - weight: '1', - 'not[weight]': '3', - health_status: 'atRisk', - 'not[health_status]': 'onTrack', -}; - -export const locationSearch = [ - '?search=find+issues', - 'assignee_username[]=bart', - 'assignee_username[]=lisa', - 'assignee_username[]=5', - 'not[assignee_username][]=patty', - 'not[assignee_username][]=selma', - 'or[assignee_username][]=carl', - 'or[assignee_username][]=lenny', - 'milestone_title=season+3', - 'milestone_title=season+4', - 'not[milestone_title]=season+20', - 'not[milestone_title]=season+30', - 'label_name[]=cartoon', - 'label_name[]=tv', - 'not[label_name][]=live action', - 'not[label_name][]=drama', - 'or[label_name][]=comedy', - 'or[label_name][]=sitcom', - 'release_tag=v3', - 'release_tag=v4', - 'not[release_tag]=v20', - 'not[release_tag]=v30', - 'my_reaction_emoji=thumbsup', - 'not[my_reaction_emoji]=thumbsdown', - 'confidential=yes', - 'iteration_id=4', - 'iteration_id=12', - 'not[iteration_id]=20', - 'not[iteration_id]=42', - 'epic_id=12', - 'not[epic_id]=34', - 'weight=1', - 'not[weight]=3', - 'health_status=atRisk', - 'not[health_status]=onTrack', -].join('&'); diff --git a/spec/frontend/sidebar/components/assignees/assignees_spec.js b/spec/frontend/sidebar/components/assignees/assignees_spec.js index 65a07382ebc..2767d36ac3d 100644 --- a/spec/frontend/sidebar/components/assignees/assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignees_spec.js @@ -145,7 +145,7 @@ describe('Assignee component', () => { }); expect(findAllAvatarLinks()).toHaveLength(users.length); - expect(wrapper.find('.user-list-more').exists()).toBe(false); + expect(wrapper.find('[data-testid="user-list-more"]').exists()).toBe(false); }); it('shows sorted assignee where "can merge" users are sorted first', () => { diff --git a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js index 501048bf056..8c42e61548f 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_invite_members_spec.js @@ -26,7 +26,7 @@ describe('Sidebar invite members component', () => { }); it('has expected attributes on the trigger', () => { - expect(findDirectInviteLink().props('triggerSource')).toBe('issue-assignee-dropdown'); + expect(findDirectInviteLink().props('triggerSource')).toBe('issue_assignee_dropdown'); }); }); }); diff --git a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js index c74a714cca4..9e7a198d32c 100644 --- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js @@ -24,7 +24,7 @@ describe('UncollapsedAssigneeList component', () => { }); } - const findMoreButton = () => wrapper.find('.user-list-more button'); + const findMoreButton = () => wrapper.find('[data-testid="user-list-more-button"]'); describe('One assignee/user', () => { let user; diff --git a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js index 1ca20dad1c6..3588e92d515 100644 --- a/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js +++ b/spec/frontend/sidebar/components/confidential/sidebar_confidentiality_form_spec.js @@ -4,7 +4,7 @@ import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import SidebarConfidentialityForm from '~/sidebar/components/confidential/sidebar_confidentiality_form.vue'; -import { confidentialityQueries } from '~/sidebar/constants'; +import { confidentialityQueries } from '~/sidebar/queries/constants'; jest.mock('~/alert'); @@ -38,6 +38,23 @@ describe('Sidebar Confidentiality Form', () => { }); }; + const confidentialityMutation = (confidential, workspacePath) => { + return { + mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, + variables: { + input: { + confidential, + iid: '1', + ...workspacePath, + }, + }, + }; + }; + + const clickConfidentialToggle = () => { + findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + }; + it('emits a `closeForm` event when Cancel button is clicked', () => { createComponent(); findCancelButton().vm.$emit('click'); @@ -94,17 +111,10 @@ describe('Sidebar Confidentiality Form', () => { }); it('calls a mutation to set confidential to true on button click', () => { - findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, - variables: { - input: { - confidential: true, - iid: '1', - projectPath: 'group/project', - }, - }, - }); + clickConfidentialToggle(); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( + confidentialityMutation(true, { projectPath: 'group/project' }), + ); }); }); @@ -150,17 +160,49 @@ describe('Sidebar Confidentiality Form', () => { }); it('calls a mutation to set epic confidentiality with correct parameters', () => { - findConfidentialToggle().vm.$emit('click', new MouseEvent('click')); + clickConfidentialToggle(); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( + confidentialityMutation(false, { groupPath: 'group/project' }), + ); + }); + }); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: confidentialityQueries[wrapper.vm.issuableType].mutation, - variables: { - input: { - confidential: false, - iid: '1', - groupPath: 'group/project', - }, - }, + describe('when issuable type is `test_case`', () => { + describe('when test case is confidential', () => { + beforeEach(() => { + createComponent({ props: { confidential: true, issuableType: 'test_case' } }); + }); + + it('renders a message about making a test case non-confidential', () => { + expect(findWarningMessage().text()).toBe( + 'You are going to turn off the confidentiality. This means everyone will be able to see this test case.', + ); + }); + + it('calls a mutation to set confidential to false on button click', () => { + clickConfidentialToggle(); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( + confidentialityMutation(false, { projectPath: 'group/project' }), + ); + }); + }); + + describe('when test case is not confidential', () => { + beforeEach(() => { + createComponent({ props: { issuableType: 'test_case' } }); + }); + + it('renders a message about making a test case confidential', () => { + expect(findWarningMessage().text()).toBe( + 'You are going to turn on confidentiality. Only project members with at least the Reporter role can view or be notified about this test case.', + ); + }); + + it('calls a mutation to set confidential to true on button click', () => { + clickConfidentialToggle(); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( + confidentialityMutation(true, { projectPath: 'group/project' }), + ); }); }); }); diff --git a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js index 00b57b4916e..f3d50f17e2d 100644 --- a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js @@ -11,11 +11,8 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import SidebarEscalationStatus from '~/sidebar/components/incidents/sidebar_escalation_status.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { - escalationStatusQuery, - escalationStatusMutation, - STATUS_ACKNOWLEDGED, -} from '~/sidebar/constants'; +import { STATUS_ACKNOWLEDGED } from '~/sidebar/constants'; +import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/queries/constants'; import waitForPromises from 'helpers/wait_for_promises'; import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; import { createAlert } from '~/alert'; diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js index 9c8d9656955..5e2ff73878f 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js @@ -5,10 +5,13 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; -import { workspaceLabelsQueries } from '~/sidebar/constants'; +import { workspaceLabelsQueries, workspaceCreateLabelMutation } from '~/sidebar/queries/constants'; import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue'; -import createLabelMutation from '~/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql'; import { DEFAULT_LABEL_COLOR } from '~/sidebar/components/labels/labels_select_widget/constants'; +import { + mockCreateLabelResponse as createAbuseReportLabelSuccessfulResponse, + mockLabelsQueryResponse as abuseReportLabelsQueryResponse, +} from '../../../../admin/abuse_report/mock_data'; import { mockRegularLabel, mockSuggestedColors, @@ -38,6 +41,9 @@ const titleTakenError = { }; const createLabelSuccessHandler = jest.fn().mockResolvedValue(createLabelSuccessfulResponse); +const createAbuseReportLabelSuccessHandler = jest + .fn() + .mockResolvedValue(createAbuseReportLabelSuccessfulResponse); const createLabelUserRecoverableErrorHandler = jest.fn().mockResolvedValue(userRecoverableError); const createLabelDuplicateErrorHandler = jest.fn().mockResolvedValue(titleTakenError); const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); @@ -66,6 +72,7 @@ describe('DropdownContentsCreateView', () => { labelsResponse = workspaceLabelsQueryResponse, searchTerm = '', } = {}) => { + const createLabelMutation = workspaceCreateLabelMutation[workspaceType]; const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); mockApollo.clients.defaultClient.cache.writeQuery({ query: workspaceLabelsQueries[workspaceType].query, @@ -203,6 +210,22 @@ describe('DropdownContentsCreateView', () => { }); }); + it('calls the correct mutation when workspaceType is `abuseReport`', () => { + createComponent({ + mutationHandler: createAbuseReportLabelSuccessHandler, + labelCreateType: '', + workspaceType: 'abuseReport', + labelsResponse: abuseReportLabelsQueryResponse, + }); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createAbuseReportLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + title: 'Test title', + }); + }); + it('calls createAlert is mutation has a user-recoverable error', async () => { createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler }); fillLabelAttributes(); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js index ad1edaa6671..7a1131b8cce 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js @@ -1,12 +1,11 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DropdownFooter from '~/sidebar/components/labels/labels_select_widget/dropdown_footer.vue'; describe('DropdownFooter', () => { let wrapper; const createComponent = ({ props = {}, injected = {} } = {}) => { - wrapper = shallowMount(DropdownFooter, { + wrapper = shallowMountExtended(DropdownFooter, { propsData: { footerCreateLabelTitle: 'create', footerManageLabelTitle: 'manage', @@ -20,7 +19,8 @@ describe('DropdownFooter', () => { }); }; - const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); + const findCreateLabelButton = () => wrapper.findByTestId('create-label-button'); + const findManageLabelsButton = () => wrapper.findByTestId('manage-labels-button'); describe('Labels view', () => { beforeEach(() => { @@ -42,12 +42,37 @@ describe('DropdownFooter', () => { expect(findCreateLabelButton().exists()).toBe(true); }); - it('emits `toggleDropdownContentsCreateView` event on create label button click', async () => { + it('emits `toggleDropdownContentsCreateView` event on create label button click', () => { findCreateLabelButton().trigger('click'); - await nextTick(); expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]); }); }); + + describe('manage labels button', () => { + it('is rendered', () => { + expect(findManageLabelsButton().exists()).toBe(true); + }); + + describe('when footerManageLabelTitle is not given', () => { + beforeEach(() => { + createComponent({ props: { footerManageLabelTitle: undefined } }); + }); + + it('does not render manage labels button', () => { + expect(findManageLabelsButton().exists()).toBe(false); + }); + }); + + describe('when labelsManagePath is not provided', () => { + beforeEach(() => { + createComponent({ injected: { labelsManagePath: '' } }); + }); + + it('does not render manage labels button', () => { + expect(findManageLabelsButton().exists()).toBe(false); + }); + }); + }); }); }); diff --git a/spec/frontend/sidebar/components/lock/__snapshots__/edit_form_spec.js.snap b/spec/frontend/sidebar/components/lock/__snapshots__/edit_form_spec.js.snap index 18d4df297df..d5bbd3bb3c9 100644 --- a/spec/frontend/sidebar/components/lock/__snapshots__/edit_form_spec.js.snap +++ b/spec/frontend/sidebar/components/lock/__snapshots__/edit_form_spec.js.snap @@ -12,7 +12,6 @@ exports[`Edit Form Dropdown In issue page when locked the appropriate warning te message="Unlock this %{issuableDisplayName}? %{strongStart}Everyone%{strongEnd} will be able to comment." />

    -

    - @@ -51,7 +49,6 @@ exports[`Edit Form Dropdown In merge request page when locked the appropriate wa message="Unlock this %{issuableDisplayName}? %{strongStart}Everyone%{strongEnd} will be able to comment." />

    -

    - diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index f43fb17ca37..5dd54d4867e 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -120,11 +120,11 @@ describe('Issuable Time Tracker', () => { describe('Remaining meter', () => { it('should display the remaining meter with the correct width', () => { - expect(findTimeRemainingProgress().attributes('value')).toBe('5'); + expect(findTimeRemainingProgress().vm.$attrs.value).toBe(5); }); it('should display the remaining meter with the correct background color when within estimate', () => { - expect(findTimeRemainingProgress().attributes('variant')).toBe('primary'); + expect(findTimeRemainingProgress().vm.$attrs.variant).toBe('primary'); }); it('should display the remaining meter with the correct background color when over estimate', () => { @@ -138,7 +138,7 @@ describe('Issuable Time Tracker', () => { }, }); - expect(findTimeRemainingProgress().attributes('variant')).toBe('danger'); + expect(findTimeRemainingProgress().vm.$attrs.variant).toBe('danger'); }); }); }); diff --git a/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap index fd525474923..b5d8d31f88f 100644 --- a/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap +++ b/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap @@ -3,7 +3,7 @@ exports[`SidebarTodo template renders component container element with proper data attributes 1`] = `
    - # + # Removed - content + content - # + # Added - content + content
    v - = - @@ -102,17 +94,15 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` v - = - @@ -135,20 +123,18 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
    s - = - @@ -170,17 +154,15 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` s - = - @@ -203,52 +183,46 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
    for - i - in - @@ -294,7 +265,6 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > , - @@ -307,17 +277,15 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` for - i - in - @@ -363,7 +328,6 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > , - @@ -377,25 +341,21 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
    - - - + @@ -411,13 +371,11 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > i - + - @@ -430,22 +388,18 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` - - - + @@ -461,13 +415,11 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > i - + - @@ -481,52 +433,46 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
    class - @@ -557,17 +502,15 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` class - @@ -599,31 +541,26 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
    - - - + def - @@ -644,7 +581,6 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > , - @@ -657,28 +593,23 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` - - - + def - @@ -699,7 +630,6 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > , - @@ -713,25 +643,21 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
    - - - + @@ -747,13 +673,11 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > val - = - @@ -761,22 +685,18 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` - - - + @@ -792,13 +712,11 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > val - = - @@ -807,25 +725,21 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
    - - - + @@ -841,13 +755,11 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > next - = - @@ -855,22 +767,18 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` - - - + @@ -886,13 +794,11 @@ exports[`DiffsColorsPreview component renders diff colors preview 1`] = ` > next - = - diff --git a/spec/frontend/projects/commit/components/form_modal_spec.js b/spec/frontend/projects/commit/components/form_modal_spec.js index d40e2d7a48c..7ea3a74418d 100644 --- a/spec/frontend/projects/commit/components/form_modal_spec.js +++ b/spec/frontend/projects/commit/components/form_modal_spec.js @@ -72,11 +72,11 @@ describe('CommitFormModal', () => { it('Shows modal', () => { createComponent(); - const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + const rootWrapper = createWrapper(wrapper.vm.$root); - wrapper.vm.show(); + eventHub.$emit(mockData.modalPropsData.openModal); - expect(rootEmit).toHaveBeenCalledWith(BV_SHOW_MODAL, mockData.modalPropsData.modalId); + expect(rootWrapper.emitted(BV_SHOW_MODAL)[0]).toContain(mockData.modalPropsData.modalId); }); it('Clears the modal state once modal is hidden', () => { @@ -150,8 +150,9 @@ describe('CommitFormModal', () => { it('Action primary button dispatches submit action', () => { getByText(mockData.modalPropsData.i18n.actionPrimaryText).trigger('click'); + const formSubmitSpy = jest.spyOn(findForm().element, 'submit'); - expect(wrapper.vm.$refs.form.$el.submit).toHaveBeenCalled(); + expect(formSubmitSpy).toHaveBeenCalled(); }); it('Changes the start_branch input value', async () => { diff --git a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js b/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js deleted file mode 100644 index e289569f8ce..00000000000 --- a/spec/frontend/projects/compare/components/revision_dropdown_legacy_spec.js +++ /dev/null @@ -1,136 +0,0 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import { createAlert } from '~/alert'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_NOT_FOUND, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import RevisionDropdown from '~/projects/compare/components/revision_dropdown_legacy.vue'; - -const defaultProps = { - refsProjectPath: 'some/refs/path', - revisionText: 'Target', - paramsName: 'from', - paramsBranch: 'main', -}; - -jest.mock('~/alert'); - -describe('RevisionDropdown component', () => { - let wrapper; - let axiosMock; - - const createComponent = (props = {}) => { - wrapper = shallowMount(RevisionDropdown, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - createComponent(); - }); - - afterEach(() => { - axiosMock.restore(); - }); - - const findGlDropdown = () => wrapper.findComponent(GlDropdown); - const findBranchesDropdownItem = () => - wrapper.findAllComponents('[data-testid="branches-dropdown-item"]'); - const findTagsDropdownItem = () => - wrapper.findAllComponents('[data-testid="tags-dropdown-item"]'); - - it('sets hidden input', () => { - expect(wrapper.find('input[type="hidden"]').attributes('value')).toBe( - defaultProps.paramsBranch, - ); - }); - - it('update the branches on success', async () => { - const Branches = ['branch-1', 'branch-2']; - const Tags = ['tag-1', 'tag-2', 'tag-3']; - - axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(HTTP_STATUS_OK, { - Branches, - Tags, - }); - - createComponent(); - - expect(findBranchesDropdownItem()).toHaveLength(0); - expect(findTagsDropdownItem()).toHaveLength(0); - - await waitForPromises(); - - Branches.forEach((branch, index) => { - expect(findBranchesDropdownItem().at(index).text()).toBe(branch); - }); - - Tags.forEach((tag, index) => { - expect(findTagsDropdownItem().at(index).text()).toBe(tag); - }); - - expect(findBranchesDropdownItem()).toHaveLength(Branches.length); - expect(findTagsDropdownItem()).toHaveLength(Tags.length); - }); - - it('sets branches and tags to be an empty array when no tags or branches are given', async () => { - axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(HTTP_STATUS_OK, { - Branches: undefined, - Tags: undefined, - }); - - await waitForPromises(); - - expect(findBranchesDropdownItem()).toHaveLength(0); - expect(findTagsDropdownItem()).toHaveLength(0); - }); - - it('shows an alert on error', async () => { - axiosMock.onGet('some/invalid/path').replyOnce(HTTP_STATUS_NOT_FOUND); - - await waitForPromises(); - - expect(createAlert).toHaveBeenCalled(); - }); - - describe('GlDropdown component', () => { - it('renders props', () => { - expect(wrapper.props()).toEqual(expect.objectContaining(defaultProps)); - }); - - it('display default text', () => { - createComponent({ - paramsBranch: null, - }); - expect(findGlDropdown().props('text')).toBe('Select branch/tag'); - }); - - it('display params branch text', () => { - expect(findGlDropdown().props('text')).toBe(defaultProps.paramsBranch); - }); - - it('emits a "selectRevision" event when a revision is selected', async () => { - const findGlDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstGlDropdownItem = () => findGlDropdownItems().at(0); - const branchName = 'some-branch'; - - axiosMock.onGet(defaultProps.refsProjectPath).replyOnce(HTTP_STATUS_OK, { - Branches: [branchName], - }); - - createComponent(); - await waitForPromises(); - - findFirstGlDropdownItem().vm.$emit('click'); - - expect(wrapper.emitted()).toEqual({ - selectRevision: [[{ direction: 'from', revision: branchName }]], - }); - }); - }); -}); diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap index 4893ee26178..479530c1d38 100644 --- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -10,12 +10,10 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` type="hidden" value="delete" /> - - - Some title

    - Total:
    - 4 pipelines @@ -15,7 +14,6 @@ exports[`StatisticsList displays the counts data with labels 1`] = ` Successful: - 2 pipelines @@ -24,20 +22,16 @@ exports[`StatisticsList displays the counts data with labels 1`] = ` Failed: - - - 2 pipelines - + 2 pipelines
  • Success ratio: - 50.00% diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js deleted file mode 100644 index a94d7669b2b..00000000000 --- a/spec/frontend/projects/settings/access_dropdown_spec.js +++ /dev/null @@ -1,204 +0,0 @@ -import $ from 'jquery'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import AccessDropdown from '~/projects/settings/access_dropdown'; -import { LEVEL_TYPES } from '~/projects/settings/constants'; - -describe('AccessDropdown', () => { - const defaultLabel = 'dummy default label'; - let dropdown; - - beforeEach(() => { - setHTMLFixture(` -
    - -
    - `); - const $dropdown = $('#dummy-dropdown'); - $dropdown.data('defaultLabel', defaultLabel); - const options = { - $dropdown, - accessLevelsData: { - roles: [ - { - id: 42, - text: 'Dummy Role', - }, - ], - }, - }; - dropdown = new AccessDropdown(options); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - describe('toggleLabel', () => { - let $dropdownToggleText; - const dummyItems = [ - { type: LEVEL_TYPES.ROLE, access_level: 42 }, - { type: LEVEL_TYPES.USER }, - { type: LEVEL_TYPES.USER }, - { type: LEVEL_TYPES.GROUP }, - { type: LEVEL_TYPES.GROUP }, - { type: LEVEL_TYPES.GROUP }, - { type: LEVEL_TYPES.DEPLOY_KEY }, - { type: LEVEL_TYPES.DEPLOY_KEY }, - { type: LEVEL_TYPES.DEPLOY_KEY }, - ]; - - beforeEach(() => { - $dropdownToggleText = $('.dropdown-toggle-text'); - }); - - it('displays number of items', () => { - dropdown.setSelectedItems(dummyItems); - $dropdownToggleText.addClass('is-default'); - - const label = dropdown.toggleLabel(); - - expect(label).toBe('1 role, 2 users, 3 deploy keys, 3 groups'); - expect($dropdownToggleText).not.toHaveClass('is-default'); - }); - - describe('without selected items', () => { - beforeEach(() => { - dropdown.setSelectedItems([]); - }); - - it('falls back to default label', () => { - const label = dropdown.toggleLabel(); - - expect(label).toBe(defaultLabel); - expect($dropdownToggleText).toHaveClass('is-default'); - }); - }); - - describe('with only role', () => { - beforeEach(() => { - dropdown.setSelectedItems(dummyItems.filter((item) => item.type === LEVEL_TYPES.ROLE)); - $dropdownToggleText.addClass('is-default'); - }); - - it('displays the role name', () => { - const label = dropdown.toggleLabel(); - - expect(label).toBe('Dummy Role'); - expect($dropdownToggleText).not.toHaveClass('is-default'); - }); - }); - - describe('with only users', () => { - beforeEach(() => { - dropdown.setSelectedItems(dummyItems.filter((item) => item.type === LEVEL_TYPES.USER)); - $dropdownToggleText.addClass('is-default'); - }); - - it('displays number of users', () => { - const label = dropdown.toggleLabel(); - - expect(label).toBe('2 users'); - expect($dropdownToggleText).not.toHaveClass('is-default'); - }); - }); - - describe('with only groups', () => { - beforeEach(() => { - dropdown.setSelectedItems(dummyItems.filter((item) => item.type === LEVEL_TYPES.GROUP)); - $dropdownToggleText.addClass('is-default'); - }); - - it('displays number of groups', () => { - const label = dropdown.toggleLabel(); - - expect(label).toBe('3 groups'); - expect($dropdownToggleText).not.toHaveClass('is-default'); - }); - }); - - describe('with users and groups', () => { - beforeEach(() => { - const selectedTypes = [LEVEL_TYPES.GROUP, LEVEL_TYPES.USER]; - dropdown.setSelectedItems(dummyItems.filter((item) => selectedTypes.includes(item.type))); - $dropdownToggleText.addClass('is-default'); - }); - - it('displays number of groups', () => { - const label = dropdown.toggleLabel(); - - expect(label).toBe('2 users, 3 groups'); - expect($dropdownToggleText).not.toHaveClass('is-default'); - }); - }); - - describe('with users and deploy keys', () => { - beforeEach(() => { - const selectedTypes = [LEVEL_TYPES.DEPLOY_KEY, LEVEL_TYPES.USER]; - dropdown.setSelectedItems(dummyItems.filter((item) => selectedTypes.includes(item.type))); - $dropdownToggleText.addClass('is-default'); - }); - - it('displays number of deploy keys', () => { - const label = dropdown.toggleLabel(); - - expect(label).toBe('2 users, 3 deploy keys'); - expect($dropdownToggleText).not.toHaveClass('is-default'); - }); - }); - }); - - describe('userRowHtml', () => { - it('escapes users name', () => { - const user = { - avatar_url: '', - name: '', - username: 'test', - }; - const template = dropdown.userRowHtml(user); - - expect(template).not.toContain(user.name); - }); - - it('show user avatar correctly', () => { - const user = { - id: 613, - avatar_url: 'some_valid_avatar.png', - name: 'test', - username: 'test', - }; - const template = dropdown.userRowHtml(user); - - expect(template).toContain(user.avatar_url); - expect(template).not.toContain('identicon'); - }); - - it('show identicon when user do not have avatar', () => { - const user = { - id: 613, - avatar_url: '', - name: 'test', - username: 'test', - }; - const template = dropdown.userRowHtml(user); - - expect(template).toContain('identicon'); - }); - }); - - describe('deployKeyRowHtml', () => { - const deployKey = { - id: 1, - title: 'title ', - fullname: 'fullname ', - avatar_url: '', - username: '', - }; - - it('escapes deploy key title and fullname', () => { - const template = dropdown.deployKeyRowHtml(deployKey); - - expect(template).not.toContain(deployKey.title); - expect(template).not.toContain(deployKey.fullname); - }); - }); -}); 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 ce696ee321b..0ed2e51e8c3 100644 --- a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -14,13 +14,11 @@ import AccessDropdown, { i18n } from '~/projects/settings/components/access_drop import { ACCESS_LEVELS, LEVEL_TYPES } from '~/projects/settings/constants'; jest.mock('~/projects/settings/api/access_dropdown_api', () => ({ - getGroups: jest.fn().mockResolvedValue({ - data: [ - { id: 4, name: 'group4' }, - { id: 5, name: 'group5' }, - { id: 6, name: 'group6' }, - ], - }), + getGroups: jest.fn().mockResolvedValue([ + { id: 4, name: 'group4' }, + { id: 5, name: 'group5' }, + { id: 6, name: 'group6' }, + ]), getUsers: jest.fn().mockResolvedValue({ data: [ { id: 7, name: 'user7' }, @@ -50,6 +48,7 @@ jest.mock('~/projects/settings/api/access_dropdown_api', () => ({ describe('Access Level Dropdown', () => { let wrapper; + const defaultToggleClass = 'gl-text-gray-500!'; const mockAccessLevelsData = [ { id: 1, @@ -63,6 +62,10 @@ describe('Access Level Dropdown', () => { id: 3, text: 'role3', }, + { + id: 0, + text: 'No one', + }, ]; const createComponent = ({ @@ -140,7 +143,7 @@ describe('Access Level Dropdown', () => { }); it('renders dropdown item for each access level type', () => { - expect(findAllDropdownItems()).toHaveLength(12); + expect(findAllDropdownItems()).toHaveLength(13); }); it.each` @@ -177,26 +180,26 @@ describe('Access Level Dropdown', () => { const customLabel = 'Set the access level'; createComponent({ label: customLabel }); expect(findDropdownToggleLabel()).toBe(customLabel); - expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); + expect(findDropdown().props('toggleClass')[defaultToggleClass]).toBe(true); }); it('when no items selected, displays a default fallback label and has default CSS class', () => { - expect(findDropdownToggleLabel()).toBe(i18n.selectUsers); - expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); + expect(findDropdownToggleLabel()).toBe(i18n.defaultLabel); + expect(findDropdown().props('toggleClass')[defaultToggleClass]).toBe(true); }); - it('displays a number of selected items for each group level', async () => { + it('displays selected items for each group level', async () => { dropdownItems.wrappers.forEach((item) => { item.trigger('click'); }); await nextTick(); - expect(findDropdownToggleLabel()).toBe('3 roles, 3 users, 3 deploy keys, 3 groups'); + expect(findDropdownToggleLabel()).toBe('No role, 3 users, 3 deploy keys, 3 groups'); }); it('with only role selected displays the role name and has no class applied', async () => { await findItemByNameAndClick('role1'); expect(findDropdownToggleLabel()).toBe('role1'); - expect(findDropdown().props('toggleClass')).toBe(''); + expect(findDropdown().props('toggleClass')[defaultToggleClass]).toBe(false); }); it('with only groups selected displays the number of selected groups', async () => { @@ -204,14 +207,14 @@ describe('Access Level Dropdown', () => { await findItemByNameAndClick('group5'); await findItemByNameAndClick('group6'); expect(findDropdownToggleLabel()).toBe('3 groups'); - expect(findDropdown().props('toggleClass')).toBe(''); + expect(findDropdown().props('toggleClass')[defaultToggleClass]).toBe(false); }); it('with only users selected displays the number of selected users', async () => { await findItemByNameAndClick('user7'); await findItemByNameAndClick('user8'); expect(findDropdownToggleLabel()).toBe('2 users'); - expect(findDropdown().props('toggleClass')).toBe(''); + expect(findDropdown().props('toggleClass')[defaultToggleClass]).toBe(false); }); it('with users and groups selected displays the number of selected users & groups', async () => { @@ -220,7 +223,7 @@ describe('Access Level Dropdown', () => { await findItemByNameAndClick('user7'); await findItemByNameAndClick('user9'); expect(findDropdownToggleLabel()).toBe('2 users, 2 groups'); - expect(findDropdown().props('toggleClass')).toBe(''); + expect(findDropdown().props('toggleClass')[defaultToggleClass]).toBe(false); }); it('with users and deploy keys selected displays the number of selected users & keys', async () => { @@ -228,7 +231,7 @@ describe('Access Level Dropdown', () => { await findItemByNameAndClick('key10'); await findItemByNameAndClick('key11'); expect(findDropdownToggleLabel()).toBe('1 user, 2 deploy keys'); - expect(findDropdown().props('toggleClass')).toBe(''); + expect(findDropdown().props('toggleClass')[defaultToggleClass]).toBe(false); }); }); @@ -393,4 +396,20 @@ describe('Access Level Dropdown', () => { expect(wrapper.emitted('hidden')[0][0]).toStrictEqual([{ access_level: 2 }]); }); }); + + describe('when no license and accessLevel is MERGE', () => { + beforeEach(async () => { + createComponent({ hasLicense: false, accessLevel: ACCESS_LEVELS.MERGE }); + await waitForPromises(); + }); + + it('dropdown is single-select', () => { + const dropdownItems = findAllDropdownItems(); + + findDropdownItemWithText(dropdownItems, mockAccessLevelsData[0].text).trigger('click'); + findDropdownItemWithText(dropdownItems, mockAccessLevelsData[1].text).trigger('click'); + + expect(wrapper.emitted('select')[1]).toHaveLength(1); + }); + }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/custom_email_form_spec.js b/spec/frontend/projects/settings_service_desk/components/custom_email_form_spec.js index ded8b181c4e..9b012995ea4 100644 --- a/spec/frontend/projects/settings_service_desk/components/custom_email_form_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/custom_email_form_spec.js @@ -1,6 +1,8 @@ import { mount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { helpPagePath } from '~/helpers/help_page_helper'; import CustomEmailForm from '~/projects/settings_service_desk/components/custom_email_form.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { I18N_FORM_FORWARDING_CLIPBOARD_BUTTON_TITLE } from '~/projects/settings_service_desk/custom_email_constants'; @@ -15,6 +17,7 @@ describe('CustomEmailForm', () => { const findForm = () => wrapper.find('form'); const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findLink = () => wrapper.findComponent(GlLink); const findInputByTestId = (testId) => wrapper.findByTestId(testId).find('input'); const findCustomEmailInput = () => findInputByTestId('form-custom-email'); const findSmtpAddressInput = () => findInputByTestId('form-smtp-address'); @@ -35,6 +38,16 @@ describe('CustomEmailForm', () => { wrapper = extendedWrapper(mount(CustomEmailForm, { propsData: { ...defaultProps, ...props } })); }; + it('displays help page link', () => { + createWrapper(); + + expect(findLink().attributes('href')).toBe( + helpPagePath('user/project/service_desk/configure.html', { + anchor: 'custom-email-address', + }), + ); + }); + it('renders a copy to clipboard button', () => { createWrapper(); diff --git a/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js b/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js index e54d09cf82f..174e05ceeee 100644 --- a/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/custom_email_wrapper_spec.js @@ -1,7 +1,8 @@ import { nextTick } from 'vue'; -import { GlLink, GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; import { HTTP_STATUS_OK, HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; @@ -40,19 +41,21 @@ describe('CustomEmailWrapper', () => { const showToast = jest.fn(); const createWrapper = (props = {}) => { - wrapper = mount(CustomEmailWrapper, { - propsData: { ...defaultProps, ...props }, - mocks: { - $toast: { - show: showToast, + wrapper = extendedWrapper( + mount(CustomEmailWrapper, { + propsData: { ...defaultProps, ...props }, + mocks: { + $toast: { + show: showToast, + }, }, - }, - }); + }), + ); }; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); - const findFeedbackLink = () => wrapper.findComponent(GlLink); + const findFeedbackLink = () => wrapper.findByTestId('feedback-link'); const findCustomEmailForm = () => wrapper.findComponent(CustomEmailForm); const findCustomEmail = () => wrapper.findComponent(CustomEmail); const findCustomEmailConfirmModal = () => wrapper.findComponent(CustomEmailConfirmModal); diff --git a/spec/frontend/protected_branches/protected_branch_create_spec.js b/spec/frontend/protected_branches/protected_branch_create_spec.js index 4b634c52b01..e2a0f02e0cf 100644 --- a/spec/frontend/protected_branches/protected_branch_create_spec.js +++ b/spec/frontend/protected_branches/protected_branch_create_spec.js @@ -1,5 +1,8 @@ +import MockAdapter from 'axios-mock-adapter'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; +import { ACCESS_LEVELS } from '~/protected_branches/constants'; +import axios from '~/lib/utils/axios_utils'; const FORCE_PUSH_TOGGLE_TESTID = 'force-push-toggle'; const CODE_OWNER_TOGGLE_TESTID = 'code-owner-toggle'; @@ -9,7 +12,12 @@ const IS_LOADING_CLASS = 'toggle-loading'; describe('ProtectedBranchCreate', () => { beforeEach(() => { - jest.spyOn(ProtectedBranchCreate.prototype, 'buildDropdowns').mockImplementation(); + // eslint-disable-next-line no-unused-vars + const mock = new MockAdapter(axios); + window.gon = { + merge_access_levels: { roles: [] }, + push_access_levels: { roles: [] }, + }; }); const findForcePushToggle = () => @@ -34,6 +42,12 @@ describe('ProtectedBranchCreate', () => { data-label="Toggle code owner approval" data-is-checked="${codeOwnerToggleChecked}" data-testid="${CODE_OWNER_TOGGLE_TESTID}"> +
    +
    +
    +
    +
    +
    `); @@ -85,14 +99,6 @@ describe('ProtectedBranchCreate', () => { forcePushToggleChecked: false, codeOwnerToggleChecked: true, }); - - // Mock access levels. This should probably be improved in future iterations. - protectedBranchCreate.merge_access_levels_dropdown = { - getSelectedItems: () => [], - }; - protectedBranchCreate.push_access_levels_dropdown = { - getSelectedItems: () => [], - }; }); afterEach(() => { @@ -116,4 +122,31 @@ describe('ProtectedBranchCreate', () => { }); }); }); + + describe('access dropdown', () => { + let protectedBranchCreate; + + beforeEach(() => { + protectedBranchCreate = create(); + }); + + it('should be initialized', () => { + expect(protectedBranchCreate[`${ACCESS_LEVELS.MERGE}_dropdown`]).toBeDefined(); + expect(protectedBranchCreate[`${ACCESS_LEVELS.PUSH}_dropdown`]).toBeDefined(); + }); + + describe('`select` event is emitted', () => { + const selected = ['foo', 'bar']; + + it('should update selected merged access items', () => { + protectedBranchCreate[`${ACCESS_LEVELS.MERGE}_dropdown`].$emit('select', selected); + expect(protectedBranchCreate.selectedItems[ACCESS_LEVELS.MERGE]).toEqual(selected); + }); + + it('should update selected push access items', () => { + protectedBranchCreate[`${ACCESS_LEVELS.PUSH}_dropdown`].$emit('select', selected); + expect(protectedBranchCreate.selectedItems[ACCESS_LEVELS.PUSH]).toEqual(selected); + }); + }); + }); }); diff --git a/spec/frontend/protected_branches/protected_branch_edit_spec.js b/spec/frontend/protected_branches/protected_branch_edit_spec.js index e1966908452..6422856ba22 100644 --- a/spec/frontend/protected_branches/protected_branch_edit_spec.js +++ b/spec/frontend/protected_branches/protected_branch_edit_spec.js @@ -20,7 +20,7 @@ describe('ProtectedBranchEdit', () => { let mock; beforeEach(() => { - jest.spyOn(ProtectedBranchEdit.prototype, 'buildDropdowns').mockImplementation(); + jest.spyOn(ProtectedBranchEdit.prototype, 'initDropdowns').mockImplementation(); mock = new MockAdapter(axios); }); diff --git a/spec/frontend/protected_tags/mock_data.js b/spec/frontend/protected_tags/mock_data.js new file mode 100644 index 00000000000..dacdecdfe74 --- /dev/null +++ b/spec/frontend/protected_tags/mock_data.js @@ -0,0 +1,18 @@ +export const mockAccessLevels = [ + { + id: 30, + text: 'Developers + Maintainers', + }, + { + id: 40, + text: 'Maintainers', + }, + { + id: 60, + text: 'Instance admins', + }, + { + id: 0, + text: 'No one', + }, +]; diff --git a/spec/frontend/protected_tags/protected_tag_edit_spec.js b/spec/frontend/protected_tags/protected_tag_edit_spec.js new file mode 100644 index 00000000000..f56b3a70d1b --- /dev/null +++ b/spec/frontend/protected_tags/protected_tag_edit_spec.js @@ -0,0 +1,113 @@ +import MockAdapter from 'axios-mock-adapter'; +import { ACCESS_LEVELS, LEVEL_TYPES } from '~/protected_tags/constants'; +import ProtectedTagEdit, { i18n } from '~/protected_tags/protected_tag_edit.vue'; +import AccessDropdown from '~/projects/settings/components/access_dropdown.vue'; +import axios from '~/lib/utils/axios_utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { mockAccessLevels } from './mock_data'; + +jest.mock('~/alert'); + +describe('Protected Tag Edit', () => { + let wrapper; + let mockAxios; + + const url = 'http://some.url'; + const toggleClass = 'js-allowed-to-create gl-max-w-34'; + + const findAccessDropdown = () => wrapper.findComponent(AccessDropdown); + + const createComponent = () => { + wrapper = shallowMountExtended(ProtectedTagEdit, { + propsData: { + url, + accessLevelsData: mockAccessLevels, + searchEnabled: false, + }, + }); + }; + + beforeEach(() => { + window.gon = { + api_version: 'v4', + deploy_access_levels: { + roles: [], + }, + }; + mockAxios = new MockAdapter(axios); + createComponent(); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('renders access dropdown with correct props', () => { + expect(findAccessDropdown().props()).toMatchObject({ + toggleClass, + accessLevel: ACCESS_LEVELS.CREATE, + accessLevelsData: mockAccessLevels, + searchEnabled: false, + }); + }); + + describe('when dropdown is closed and has no changes', () => { + it('does not make a patch request to update permission', () => { + jest.spyOn(axios, 'patch'); + + findAccessDropdown().vm.$emit('hidden', []); + + expect(axios.patch).not.toHaveBeenCalled(); + }); + }); + + describe('when dropdown is closed and has changes', () => { + it('makes patch request to update permission', () => { + jest.spyOn(axios, 'patch'); + + const newPermissions = [{ id: 1, access_level: 30 }]; + findAccessDropdown().vm.$emit('hidden', newPermissions); + + expect(axios.patch).not.toHaveBeenCalled(); + }); + }); + + describe('when permission is updated successfully', () => { + beforeEach(async () => { + const updatedPermissions = [ + { user_id: 1, id: 1 }, + { group_id: 1, id: 2 }, + { access_level: 3, id: 3 }, + ]; + mockAxios.onPatch().replyOnce(HTTP_STATUS_OK, { [ACCESS_LEVELS.CREATE]: updatedPermissions }); + findAccessDropdown().vm.$emit('hidden', [{ user_id: 1 }]); + await waitForPromises(); + }); + + it('should update selected items', () => { + const newPreselected = [ + { user_id: 1, id: 1, type: LEVEL_TYPES.USER }, + { group_id: 1, id: 2, type: LEVEL_TYPES.GROUP }, + { access_level: 3, id: 3, type: LEVEL_TYPES.ROLE }, + ]; + expect(findAccessDropdown().props('preselectedItems')).toEqual(newPreselected); + }); + }); + + describe('when permission update fails', () => { + beforeEach(async () => { + mockAxios.onPatch().replyOnce(HTTP_STATUS_BAD_REQUEST, {}); + findAccessDropdown().vm.$emit('hidden', [{ user_id: 1 }]); + await waitForPromises(); + }); + + it('should show error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: i18n.failureMessage, + }); + }); + }); +}); diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index 79792a4a0ea..c02c1bb959c 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -54,7 +54,19 @@ Object { }, "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", "createdAt": 2019-01-03T00:00:00.000Z, - "descriptionHtml": "

    An okay release 🤷

    ", + "descriptionHtml":

    + An okay release + + 🤷 + +

    , "evidences": Array [], "historicalRelease": false, "milestones": Array [], @@ -148,7 +160,22 @@ Object { }, "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", "createdAt": 2018-12-03T00:00:00.000Z, - "descriptionHtml": "

    Best. Release. Ever. 🚀

    ", + "descriptionHtml":

    + Best. Release. + + Ever. + + + 🚀 + +

    , "evidences": Array [ Object { "__typename": "ReleaseEvidence", @@ -368,7 +395,22 @@ Object { }, "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", "createdAt": 2018-12-03T00:00:00.000Z, - "descriptionHtml": "

    Best. Release. Ever. 🚀

    ", + "descriptionHtml":

    + Best. Release. + + Ever. + + + 🚀 + +

    , "evidences": Array [ Object { "__typename": "ReleaseEvidence", diff --git a/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap b/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap index e53ea6b2ec6..8f811d31af8 100644 --- a/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap +++ b/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap @@ -1,9 +1,68 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`~/releases/components/issuable_stats.vue matches snapshot 1`] = ` -"
    +
    + Items - 10 -
    Open: 1 Merged: 7 Closed: 2
    -
    " + + 10 + +
    +
    + + Open: + + 1 + + + + • + + + Merged: + + 7 + + + + • + + + Closed: + + 2 + + +
    +
    `; diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js index b8030ae1fd2..26068b392d1 100644 --- a/spec/frontend/releases/components/release_block_milestone_info_spec.js +++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js @@ -41,10 +41,10 @@ describe('Release block milestone info', () => { const progressBar = milestoneProgressBarContainer().findComponent(GlProgressBar); expect(progressBar.exists()).toBe(true); - expect(progressBar.attributes()).toEqual( + expect(progressBar.vm.$attrs).toEqual( expect.objectContaining({ - value: '4', - max: '9', + value: 4, + max: 9, }), ); }); diff --git a/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap index 836ae5c22e6..02f75edd57a 100644 --- a/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap @@ -2,14 +2,13 @@ exports[`Repository directory download links component renders downloads links for path app 1`] = `
    -
    @@ -47,14 +42,13 @@ exports[`Repository directory download links component renders downloads links f exports[`Repository directory download links component renders downloads links for path app/assets 1`] = `
    -
    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 ede04390586..3f901dc61b8 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -2,10 +2,10 @@ exports[`Repository last commit component renders commit widget 1`] = `
    -
    Commit title - - -
    @@ -42,12 +38,9 @@ exports[`Repository last commit component renders commit widget 1`] = ` class="commit-author-link js-user-link" href="/test" > - - Test + Test - - authored - + authored
    - -
    -
    -
    - - - @@ -100,7 +81,6 @@ exports[`Repository last commit component renders commit widget 1`] = ` > 12345678 - { describe('Blob content viewer component', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findBlobHeader = () => wrapper.findComponent(BlobHeader); - const findWebIdeLink = () => wrapper.findComponent(WebIdeLink); const findBlobContent = () => wrapper.findComponent(BlobContent); const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion); @@ -197,9 +181,22 @@ describe('Blob content viewer component', () => { expect(findBlobHeader().props('hasRenderError')).toEqual(false); expect(findBlobHeader().props('hideViewerSwitcher')).toEqual(true); expect(findBlobHeader().props('blob')).toEqual(simpleViewerMock); + expect(findBlobHeader().props('showForkSuggestion')).toEqual(false); + expect(findBlobHeader().props('projectPath')).toEqual(propsMock.projectPath); + expect(findBlobHeader().props('projectId')).toEqual(projectMock.id); expect(mockRouterPush).not.toHaveBeenCalled(); }); + it('creates an alert when the BlobHeader component emits an error', async () => { + await createComponent(); + + findBlobHeader().vm.$emit('error'); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while loading the file. Please try again.', + }); + }); + it('copies blob text to clipboard', async () => { jest.spyOn(navigator.clipboard, 'writeText'); await createComponent(); @@ -401,45 +398,6 @@ describe('Blob content viewer component', () => { }); describe('BlobHeader action slot', () => { - const { ideEditPath, editBlobPath } = simpleViewerMock; - - it('renders WebIdeLink button in simple viewer', async () => { - await createComponent({ inject: { BlobContent: true, BlobReplace: true } }, mount); - - expect(findWebIdeLink().props()).toMatchObject({ - editUrl: editBlobPath, - webIdeUrl: ideEditPath, - showEditButton: true, - showGitpodButton: applicationInfoMock.gitpodEnabled, - gitpodEnabled: userInfoMock.currentUser.gitpodEnabled, - showPipelineEditorButton: true, - gitpodUrl: simpleViewerMock.gitpodBlobUrl, - pipelineEditorUrl: simpleViewerMock.pipelineEditorPath, - userPreferencesGitpodPath: userInfoMock.currentUser.preferencesGitpodPath, - userProfileEnableGitpodPath: userInfoMock.currentUser.profileEnableGitpodPath, - }); - }); - - it('renders WebIdeLink button in rich viewer', async () => { - await createComponent({ blob: richViewerMock }, mount); - - expect(findWebIdeLink().props()).toMatchObject({ - editUrl: editBlobPath, - webIdeUrl: ideEditPath, - showEditButton: true, - }); - }); - - it('renders WebIdeLink button for binary files', async () => { - mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, axiosMockResponse); - await createComponent({}, mount); - expect(findWebIdeLink().props()).toMatchObject({ - editUrl: editBlobPath, - webIdeUrl: ideEditPath, - showEditButton: false, - }); - }); - describe('blob header binary file', () => { it('passes the correct isBinary value when viewing a binary file', async () => { mockAxios.onGet(legacyViewerUrl).replyOnce(HTTP_STATUS_OK, axiosMockResponse); @@ -465,7 +423,6 @@ describe('Blob content viewer component', () => { expect(findBlobHeader().props('hideViewerSwitcher')).toBe(true); expect(findBlobHeader().props('isBinary')).toBe(true); - expect(findWebIdeLink().props('showEditButton')).toBe(false); }); }); @@ -538,12 +495,12 @@ describe('Blob content viewer component', () => { beforeEach(() => createComponent({}, mount)); it('simple edit redirects to the simple editor', () => { - findWebIdeLink().vm.$emit('edit', 'simple'); + findBlobHeader().vm.$emit('edit', 'simple'); expect(urlUtility.redirectTo).toHaveBeenCalledWith(simpleViewerMock.editBlobPath); // eslint-disable-line import/no-deprecated }); it('IDE edit redirects to the IDE editor', () => { - findWebIdeLink().vm.$emit('edit', 'ide'); + findBlobHeader().vm.$emit('edit', 'ide'); expect(urlUtility.redirectTo).toHaveBeenCalledWith(simpleViewerMock.ideEditPath); // eslint-disable-line import/no-deprecated }); @@ -572,7 +529,7 @@ describe('Blob content viewer component', () => { mount, ); - findWebIdeLink().vm.$emit('edit', 'simple'); + findBlobHeader().vm.$emit('edit', 'simple'); await nextTick(); expect(findForkSuggestion().exists()).toBe(showForkSuggestion); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index 85bf683fdf6..17ebdf8725d 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -5,10 +5,10 @@ exports[`Repository table row component renders a symlink table row 1`] = ` class="tree-item" >
  • - - - - - - - - - + - - - + + +
    - - - - - - - - - + - - - + + +
    - - - - - - - - - + - - - + + +