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: '',
});
- 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}"
/>
-
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
+
+
- 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 '';
- } 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 '';
- } 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
-
-
-
-
-
-
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"
>
-
-
-
-
-
- Remove integration and resources
-
+ Remove integration and resources
-
-
-
-
-
-
- Remove integration
-
+ Remove integration
-
-
`;
@@ -49,63 +34,44 @@ exports[`Remove cluster confirmation modal two buttons open modal with "cleanup"
class="gl-display-flex"
>
-
-
-
-
-
- Remove integration and resources
-
+ Remove integration and resources
-
-
-
-
-
-
- Remove integration
-
+ Remove integration
-
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"
>
-
-
-
-
-
- Remove integration and resources
-
+ Remove integration and resources
-
-
-
-
-
-
- Remove integration
-
+ Remove integration
-
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
-
-
-
-
-
- Comment template actions
-
+ Comment template actions
-
-
-
-
-
- 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
Project
-
-
-
-
- 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
-
@@ -57,11 +45,8 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
-
- Heading 1.2
-
+ Heading 1.2
-
@@ -86,12 +67,8 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
-
- Heading 1.3
-
+ Heading 1.3
-
-
-
- Heading 1.4
-
+ Heading 1.4
-
@@ -130,12 +100,8 @@ exports[`content/components/wrappers/table_of_contents collects all headings and
-
- Heading 2
-
+ Heading 2
-
-
`;
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)
-
-
-
+
-
-
-
-
-
- New custom emoji
-
+ New custom emoji
-
-
-
-
-
-
-
-
-
- New custom emoji
-
+ New custom emoji
-
-
-
-
-
-
-
-
-
- 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`] = `
-
-
-
@@ -18,11 +16,9 @@ exports[`Design management large image component renders image 1`] = `
-
-
@@ -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
-
-
-
-
- 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
`;
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`] = `
-"
-
-
- Comment
- "
+
+
+ Comment
+
+
`;
exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
-"
-
-
- Save comment
- "
+
+
+ Save comment
+
+
`;
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`] = `
-
-
-
-
-
-
-
+
`;
exports[`Design management list item component with notes renders item with single comment 1`] = `
-
-
-
-
-
-
-
+
`;
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`] = `