diff options
Diffstat (limited to 'spec/frontend/ci/pipelines_page/components')
20 files changed, 2682 insertions, 0 deletions
diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js new file mode 100644 index 00000000000..980a8be24ea --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js @@ -0,0 +1,107 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const suggestedCiTemplates = [ + { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, + { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, + { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, +]; + +describe('CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = (propsData = {}) => { + wrapper = shallowMountExtended(CiTemplates, { + provide: { + pipelineEditorPath, + suggestedCiTemplates, + }, + propsData, + }); + }; + + const findTemplateDescription = () => wrapper.findByTestId('template-description'); + const findTemplateLink = () => wrapper.findByTestId('template-link'); + const findTemplateNames = () => wrapper.findAllByTestId('template-name'); + const findTemplateName = () => wrapper.findByTestId('template-name'); + const findTemplateLogo = () => wrapper.findByTestId('template-logo'); + + describe('renders template list', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders all suggested templates', () => { + expect(findTemplateNames().length).toBe(3); + expect(wrapper.text()).toContain('Android', 'Bash', 'C++'); + }); + + it('has the correct template name', () => { + expect(findTemplateName().text()).toBe('Android'); + }); + + it('links to the correct template', () => { + expect(findTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Android'), + ); + }); + + it('has the link button enabled', () => { + expect(findTemplateLink().props('disabled')).toBe(false); + }); + + it('has the description of the template', () => { + expect(findTemplateDescription().text()).toBe( + 'Continuous integration and deployment template to test and deploy your Android project.', + ); + }); + + it('has the right logo of the template', () => { + expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg'); + }); + }); + + describe('filtering the templates', () => { + beforeEach(() => { + createWrapper({ filterTemplates: ['Bash'] }); + }); + + it('renders only the filtered templates', () => { + expect(findTemplateNames()).toHaveLength(1); + expect(findTemplateName().text()).toBe('Bash'); + }); + }); + + describe('disabling the templates', () => { + beforeEach(() => { + createWrapper({ disabled: true }); + }); + + it('has the link button disabled', () => { + expect(findTemplateLink().props('disabled')).toBe(true); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when template is clicked', () => { + findTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Android', + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js new file mode 100644 index 00000000000..8620d41886e --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js @@ -0,0 +1,133 @@ +import '~/commons'; +import { nextTick } from 'vue'; +import { GlPopover, GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import IosTemplates from '~/ci/pipelines_page/components/empty_state/ios_templates.vue'; +import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; +const registrationToken = 'SECRET_TOKEN'; +const iOSTemplateName = 'iOS-Fastlane'; + +describe('iOS Templates', () => { + let wrapper; + + const createWrapper = (providedPropsData = {}) => { + return shallowMountExtended(IosTemplates, { + provide: { + pipelineEditorPath, + iosRunnersAvailable: true, + ...providedPropsData, + }, + propsData: { + registrationToken, + }, + stubs: { + GlButton, + }, + }); + }; + + const findIosTemplate = () => wrapper.findComponent(CiTemplates); + const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal); + const findRunnerInstructionsPopover = () => wrapper.findComponent(GlPopover); + const findRunnerSetupTodoEmoji = () => wrapper.findByTestId('runner-setup-marked-todo'); + const findRunnerSetupCompletedEmoji = () => wrapper.findByTestId('runner-setup-marked-completed'); + const findSetupRunnerLink = () => wrapper.findByText('Set up a runner'); + const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link'); + + describe('when ios runners are not available', () => { + beforeEach(() => { + wrapper = createWrapper({ iosRunnersAvailable: false }); + }); + + describe('the runner setup section', () => { + it('marks the section as todo', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(true); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(false); + }); + + it('renders the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(true); + }); + + it('renders the runner instructions modal with a popover once clicked', async () => { + findSetupRunnerLink().element.parentElement.click(); + + await nextTick(); + + expect(findRunnerInstructionsModal().exists()).toBe(true); + expect(findRunnerInstructionsModal().props('registrationToken')).toBe(registrationToken); + expect(findRunnerInstructionsModal().props('defaultPlatformName')).toBe('osx'); + + findRunnerInstructionsModal().vm.$emit('shown'); + + await nextTick(); + + expect(findRunnerInstructionsPopover().exists()).toBe(true); + }); + }); + + describe('the configure pipeline section', () => { + it('has a disabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(true); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has a disabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(true); + }); + }); + }); + + describe('when ios runners are available', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + describe('the runner setup section', () => { + it('marks the section as completed', () => { + expect(findRunnerSetupTodoEmoji().isVisible()).toBe(false); + expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(true); + }); + + it('does not render the setup runner link', () => { + expect(findSetupRunnerLink().exists()).toBe(false); + }); + }); + + describe('the configure pipeline section', () => { + it('has an enabled link button', () => { + expect(configurePipelineLink().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + + describe('the ios-Fastlane template', () => { + it('renders the template', () => { + expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]); + }); + + it('has an enabled link button', () => { + expect(findIosTemplate().props('disabled')).toBe(false); + }); + + it('links to the pipeline editor with the right template', () => { + expect(configurePipelineLink().attributes('href')).toBe( + `${pipelineEditorPath}?template=${iOSTemplateName}`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js new file mode 100644 index 00000000000..0c42723f753 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js @@ -0,0 +1,87 @@ +import '~/commons'; +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import { stubExperiments } from 'helpers/experimentation_helper'; +import EmptyState from '~/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; +import IosTemplates from '~/ci/pipelines_page/components/empty_state/ios_templates.vue'; + +describe('Pipelines Empty State', () => { + let wrapper; + + const findIllustration = () => wrapper.find('img'); + const findButton = () => wrapper.find('a'); + const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates); + const iosTemplates = () => wrapper.findComponent(IosTemplates); + + const createWrapper = (props = {}) => { + wrapper = shallowMount(EmptyState, { + provide: { + pipelineEditorPath: '', + suggestedCiTemplates: [], + anyRunnersAvailable: true, + ciRunnerSettingsPath: '', + }, + propsData: { + emptyStateSvgPath: 'foo.svg', + canSetCi: true, + ...props, + }, + stubs: { + GlEmptyState, + GitlabExperiment, + }, + }); + }; + + describe('when user can configure CI', () => { + describe('when the ios_specific_templates experiment is active', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'candidate' }); + createWrapper(); + }); + + it('should render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(true); + }); + + it('should not render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(false); + }); + }); + + describe('when the ios_specific_templates experiment is inactive', () => { + beforeEach(() => { + stubExperiments({ ios_specific_templates: 'control' }); + createWrapper(); + }); + + it('should render the CI/CD templates', () => { + expect(pipelinesCiTemplates().exists()).toBe(true); + }); + + it('should not render the iOS templates', () => { + expect(iosTemplates().exists()).toBe(false); + }); + }); + }); + + describe('when user cannot configure CI', () => { + beforeEach(() => { + createWrapper({ canSetCi: false }); + }); + + it('should render empty state SVG', () => { + expect(findIllustration().attributes('src')).toBe('foo.svg'); + }); + + it('should render empty state header', () => { + expect(wrapper.text()).toBe('This project is not currently set up to run pipelines.'); + }); + + it('should not render a link', () => { + expect(findButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js new file mode 100644 index 00000000000..fbef4aa08eb --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js @@ -0,0 +1,58 @@ +import '~/commons'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; +import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue'; + +const pipelineEditorPath = '/-/ci/editor'; + +describe('Pipelines CI Templates', () => { + let wrapper; + let trackingSpy; + + const createWrapper = (propsData = {}, stubs = {}) => { + return shallowMountExtended(PipelinesCiTemplates, { + provide: { + pipelineEditorPath, + ...propsData, + }, + stubs, + }); + }; + + const findTestTemplateLink = () => wrapper.findByTestId('test-template-link'); + const findCiTemplates = () => wrapper.findComponent(CiTemplates); + + describe('templates', () => { + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders test template and Ci templates', () => { + expect(findTestTemplateLink().attributes('href')).toBe( + pipelineEditorPath.concat('?template=Getting-Started'), + ); + expect(findCiTemplates().exists()).toBe(true); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + wrapper = createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('sends an event when Getting-Started template is clicked', () => { + findTestTemplateLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Getting-Started', + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js new file mode 100644 index 00000000000..6967a369338 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js @@ -0,0 +1,254 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlIcon, GlLink } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue'; +import RetryMrFailedJobMutation from '~/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql'; +import { BRIDGE_KIND } from '~/ci/pipeline_details/graph/constants'; +import { job } from './mock'; + +Vue.use(VueApollo); +jest.mock('~/alert'); + +const createFakeEvent = () => ({ stopPropagation: jest.fn() }); + +describe('FailedJobDetails component', () => { + let wrapper; + let mockRetryResponse; + + const retrySuccessResponse = { + data: { + jobRetry: { + errors: [], + }, + }, + }; + + const defaultProps = { + job, + }; + + const createComponent = ({ props = {} } = {}) => { + const handlers = [[RetryMrFailedJobMutation, mockRetryResponse]]; + + wrapper = shallowMountExtended(FailedJobDetails, { + propsData: { + ...defaultProps, + ...props, + }, + apolloProvider: createMockApollo(handlers), + }); + }; + + const findArrowIcon = () => wrapper.findComponent(GlIcon); + const findJobId = () => wrapper.findComponent(GlLink); + const findJobLog = () => wrapper.findByTestId('job-log'); + const findJobName = () => wrapper.findByText(defaultProps.job.name); + const findRetryButton = () => wrapper.findByLabelText('Retry'); + const findRow = () => wrapper.findByTestId('widget-row'); + const findStageName = () => wrapper.findByText(defaultProps.job.stage.name); + + beforeEach(() => { + mockRetryResponse = jest.fn(); + mockRetryResponse.mockResolvedValue(retrySuccessResponse); + }); + + describe('ui', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the job name', () => { + expect(findJobName().exists()).toBe(true); + }); + + it('renders the stage name', () => { + expect(findStageName().exists()).toBe(true); + }); + + it('renders the job id as a link', () => { + const jobId = getIdFromGraphQLId(defaultProps.job.id); + + expect(findJobId().exists()).toBe(true); + expect(findJobId().text()).toContain(String(jobId)); + }); + + it('does not renders the job lob', () => { + expect(findJobLog().exists()).toBe(false); + }); + }); + + describe('Retry action', () => { + describe('when the job is not retryable', () => { + beforeEach(() => { + createComponent({ props: { job: { ...job, retryable: false } } }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + + describe('when the job is a bridge', () => { + beforeEach(() => { + createComponent({ props: { job: { ...job, kind: BRIDGE_KIND } } }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + + describe('when the job is retryable', () => { + describe('and user has permission to update the build', () => { + beforeEach(() => { + createComponent(); + }); + + it('enables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(false); + }); + + describe('when clicking on the retry button', () => { + it('passes the loading state to the button', async () => { + await findRetryButton().vm.$emit('click', createFakeEvent()); + + expect(findRetryButton().props().loading).toBe(true); + }); + + describe('and it succeeds', () => { + beforeEach(async () => { + findRetryButton().vm.$emit('click', createFakeEvent()); + await waitForPromises(); + }); + + it('is no longer loading', () => { + expect(findRetryButton().props().loading).toBe(false); + }); + + it('calls the retry mutation', () => { + expect(mockRetryResponse).toHaveBeenCalled(); + expect(mockRetryResponse).toHaveBeenCalledWith({ + id: job.id, + }); + }); + + it('emits the `retried-job` event', () => { + expect(wrapper.emitted('job-retried')).toStrictEqual([[job.name]]); + }); + }); + + describe('and it fails', () => { + const customErrorMsg = 'Custom error message from API'; + + beforeEach(async () => { + mockRetryResponse.mockResolvedValue({ + data: { jobRetry: { errors: [customErrorMsg] } }, + }); + findRetryButton().vm.$emit('click', createFakeEvent()); + + await waitForPromises(); + }); + + it('shows an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ message: customErrorMsg }); + }); + + it('does not emits the `refetch-jobs` event', () => { + expect(wrapper.emitted('refetch-jobs')).toBeUndefined(); + }); + }); + }); + }); + + describe('and user does not have permission to update the build', () => { + beforeEach(() => { + createComponent({ + props: { job: { ...job, retryable: true, userPermissions: { updateBuild: false } } }, + }); + }); + + it('disables the retry button', () => { + expect(findRetryButton().props().disabled).toBe(true); + }); + }); + }); + }); + + describe('Job log', () => { + describe('without permissions', () => { + beforeEach(async () => { + createComponent({ props: { job: { ...job, userPermissions: { readBuild: false } } } }); + await findRow().trigger('click'); + }); + + it('does not renders the received html of the job log', () => { + expect(findJobLog().html()).not.toContain(defaultProps.job.trace.htmlSummary); + }); + + it('shows a permission error message', () => { + expect(findJobLog().text()).toBe("You do not have permission to read this job's log."); + }); + }); + + describe('with permissions', () => { + beforeEach(() => { + createComponent(); + }); + + describe('when clicking on the row', () => { + beforeEach(async () => { + await findRow().trigger('click'); + }); + + describe('while collapsed', () => { + it('expands the job log', () => { + expect(findJobLog().exists()).toBe(true); + }); + + it('renders the down arrow', () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + }); + + it('renders the received html of the job log', () => { + expect(findJobLog().html()).toContain(defaultProps.job.trace.htmlSummary); + }); + }); + + describe('while expanded', () => { + it('collapes the job log', async () => { + expect(findJobLog().exists()).toBe(true); + + await findRow().trigger('click'); + + expect(findJobLog().exists()).toBe(false); + }); + + it('renders the right arrow', async () => { + expect(findArrowIcon().props().name).toBe('chevron-down'); + + await findRow().trigger('click'); + + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + + describe('when clicking on a link element within the row', () => { + it('does not expands/collapse the job log', async () => { + expect(findJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + + await findJobId().vm.$emit('click'); + + expect(findJobLog().exists()).toBe(false); + expect(findArrowIcon().props().name).toBe('chevron-right'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js new file mode 100644 index 00000000000..af075b02b64 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js @@ -0,0 +1,279 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { GlLoadingIcon, GlToast } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue'; +import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue'; +import * as utils from '~/ci/pipelines_page/components/failure_widget/utils'; +import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { failedJobsMock, failedJobsMock2, failedJobsMockEmpty, activeFailedJobsMock } from './mock'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +jest.mock('~/alert'); + +describe('FailedJobsList component', () => { + let wrapper; + let mockFailedJobsResponse; + const showToast = jest.fn(); + + const defaultProps = { + failedJobsCount: 0, + graphqlResourceEtag: 'api/graphql', + isPipelineActive: false, + pipelineIid: 1, + projectPath: 'namespace/project/', + }; + + const defaultProvide = { + graphqlPath: 'api/graphql', + }; + + const createComponent = ({ props = {}, provide } = {}) => { + const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]]; + const mockApollo = createMockApollo(handlers); + + wrapper = shallowMountExtended(FailedJobsList, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + apolloProvider: mockApollo, + mocks: { + $toast: { + show: showToast, + }, + }, + }); + }; + + const findAllHeaders = () => wrapper.findAllByTestId('header'); + const findFailedJobRows = () => wrapper.findAllComponents(FailedJobDetails); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findNoFailedJobsText = () => wrapper.findByText('No failed jobs in this pipeline 🎉'); + + beforeEach(() => { + mockFailedJobsResponse = jest.fn(); + }); + + describe('on mount', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + createComponent(); + }); + + it('fires the graphql query', () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(mockFailedJobsResponse).toHaveBeenCalledWith({ + fullPath: defaultProps.projectPath, + pipelineIid: defaultProps.pipelineIid, + }); + }); + }); + + describe('when loading failed jobs', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + createComponent(); + }); + + it('shows a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('when failed jobs have loaded', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMock); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await waitForPromises(); + }); + + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders table column', () => { + expect(findAllHeaders()).toHaveLength(3); + }); + + it('shows the list of failed jobs', () => { + expect(findFailedJobRows()).toHaveLength( + failedJobsMock.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('does not renders the empty state', () => { + expect(findNoFailedJobsText().exists()).toBe(false); + }); + + it('calls sortJobsByStatus', () => { + expect(utils.sortJobsByStatus).toHaveBeenCalledWith( + failedJobsMock.data.project.pipeline.jobs.nodes, + ); + }); + }); + + describe('when there are no failed jobs', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); + jest.spyOn(utils, 'sortJobsByStatus'); + + createComponent(); + + await waitForPromises(); + }); + + it('renders the empty state', () => { + expect(findNoFailedJobsText().exists()).toBe(true); + }); + }); + + describe('polling', () => { + it.each` + isGraphqlActive | text + ${true} | ${'polls'} + ${false} | ${'does not poll'} + `(`$text when isGraphqlActive: $isGraphqlActive`, async ({ isGraphqlActive }) => { + const defaultCount = 2; + const newCount = 1; + + const expectedCount = isGraphqlActive ? newCount : defaultCount; + const expectedCallCount = isGraphqlActive ? 2 : 1; + const mockResponse = isGraphqlActive ? activeFailedJobsMock : failedJobsMock; + + // Second result is to simulate polling with a different response + mockFailedJobsResponse.mockResolvedValueOnce(mockResponse); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + + createComponent(); + await waitForPromises(); + + // Initially, we get the first response which is always the default + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(findFailedJobRows()).toHaveLength(defaultCount); + + jest.advanceTimersByTime(10000); + await waitForPromises(); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(expectedCallCount); + expect(findFailedJobRows()).toHaveLength(expectedCount); + }); + }); + + describe('when a REST action occurs', () => { + beforeEach(() => { + // Second result is to simulate polling with a different response + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + }); + + it.each([true, false])('triggers a refetch of the jobs count', async (isPipelineActive) => { + const defaultCount = 2; + const newCount = 1; + + createComponent({ props: { isPipelineActive } }); + await waitForPromises(); + + // Initially, we get the first response which is always the default + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + expect(findFailedJobRows()).toHaveLength(defaultCount); + + wrapper.setProps({ isPipelineActive: !isPipelineActive }); + await waitForPromises(); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); + expect(findFailedJobRows()).toHaveLength(newCount); + }); + }); + + describe('When the job count changes from REST', () => { + beforeEach(() => { + mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty); + + createComponent(); + }); + + describe('and the count is the same', () => { + it('does not re-fetch the query', async () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ failedJobsCount: 0 }); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + }); + }); + + describe('and the count is different', () => { + it('re-fetches the query', async () => { + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ failedJobsCount: 10 }); + + expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('when an error occurs loading jobs', () => { + const errorMessage = "We couldn't fetch jobs for you because you are not qualified"; + + beforeEach(async () => { + mockFailedJobsResponse.mockRejectedValue({ message: errorMessage }); + + createComponent(); + + await waitForPromises(); + }); + it('does not renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('calls create Alert with the error message and danger variant', () => { + expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' }); + }); + }); + + describe('when `refetch-jobs` job is fired from the widget', () => { + beforeEach(async () => { + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock); + mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2); + + createComponent(); + + await waitForPromises(); + }); + + it('refetches all failed jobs', async () => { + expect(findFailedJobRows()).not.toHaveLength( + failedJobsMock2.data.project.pipeline.jobs.nodes.length, + ); + + await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); + await waitForPromises(); + + expect(findFailedJobRows()).toHaveLength( + failedJobsMock2.data.project.pipeline.jobs.nodes.length, + ); + }); + + it('shows a toast message', async () => { + await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name'); + await waitForPromises(); + + expect(showToast).toHaveBeenCalledWith('job-name job is being retried'); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js b/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js new file mode 100644 index 00000000000..318d787a984 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js @@ -0,0 +1,78 @@ +export const job = { + id: 'gid://gitlab/Ci::Build/5241', + allowFailure: false, + detailedStatus: { + id: 'status', + detailsPath: '/jobs/5241', + action: { + id: 'action', + path: '/retry', + icon: 'retry', + }, + group: 'running', + icon: 'status_running_icon', + }, + name: 'job-name', + retried: false, + retryable: true, + kind: 'BUILD', + stage: { + id: '1', + name: 'build', + }, + trace: { + htmlSummary: '<h1>Hello</h1>', + }, + userPermissions: { + readBuild: true, + updateBuild: true, + }, +}; + +export const allowedToFailJob = { + ...job, + id: 'gid://gitlab/Ci::Build/5242', + allowFailure: true, +}; + +export const createFailedJobsMockCount = ({ count = 4, active = false } = {}) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Pipeline/20', + active, + jobs: { + count, + }, + }, + }, + }, + }; +}; + +const createFailedJobsMock = (nodes, active = false) => { + return { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + active, + id: 'gid://gitlab/Pipeline/20', + jobs: { + count: nodes.length, + nodes, + }, + }, + }, + }, + }; +}; + +export const failedJobsMock = createFailedJobsMock([allowedToFailJob, job]); +export const failedJobsMockEmpty = createFailedJobsMock([]); + +export const activeFailedJobsMock = createFailedJobsMock([allowedToFailJob, job], true); + +export const failedJobsMock2 = createFailedJobsMock([job]); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js new file mode 100644 index 00000000000..e52b62feb23 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js @@ -0,0 +1,139 @@ +import { GlButton, GlCard, GlIcon, GlPopover } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue'; +import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue'; + +jest.mock('~/alert'); + +describe('PipelineFailedJobsWidget component', () => { + let wrapper; + + const defaultProps = { + failedJobsCount: 4, + isPipelineActive: false, + pipelineIid: 1, + pipelinePath: '/pipelines/1', + projectPath: 'namespace/project/', + }; + + const defaultProvide = { + fullPath: 'namespace/project/', + }; + + const createComponent = ({ props = {}, provide = {} } = {}) => { + wrapper = shallowMountExtended(PipelineFailedJobsWidget, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { GlCard }, + }); + }; + + const findFailedJobsCard = () => wrapper.findByTestId('failed-jobs-card'); + const findFailedJobsButton = () => wrapper.findComponent(GlButton); + const findFailedJobsList = () => wrapper.findAllComponents(FailedJobsList); + const findInfoIcon = () => wrapper.findComponent(GlIcon); + const findInfoPopover = () => wrapper.findComponent(GlPopover); + + describe('when there are no failed jobs', () => { + beforeEach(() => { + createComponent({ props: { failedJobsCount: 0 } }); + }); + + it('renders the show failed jobs button with a count of 0', () => { + expect(findFailedJobsButton().exists()).toBe(true); + expect(findFailedJobsButton().text()).toBe('Failed jobs (0)'); + }); + }); + + describe('when there are failed jobs', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the show failed jobs button with correct count', () => { + expect(findFailedJobsButton().exists()).toBe(true); + expect(findFailedJobsButton().text()).toBe(`Failed jobs (${defaultProps.failedJobsCount})`); + }); + + it('renders the info icon', () => { + expect(findInfoIcon().exists()).toBe(true); + }); + + it('renders the info popover', () => { + expect(findInfoPopover().exists()).toBe(true); + }); + + it('does not render the failed jobs widget', () => { + expect(findFailedJobsList().exists()).toBe(false); + }); + }); + + describe('when the job button is clicked', () => { + beforeEach(async () => { + createComponent(); + await findFailedJobsButton().vm.$emit('click'); + }); + + it('renders the failed jobs widget', () => { + expect(findFailedJobsList().exists()).toBe(true); + }); + + it('removes the CSS border classes', () => { + expect(findFailedJobsCard().attributes('class')).not.toContain( + 'gl-border-white gl-hover-border-gray-100', + ); + }); + }); + + describe('when the job details are not expanded', () => { + beforeEach(() => { + createComponent(); + }); + + it('has the CSS border classes', () => { + expect(findFailedJobsCard().attributes('class')).toContain( + 'gl-border-white gl-hover-border-gray-100', + ); + }); + }); + + describe('when the job count changes', () => { + beforeEach(() => { + createComponent(); + }); + + describe('from the prop', () => { + it('updates the job count', async () => { + const newJobCount = 12; + + expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); + + await wrapper.setProps({ failedJobsCount: newJobCount }); + + expect(findFailedJobsButton().text()).toContain(String(newJobCount)); + }); + }); + + describe('from the event', () => { + beforeEach(async () => { + await findFailedJobsButton().vm.$emit('click'); + }); + + it('updates the job count', async () => { + const newJobCount = 12; + + expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount)); + + await findFailedJobsList().at(0).vm.$emit('failed-jobs-count', newJobCount); + + expect(findFailedJobsButton().text()).toContain(String(newJobCount)); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js new file mode 100644 index 00000000000..5755cd846ac --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js @@ -0,0 +1,55 @@ +import { isFailedJob, sortJobsByStatus } from '~/ci/pipelines_page/components/failure_widget/utils'; + +describe('isFailedJob', () => { + describe('when the job argument is undefined', () => { + it('returns false', () => { + expect(isFailedJob()).toBe(false); + }); + }); + + describe('when the job is of status `failed`', () => { + it('returns false', () => { + expect(isFailedJob({ detailedStatus: { group: 'success' } })).toBe(false); + }); + }); + + describe('when the job status is `failed`', () => { + it('returns true', () => { + expect(isFailedJob({ detailedStatus: { group: 'failed' } })).toBe(true); + }); + }); +}); + +describe('sortJobsByStatus', () => { + describe('when the arg is undefined', () => { + it('returns an empty array', () => { + expect(sortJobsByStatus()).toEqual([]); + }); + }); + + describe('when receiving an empty array', () => { + it('returns an empty array', () => { + expect(sortJobsByStatus([])).toEqual([]); + }); + }); + + describe('when reciving a list of jobs', () => { + const jobArr = [ + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'allowed_to_fail' } }, + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'success' } }, + ]; + + const expectedResult = [ + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'failed' } }, + { detailedStatus: { group: 'allowed_to_fail' } }, + { detailedStatus: { group: 'success' } }, + ]; + + it('sorts failed jobs first', () => { + expect(sortJobsByStatus(jobArr)).toEqual(expectedResult); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js b/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js new file mode 100644 index 00000000000..f4858ac27ea --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js @@ -0,0 +1,80 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import NavControls from '~/ci/pipelines_page/components/nav_controls.vue'; + +describe('Pipelines Nav Controls', () => { + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMountExtended(NavControls, { + propsData: { + ...props, + }, + }); + }; + + const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); + const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); + const findClearCacheButton = () => wrapper.findByTestId('clear-cache-button'); + + it('should render link to create a new pipeline', () => { + const mockData = { + newPipelinePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + + const runPipelineButton = findRunPipelineButton(); + expect(runPipelineButton.text()).toContain('Run pipeline'); + expect(runPipelineButton.attributes('href')).toBe(mockData.newPipelinePath); + }); + + it('should not render link to create pipeline if no path is provided', () => { + const mockData = { + helpPagePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + + expect(findRunPipelineButton().exists()).toBe(false); + }); + + it('should render link for CI lint', () => { + const mockData = { + newPipelinePath: 'foo', + helpPagePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + createComponent(mockData); + const ciLintButton = findCiLintButton(); + + expect(ciLintButton.text()).toContain('CI lint'); + expect(ciLintButton.attributes('href')).toBe(mockData.ciLintPath); + }); + + describe('Reset Runners Cache', () => { + beforeEach(() => { + const mockData = { + newPipelinePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + createComponent(mockData); + }); + + it('should render button for resetting runner caches', () => { + expect(findClearCacheButton().text()).toContain('Clear runner caches'); + }); + + it('should emit postAction event when reset runner cache button is clicked', () => { + findClearCacheButton().vm.$emit('click'); + + expect(wrapper.emitted('resetRunnersCache')).toEqual([['foo']]); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js new file mode 100644 index 00000000000..b5c9a3030e0 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js @@ -0,0 +1,164 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { trimText } from 'helpers/text_helper'; +import PipelineLabelsComponent from '~/ci/pipelines_page/components/pipeline_labels.vue'; +import { mockPipeline } from 'jest/ci/pipeline_details/mock_data'; + +const projectPath = 'test/test'; + +describe('Pipeline label component', () => { + let wrapper; + + const findScheduledTag = () => wrapper.findByTestId('pipeline-url-scheduled'); + const findLatestTag = () => wrapper.findByTestId('pipeline-url-latest'); + const findYamlTag = () => wrapper.findByTestId('pipeline-url-yaml'); + const findStuckTag = () => wrapper.findByTestId('pipeline-url-stuck'); + const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops'); + const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link'); + const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached'); + const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure'); + const findForkTag = () => wrapper.findByTestId('pipeline-url-fork'); + const findTrainTag = () => wrapper.findByTestId('pipeline-url-train'); + + const defaultProps = mockPipeline(projectPath); + + const createComponent = (props) => { + wrapper = shallowMountExtended(PipelineLabelsComponent, { + propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: projectPath, + }, + }); + }; + + it('should not render tags when flags are not set', () => { + createComponent(); + + expect(findStuckTag().exists()).toBe(false); + expect(findLatestTag().exists()).toBe(false); + expect(findYamlTag().exists()).toBe(false); + expect(findAutoDevopsTag().exists()).toBe(false); + expect(findFailureTag().exists()).toBe(false); + expect(findScheduledTag().exists()).toBe(false); + expect(findForkTag().exists()).toBe(false); + expect(findTrainTag().exists()).toBe(false); + }); + + it('should render the stuck tag when flag is provided', () => { + const stuckPipeline = defaultProps.pipeline; + stuckPipeline.flags.stuck = true; + + createComponent({ + ...stuckPipeline.pipeline, + }); + + expect(findStuckTag().text()).toContain('stuck'); + }); + + it('should render latest tag when flag is provided', () => { + const latestPipeline = defaultProps.pipeline; + latestPipeline.flags.latest = true; + + createComponent({ + ...latestPipeline, + }); + + expect(findLatestTag().text()).toContain('latest'); + }); + + it('should render a yaml badge when it is invalid', () => { + const yamlPipeline = defaultProps.pipeline; + yamlPipeline.flags.yaml_errors = true; + + createComponent({ + ...yamlPipeline, + }); + + expect(findYamlTag().text()).toContain('yaml invalid'); + }); + + it('should render an autodevops badge when flag is provided', () => { + const autoDevopsPipeline = defaultProps.pipeline; + autoDevopsPipeline.flags.auto_devops = true; + + createComponent({ + ...autoDevopsPipeline, + }); + + expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps'); + + expect(findAutoDevopsTagLink().attributes()).toMatchObject({ + href: '/help/topics/autodevops/index.md', + target: '_blank', + }); + }); + + it('should render a detached badge when flag is provided', () => { + const detachedMRPipeline = defaultProps.pipeline; + detachedMRPipeline.flags.detached_merge_request_pipeline = true; + + createComponent({ + ...detachedMRPipeline, + }); + + expect(findDetachedTag().text()).toBe('merge request'); + }); + + it('should render error badge when pipeline has a failure reason set', () => { + const failedPipeline = defaultProps.pipeline; + failedPipeline.flags.failure_reason = true; + failedPipeline.failure_reason = 'some reason'; + + createComponent({ + ...failedPipeline, + }); + + expect(findFailureTag().text()).toContain('error'); + expect(findFailureTag().attributes('title')).toContain('some reason'); + }); + + it('should render scheduled badge when pipeline was triggered by a schedule', () => { + const scheduledPipeline = defaultProps.pipeline; + scheduledPipeline.source = 'schedule'; + + createComponent({ + ...scheduledPipeline, + }); + + expect(findScheduledTag().exists()).toBe(true); + expect(findScheduledTag().text()).toContain('Scheduled'); + }); + + it('should render the fork badge when the pipeline was run in a fork', () => { + const forkedPipeline = defaultProps.pipeline; + forkedPipeline.project.full_path = '/test/forked'; + + createComponent({ + ...forkedPipeline, + }); + + expect(findForkTag().exists()).toBe(true); + expect(findForkTag().text()).toBe('fork'); + }); + + it('should render the train badge when the pipeline is a merge train pipeline', () => { + const mergeTrainPipeline = defaultProps.pipeline; + mergeTrainPipeline.flags.merge_train_pipeline = true; + + createComponent({ + ...mergeTrainPipeline, + }); + + expect(findTrainTag().text()).toBe('merge train'); + }); + + it('should not render the train badge when the pipeline is not a merge train pipeline', () => { + const mergeTrainPipeline = defaultProps.pipeline; + mergeTrainPipeline.flags.merge_train_pipeline = false; + + createComponent({ + ...mergeTrainPipeline, + }); + + expect(findTrainTag().exists()).toBe(false); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js new file mode 100644 index 00000000000..7ae21db8815 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js @@ -0,0 +1,316 @@ +import { nextTick } from 'vue'; +import { + GlAlert, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlSprintf, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stubComponent } from 'helpers/stub_component'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import PipelineMultiActions, { + i18n, +} from '~/ci/pipelines_page/components/pipeline_multi_actions.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; + +describe('Pipeline Multi Actions Dropdown', () => { + let wrapper; + let mockAxios; + + const artifacts = [ + { + name: 'job my-artifact', + path: '/download/path', + }, + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]; + const newArtifacts = [ + { + name: 'job-3 my-new-artifact', + path: '/new/download/path', + }, + { + name: 'job-4 my-new-artifact-2', + path: '/new/download/path-two', + }, + { + name: 'job-5 my-new-artifact-3', + path: '/new/download/path-three', + }, + ]; + const artifactItemTestId = 'artifact-item'; + const artifactsEndpointPlaceholder = ':pipeline_artifacts_id'; + const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`; + const pipelineId = 108; + + const createComponent = () => { + wrapper = extendedWrapper( + shallowMount(PipelineMultiActions, { + provide: { + artifactsEndpoint, + artifactsEndpointPlaceholder, + }, + propsData: { + pipelineId, + }, + stubs: { + GlAlert, + GlSprintf, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlSearchBoxByType: stubComponent(GlSearchBoxByType), + }, + }), + ); + }; + + const findAlert = () => wrapper.findByTestId('artifacts-fetch-error'); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId); + const findAllArtifactItemsData = () => + findDropdown() + .props('items') + .map(({ text, href }) => ({ + name: text, + path: href, + })); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message'); + const findWarning = () => wrapper.findByTestId('artifacts-fetch-warning'); + const changePipelineId = (newId) => wrapper.setProps({ pipelineId: newId }); + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('should render the dropdown', () => { + createComponent(); + + expect(findDropdown().exists()).toBe(true); + }); + + describe('Artifacts', () => { + const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId); + + describe('while loading artifacts', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); + }); + + it('should render a loading spinner and no empty message', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findEmptyMessage().exists()).toBe(false); + }); + }); + + describe('artifacts loaded successfully', () => { + describe('artifacts exist', () => { + beforeEach(async () => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts }); + + createComponent(); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('should fetch artifacts and show search box on dropdown click', () => { + expect(mockAxios.history.get).toHaveLength(1); + expect(findSearchBox().exists()).toBe(true); + }); + + it('should focus the search box when opened with artifacts', () => { + findDropdown().vm.$emit('shown'); + + expect(findSearchBox().attributes('autofocus')).not.toBe(undefined); + }); + + it('should clear searchQuery when dropdown is closed', async () => { + findDropdown().vm.$emit('shown'); + findSearchBox().vm.$emit('input', 'job-2'); + await waitForPromises(); + + expect(findSearchBox().vm.value).toBe('job-2'); + + findDropdown().vm.$emit('hidden'); + await waitForPromises(); + + expect(findSearchBox().vm.value).toBe(''); + }); + + it('should render all the provided artifacts when search query is empty', async () => { + findSearchBox().vm.$emit('input', ''); + await waitForPromises(); + + expect(findAllArtifactItemsData()).toEqual( + artifacts.map(({ name, path }) => ({ name, path })), + ); + expect(findEmptyMessage().exists()).toBe(false); + }); + + it('should render filtered artifacts when search query is not empty', async () => { + findSearchBox().vm.$emit('input', 'job-2'); + await waitForPromises(); + + expect(findAllArtifactItemsData()).toEqual([ + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]); + expect(findEmptyMessage().exists()).toBe(false); + }); + + it('should render the correct artifact name and path', () => { + expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); + expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); + }); + + describe('when opened again with new artifacts', () => { + describe('with a successful refetch', () => { + beforeEach(async () => { + mockAxios.resetHistory(); + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: newArtifacts }); + + findDropdown().vm.$emit('shown'); + await nextTick(); + }); + + it('should hide list and render a loading spinner on dropdown click', () => { + expect(findAllArtifactItemsData()).toHaveLength(0); + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('should not render warning or empty message while loading', () => { + expect(findEmptyMessage().exists()).toBe(false); + expect(findWarning().exists()).toBe(false); + }); + + it('should render the correct new list', async () => { + await waitForPromises(); + + expect(findAllArtifactItemsData()).toEqual(newArtifacts); + }); + }); + + describe('with a failing refetch', () => { + beforeEach(async () => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('should render warning', () => { + expect(findWarning().text()).toBe(i18n.artifactsFetchWarningMessage); + }); + + it('should render old list', () => { + expect(findAllArtifactItemsData()).toEqual(artifacts); + }); + }); + }); + + describe('pipeline id has changed', () => { + const newEndpoint = artifactsEndpoint.replace( + artifactsEndpointPlaceholder, + pipelineId + 1, + ); + + beforeEach(() => { + changePipelineId(pipelineId + 1); + }); + + describe('followed by a failing request', () => { + beforeEach(async () => { + mockAxios.onGet(newEndpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + }); + + it('should render error message and no warning', () => { + expect(findWarning().exists()).toBe(false); + expect(findAlert().text()).toBe(i18n.artifactsFetchErrorMessage); + }); + + it('should clear list', () => { + expect(findAllArtifactItemsData()).toHaveLength(0); + }); + }); + }); + }); + + describe('artifacts list is empty', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: [] }); + }); + + it('should render empty message and no search box when no artifacts are found', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + await waitForPromises(); + + expect(findEmptyMessage().exists()).toBe(true); + expect(findSearchBox().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + }); + + describe('with a failing request', () => { + beforeEach(() => { + mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR); + }); + + it('should render an error message', async () => { + createComponent(); + findDropdown().vm.$emit('shown'); + await waitForPromises(); + + const error = findAlert(); + expect(error.exists()).toBe(true); + expect(error.text()).toBe(i18n.artifactsFetchErrorMessage); + }); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks artifacts dropdown click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(); + + findDropdown().vm.$emit('shown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_artifacts_dropdown', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js new file mode 100644 index 00000000000..d2eab64b317 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js @@ -0,0 +1,77 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue'; +import PipelineMultiActions from '~/ci/pipelines_page/components/pipeline_multi_actions.vue'; +import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue'; +import eventHub from '~/ci/event_hub'; + +describe('Pipeline operations', () => { + let wrapper; + + const defaultProps = { + pipeline: { + id: 329, + iid: 234, + details: { + has_manual_actions: true, + has_scheduled_actions: false, + }, + flags: { + retryable: true, + cancelable: true, + }, + cancel_path: '/root/ci-project/-/pipelines/329/cancel', + retry_path: '/root/ci-project/-/pipelines/329/retry', + }, + }; + + const createComponent = (props = defaultProps) => { + wrapper = shallowMountExtended(PipelineOperations, { + propsData: { + ...props, + }, + }); + }; + + const findManualActions = () => wrapper.findComponent(PipelinesManualActions); + const findMultiActions = () => wrapper.findComponent(PipelineMultiActions); + const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); + const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + + it('should display pipeline manual actions', () => { + createComponent(); + + expect(findManualActions().exists()).toBe(true); + }); + + it('should display pipeline multi actions', () => { + createComponent(); + + expect(findMultiActions().exists()).toBe(true); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('should emit retryPipeline event', () => { + findRetryBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'retryPipeline', + defaultProps.pipeline.retry_path, + ); + }); + + it('should emit openConfirmationModal event', () => { + findCancelBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', { + pipeline: defaultProps.pipeline, + endpoint: defaultProps.pipeline.cancel_path, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js new file mode 100644 index 00000000000..4d78a923542 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import { mockPipelineHeader } from 'jest/ci/pipeline_details/mock_data'; +import PipelineStopModal from '~/ci/pipelines_page/components/pipeline_stop_modal.vue'; + +describe('PipelineStopModal', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineStopModal, { + propsData: { + pipeline: mockPipelineHeader, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should render "stop pipeline" warning', () => { + expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js new file mode 100644 index 00000000000..cb04171f031 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js @@ -0,0 +1,76 @@ +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import pipelineTriggerer from '~/ci/pipelines_page/components/pipeline_triggerer.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('Pipelines Triggerer', () => { + let wrapper; + + const mockData = { + pipeline: { + user: { + name: 'foo', + avatar_url: '/avatar', + path: '/path', + }, + }, + }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(pipelineTriggerer, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + }); + }; + + const findAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findTriggerer = () => wrapper.findByText('API'); + + describe('when user was a triggerer', () => { + beforeEach(() => { + createComponent(mockData); + }); + + it('should render pipeline triggerer table cell', () => { + expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true); + }); + + it('should render only user avatar', () => { + expect(findAvatarLink().exists()).toBe(true); + expect(findTriggerer().exists()).toBe(false); + }); + + it('should set correct props on avatar link component', () => { + expect(findAvatarLink().attributes()).toMatchObject({ + title: mockData.pipeline.user.name, + href: mockData.pipeline.user.path, + }); + }); + + it('should add tooltip to avatar link', () => { + const tooltip = getBinding(findAvatarLink().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + }); + + it('should set correct props on avatar component', () => { + expect(findAvatar().attributes().src).toBe(mockData.pipeline.user.avatar_url); + }); + }); + + describe('when API was a triggerer', () => { + beforeEach(() => { + createComponent({ pipeline: {} }); + }); + + it('should render label only', () => { + expect(findAvatarLink().exists()).toBe(false); + expect(findTriggerer().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js new file mode 100644 index 00000000000..0ee22dda826 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js @@ -0,0 +1,188 @@ +import { merge } from 'lodash'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelineUrlComponent from '~/ci/pipelines_page/components/pipeline_url.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { + mockPipeline, + mockPipelineBranch, + mockPipelineTag, +} from 'jest/ci/pipeline_details/mock_data'; + +const projectPath = 'test/test'; + +describe('Pipeline Url Component', () => { + let wrapper; + let trackingSpy; + + const findTableCell = () => wrapper.findByTestId('pipeline-url-table-cell'); + const findPipelineUrlLink = () => wrapper.findByTestId('pipeline-url-link'); + const findRefName = () => wrapper.findByTestId('merge-request-ref'); + const findCommitShortSha = () => wrapper.findByTestId('commit-short-sha'); + const findCommitIcon = () => wrapper.findByTestId('commit-icon'); + const findCommitIconType = () => wrapper.findByTestId('commit-icon-type'); + const findCommitRefName = () => wrapper.findByTestId('commit-ref-name'); + + const findCommitTitleContainer = () => wrapper.findByTestId('commit-title-container'); + const findPipelineNameContainer = () => wrapper.findByTestId('pipeline-name-container'); + const findCommitTitle = (commitWrapper) => commitWrapper.find('[data-testid="commit-title"]'); + + const defaultProps = { ...mockPipeline(projectPath), refClass: 'gl-text-black' }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(PipelineUrlComponent, { + propsData: { ...defaultProps, ...props }, + provide: { + targetProjectFullPath: projectPath, + }, + }); + }; + + it('should render pipeline url table cell', () => { + createComponent(); + + expect(findTableCell().exists()).toBe(true); + }); + + it('should render a link the provided path and id', () => { + createComponent(); + + expect(findPipelineUrlLink().attributes('href')).toBe('foo'); + + expect(findPipelineUrlLink().text()).toBe('#1'); + }); + + it('should render the pipeline name instead of commit title', () => { + createComponent(merge(mockPipeline(projectPath), { pipeline: { name: 'Build pipeline' } })); + + expect(findCommitTitleContainer().exists()).toBe(false); + expect(findPipelineNameContainer().exists()).toBe(true); + expect(findRefName().exists()).toBe(true); + expect(findCommitShortSha().exists()).toBe(true); + }); + + it('should render the commit title when pipeline has no name', () => { + createComponent(); + + const commitWrapper = findCommitTitleContainer(); + + expect(findCommitTitle(commitWrapper).exists()).toBe(true); + expect(findRefName().exists()).toBe(true); + expect(findCommitShortSha().exists()).toBe(true); + expect(findPipelineNameContainer().exists()).toBe(false); + }); + + it('should pass the refClass prop to merge request link', () => { + createComponent(); + + expect(findRefName().classes()).toContain(defaultProps.refClass); + }); + + it('should pass the refClass prop to the commit ref name link', () => { + createComponent(mockPipelineBranch()); + + expect(findCommitRefName().classes()).toContain(defaultProps.refClass); + }); + + describe('commit user avatar', () => { + it('renders when commit author exists', () => { + const pipelineBranch = mockPipelineBranch(); + const { avatar_url: imgSrc, name, path } = pipelineBranch.pipeline.commit.author; + createComponent(pipelineBranch); + + const component = wrapper.findComponent(UserAvatarLink); + expect(component.exists()).toBe(true); + expect(component.props()).toMatchObject({ + imgSize: 16, + imgSrc, + imgAlt: name, + linkHref: path, + tooltipText: name, + }); + }); + + it('does not render when commit author does not exist', () => { + createComponent(); + + expect(wrapper.findComponent(UserAvatarLink).exists()).toBe(false); + }); + }); + + it('should render commit icon tooltip', () => { + createComponent(); + + expect(findCommitIcon().attributes('title')).toBe('Commit'); + }); + + it.each` + pipeline | expectedTitle + ${mockPipelineTag()} | ${'Tag'} + ${mockPipelineBranch()} | ${'Branch'} + ${mockPipeline()} | ${'Merge Request'} + `('should render tooltip $expectedTitle for commit icon type', ({ pipeline, expectedTitle }) => { + createComponent(pipeline); + + expect(findCommitIconType().attributes('title')).toBe(expectedTitle); + }); + + describe('tracking', () => { + beforeEach(() => { + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks pipeline id click', () => { + createComponent(); + + findPipelineUrlLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_pipeline_id', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks merge request ref click', () => { + createComponent(); + + findRefName().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_mr_ref', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit ref name click', () => { + createComponent(mockPipelineBranch()); + + findCommitRefName().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_name', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit title click', () => { + createComponent(merge(mockPipelineBranch(), { pipeline: { name: null } })); + + findCommitTitle(findCommitTitleContainer()).vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_title', { + label: TRACKING_CATEGORIES.table, + }); + }); + + it('tracks commit short sha click', () => { + createComponent(mockPipelineBranch()); + + findCommitShortSha().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_sha', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js new file mode 100644 index 00000000000..557403b3de9 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js @@ -0,0 +1,64 @@ +import { + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlSprintf, +} from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue'; + +describe('Pipelines Artifacts dropdown', () => { + let wrapper; + + const artifacts = [ + { + name: 'job my-artifact', + path: '/download/path', + }, + { + name: 'job-2 my-artifact-2', + path: '/download/path-two', + }, + ]; + const pipelineId = 108; + + const createComponent = ({ mockArtifacts = artifacts } = {}) => { + wrapper = shallowMount(PipelineArtifacts, { + propsData: { + pipelineId, + artifacts: mockArtifacts, + }, + stubs: { + GlSprintf, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + }, + }); + }; + + const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findFirstGlDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem); + + it('should render a dropdown with all the provided artifacts', () => { + createComponent(); + + const [{ items }] = findGlDropdown().props('items'); + expect(items).toHaveLength(artifacts.length); + }); + + it('should render a link with the provided path', () => { + createComponent(); + + expect(findFirstGlDropdownItem().props('item').href).toBe(artifacts[0].path); + expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name); + }); + + describe('with no artifacts', () => { + it('should not render the dropdown', () => { + createComponent({ mockArtifacts: [] }); + + expect(findGlDropdown().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js new file mode 100644 index 00000000000..4cd85b86e31 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js @@ -0,0 +1,199 @@ +import { GlFilteredSearch } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import PipelinesFilteredSearch from '~/ci/pipelines_page/components/pipelines_filtered_search.vue'; +import { + FILTERED_SEARCH_TERM, + OPERATORS_IS, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { users, mockSearch, branches, tags } from 'jest/ci/pipeline_details/mock_data'; + +describe('Pipelines filtered search', () => { + let wrapper; + let mock; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const getSearchToken = (type) => + findFilteredSearch() + .props('availableTokens') + .find((token) => token.type === type); + const findBranchToken = () => getSearchToken('ref'); + const findTagToken = () => getSearchToken('tag'); + const findUserToken = () => getSearchToken('username'); + const findStatusToken = () => getSearchToken('status'); + const findSourceToken = () => getSearchToken('source'); + + const createComponent = (params = {}) => { + wrapper = mount(PipelinesFilteredSearch, { + propsData: { + projectId: '21', + defaultBranchName: 'main', + params, + }, + attachTo: document.body, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); + + createComponent(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('displays UI elements', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('displays search tokens', () => { + expect(findUserToken()).toMatchObject({ + type: 'username', + icon: 'user', + title: 'Trigger author', + unique: true, + projectId: '21', + operators: OPERATORS_IS, + }); + + expect(findBranchToken()).toMatchObject({ + type: 'ref', + icon: 'branch', + title: 'Branch name', + unique: true, + projectId: '21', + defaultBranchName: 'main', + operators: OPERATORS_IS, + }); + + expect(findSourceToken()).toMatchObject({ + type: 'source', + icon: 'trigger-source', + title: 'Source', + unique: true, + operators: OPERATORS_IS, + }); + + expect(findStatusToken()).toMatchObject({ + type: 'status', + icon: 'status', + title: 'Status', + unique: true, + operators: OPERATORS_IS, + }); + + expect(findTagToken()).toMatchObject({ + type: 'tag', + icon: 'tag', + title: 'Tag name', + unique: true, + operators: OPERATORS_IS, + }); + }); + + it('emits filterPipelines on submit with correct filter', () => { + findFilteredSearch().vm.$emit('submit', mockSearch); + + expect(wrapper.emitted('filterPipelines')).toHaveLength(1); + expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]); + }); + + it('disables tag name token when branch name token is active', async () => { + findFilteredSearch().vm.$emit('input', [ + { type: 'ref', value: { data: 'branch-1', operator: '=' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(true); + }); + + it('disables branch name token when tag name token is active', async () => { + findFilteredSearch().vm.$emit('input', [ + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(true); + expect(findTagToken().disabled).toBe(false); + }); + + it('resets tokens disabled state on clear', async () => { + findFilteredSearch().vm.$emit('clearInput'); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(false); + }); + + it('resets tokens disabled state when clearing tokens by backspace', async () => { + findFilteredSearch().vm.$emit('input', [{ type: FILTERED_SEARCH_TERM, value: { data: '' } }]); + + await nextTick(); + expect(findBranchToken().disabled).toBe(false); + expect(findTagToken().disabled).toBe(false); + }); + + describe('Url query params', () => { + const params = { + username: 'deja.green', + ref: 'main', + }; + + beforeEach(() => { + createComponent(params); + }); + + it('sets default value if url query params', () => { + const expectedValueProp = [ + { + type: 'username', + value: { + data: params.username, + operator: '=', + }, + }, + { + type: 'ref', + value: { + data: params.ref, + operator: '=', + }, + }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, + ]; + + expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp); + expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks filtered search click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findFilteredSearch().vm.$emit('submit', mockSearch); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filtered_search', { + label: TRACKING_CATEGORIES.search, + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js new file mode 100644 index 00000000000..a24e136f1ff --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js @@ -0,0 +1,216 @@ +import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineActionsQueryResponse from 'test_fixtures/graphql/pipelines/get_pipeline_actions.query.graphql.json'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue'; +import getPipelineActionsQuery from '~/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +describe('Pipeline manual actions', () => { + let wrapper; + let mock; + + const queryHandler = jest.fn().mockResolvedValue(mockPipelineActionsQueryResponse); + const { + data: { + project: { + pipeline: { + jobs: { nodes }, + }, + }, + }, + } = mockPipelineActionsQueryResponse; + + const mockPath = nodes[2].playPath; + + const createComponent = (limit = 50) => { + wrapper = shallowMountExtended(PipelinesManualActions, { + provide: { + fullPath: 'root/ci-project', + manualActionsLimit: limit, + }, + propsData: { + iid: 100, + }, + stubs: { + GlDropdown, + }, + apolloProvider: createMockApollo([[getPipelineActionsQuery, queryHandler]]), + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLimitMessage = () => wrapper.findByTestId('limit-reached-msg'); + + it('skips calling query on mount', () => { + createComponent(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + + describe('loading', () => { + beforeEach(() => { + createComponent(); + + findDropdown().vm.$emit('shown'); + }); + + it('display loading state while actions are being fetched', () => { + expect(findAllDropdownItems().at(0).text()).toBe('Loading...'); + expect(findLoadingIcon().exists()).toBe(true); + expect(findAllDropdownItems()).toHaveLength(1); + }); + }); + + describe('loaded', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + }); + + afterEach(() => { + mock.restore(); + confirmAction.mockReset(); + }); + + it('displays dropdown with the provided actions', () => { + expect(findAllDropdownItems()).toHaveLength(3); + }); + + it("displays a disabled action when it's not playable", () => { + expect(findAllDropdownItems().at(0).attributes('disabled')).toBeDefined(); + }); + + describe('on action click', () => { + it('makes a request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + }); + + it('makes a failed request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks manual actions click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('shown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); + + describe('scheduled jobs', () => { + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + }); + + it('makes post request after confirming', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(true); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(1); + }); + + it('does not make post request if confirmation is cancelled', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(false); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(0); + }); + + it('displays the remaining time in the dropdown', () => { + expect(findAllCountdowns().at(0).props('endDateString')).toBe(nodes[2].scheduledAt); + }); + }); + }); + + describe('limit message', () => { + it('limit message does not show', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(false); + }); + + it('limit message does show', async () => { + createComponent(3); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/components/time_ago_spec.js b/spec/frontend/ci/pipelines_page/components/time_ago_spec.js new file mode 100644 index 00000000000..f7203f8d1b4 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/components/time_ago_spec.js @@ -0,0 +1,85 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TimeAgo from '~/ci/pipelines_page/components/time_ago.vue'; + +describe('Timeago component', () => { + let wrapper; + + const defaultProps = { duration: 0, finished_at: '' }; + + const createComponent = (props = defaultProps, extraProps) => { + wrapper = extendedWrapper( + shallowMount(TimeAgo, { + propsData: { + pipeline: { + details: { + ...props, + }, + }, + ...extraProps, + }, + data() { + return { + iconTimerSvg: `<svg></svg>`, + }; + }, + }), + ); + }; + + const duration = () => wrapper.find('.duration'); + const finishedAt = () => wrapper.find('.finished-at'); + const findCalendarIcon = () => wrapper.findByTestId('calendar-icon'); + + describe('with duration', () => { + beforeEach(() => { + createComponent({ duration: 10, finished_at: '' }); + }); + + it('should render duration and timer svg', () => { + const icon = duration().findComponent(GlIcon); + + expect(duration().exists()).toBe(true); + expect(icon.props('name')).toBe('timer'); + }); + }); + + describe('without duration', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not render duration and timer svg', () => { + expect(duration().exists()).toBe(false); + }); + }); + + describe('with finishedTime', () => { + it('should render time', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); + + const time = finishedAt().find('time'); + + expect(finishedAt().exists()).toBe(true); + expect(time.exists()).toBe(true); + }); + + it('should display calendar icon', () => { + createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' }); + + expect(findCalendarIcon().exists()).toBe(true); + }); + }); + + describe('without finishedTime', () => { + beforeEach(() => { + createComponent(); + }); + + it('should not render time and calendar icon', () => { + expect(finishedAt().exists()).toBe(false); + expect(findCalendarIcon().exists()).toBe(false); + }); + }); +}); |