From 36a59d088eca61b834191dacea009677a96c052f Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 19 May 2022 07:33:21 +0000 Subject: Add latest changes from gitlab-org/gitlab@15-0-stable-ee --- .../pipelines/__snapshots__/utils_spec.js.snap | 11 + .../components/jobs/failed_jobs_app_spec.js | 87 ++++++ .../components/jobs/failed_jobs_table_spec.js | 117 +++++++ .../pipelines/components/jobs/utils_spec.js | 14 + .../pipelines/components/pipeline_tabs_spec.js | 9 +- .../empty_state/ci_templates_spec.js | 112 +++++++ .../empty_state/ios_templates_spec.js | 138 +++++++++ .../empty_state/pipelines_ci_templates_spec.js | 161 ++++++++++ .../pipelines_list/pipeline_stage_spec.js | 6 +- .../pipelines/empty_state/ci_templates_spec.js | 85 ------ .../empty_state/pipelines_ci_templates_spec.js | 158 ---------- spec/frontend/pipelines/empty_state_spec.js | 60 +++- .../graph/graph_component_wrapper_spec.js | 27 +- spec/frontend/pipelines/graph/job_item_spec.js | 184 +++++------ .../pipelines/graph/linked_pipeline_spec.js | 336 +++++++++++++++++---- .../graph/linked_pipelines_column_spec.js | 1 - .../pipelines/graph/linked_pipelines_mock_data.js | 5 + spec/frontend/pipelines/graph/mock_data.js | 122 ++++++++ .../pipelines/graph_shared/links_inner_spec.js | 15 +- spec/frontend/pipelines/header_component_spec.js | 51 +++- spec/frontend/pipelines/mock_data.js | 215 +++++++++++++ .../pipelines/pipeline_graph/utils_spec.js | 20 +- spec/frontend/pipelines/pipelines_spec.js | 2 + .../pipelines/test_reports/stores/actions_spec.js | 7 +- .../test_reports/stores/mutations_spec.js | 21 ++ .../test_reports/test_suite_table_spec.js | 57 ++-- .../tokens/pipeline_branch_name_token_spec.js | 1 + .../pipelines/tokens/pipeline_source_token_spec.js | 1 + .../pipelines/tokens/pipeline_status_token_spec.js | 1 + .../tokens/pipeline_tag_name_token_spec.js | 1 + .../tokens/pipeline_trigger_author_token_spec.js | 1 + 31 files changed, 1559 insertions(+), 467 deletions(-) create mode 100644 spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js create mode 100644 spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js create mode 100644 spec/frontend/pipelines/components/jobs/utils_spec.js create mode 100644 spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js create mode 100644 spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js create mode 100644 spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js delete mode 100644 spec/frontend/pipelines/empty_state/ci_templates_spec.js delete mode 100644 spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js (limited to 'spec/frontend/pipelines') diff --git a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap index 2d2e5db598a..724ec7366d3 100644 --- a/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/pipelines/__snapshots__/utils_spec.js.snap @@ -11,6 +11,7 @@ Array [ Object { "__typename": "CiJob", "id": "6", + "kind": "BUILD", "name": "build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -53,6 +54,7 @@ Array [ Object { "__typename": "CiJob", "id": "11", + "kind": "BUILD", "name": "build_b", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -95,6 +97,7 @@ Array [ Object { "__typename": "CiJob", "id": "16", + "kind": "BUILD", "name": "build_c", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -137,6 +140,7 @@ Array [ Object { "__typename": "CiJob", "id": "21", + "kind": "BUILD", "name": "build_d 1/3", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -163,6 +167,7 @@ Array [ Object { "__typename": "CiJob", "id": "24", + "kind": "BUILD", "name": "build_d 2/3", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -189,6 +194,7 @@ Array [ Object { "__typename": "CiJob", "id": "27", + "kind": "BUILD", "name": "build_d 3/3", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -231,6 +237,7 @@ Array [ Object { "__typename": "CiJob", "id": "59", + "kind": "BUILD", "name": "test_c", "needs": Array [], "previousStageJobsOrNeeds": Array [], @@ -275,6 +282,7 @@ Array [ Object { "__typename": "CiJob", "id": "34", + "kind": "BUILD", "name": "test_a", "needs": Array [ "build_c", @@ -325,6 +333,7 @@ Array [ Object { "__typename": "CiJob", "id": "42", + "kind": "BUILD", "name": "test_b 1/2", "needs": Array [ "build_d 3/3", @@ -363,6 +372,7 @@ Array [ Object { "__typename": "CiJob", "id": "67", + "kind": "BUILD", "name": "test_b 2/2", "needs": Array [ "build_d 3/3", @@ -417,6 +427,7 @@ Array [ Object { "__typename": "CiJob", "id": "53", + "kind": "BUILD", "name": "test_d", "needs": Array [ "build_b", diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js new file mode 100644 index 00000000000..3b5632a8a4e --- /dev/null +++ b/spec/frontend/pipelines/components/jobs/failed_jobs_app_spec.js @@ -0,0 +1,87 @@ +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 createFlash from '~/flash'; +import FailedJobsApp from '~/pipelines/components/jobs/failed_jobs_app.vue'; +import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue'; +import GetFailedJobsQuery from '~/pipelines/graphql/queries/get_failed_jobs.query.graphql'; +import { mockFailedJobsQueryResponse, mockFailedJobsSummaryData } from '../../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +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, failedJobsSummaryData = mockFailedJobsSummaryData) => { + wrapper = shallowMount(FailedJobsApp, { + provide: { + fullPath: 'root/ci-project', + pipelineIid: 1, + }, + propsData: { + failedJobsSummary: failedJobsSummaryData, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + beforeEach(() => { + resolverSpy = jest.fn().mockResolvedValue(mockFailedJobsQueryResponse); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('loading spinner', () => { + beforeEach(() => { + createComponent(resolverSpy); + }); + + it('displays loading spinner when fetching failed jobs', () => { + expect(findLoadingSpinner().exists()).toBe(true); + }); + + it('hides loading spinner after the failed jobs have been fetched', async () => { + await waitForPromises(); + + expect(findLoadingSpinner().exists()).toBe(false); + }); + }); + + it('displays the failed jobs table', async () => { + createComponent(resolverSpy); + + await waitForPromises(); + + expect(findJobsTable().exists()).toBe(true); + expect(createFlash).not.toHaveBeenCalled(); + }); + + it('handles query fetch error correctly', async () => { + resolverSpy = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + createComponent(resolverSpy); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching the failed jobs.', + }); + }); +}); diff --git a/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js new file mode 100644 index 00000000000..b597a3bf4b0 --- /dev/null +++ b/spec/frontend/pipelines/components/jobs/failed_jobs_table_spec.js @@ -0,0 +1,117 @@ +import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createFlash from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import FailedJobsTable from '~/pipelines/components/jobs/failed_jobs_table.vue'; +import RetryFailedJobMutation from '~/pipelines/graphql/mutations/retry_failed_job.mutation.graphql'; +import { + successRetryMutationResponse, + failedRetryMutationResponse, + mockPreparedFailedJobsData, + mockPreparedFailedJobsDataNoPermission, +} from '../../mock_data'; + +jest.mock('~/flash'); +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 createMockApolloProvider = (resolver) => { + const requestHandlers = [[RetryFailedJobMutation, resolver]]; + return createMockApollo(requestHandlers); + }; + + const createComponent = (resolver, failedJobsData = mockPreparedFailedJobsData) => { + wrapper = mountExtended(FailedJobsTable, { + propsData: { + failedJobs: failedJobsData, + }, + apolloProvider: createMockApolloProvider(resolver), + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the failed jobs table', () => { + createComponent(); + + expect(findJobsTable().exists()).toBe(true); + }); + + it('calls the retry failed job mutation correctly', () => { + createComponent(successRetryMutationHandler); + + findRetryButton().trigger('click'); + + expect(successRetryMutationHandler).toHaveBeenCalledWith({ + id: mockPreparedFailedJobsData[0].id, + }); + }); + + 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); + }); + + it('shows error message if the retry failed job mutation fails', async () => { + createComponent(failedRetryMutationHandler); + + findRetryButton().trigger('click'); + + await waitForPromises(); + + expect(createFlash).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([[]], mockPreparedFailedJobsDataNoPermission); + + 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( + mockPreparedFailedJobsData[0].detailedStatus.detailsPath, + ); + }); +}); diff --git a/spec/frontend/pipelines/components/jobs/utils_spec.js b/spec/frontend/pipelines/components/jobs/utils_spec.js new file mode 100644 index 00000000000..720446cfda3 --- /dev/null +++ b/spec/frontend/pipelines/components/jobs/utils_spec.js @@ -0,0 +1,14 @@ +import { prepareFailedJobs } from '~/pipelines/components/jobs/utils'; +import { + mockFailedJobsData, + mockFailedJobsSummaryData, + mockPreparedFailedJobsData, +} from '../../mock_data'; + +describe('Utils', () => { + it('prepares failed jobs data correctly', () => { + expect(prepareFailedJobs(mockFailedJobsData, mockFailedJobsSummaryData)).toEqual( + mockPreparedFailedJobsData, + ); + }); +}); diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js index e18c3edbad9..89002ee47a8 100644 --- a/spec/frontend/pipelines/components/pipeline_tabs_spec.js +++ b/spec/frontend/pipelines/components/pipeline_tabs_spec.js @@ -21,14 +21,19 @@ describe('The Pipeline Tabs', () => { const findPipelineApp = () => wrapper.findComponent(PipelineGraphWrapper); const findTestsApp = () => wrapper.findComponent(TestReports); + const defaultProvide = { + defaultTabValue: '', + }; + const createComponent = (propsData = {}) => { wrapper = extendedWrapper( shallowMount(PipelineTabs, { propsData, + provide: { + ...defaultProvide, + }, stubs: { - Dag: { template: '
' }, JobsApp: { template: '
' }, - PipelineGraph: { template: '
' }, TestReports: { template: '
' }, }, }), diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js new file mode 100644 index 00000000000..6531a15ab8e --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/ci_templates_spec.js @@ -0,0 +1,112 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const suggestedCiTemplates = [ + { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, + { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, + { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, +]; + +describe('CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(CiTemplates, { + provide: { + pipelineEditorPath, + suggestedCiTemplates, + }, + propsData, + }); + }; + + const findTemplateDescription = () => wrapper.findByTestId('template-description'); + const findTemplateLink = () => wrapper.findByTestId('template-link'); + const findTemplateNames = () => wrapper.findAllByTestId('template-name'); + const findTemplateName = () => wrapper.findByTestId('template-name'); + const findTemplateLogo = () => wrapper.findByTestId('template-logo'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('renders template list', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders all suggested templates', () => { + expect(findTemplateNames().length).toBe(3); + expect(wrapper.text()).toContain('Android', 'Bash', 'C++'); + }); + + it('has the correct template name', () => { + expect(findTemplateName().text()).toBe('Android'); + }); + + it('links to the correct template', () => { + expect(findTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Android'), + ); + }); + + it('has the link button enabled', () => { + expect(findTemplateLink().props('disabled')).toBe(false); + }); + + it('has the description of the template', () => { + expect(findTemplateDescription().text()).toBe( + 'Continuous integration and deployment template to test and deploy your Android project.', + ); + }); + + it('has the right logo of the template', () => { + expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg'); + }); + }); + + describe('filtering the templates', () => { + beforeEach(() => { + createWrapper({ filterTemplates: ['Bash'] }); + }); + + it('renders only the filtered templates', () => { + expect(findTemplateNames()).toHaveLength(1); + expect(findTemplateName().text()).toBe('Bash'); + }); + }); + + describe('disabling the templates', () => { + beforeEach(() => { + createWrapper({ disabled: true }); + }); + + it('has the link button disabled', () => { + expect(findTemplateLink().props('disabled')).toBe(true); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when template is clicked', () => { + findTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Android', + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js new file mode 100644 index 00000000000..0c2938921d6 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/ios_templates_spec.js @@ -0,0 +1,138 @@ +import '~/commons'; +import { nextTick } from 'vue'; +import { GlPopover, GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import IosTemplates from '~/pipelines/components/pipelines_list/empty_state/ios_templates.vue'; +import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const registrationToken = 'SECRET_TOKEN'; +const iOSTemplateName = 'iOS-Fastlane'; + +describe('iOS Templates', () => { + let wrapper; + + const createWrapper = (providedPropsData = {}) => { + return shallowMountExtended(IosTemplates, { + provide: { + pipelineEditorPath, + iosRunnersAvailable: true, + ...providedPropsData, + }, + propsData: { + registrationToken, + }, + stubs: { + GlButton, + }, + }); + }; + + const findIosTemplate = () => wrapper.findComponent(CiTemplates); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + const findRunnerInstructionsPopover = () => wrapper.findComponent(GlPopover); + const findRunnerSetupTodoEmoji = () => wrapper.findByTestId('runner-setup-marked-todo'); + const findRunnerSetupCompletedEmoji = () => wrapper.findByTestId('runner-setup-marked-completed'); + const findSetupRunnerLink = () => wrapper.findByText('Set up a runner'); + const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when ios runners are not available', () => { + beforeEach(() => { + wrapper = createWrapper({ iosRunnersAvailable: false }); + }); + + describe('the runner setup section', () => { + it('marks the section as todo', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(true); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(false); + }); + + it('renders the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(true); + }); + + it('renders the runner instructions modal with a popover once clicked', async () => { + findSetupRunnerLink().element.parentElement.click(); + + await nextTick(); + + expect(findRunnerInstructionsModal().exists()).toBe(true); + expect(findRunnerInstructionsModal().props('registrationToken')).toBe(registrationToken); + expect(findRunnerInstructionsModal().props('defaultPlatformName')).toBe('osx'); + + findRunnerInstructionsModal().vm.$emit('shown'); + + await nextTick(); + + expect(findRunnerInstructionsPopover().exists()).toBe(true); + }); + }); + + describe('the configure pipeline section', () => { + it('has a disabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(true); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has a disabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(true); + }); + }); + }); + + describe('when ios runners are available', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + describe('the runner setup section', () => { + it('marks the section as completed', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(false); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(true); + }); + + it('does not render the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(false); + }); + }); + + describe('the configure pipeline section', () => { + it('has an enabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has an enabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js new file mode 100644 index 00000000000..b537c81da3f --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js @@ -0,0 +1,161 @@ +import '~/commons'; +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { stubExperiments } from 'helpers/experimentation_helper'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; +import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; +import { + RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, + RUNNERS_SETTINGS_LINK_CLICKED_EVENT, + RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, + RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, + I18N, +} from '~/pipeline_editor/constants'; + +const pipelineEditorPath = '/-/ci/editor'; +const ciRunnerSettingsPath = '/-/settings/ci_cd'; + +jest.mock('~/experimentation/experiment_tracking'); + +describe('Pipelines CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = (propsData = {}, stubs = {}) => { + return shallowMountExtended(PipelinesCiTemplates, { + provide: { + pipelineEditorPath, + ciRunnerSettingsPath, + anyRunnersAvailable: true, + ...propsData, + }, + stubs, + }); + }; + + const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); + const findCiTemplates = () => wrapper.findComponent(CiTemplates); + const findSettingsLink = () => wrapper.findByTestId('settings-link'); + const findDocumentationLink = () => wrapper.findByTestId('documentation-link'); + const findSettingsButton = () => wrapper.findByTestId('settings-button'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('renders test template', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('links to the getting started template', () => { + expect(findTestTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Getting-Started'), + ); + }); + }); + + 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', + }); + }); + }); + + describe('when the runners_availability_section experiment is active', () => { + beforeEach(() => { + stubExperiments({ runners_availability_section: 'candidate' }); + }); + + describe('when runners are available', () => { + beforeEach(() => { + wrapper = createWrapper({ anyRunnersAvailable: true }, { GitlabExperiment, GlSprintf }); + }); + + it('show the runners available section', () => { + expect(wrapper.text()).toContain(I18N.runners.title); + }); + + it('tracks an event when clicking the settings link', () => { + findSettingsLink().vm.$emit('click'); + + expect(ExperimentTracking).toHaveBeenCalledWith( + RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, + ); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + RUNNERS_SETTINGS_LINK_CLICKED_EVENT, + ); + }); + + it('tracks an event when clicking the documentation link', () => { + findDocumentationLink().vm.$emit('click'); + + expect(ExperimentTracking).toHaveBeenCalledWith( + RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, + ); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, + ); + }); + }); + + describe('when runners are not available', () => { + beforeEach(() => { + wrapper = createWrapper({ anyRunnersAvailable: false }, { GitlabExperiment, GlButton }); + }); + + it('show the no runners available section', () => { + expect(wrapper.text()).toContain(I18N.noRunners.title); + }); + + it('tracks an event when clicking the settings button', () => { + findSettingsButton().trigger('click'); + + expect(ExperimentTracking).toHaveBeenCalledWith( + RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, + ); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, + ); + }); + }); + }); + + describe.each` + experimentVariant | anyRunnersAvailable | templatesRendered + ${'control'} | ${true} | ${true} + ${'control'} | ${false} | ${true} + ${'candidate'} | ${true} | ${true} + ${'candidate'} | ${false} | ${false} + `( + 'when the runners_availability_section experiment variant is $experimentVariant and runners are available: $anyRunnersAvailable', + ({ experimentVariant, anyRunnersAvailable, templatesRendered }) => { + beforeEach(() => { + stubExperiments({ runners_availability_section: experimentVariant }); + wrapper = createWrapper({ anyRunnersAvailable }); + }); + + it(`renders the templates: ${templatesRendered}`, () => { + expect(findTestTemplateLink().exists()).toBe(templatesRendered); + expect(findCiTemplates().exists()).toBe(templatesRendered); + }); + }, + ); +}); diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js index 93bc8faa51b..6d0e99ff63e 100644 --- a/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_stage_spec.js @@ -1,6 +1,7 @@ import { GlDropdown } from '@gitlab/ui'; 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 PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; import eventHub from '~/pipelines/event_hub'; @@ -48,11 +49,12 @@ describe('Pipelines stage component', () => { mock.restore(); }); + 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 findCiActionBtn = () => wrapper.find('.js-ci-action'); const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); const openStageDropdown = () => { @@ -74,7 +76,7 @@ describe('Pipelines stage component', () => { it('should render a dropdown with the status icon', () => { expect(findDropdown().exists()).toBe(true); expect(findDropdownToggle().exists()).toBe(true); - expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true); + expect(findCiIcon().exists()).toBe(true); }); }); diff --git a/spec/frontend/pipelines/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/empty_state/ci_templates_spec.js deleted file mode 100644 index 606fdc9cac1..00000000000 --- a/spec/frontend/pipelines/empty_state/ci_templates_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; - -const pipelineEditorPath = '/-/ci/editor'; -const suggestedCiTemplates = [ - { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, - { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, - { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, -]; - -describe('CI Templates', () => { - let wrapper; - let trackingSpy; - - const createWrapper = () => { - return shallowMountExtended(CiTemplates, { - provide: { - pipelineEditorPath, - suggestedCiTemplates, - }, - }); - }; - - const findTemplateDescription = () => wrapper.findByTestId('template-description'); - const findTemplateLink = () => wrapper.findByTestId('template-link'); - const findTemplateName = () => wrapper.findByTestId('template-name'); - const findTemplateLogo = () => wrapper.findByTestId('template-logo'); - - beforeEach(() => { - wrapper = createWrapper(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('renders template list', () => { - it('renders all suggested templates', () => { - const content = wrapper.text(); - - expect(content).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 description of the template', () => { - expect(findTemplateDescription().text()).toBe( - 'CI/CD 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('tracking', () => { - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('sends an event when template is clicked', () => { - findTemplateLink().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { - label: 'Android', - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js deleted file mode 100644 index 14860f20317..00000000000 --- a/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js +++ /dev/null @@ -1,158 +0,0 @@ -import '~/commons'; -import { GlButton, GlSprintf } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { stubExperiments } from 'helpers/experimentation_helper'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; -import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; -import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue'; -import { - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - RUNNERS_SETTINGS_LINK_CLICKED_EVENT, - RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, - RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, - I18N, -} from '~/pipeline_editor/constants'; - -const pipelineEditorPath = '/-/ci/editor'; - -jest.mock('~/experimentation/experiment_tracking'); - -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); - const findSettingsLink = () => wrapper.findByTestId('settings-link'); - const findDocumentationLink = () => wrapper.findByTestId('documentation-link'); - const findSettingsButton = () => wrapper.findByTestId('settings-button'); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('renders test template', () => { - beforeEach(() => { - wrapper = createWrapper(); - }); - - it('links to the getting started template', () => { - expect(findTestTemplateLink().attributes('href')).toBe( - pipelineEditorPath.concat('?template=Getting-Started'), - ); - }); - }); - - 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', - }); - }); - }); - - describe('when the runners_availability_section experiment is active', () => { - beforeEach(() => { - stubExperiments({ runners_availability_section: 'candidate' }); - }); - - describe('when runners are available', () => { - beforeEach(() => { - wrapper = createWrapper({ anyRunnersAvailable: true }, { GitlabExperiment, GlSprintf }); - }); - - it('show the runners available section', () => { - expect(wrapper.text()).toContain(I18N.runners.title); - }); - - it('tracks an event when clicking the settings link', () => { - findSettingsLink().vm.$emit('click'); - - expect(ExperimentTracking).toHaveBeenCalledWith( - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - ); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - RUNNERS_SETTINGS_LINK_CLICKED_EVENT, - ); - }); - - it('tracks an event when clicking the documentation link', () => { - findDocumentationLink().vm.$emit('click'); - - expect(ExperimentTracking).toHaveBeenCalledWith( - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - ); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, - ); - }); - }); - - describe('when runners are not available', () => { - beforeEach(() => { - wrapper = createWrapper({ anyRunnersAvailable: false }, { GitlabExperiment, GlButton }); - }); - - it('show the no runners available section', () => { - expect(wrapper.text()).toContain(I18N.noRunners.title); - }); - - it('tracks an event when clicking the settings button', () => { - findSettingsButton().trigger('click'); - - expect(ExperimentTracking).toHaveBeenCalledWith( - RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME, - ); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( - RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, - ); - }); - }); - }); - - describe.each` - experimentVariant | anyRunnersAvailable | templatesRendered - ${'control'} | ${true} | ${true} - ${'control'} | ${false} | ${true} - ${'candidate'} | ${true} | ${true} - ${'candidate'} | ${false} | ${false} - `( - 'when the runners_availability_section experiment variant is $experimentVariant and runners are available: $anyRunnersAvailable', - ({ experimentVariant, anyRunnersAvailable, templatesRendered }) => { - beforeEach(() => { - stubExperiments({ runners_availability_section: experimentVariant }); - wrapper = createWrapper({ anyRunnersAvailable }); - }); - - it(`renders the templates: ${templatesRendered}`, () => { - expect(findTestTemplateLink().exists()).toBe(templatesRendered); - expect(findCiTemplates().exists()).toBe(templatesRendered); - }); - }, - ); -}); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 46dad4a035c..0abf7f59717 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,7 +1,11 @@ import '~/commons'; -import { mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import { stubExperiments } from 'helpers/experimentation_helper'; import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue'; +import IosTemplates from '~/pipelines/components/pipelines_list/empty_state/ios_templates.vue'; describe('Pipelines Empty State', () => { let wrapper; @@ -9,44 +13,68 @@ describe('Pipelines Empty State', () => { const findIllustration = () => wrapper.find('img'); const findButton = () => wrapper.find('a'); const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates); + const iosTemplates = () => wrapper.findComponent(IosTemplates); const createWrapper = (props = {}) => { - wrapper = mount(EmptyState, { + wrapper = shallowMount(EmptyState, { provide: { pipelineEditorPath: '', suggestedCiTemplates: [], + anyRunnersAvailable: true, + ciRunnerSettingsPath: '', }, propsData: { emptyStateSvgPath: 'foo.svg', canSetCi: true, ...props, }, + stubs: { + GlEmptyState, + GitlabExperiment, + }, }); }; + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + describe('when user can configure CI', () => { - beforeEach(() => { - createWrapper({}, mount); - }); + describe('when the ios_specific_templates experiment is active', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'candidate' }); + createWrapper(); + }); - afterEach(() => { - wrapper.destroy(); - wrapper = null; + it('should render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(true); + }); + + it('should not render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(false); + }); }); - it('should render the CI/CD templates', () => { - expect(pipelinesCiTemplates().exists()).toBe(true); + 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 }, mount); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; + createWrapper({ canSetCi: false }); }); it('should render empty state SVG', () => { diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index cb7073fb5f5..49d64c6eac0 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -16,7 +16,7 @@ import { } from '~/performance/constants'; import * as perfUtils from '~/performance/utils'; import { - IID_FAILURE, + ACTION_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY, @@ -188,7 +188,9 @@ describe('Pipeline graph wrapper', () => { it('displays the no iid alert', () => { expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(wrapper.vm.$options.errorTexts[IID_FAILURE]); + expect(getAlert().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', () => { @@ -196,6 +198,27 @@ describe('Pipeline graph wrapper', () => { }); }); + describe('when there is an error with an action in the graph', () => { + beforeEach(async () => { + createComponentWithApollo(); + await waitForPromises(); + await getGraph().vm.$emit('error', { type: ACTION_FAILURE }); + }); + + it('does not display the loading icon', () => { + expect(getLoadingIcon().exists()).toBe(false); + }); + + it('displays the action error alert', () => { + expect(getAlert().exists()).toBe(true); + expect(getAlert().text()).toBe('An error occurred while performing this action.'); + }); + + it('displays the graph', () => { + expect(getGraph().exists()).toBe(true); + }); + }); + describe('when refresh action is emitted', () => { beforeEach(async () => { createComponentWithApollo(); diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index 23e7ed7ebb4..4f0da09fec6 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -1,89 +1,34 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import { GlBadge } from '@gitlab/ui'; import JobItem from '~/pipelines/components/graph/job_item.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { + delayedJob, + mockJob, + mockJobWithoutDetails, + mockJobWithUnauthorizedAction, + triggerJob, +} from './mock_data'; describe('pipeline graph job item', () => { let wrapper; - const findJobWithoutLink = () => wrapper.find('[data-testid="job-without-link"]'); - const findJobWithLink = () => wrapper.find('[data-testid="job-with-link"]'); - const findActionComponent = () => wrapper.find('[data-testid="ci-action-component"]'); + const findJobWithoutLink = () => wrapper.findByTestId('job-without-link'); + const findJobWithLink = () => wrapper.findByTestId('job-with-link'); + const findActionComponent = () => wrapper.findByTestId('ci-action-component'); + const findBadge = () => wrapper.findComponent(GlBadge); const createWrapper = (propsData) => { - wrapper = mount(JobItem, { - propsData, - }); + wrapper = extendedWrapper( + mount(JobItem, { + propsData, + }), + ); }; const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; - const delayedJob = { - __typename: 'CiJob', - 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', - }, - }, - }; - - const mockJob = { - id: 4256, - name: 'test', - 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', - }, - }, - }; - 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, - }, - }; - 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, - }, - }; - afterEach(() => { wrapper.destroy(); }); @@ -148,13 +93,25 @@ describe('pipeline graph job item', () => { }); }); - it('should render provided class name', () => { - createWrapper({ - job: mockJob, - cssClassJobName: 'css-class-job-name', + describe('job style', () => { + beforeEach(() => { + createWrapper({ + job: mockJob, + cssClassJobName: 'css-class-job-name', + }); + }); + + it('should render provided class name', () => { + expect(wrapper.find('a').classes()).toContain('css-class-job-name'); + }); + + it('does not show a badge on the job item', () => { + expect(findBadge().exists()).toBe(false); }); - expect(wrapper.find('a').classes()).toContain('css-class-job-name'); + it('does not apply the trigger job class', () => { + expect(findJobWithLink().classes()).not.toContain('gl-rounded-lg'); + }); }); describe('status label', () => { @@ -201,34 +158,51 @@ describe('pipeline graph job item', () => { }); }); - describe('trigger job 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({ job, pipelineExpanded: { jobName, expanded } }); - const findJobEl = link ? findJobWithLink : findJobWithoutLink; - - expect(findJobEl().classes()).toContain(triggerActiveClass); - }, - ); + describe('trigger job', () => { + describe('card', () => { + beforeEach(() => { + createWrapper({ job: triggerJob }); + }); - 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({ job, pipelineExpanded: { jobName, expanded } }); - const findJobEl = link ? findJobWithLink : findJobWithoutLink; - - expect(findJobEl().classes()).not.toContain(triggerActiveClass); - }, - ); + 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('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({ 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({ job, pipelineExpanded: { jobName, expanded } }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).not.toContain(triggerActiveClass); + }, + ); + }); }); describe('job classes', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index d800a8c341e..06fd970778c 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -1,11 +1,21 @@ -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; -import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; +import { ACTION_FAILURE, UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; +import { PIPELINE_GRAPHQL_TYPE } from '~/pipelines/constants'; +import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; +import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; import mockPipeline from './linked_pipelines_mock_data'; +Vue.use(VueApollo); + describe('Linked pipeline', () => { let wrapper; @@ -27,22 +37,30 @@ describe('Linked pipeline', () => { }; const findButton = () => wrapper.find(GlButton); - const findDownstreamPipelineTitle = () => wrapper.find('[data-testid="downstream-title"]'); - const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]'); + 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.find({ ref: 'linkedPipeline' }); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]'); - const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]'); - - const createWrapper = (propsData, data = []) => { - wrapper = mount(LinkedPipelineComponent, { - propsData, - data() { - return { - ...data, - }; - }, - }); + const findPipelineLabel = () => wrapper.findByTestId('downstream-pipeline-label'); + const findPipelineLink = () => wrapper.findByTestId('pipelineLink'); + const findRetryButton = () => wrapper.findByLabelText('Retry downstream pipeline'); + + const createWrapper = ({ propsData, downstreamRetryAction = false }) => { + const mockApollo = createMockApollo(); + + wrapper = extendedWrapper( + mount(LinkedPipelineComponent, { + propsData, + provide: { + glFeatures: { + downstreamRetryAction, + }, + }, + apolloProvider: mockApollo, + }), + ); }; afterEach(() => { @@ -59,7 +77,7 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper(props); + createWrapper({ propsData: props }); }); it('should render the project name', () => { @@ -84,18 +102,13 @@ describe('Linked pipeline', () => { expect(wrapper.text()).toContain(`#${props.pipeline.id}`); }); - it('should correctly compute the tooltip text', () => { - expect(wrapper.vm.tooltipText).toContain(mockPipeline.project.name); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.status.label); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.sourceJob.name); - expect(wrapper.vm.tooltipText).toContain(mockPipeline.id); - }); + it('adds the card tooltip text to the DOM', () => { + expect(findCardTooltip().exists()).toBe(true); - it('should render the tooltip text as the title attribute', () => { - const titleAttr = findLinkedPipeline().attributes('title'); - - expect(titleAttr).toContain(mockPipeline.project.name); - expect(titleAttr).toContain(mockPipeline.status.label); + 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); }); it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { @@ -105,7 +118,7 @@ describe('Linked pipeline', () => { describe('upstream pipelines', () => { beforeEach(() => { - createWrapper(upstreamProps); + createWrapper({ propsData: upstreamProps }); }); it('should display parent label when pipeline project id is the same as triggered_by pipeline project id', () => { @@ -123,45 +136,246 @@ describe('Linked pipeline', () => { }); describe('downstream pipelines', () => { - beforeEach(() => { - createWrapper(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); + describe('styling', () => { + beforeEach(() => { + createWrapper({ 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'); + }); }); - 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 the `downstream_retry_action` flag on', () => { + describe('with permissions', () => { + describe('on an upstream', () => { + describe('when retryable', () => { + beforeEach(() => { + const retryablePipeline = { + ...upstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true }); + }); + + it('does not show the retry or cancel button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); + }); + }); + }); + + describe('on a downstream', () => { + describe('when retryable', () => { + beforeEach(() => { + const retryablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, retryable: true }, + }; + + createWrapper({ propsData: retryablePipeline, downstreamRetryAction: true }); + }); + + it('shows only the retry button', () => { + expect(findCancelButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(true); + }); + + it('hides the card tooltip when the action button tooltip is hovered', async () => { + expect(findCardTooltip().exists()).toBe(true); + + await findRetryButton().trigger('mouseover'); + + expect(findCardTooltip().exists()).toBe(false); + }); + + describe('and the retry button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + jest.spyOn(wrapper.vm, '$emit'); + await findRetryButton().trigger('click'); + }); + + it('calls the retry mutation ', () => { + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: RetryPipelineMutation, + variables: { + id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id), + }, + }); + }); + + it('emits the refreshPipelineGraph event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); + }); + }); + + describe('on failure', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); + jest.spyOn(wrapper.vm, '$emit'); + await findRetryButton().trigger('click'); + }); + + it('emits an error event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { + type: ACTION_FAILURE, + }); + }); + }); + }); + }); + + describe('when cancelable', () => { + beforeEach(() => { + const cancelablePipeline = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true }, + }; + + createWrapper({ propsData: cancelablePipeline, downstreamRetryAction: true }); + }); + + it('shows only the cancel button ', () => { + expect(findCancelButton().exists()).toBe(true); + expect(findRetryButton().exists()).toBe(false); + }); + + it('hides the card tooltip when the action button tooltip is hovered', async () => { + expect(findCardTooltip().exists()).toBe(true); + + await findCancelButton().trigger('mouseover'); + + expect(findCardTooltip().exists()).toBe(false); + }); + + describe('and the cancel button is clicked', () => { + describe('on success', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(); + jest.spyOn(wrapper.vm, '$emit'); + await findCancelButton().trigger('click'); + }); + + it('calls the cancel mutation', () => { + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: CancelPipelineMutation, + variables: { + id: convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, mockPipeline.id), + }, + }); + }); + it('emits the refreshPipelineGraph event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('refreshPipelineGraph'); + }); + }); + describe('on failure', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue({ errors: [] }); + jest.spyOn(wrapper.vm, '$emit'); + await findCancelButton().trigger('click'); + }); + it('emits an error event', () => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('error', { + type: ACTION_FAILURE, + }); + }); + }); + }); + }); + + describe('when both cancellable and retryable', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true, retryable: true }, + }; + + createWrapper({ propsData: pipelineWithTwoActions, downstreamRetryAction: true }); + }); + + 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 }, + }, + }; + + createWrapper({ propsData: pipelineWithTwoActions }); + }); + + it('does not show any action button', () => { + expect(findRetryButton().exists()).toBe(false); + expect(findCancelButton().exists()).toBe(false); + }); + }); + }); + + describe('with the `downstream_retry_action` flag off', () => { + beforeEach(() => { + const pipelineWithTwoActions = { + ...downstreamProps, + pipeline: { ...mockPipeline, cancelable: true, retryable: true }, + }; + + createWrapper({ 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 | anglePosition | borderClass | expanded - ${downstreamProps} | ${'angle-right'} | ${'gl-border-l-1!'} | ${false} - ${downstreamProps} | ${'angle-left'} | ${'gl-border-l-1!'} | ${true} - ${upstreamProps} | ${'angle-left'} | ${'gl-border-r-1!'} | ${false} - ${upstreamProps} | ${'angle-right'} | ${'gl-border-r-1!'} | ${true} + pipelineType | anglePosition | buttonBorderClasses | expanded + ${downstreamProps} | ${'angle-right'} | ${'gl-border-l-0!'} | ${false} + ${downstreamProps} | ${'angle-left'} | ${'gl-border-l-0!'} | ${true} + ${upstreamProps} | ${'angle-left'} | ${'gl-border-r-0!'} | ${false} + ${upstreamProps} | ${'angle-right'} | ${'gl-border-r-0!'} | ${true} `( - '$pipelineType.columnTitle pipeline button icon should be $anglePosition with $borderClass if expanded state is $expanded', - ({ pipelineType, anglePosition, borderClass, expanded }) => { - createWrapper({ ...pipelineType, expanded }); + '$pipelineType.columnTitle pipeline button icon should be $anglePosition with $buttonBorderClasses if expanded state is $expanded', + ({ pipelineType, anglePosition, buttonBorderClasses, expanded }) => { + createWrapper({ propsData: { ...pipelineType, expanded } }); expect(findExpandButton().props('icon')).toBe(anglePosition); - expect(findExpandButton().classes()).toContain(borderClass); + expect(findExpandButton().classes()).toContain(buttonBorderClasses); }, ); }); @@ -176,7 +390,7 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper(props); + createWrapper({ propsData: props }); }); it('loading icon is visible', () => { @@ -194,7 +408,7 @@ describe('Linked pipeline', () => { }; beforeEach(() => { - createWrapper(props); + createWrapper({ propsData: props }); }); it('emits `pipelineClicked` event', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 1673065e09c..46000711110 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -67,7 +67,6 @@ describe('Linked Pipelines Column', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('it renders correctly', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js index 955b70cbd3b..f7f5738e46d 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_mock_data.js @@ -2,6 +2,11 @@ export default { __typename: 'Pipeline', id: 195, iid: '5', + retryable: false, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, path: '/root/elemenohpee/-/pipelines/195', status: { __typename: 'DetailedStatus', diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index 0cf7dc507f4..6124d67af09 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -1,4 +1,5 @@ import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; +import { BUILD_KIND, BRIDGE_KIND } from '~/pipelines/components/graph/constants'; export const mockPipelineResponse = { data: { @@ -50,6 +51,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '6', + kind: BUILD_KIND, name: 'build_a_nlfjkdnlvskfnksvjknlfdjvlvnjdkjdf_nvjkenjkrlngjeknjkl', scheduledAt: null, status: { @@ -101,6 +103,7 @@ export const mockPipelineResponse = { __typename: 'CiJob', id: '11', name: 'build_b', + kind: BUILD_KIND, scheduledAt: null, status: { __typename: 'DetailedStatus', @@ -151,6 +154,7 @@ export const mockPipelineResponse = { __typename: 'CiJob', id: '16', name: 'build_c', + kind: BUILD_KIND, scheduledAt: null, status: { __typename: 'DetailedStatus', @@ -200,6 +204,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '21', + kind: BUILD_KIND, name: 'build_d 1/3', scheduledAt: null, status: { @@ -232,6 +237,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '24', + kind: BUILD_KIND, name: 'build_d 2/3', scheduledAt: null, status: { @@ -264,6 +270,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '27', + kind: BUILD_KIND, name: 'build_d 3/3', scheduledAt: null, status: { @@ -329,6 +336,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '34', + kind: BUILD_KIND, name: 'test_a', scheduledAt: null, status: { @@ -413,6 +421,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '42', + kind: BUILD_KIND, name: 'test_b 1/2', scheduledAt: null, status: { @@ -499,6 +508,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '67', + kind: BUILD_KIND, name: 'test_b 2/2', scheduledAt: null, status: { @@ -603,6 +613,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '59', + kind: BUILD_KIND, name: 'test_c', scheduledAt: null, status: { @@ -646,6 +657,7 @@ export const mockPipelineResponse = { { __typename: 'CiJob', id: '53', + kind: BUILD_KIND, name: 'test_d', scheduledAt: null, status: { @@ -699,6 +711,11 @@ export const downstream = { id: 175, iid: '31', path: '/root/elemenohpee/-/pipelines/175', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, status: { id: '70', group: 'success', @@ -724,6 +741,11 @@ export const downstream = { id: 181, iid: '27', path: '/root/abcd-dag/-/pipelines/181', + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, status: { id: '72', group: 'success', @@ -752,6 +774,11 @@ 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', @@ -786,6 +813,11 @@ export const wrappedPipelineReturn = { updatePipeline: true, }, downstream: { + retryable: true, + cancelable: false, + userPermissions: { + updatePipeline: true, + }, __typename: 'PipelineConnection', nodes: [], }, @@ -793,6 +825,11 @@ export const wrappedPipelineReturn = { 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', @@ -846,6 +883,7 @@ export const wrappedPipelineReturn = { { __typename: 'CiJob', id: '83', + kind: BUILD_KIND, name: 'build_n', scheduledAt: null, needs: { @@ -916,3 +954,87 @@ export const mockCalloutsResponse = (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, + }, +}; diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js index be422fac92c..2c6d126e12c 100644 --- a/spec/frontend/pipelines/graph_shared/links_inner_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { setHTMLFixture } from 'helpers/fixtures'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import { parseData } from '~/pipelines/components/parsing_utils'; import { createJobsHash } from '~/pipelines/utils'; @@ -42,7 +42,7 @@ describe('Links Inner component', () => { // 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 setFixtures = ({ stages }) => { + const setHTMLFixtureLocal = ({ stages }) => { const jobs = createJobsHash(stages); const arrayOfJobs = Object.keys(jobs); @@ -82,6 +82,7 @@ describe('Links Inner component', () => { afterEach(() => { jest.restoreAllMocks(); wrapper.destroy(); + resetHTMLFixture(); }); describe('basic SVG creation', () => { @@ -124,7 +125,7 @@ describe('Links Inner component', () => { describe('with one need', () => { beforeEach(() => { - setFixtures(pipelineData); + setHTMLFixtureLocal(pipelineData); createComponent({ pipelineData: pipelineData.stages }); }); @@ -143,7 +144,7 @@ describe('Links Inner component', () => { describe('with a parallel need', () => { beforeEach(() => { - setFixtures(parallelNeedData); + setHTMLFixtureLocal(parallelNeedData); createComponent({ pipelineData: parallelNeedData.stages }); }); @@ -162,7 +163,7 @@ describe('Links Inner component', () => { describe('with same stage needs', () => { beforeEach(() => { - setFixtures(sameStageNeeds); + setHTMLFixtureLocal(sameStageNeeds); createComponent({ pipelineData: sameStageNeeds.stages }); }); @@ -181,7 +182,7 @@ describe('Links Inner component', () => { describe('with a large number of needs', () => { beforeEach(() => { - setFixtures(largePipelineData); + setHTMLFixtureLocal(largePipelineData); createComponent({ pipelineData: largePipelineData.stages }); }); @@ -200,7 +201,7 @@ describe('Links Inner component', () => { describe('interactions', () => { beforeEach(() => { - setFixtures(largePipelineData); + setHTMLFixtureLocal(largePipelineData); createComponent({ pipelineData: largePipelineData.stages }); }); diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index c4639bd8e16..5cc11adf696 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -1,4 +1,4 @@ -import { GlModal, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlModal, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; @@ -21,6 +21,7 @@ describe('Pipeline details header', () => { let glModalDirective; let mutate = jest.fn(); + const findAlert = () => wrapper.find(GlAlert); const findDeleteModal = () => wrapper.find(GlModal); const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]'); const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]'); @@ -121,6 +122,22 @@ describe('Pipeline details header', () => { it('should render retry action tooltip', () => { expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY); }); + + it('should display error message on failure', async () => { + const failureMessage = 'failure message'; + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { + pipelineRetry: { + errors: [failureMessage], + }, + }, + }); + + findRetryButton().vm.$emit('click'); + await waitForPromises(); + + expect(findAlert().text()).toBe(failureMessage); + }); }); describe('Retry action failed', () => { @@ -156,6 +173,22 @@ describe('Pipeline details header', () => { variables: { id: mockRunningPipelineHeader.id }, }); }); + + it('should display error message on failure', async () => { + const failureMessage = 'failure message'; + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { + pipelineCancel: { + errors: [failureMessage], + }, + }, + }); + + findCancelButton().vm.$emit('click'); + await waitForPromises(); + + expect(findAlert().text()).toBe(failureMessage); + }); }); describe('Delete action', () => { @@ -179,6 +212,22 @@ describe('Pipeline details header', () => { variables: { id: mockFailedPipelineHeader.id }, }); }); + + it('should display error message on failure', async () => { + const failureMessage = 'failure message'; + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({ + data: { + pipelineDestroy: { + errors: [failureMessage], + }, + }, + }); + + findDeleteModal().vm.$emit('ok'); + await waitForPromises(); + + expect(findAlert().text()).toBe(failureMessage); + }); }); describe('Permissions', () => { diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index 59d4e808b32..57d1511d859 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -1141,3 +1141,218 @@ export const mockPipelineBranch = () => { 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, + }, + }, + { + __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, + }, + }, + ], + }, + }, + }, + }, +}; + +export const mockFailedJobsSummaryData = [ + { + id: 1848, + failure: null, + failure_summary: + 'Pulling docker image node:latest ...
Using docker image sha256:738d733448be00c72cb6618b7a06a1424806c6d239d8885e92f9b1e8727092b5 for node:latest with digest node@sha256:e5b7b349d517159246070bf14242027a9e220ffa8bd98a67ba1495d969c06c01 ...
Preparing environment
Running on runner-kvkqh24-project-20-concurrent-0 via 0706719b1b8d...
Getting source from Git repository
Fetching changes with git depth set to 50...
Reinitialized existing Git repository in /builds/root/ci-project/.git/
fatal: couldn\'t find remote ref refs/heads/test
ERROR: Job failed: exit code 1
', + }, +]; + +export const mockFailedJobsData = [ + { + normalizedId: 1848, + __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 }, + }, + { + normalizedId: 1710, + __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 }, + }, +]; + +export const mockPreparedFailedJobsData = [ + { + __typename: 'CiJob', + _showDetails: true, + detailedStatus: { + __typename: 'DetailedStatus', + action: { + __typename: 'StatusAction', + buttonTitle: 'Retry this job', + icon: 'retry', + id: 'Ci::Build-failed-1848', + method: 'post', + path: '/root/ci-project/-/jobs/1848/retry', + title: 'Retry', + }, + detailsPath: '/root/ci-project/-/jobs/1848', + group: 'failed', + icon: 'status_failed', + id: 'failed-1848-1848', + label: 'failed', + text: 'failed', + tooltip: 'failed - (script failure)', + }, + failure: null, + failureSummary: + 'Pulling docker image node:latest ...
Using docker image sha256:738d733448be00c72cb6618b7a06a1424806c6d239d8885e92f9b1e8727092b5 for node:latest with digest node@sha256:e5b7b349d517159246070bf14242027a9e220ffa8bd98a67ba1495d969c06c01 ...
Preparing environment
Running on runner-kvkqh24-project-20-concurrent-0 via 0706719b1b8d...
Getting source from Git repository
Fetching changes with git depth set to 50...
Reinitialized existing Git repository in /builds/root/ci-project/.git/
fatal: couldn\'t find remote ref refs/heads/test
ERROR: Job failed: exit code 1
', + id: 'gid://gitlab/Ci::Build/1848', + name: 'wait_job', + normalizedId: 1848, + retryable: true, + stage: { __typename: 'CiStage', id: 'gid://gitlab/Ci::Stage/358', name: 'build' }, + status: 'FAILED', + userPermissions: { __typename: 'JobPermissions', readBuild: true, updateBuild: true }, + }, +]; + +export const mockPreparedFailedJobsDataNoPermission = [ + { + ...mockPreparedFailedJobsData[0], + userPermissions: { __typename: 'JobPermissions', readBuild: false, updateBuild: false }, + }, +]; + +export const successRetryMutationResponse = { + data: { + jobRetry: { + job: { + __typename: 'CiJob', + id: '"gid://gitlab/Ci::Build/1985"', + detailedStatus: { + detailsPath: '/root/project/-/jobs/1985', + id: 'pending-1985-1985', + __typename: 'DetailedStatus', + }, + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; + +export const failedRetryMutationResponse = { + data: { + jobRetry: { + job: {}, + errors: ['New Error'], + __typename: 'JobRetryPayload', + }, + }, +}; diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js index 5816bc06fe3..d6b13da3c3a 100644 --- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js @@ -1,4 +1,5 @@ -import { createJobsHash, generateJobNeedsDict } from '~/pipelines/utils'; +import { createJobsHash, generateJobNeedsDict, getPipelineDefaultTab } from '~/pipelines/utils'; +import { TAB_QUERY_PARAM, validPipelineTabNames } from '~/pipelines/constants'; describe('utils functions', () => { const jobName1 = 'build_1'; @@ -169,4 +170,21 @@ describe('utils functions', () => { }); }); }); + + describe('getPipelineDefaultTab', () => { + const baseUrl = 'http://gitlab.com/user/multi-projects-small/-/pipelines/332/'; + it('returns null if there was no `tab` params', () => { + expect(getPipelineDefaultTab(baseUrl)).toBe(null); + }); + + it('returns null if there was no valid tab param', () => { + expect(getPipelineDefaultTab(`${baseUrl}?${TAB_QUERY_PARAM}=invalid`)).toBe(null); + }); + + it('returns the correct tab name if present', () => { + validPipelineTabNames.forEach((tabName) => { + expect(getPipelineDefaultTab(`${baseUrl}?${TAB_QUERY_PARAM}=${tabName}`)).toBe(tabName); + }); + }); + }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index d2b30c93746..de9f394db43 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -82,6 +82,8 @@ describe('Pipelines', () => { provide: { pipelineEditorPath: '', suggestedCiTemplates: [], + ciRunnerSettingsPath: paths.ciRunnerSettingsPath, + anyRunnersAvailable: true, }, propsData: { store: new Store(), diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index d5acb115bc1..74a9d8c354f 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -82,17 +82,16 @@ describe('Actions TestReports Store', () => { ); }); - it('should create flash on API error', async () => { + it('should call SET_SUITE_ERROR on error', () => { const index = 0; - await testAction( + return testAction( actions.fetchTestSuite, index, { ...state, testReports, suiteEndpoint: null }, - [], + [{ type: types.SET_SUITE_ERROR, payload: expect.any(Error) }], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], ); - expect(createFlash).toHaveBeenCalled(); }); describe('when we already have the suite data', () => { diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index f2dbeec6a06..6ab479a257c 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -1,6 +1,9 @@ import testReports from 'test_fixtures/pipelines/test_report.json'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; import mutations from '~/pipelines/stores/test_reports/mutations'; +import createFlash from '~/flash'; + +jest.mock('~/flash.js'); describe('Mutations TestReports Store', () => { let mockState; @@ -44,6 +47,24 @@ describe('Mutations TestReports Store', () => { }); }); + describe('set suite error', () => { + it('should set the error message in state if provided', () => { + const message = 'Test report artifacts have expired'; + + mutations[types.SET_SUITE_ERROR](mockState, { + response: { data: { errors: message } }, + }); + + expect(mockState.errorMessage).toBe(message); + }); + + it('should show a flash message otherwise', () => { + mutations[types.SET_SUITE_ERROR](mockState, {}); + + expect(createFlash).toHaveBeenCalled(); + }); + }); + describe('set selected suite index', () => { it('should set selectedSuiteIndex', () => { const selectedSuiteIndex = 0; diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index 97241e14129..dc72fa31ace 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,12 +1,13 @@ import { GlButton, GlFriendlyWrap, GlLink, GlPagination } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import testReports from 'test_fixtures/pipelines/test_report.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import { TestStatus } from '~/pipelines/constants'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { formatFilePath } from '~/pipelines/stores/test_reports/utils'; +import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from '~/pipelines/stores/test_reports/constants'; import skippedTestCases from './mock_data'; Vue.use(Vuex); @@ -23,13 +24,14 @@ describe('Test reports suite table', () => { const testCases = testSuite.test_cases; const blobPath = '/test/blob/path'; - const noCasesMessage = () => wrapper.find('.js-no-test-cases'); - const allCaseRows = () => wrapper.findAll('.js-case-row'); - const findCaseRowAtIndex = (index) => wrapper.findAll('.js-case-row').at(index); + const noCasesMessage = () => wrapper.findByTestId('no-test-cases'); + const artifactsExpiredMessage = () => wrapper.findByTestId('artifacts-expired'); + const allCaseRows = () => wrapper.findAllByTestId('test-case-row'); + const findCaseRowAtIndex = (index) => wrapper.findAllByTestId('test-case-row').at(index); const findLinkForRow = (row) => row.find(GlLink); const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); - const createComponent = (suite = testSuite, perPage = 20) => { + const createComponent = ({ suite = testSuite, perPage = 20, errorMessage } = {}) => { store = new Vuex.Store({ state: { blobPath, @@ -41,11 +43,12 @@ describe('Test reports suite table', () => { page: 1, perPage, }, + errorMessage, }, getters, }); - wrapper = shallowMount(SuiteTable, { + wrapper = shallowMountExtended(SuiteTable, { store, stubs: { GlFriendlyWrap }, }); @@ -55,12 +58,18 @@ describe('Test reports suite table', () => { wrapper.destroy(); }); - describe('should not render', () => { - beforeEach(() => createComponent([])); + it('should render a message when there are no test cases', () => { + createComponent({ suite: [] }); - it('a table when there are no test cases', () => { - expect(noCasesMessage().exists()).toBe(true); - }); + expect(noCasesMessage().exists()).toBe(true); + expect(artifactsExpiredMessage().exists()).toBe(false); + }); + + it('should render a message when artifacts have expired', () => { + createComponent({ suite: [], errorMessage: ARTIFACTS_EXPIRED_ERROR_MESSAGE }); + + expect(noCasesMessage().exists()).toBe(true); + expect(artifactsExpiredMessage().exists()).toBe(true); }); describe('when a test suite is supplied', () => { @@ -102,7 +111,7 @@ describe('Test reports suite table', () => { const perPage = 2; beforeEach(() => { - createComponent(testSuite, perPage); + createComponent({ testSuite, perPage }); }); it('renders one page of test cases', () => { @@ -117,11 +126,13 @@ describe('Test reports suite table', () => { describe('when a test case classname property is null', () => { it('still renders all test cases', () => { createComponent({ - ...testSuite, - test_cases: testSuite.test_cases.map((testCase) => ({ - ...testCase, - classname: null, - })), + testSuite: { + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + classname: null, + })), + }, }); expect(allCaseRows()).toHaveLength(testCases.length); @@ -131,11 +142,13 @@ describe('Test reports suite table', () => { describe('when a test case name property is null', () => { it('still renders all test cases', () => { createComponent({ - ...testSuite, - test_cases: testSuite.test_cases.map((testCase) => ({ - ...testCase, - name: null, - })), + testSuite: { + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + name: null, + })), + }, }); expect(allCaseRows()).toHaveLength(testCases.length); diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js index 42ae154fb5e..ba478363d04 100644 --- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js @@ -34,6 +34,7 @@ describe('Pipeline Branch Name Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const optionsWithDefaultBranchName = (options) => { diff --git a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js index 684d2d0664a..b8abf2c1727 100644 --- a/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_source_token_spec.js @@ -20,6 +20,7 @@ describe('Pipeline Source Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js index 1db736ba01e..2c5fa8b00e2 100644 --- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js @@ -20,6 +20,7 @@ describe('Pipeline Status Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js index b03dbb73b95..596a9218c39 100644 --- a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js @@ -29,6 +29,7 @@ describe('Pipeline Branch Name Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = (options, data) => { diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js index 7ddbbb3b005..397dbdf95a9 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -24,6 +24,7 @@ describe('Pipeline Trigger Author Token', () => { value: { data: '', }, + cursorPosition: 'start', }; const createComponent = (data) => { -- cgit v1.2.3