diff --git a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue index e7cc55f95fac88ec70d6a384bf76ea19111fb814..7d4395dd579c13804e481c959f9ecb42587a7645 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_details_header.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_details_header.vue @@ -1,9 +1,12 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlBadge, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; +import TimeAgo from './pipelines_list/time_ago.vue'; import { getQueryHeaders } from './graph/utils'; const POLL_INTERVAL = 10000; @@ -13,7 +16,41 @@ export default { finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'], components: { CiBadgeLink, + ClipboardButton, + GlBadge, + GlIcon, + GlLink, GlLoadingIcon, + GlSprintf, + TimeAgo, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + i18n: { + scheduleBadgeText: s__('Pipelines|Scheduled'), + scheduleBadgeTooltip: __('This pipeline was triggered by a schedule'), + childBadgeText: s__('Pipelines|Child pipeline (%{linkStart}parent%{linkEnd})'), + childBadgeTooltip: __('This is a child pipeline within the parent pipeline'), + latestBadgeText: s__('Pipelines|latest'), + latestBadgeTooltip: __('Latest pipeline for the most recent commit on this branch'), + mergeTrainBadgeText: s__('Pipelines|merge train'), + mergeTrainBadgeTooltip: s__( + 'Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.', + ), + invalidBadgeText: s__('Pipelines|yaml invalid'), + failedBadgeText: s__('Pipelines|error'), + autoDevopsBadgeText: s__('Pipelines|Auto DevOps'), + autoDevopsBadgeTooltip: __( + 'This pipeline makes use of a predefined CI/CD configuration enabled by Auto DevOps.', + ), + detachedBadgeText: s__('Pipelines|merge request'), + detachedBadgeTooltip: s__( + "Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.", + ), + stuckBadgeText: s__('Pipelines|stuck'), + stuckBadgeTooltip: s__('Pipelines|This pipeline is stuck'), }, errorTexts: { [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'), @@ -58,6 +95,11 @@ export default { required: false, default: '', }, + refText: { + type: String, + required: false, + default: '', + }, badges: { type: Object, required: false, @@ -76,7 +118,9 @@ export default { iid: this.pipelineIid, }; }, - update: (data) => data.project.pipeline, + update(data) { + return data.project.pipeline; + }, error() { this.reportFailure(LOAD_FAILURE); }, @@ -150,6 +194,36 @@ export default { }; } }, + usersName() { + return this.pipeline?.user?.name || ''; + }, + userPath() { + return this.pipeline?.user?.webPath || ''; + }, + shortId() { + return this.pipeline?.commit?.shortId || ''; + }, + commitPath() { + return this.pipeline?.commit?.webPath || ''; + }, + totalJobsText() { + return sprintf(__('%{jobs} Jobs'), { + jobs: this.totalJobs, + }); + }, + triggeredText() { + return sprintf(__('%{linkStart}%{name}%{linkEnd} triggered pipeline for commit'), { + name: this.usersName, + }); + }, + inProgress() { + return this.status === 'RUNNING'; + }, + inProgressText() { + return sprintf(__('In progress, queued for %{queuedDuration} seconds'), { + queuedDuration: this.pipeline?.queuedDuration || 0, + }); + }, }, methods: { reportFailure(errorType, errorMessages = []) { @@ -164,8 +238,126 @@ export default { <div class="gl-mt-3"> <gl-loading-icon v-if="loading" class="gl-text-left" size="lg" /> <template v-else> - <div class="gl-mb-2"> + <h3 v-if="name" class="gl-mt-0 gl-mb-2" data-testid="pipeline-name">{{ name }}</h3> + <div> <ci-badge-link :status="detailedStatus" /> + <div class="gl-ml-2 gl-mb-2 gl-display-inline-block gl-h-6"> + <gl-sprintf :message="triggeredText"> + <template #link="{ content }"> + <gl-link + :href="userPath" + class="gl-text-gray-900 gl-font-weight-bold" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + <gl-link + :href="commitPath" + class="gl-bg-blue-50 gl-rounded-base gl-px-2 gl-mx-2" + data-testid="commit-link" + > + {{ shortId }} + </gl-link> + <clipboard-button + :text="shortId" + category="tertiary" + :title="__('Copy commit SHA')" + size="small" + /> + <time-ago + v-if="isFinished" + :pipeline="pipeline" + class="gl-display-inline gl-mb-0" + :display-calendar-icon="false" + font-size="gl-font-md" + /> + </div> + </div> + <div v-safe-html="refText" class="gl-mb-2" data-testid="pipeline-ref-text"></div> + <div> + <gl-badge + v-if="badges.schedule" + v-gl-tooltip + :title="$options.i18n.scheduleBadgeTooltip" + variant="info" + > + {{ $options.i18n.scheduleBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.child" + v-gl-tooltip + :title="$options.i18n.childBadgeTooltip" + variant="info" + > + <gl-sprintf :message="$options.i18n.childBadgeText"> + <template #link="{ content }"> + <gl-link :href="paths.triggeredByPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-badge> + <gl-badge + v-if="badges.latest" + v-gl-tooltip + :title="$options.i18n.latestBadgeTooltip" + variant="success" + > + {{ $options.i18n.latestBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.mergeTrainPipeline" + v-gl-tooltip + :title="$options.i18n.mergeTrainBadgeTooltip" + variant="info" + > + {{ $options.i18n.mergeTrainBadgeText }} + </gl-badge> + <gl-badge v-if="badges.invalid" v-gl-tooltip :title="yamlErrors" variant="danger"> + {{ $options.i18n.invalidBadgeText }} + </gl-badge> + <gl-badge v-if="badges.failed" v-gl-tooltip :title="failureReason" variant="danger"> + {{ $options.i18n.failedBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.autoDevops" + v-gl-tooltip + :title="$options.i18n.autoDevopsBadgeTooltip" + variant="info" + > + {{ $options.i18n.autoDevopsBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.detached" + v-gl-tooltip + :title="$options.i18n.detachedBadgeTooltip" + variant="info" + data-qa-selector="merge_request_badge_tag" + > + {{ $options.i18n.detachedBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.stuck" + v-gl-tooltip + :title="$options.i18n.stuckBadgeTooltip" + variant="warning" + > + {{ $options.i18n.stuckBadgeText }} + </gl-badge> + <span class="gl-ml-2" data-testid="total-jobs"> + <gl-icon name="pipeline" /> + {{ totalJobsText }} + </span> + <span v-if="isFinished" class="gl-ml-2" data-testid="compute-credits"> + <gl-icon name="quota" /> + {{ computeCredits }} + </span> + <span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text"> + <gl-icon name="timer" /> + {{ inProgressText }} + </span> </div> </template> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index d068eb16ed45a49fe441a46001ce60dd0544177f..b7c812162b18669fe171dd02eb952b8e22bc1ba2 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -14,6 +14,17 @@ export default { type: Object, required: true, }, + displayCalendarIcon: { + type: Boolean, + required: false, + default: true, + }, + fontSize: { + type: String, + required: false, + default: 'gl-font-sm', + validator: (fontSize) => ['gl-font-sm', 'gl-font-md'].includes(fontSize), + }, }, computed: { duration() { @@ -23,7 +34,7 @@ export default { return formatTime(this.duration * 1000); }, finishedTime() { - return this.pipeline?.details?.finished_at; + return this.pipeline?.details?.finished_at || this.pipeline?.finishedAt; }, showInProgress() { return !this.duration && !this.finishedTime && !this.skipped; @@ -35,13 +46,13 @@ export default { return this.pipeline?.details?.status?.label === 'skipped'; }, stuck() { - return this.pipeline.flags.stuck; + return this.pipeline?.flags?.stuck; }, }, }; </script> <template> - <div class="gl-display-flex gl-flex-direction-column gl-font-sm time-ago"> + <div class="gl-display-flex gl-flex-direction-column time-ago" :class="fontSize"> <span v-if="showInProgress" class="gl-display-inline-flex gl-align-items-center" @@ -63,7 +74,13 @@ export default { </p> <p v-if="finishedTime" class="finished-at gl-display-inline-flex gl-align-items-center"> - <gl-icon name="calendar" class="gl-mr-2" :size="12" /> + <gl-icon + v-if="displayCalendarIcon" + name="calendar" + class="gl-mr-2" + :size="12" + data-testid="calendar-icon" + /> <time v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql index 47bc167ca525c87eccff77c265c2c2483c48ba5e..a47df2c0197ddea5f666f099d80133e41034dade 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql @@ -32,6 +32,13 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { emoji } } + commit { + id + shortId + webPath + } + finishedAt + queuedDuration } } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index e3e95ba3ccce1eb82016c8b82a07007ef523dd99..807ef225eddc35d12c8f1e893cad57526274bab1 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -62,6 +62,7 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph autoDevops, detached, stuck, + refText, } = el.dataset; // eslint-disable-next-line no-new @@ -86,6 +87,7 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph computeCredits, yamlErrors, failureReason, + refText, badges: { schedule: parseBoolean(schedule), child: parseBoolean(child), diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index 8a65340062e10b736593ade70b73b582f11f98b0..caebbd5250e5f7174eb764be675f6952107779b6 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -44,7 +44,8 @@ def js_pipeline_details_header_data(project, pipeline) failed: pipeline.failure_reason?.to_s, auto_devops: pipeline.auto_devops_source?.to_s, detached: pipeline.detached_merge_request_pipeline?.to_s, - stuck: pipeline.stuck? + stuck: pipeline.stuck?, + ref_text: pipeline.ref_text } end end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 8c9ff49b0e7b55806b32ff6a33710777e46aea42..4ad88188e45688aba3e3460add8b545985fec300 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -65,7 +65,7 @@ def coverage '%.2f' % pipeline.coverage end - def ref_text + def ref_text_legacy if pipeline.detached_merge_request_pipeline? _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}") .html_safe % { @@ -87,6 +87,28 @@ def ref_text end end + def ref_text + if pipeline.detached_merge_request_pipeline? + _("For merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}") + .html_safe % { + link_to_merge_request: link_to_merge_request, + link_to_merge_request_source_branch: link_to_merge_request_source_branch + } + elsif pipeline.merged_result_pipeline? + _("For merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}") + .html_safe % { + link_to_merge_request: link_to_merge_request, + link_to_merge_request_source_branch: link_to_merge_request_source_branch, + link_to_merge_request_target_branch: link_to_merge_request_target_branch + } + elsif pipeline.ref && pipeline.ref_exists? + _("For %{link_to_pipeline_ref}") + .html_safe % { link_to_pipeline_ref: link_to_pipeline_ref } + elsif pipeline.ref + _("For %{ref}").html_safe % { ref: plain_ref_name } + end + end + def all_related_merge_request_text(limit: nil) if all_related_merge_requests.none? _("No related merge requests found.") @@ -106,7 +128,7 @@ def has_many_merge_requests? def link_to_pipeline_ref ApplicationController.helpers.link_to(pipeline.ref, project_commits_path(pipeline.project, pipeline.ref), - class: "ref-name") + class: "ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2") end def link_to_merge_request diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 12f4b0496e48fb0eafe2cd00b9acf33cb33b04fc..8d2baa6ee99b8ecb8b8540e1aa258adb3cb46bce 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -197,7 +197,7 @@ def subscribed? def source_branch_link if source_branch_exists? - link_to(source_branch, source_branch_commits_path, class: 'ref-name') + link_to(source_branch, source_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2') else content_tag(:span, source_branch, class: 'ref-name') end @@ -205,7 +205,7 @@ def source_branch_link def target_branch_link if target_branch_exists? - link_to(target_branch, target_branch_commits_path, class: 'ref-name') + link_to(target_branch, target_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2') else content_tag(:span, target_branch, class: 'ref-name') end diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 3ff370dfaa4f9cd016c7aaa5927b5fbab2451612..753bb77e7552ae31dfb930c23240077d90d93dc1 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -16,7 +16,7 @@ .icon-container = sprite_icon('clock', css_class: 'gl-top-0!') = n_('%d job', '%d jobs', @pipeline.total_size) % @pipeline.total_size - = @pipeline.ref_text + = @pipeline.ref_text_legacy - if @pipeline.finished_at - duration = time_interval_in_words(@pipeline.duration) - queued_duration = time_interval_in_words(@pipeline.queued_duration) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b5e6e8e2cdeaf94692cca4904d8648e3731e0065..0b33fa34a11a0c401bcad3afdeadfbdd105bd311 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -789,6 +789,9 @@ msgstr "" msgid "%{itemsCount} issues with a limit of %{maxIssueCount}" msgstr "" +msgid "%{jobs} Jobs" +msgstr "" + msgid "%{key} is not a valid URL." msgstr "" @@ -870,6 +873,9 @@ msgstr "" msgid "%{linkStart} Learn more%{linkEnd}." msgstr "" +msgid "%{linkStart}%{name}%{linkEnd} triggered pipeline for commit" +msgstr "" + msgid "%{listToShow}, and %{awardsListLength} more" msgstr "" @@ -19070,6 +19076,12 @@ msgstr "" msgid "Footer message" msgstr "" +msgid "For %{link_to_pipeline_ref}" +msgstr "" + +msgid "For %{ref}" +msgstr "" + msgid "For a faster browsing experience, only %{strongStart}%{visible} of %{total}%{strongEnd} files are shown. Download one of the files below to see all changes." msgstr "" @@ -19106,6 +19118,12 @@ msgstr "" msgid "For investigating IT service disruptions or outages" msgstr "" +msgid "For merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch}" +msgstr "" + +msgid "For merge request %{link_to_merge_request} to merge %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}" +msgstr "" + msgid "For more info, read the documentation." msgstr "" @@ -22972,6 +22990,9 @@ msgstr "" msgid "In progress" msgstr "" +msgid "In progress, queued for %{queuedDuration} seconds" +msgstr "" + msgid "In the background, we're attempting to connect you again." msgstr "" @@ -33169,6 +33190,9 @@ msgstr "" msgid "Pipelines|CI/CD Catalog" msgstr "" +msgid "Pipelines|Child pipeline (%{linkStart}parent%{linkEnd})" +msgstr "" + msgid "Pipelines|Child pipeline (%{link_start}parent%{link_end})" msgstr "" @@ -33286,6 +33310,9 @@ msgstr "" msgid "Pipelines|Revoke trigger" msgstr "" +msgid "Pipelines|Scheduled" +msgstr "" + msgid "Pipelines|Set up a runner" msgstr "" @@ -33334,6 +33361,9 @@ msgstr "" msgid "Pipelines|This is a child pipeline within the parent pipeline" msgstr "" +msgid "Pipelines|This pipeline is stuck" +msgstr "" + msgid "Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch." msgstr "" @@ -46262,6 +46292,9 @@ msgstr "" msgid "This is a Jira user." msgstr "" +msgid "This is a child pipeline within the parent pipeline" +msgstr "" + msgid "This is a confidential %{noteableTypeText}." msgstr "" @@ -46466,6 +46499,12 @@ msgstr "" msgid "This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}" msgstr "" +msgid "This pipeline makes use of a predefined CI/CD configuration enabled by Auto DevOps." +msgstr "" + +msgid "This pipeline was triggered by a schedule" +msgstr "" + msgid "This pipeline was triggered by a schedule." msgstr "" diff --git a/spec/frontend/fixtures/pipeline_header.rb b/spec/frontend/fixtures/pipeline_header.rb new file mode 100644 index 0000000000000000000000000000000000000000..a4fba7e8675b9a4f8ce707b1ba292f6c098a42be --- /dev/null +++ b/spec/frontend/fixtures/pipeline_header.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "GraphQL Pipeline Header", '(JavaScript fixtures)', type: :request, feature_category: :pipeline_composition do + include ApiHelpers + include GraphqlHelpers + include JavaScriptFixturesHelpers + + let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') } + let_it_be(:project) { create(:project, :public, :repository) } + let_it_be(:user) { project.first_owner } + let_it_be(:commit) { create(:commit, project: project) } + + let(:query_path) { 'pipelines/graphql/queries/get_pipeline_header_data.query.graphql' } + + context 'with successful pipeline' do + let_it_be(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + user: user, + status: :success, + started_at: 1.hour.ago, + finished_at: Time.current + ) + end + + it "graphql/pipelines/pipeline_header_success.json" do + query = get_graphql_query_as_string(query_path) + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid }) + + expect_graphql_errors_to_be_empty + end + end + + context 'with running pipeline' do + let_it_be(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + user: user, + status: :running, + created_at: 2.hours.ago, + started_at: 1.hour.ago + ) + end + + it "graphql/pipelines/pipeline_header_running.json" do + query = get_graphql_query_as_string(query_path) + + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, iid: pipeline.iid }) + + expect_graphql_errors_to_be_empty + end + end +end diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index a4b8d223a0c9d9c15ce883bae4732581fbd0e8b9..fd654eb6f106ee174eb7d53dfd4fe32efece8e0b 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -1,3 +1,6 @@ +import pipelineHeaderSuccess from 'test_fixtures/graphql/pipelines/pipeline_header_success.json'; +import pipelineHeaderRunning from 'test_fixtures/graphql/pipelines/pipeline_header_running.json'; + const PIPELINE_RUNNING = 'RUNNING'; const PIPELINE_CANCELED = 'CANCELED'; const PIPELINE_FAILED = 'FAILED'; @@ -5,6 +8,8 @@ const PIPELINE_FAILED = 'FAILED'; const threeWeeksAgo = new Date(); threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); +export { pipelineHeaderSuccess, pipelineHeaderRunning }; + export const mockPipelineHeader = { detailedStatus: {}, id: 123, diff --git a/spec/frontend/pipelines/pipeline_details_header_spec.js b/spec/frontend/pipelines/pipeline_details_header_spec.js index c049dad9e539110c17c9268d799f15bab1d0323c..08ae35fe808ca926d00f1392cf651f0c7082f943 100644 --- a/spec/frontend/pipelines/pipeline_details_header_spec.js +++ b/spec/frontend/pipelines/pipeline_details_header_spec.js @@ -1,44 +1,78 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlBadge, GlLoadingIcon } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineDetailsHeader from '~/pipelines/components/pipeline_details_header.vue'; +import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import getPipelineDetailsQuery from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql'; -import { mockSuccessfulPipelineHeader } from './mock_data'; +import { pipelineHeaderSuccess, pipelineHeaderRunning } from './mock_data'; Vue.use(VueApollo); describe('Pipeline details header', () => { let wrapper; - const successHandler = jest.fn().mockResolvedValue(mockSuccessfulPipelineHeader); + const successHandler = jest.fn().mockResolvedValue(pipelineHeaderSuccess); + const runningHandler = jest.fn().mockResolvedValue(pipelineHeaderRunning); const findStatus = () => wrapper.findComponent(CiBadgeLink); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTimeAgo = () => wrapper.findComponent(TimeAgo); + const findAllBadges = () => wrapper.findAllComponents(GlBadge); + const findPipelineName = () => wrapper.findByTestId('pipeline-name'); + const findTotalJobs = () => wrapper.findByTestId('total-jobs'); + const findComputeCredits = () => wrapper.findByTestId('compute-credits'); + const findCommitLink = () => wrapper.findByTestId('commit-link'); + const findPipelineRunningText = () => wrapper.findByTestId('pipeline-running-text').text(); + const findPipelineRefText = () => wrapper.findByTestId('pipeline-ref-text').text(); const defaultHandlers = [[getPipelineDetailsQuery, successHandler]]; const defaultProvideOptions = { - pipelineId: '14', pipelineIid: 1, paths: { pipelinesPath: '/namespace/my-project/-/pipelines', fullProject: '/namespace/my-project', + triggeredByPath: '', }, }; + const defaultProps = { + name: 'Ruby 3.0 master branch pipeline', + totalJobs: '50', + computeCredits: '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: + 'For merge request <a class="mr-iid" href="/root/ci-project/-/merge_requests/1">!1</a> to merge <a class="ref-name" href="/root/ci-project/-/commits/test">test</a>', + }; + const createMockApolloProvider = (handlers) => { return createMockApollo(handlers); }; - const createComponent = (handlers = defaultHandlers) => { - wrapper = shallowMount(PipelineDetailsHeader, { + const createComponent = (handlers = defaultHandlers, props = defaultProps) => { + wrapper = shallowMountExtended(PipelineDetailsHeader, { provide: { ...defaultProvideOptions, }, + propsData: { + ...props, + }, apolloProvider: createMockApolloProvider(handlers), }); }; @@ -51,21 +85,83 @@ describe('Pipeline details header', () => { }); }); - describe('loaded state', () => { - it('does not display loading icon', async () => { + describe('defaults', () => { + beforeEach(async () => { createComponent(); await waitForPromises(); + }); + it('does not display loading icon', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('displays pipeline status', async () => { + 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('For merge request !1 to merge test'); + }); + }); + + describe('finished pipeline', () => { + beforeEach(async () => { createComponent(); await waitForPromises(); + }); - expect(findStatus().exists()).toBe(true); + it('displays compute credits', () => { + expect(findComputeCredits().text()).toBe('0.65'); + }); + + it('displays time ago', () => { + expect(findTimeAgo().exists()).toBe(true); + }); + }); + + describe('running pipeline', () => { + beforeEach(async () => { + createComponent([[getPipelineDetailsQuery, runningHandler]]); + + await waitForPromises(); + }); + + it('does not display compute credits', () => { + expect(findComputeCredits().exists()).toBe(false); + }); + + it('does not display time ago', () => { + expect(findTimeAgo().exists()).toBe(false); + }); + + it('displays pipeline running text', () => { + expect(findPipelineRunningText()).toBe('In progress, queued for 3600 seconds'); }); }); }); diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index efb1bf09d2030038d701c3ac734d591b1555d0f2..ccc07d9b1a897728c789bc8cebe787e9d5a4294b 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -8,7 +8,7 @@ describe('Timeago component', () => { const defaultProps = { duration: 0, finished_at: '' }; - const createComponent = (props = defaultProps, stuck = false) => { + const createComponent = (props = defaultProps, stuck = false, extraProps) => { wrapper = extendedWrapper( shallowMount(TimeAgo, { propsData: { @@ -20,6 +20,7 @@ describe('Timeago component', () => { stuck, }, }, + ...extraProps, }, data() { return { @@ -36,6 +37,7 @@ describe('Timeago component', () => { const findSkipped = () => wrapper.findByTestId('pipeline-skipped'); const findHourGlassIcon = () => wrapper.findByTestId('hourglass-icon'); const findWarningIcon = () => wrapper.findByTestId('warning-icon'); + const findCalendarIcon = () => wrapper.findByTestId('calendar-icon'); describe('with duration', () => { beforeEach(() => { @@ -61,18 +63,28 @@ describe('Timeago component', () => { }); describe('with finishedTime', () => { - beforeEach(() => { + it('should render time', () => { createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); - }); - it('should render time and calendar icon', () => { - const icon = finishedAt().findComponent(GlIcon); const time = finishedAt().find('time'); expect(finishedAt().exists()).toBe(true); - expect(icon.props('name')).toBe('calendar'); expect(time.exists()).toBe(true); }); + + it('should display calendar icon by default', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); + + expect(findCalendarIcon().exists()).toBe(true); + }); + + it('should hide calendar icon if correct prop is passed', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }, false, { + displayCalendarIcon: false, + }); + + expect(findCalendarIcon().exists()).toBe(false); + }); }); describe('without finishedTime', () => { @@ -82,6 +94,7 @@ describe('Timeago component', () => { it('should not render time and calendar icon', () => { expect(finishedAt().exists()).toBe(false); + expect(findCalendarIcon().exists()).toBe(false); }); }); diff --git a/spec/helpers/projects/pipeline_helper_spec.rb b/spec/helpers/projects/pipeline_helper_spec.rb index e9b2738cfad5169d6b6a0f7b705c807677262762..a69da91599021d18af4b1d3b1ea66e9e09af4b92 100644 --- a/spec/helpers/projects/pipeline_helper_spec.rb +++ b/spec/helpers/projects/pipeline_helper_spec.rb @@ -61,7 +61,8 @@ failed: pipeline.failure_reason?.to_s, auto_devops: pipeline.auto_devops_source?.to_s, detached: pipeline.detached_merge_request_pipeline?.to_s, - stuck: pipeline.stuck? + stuck: pipeline.stuck?, + ref_text: pipeline.ref_text }) end end diff --git a/spec/presenters/ci/pipeline_presenter_spec.rb b/spec/presenters/ci/pipeline_presenter_spec.rb index 7f4c8120e1721c5cbb9f77b957ace80edf6a2019..86e4bb703dca2473b1de26cf8de08d188b4a0051 100644 --- a/spec/presenters/ci/pipeline_presenter_spec.rb +++ b/spec/presenters/ci/pipeline_presenter_spec.rb @@ -146,8 +146,8 @@ end end - describe '#ref_text' do - subject { presenter.ref_text } + describe '#ref_text_legacy' do + subject { presenter.ref_text_legacy } context 'when pipeline is detached merge request pipeline' do let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) } @@ -155,7 +155,7 @@ it 'returns a correct ref text' do is_expected.to eq("for <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \ - "with <a class=\"ref-name\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a>") + "with <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a>") end end @@ -165,8 +165,8 @@ it 'returns a correct ref text' do is_expected.to eq("for <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \ - "with <a class=\"ref-name\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a> " \ - "into <a class=\"ref-name\" href=\"#{project_commits_path(merge_request.target_project, merge_request.target_branch)}\">#{merge_request.target_branch}</a>") + "with <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a> " \ + "into <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.target_project, merge_request.target_branch)}\">#{merge_request.target_branch}</a>") end end @@ -177,7 +177,7 @@ end it 'returns a correct ref text' do - is_expected.to eq("for <a class=\"ref-name\" href=\"#{project_commits_path(pipeline.project, pipeline.ref)}\">#{pipeline.ref}</a>") + is_expected.to eq("for <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(pipeline.project, pipeline.ref)}\">#{pipeline.ref}</a>") end context 'when ref contains malicious script' do @@ -209,6 +209,69 @@ end end + describe '#ref_text' do + subject { presenter.ref_text } + + context 'when pipeline is detached merge request pipeline' do + let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) } + let(:pipeline) { merge_request.all_pipelines.last } + + it 'returns a correct ref text' do + is_expected.to eq("For merge request <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \ + "to merge <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a>") + end + end + + context 'when pipeline is merge request pipeline' do + let(:merge_request) { create(:merge_request, :with_merge_request_pipeline) } + let(:pipeline) { merge_request.all_pipelines.last } + + it 'returns a correct ref text' do + is_expected.to eq("For merge request <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \ + "to merge <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a> " \ + "into <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.target_project, merge_request.target_branch)}\">#{merge_request.target_branch}</a>") + end + end + + context 'when pipeline is branch pipeline' do + context 'when ref exists in the repository' do + before do + allow(pipeline).to receive(:ref_exists?) { true } + end + + it 'returns a correct ref text' do + is_expected.to eq("For <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(pipeline.project, pipeline.ref)}\">#{pipeline.ref}</a>") + end + + context 'when ref contains malicious script' do + let(:pipeline) { create(:ci_pipeline, ref: "<script>alter('1')</script>", project: project) } + + it 'does not include the malicious script' do + is_expected.not_to include("<script>alter('1')</script>") + end + end + end + + context 'when ref does not exist in the repository' do + before do + allow(pipeline).to receive(:ref_exists?) { false } + end + + it 'returns a correct ref text' do + is_expected.to eq("For <span class=\"ref-name\">#{pipeline.ref}</span>") + end + + context 'when ref contains malicious script' do + let(:pipeline) { create(:ci_pipeline, ref: "<script>alter('1')</script>", project: project) } + + it 'does not include the malicious script' do + is_expected.not_to include("<script>alter('1')</script>") + end + end + end + end + end + describe '#all_related_merge_request_text' do subject { presenter.all_related_merge_request_text } diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb index 6f40d3f5b48a0a48f5c258af0d81cd53b580b825..d0febf640351cb0cdf149045e060132fb49e10d2 100644 --- a/spec/presenters/merge_request_presenter_spec.rb +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -474,7 +474,7 @@ allow(resource).to receive(:source_branch_exists?) { true } is_expected - .to eq("<a class=\"ref-name\" href=\"#{presenter.source_branch_commits_path}\">#{presenter.source_branch}</a>") + .to eq("<a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{presenter.source_branch_commits_path}\">#{presenter.source_branch}</a>") end end @@ -497,7 +497,7 @@ allow(resource).to receive(:target_branch_exists?) { true } is_expected - .to eq("<a class=\"ref-name\" href=\"#{presenter.target_branch_commits_path}\">#{presenter.target_branch}</a>") + .to eq("<a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{presenter.target_branch_commits_path}\">#{presenter.target_branch}</a>") end end