diff options
Diffstat (limited to 'spec/frontend/ci/pipelines_page')
26 files changed, 3980 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); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/pipelines_spec.js b/spec/frontend/ci/pipelines_page/pipelines_spec.js new file mode 100644 index 00000000000..5d1f431e57c --- /dev/null +++ b/spec/frontend/ci/pipelines_page/pipelines_spec.js @@ -0,0 +1,851 @@ +import '~/commons'; +import { + GlButton, + GlEmptyState, + GlFilteredSearch, + GlLoadingIcon, + GlPagination, + GlCollapsibleListbox, +} from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { chunk } from 'lodash'; +import { nextTick } from 'vue'; +import mockPipelinesResponse from 'test_fixtures/pipelines/pipelines.json'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import { mockTracking } from 'helpers/tracking_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import Api from '~/api'; +import { createAlert, VARIANT_WARNING } from '~/alert'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import NavigationControls from '~/ci/pipelines_page/components/nav_controls.vue'; +import PipelinesComponent from '~/ci/pipelines_page/pipelines.vue'; +import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue'; +import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; +import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/constants'; +import Store from '~/ci/pipeline_details/stores/pipelines_store'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { + setIdTypePreferenceMutationResponse, + setIdTypePreferenceMutationResponseWithErrors, +} from 'jest/issues/list/mock_data'; + +import { stageReply } from 'jest/ci/pipeline_mini_graph/mock_data'; +import { users, mockSearch, branches } from '../pipeline_details/mock_data'; + +jest.mock('@sentry/browser'); +jest.mock('~/alert'); + +const mockProjectPath = 'twitter/flight'; +const mockProjectId = '21'; +const mockDefaultBranchName = 'main'; +const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`; +const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id); +const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( + (p) => p.details.stages && p.details.stages.length, +); + +describe('Pipelines', () => { + let wrapper; + let mockApollo; + let mock; + let trackingSpy; + + const paths = { + emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', + noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + ciLintPath: '/ci/lint', + resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, + newPipelinePath: `${mockProjectPath}/pipelines/new`, + + ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`, + }; + + const noPermissions = { + emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', + noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg', + }; + + const defaultProps = { + hasGitlabCi: true, + canCreatePipeline: true, + ...paths, + }; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findNavigationTabs = () => wrapper.findComponent(NavigationTabs); + const findNavigationControls = () => wrapper.findComponent(NavigationControls); + const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); + const findTablePagination = () => wrapper.findComponent(TablePagination); + const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox); + + const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); + const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box'); + const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); + const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); + const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); + const findStagesDropdownToggle = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle'); + const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); + + const createComponent = (props = defaultProps) => { + const { mutationMock, ...restProps } = props; + mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]); + + wrapper = extendedWrapper( + mount(PipelinesComponent, { + provide: { + pipelineEditorPath: '', + suggestedCiTemplates: [], + ciRunnerSettingsPath: paths.ciRunnerSettingsPath, + anyRunnersAvailable: true, + }, + propsData: { + store: new Store(), + projectId: mockProjectId, + defaultBranchName: mockDefaultBranchName, + endpoint: mockPipelinesEndpoint, + params: {}, + ...restProps, + }, + apolloProvider: mockApollo, + }), + ); + }; + + beforeEach(() => { + setWindowLocation(TEST_HOST); + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(window.history, 'pushState'); + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + }); + + afterEach(() => { + mock.reset(); + mockApollo = null; + window.history.pushState.mockReset(); + }); + + describe('when pipelines are not yet loaded', () => { + beforeEach(async () => { + createComponent(); + await nextTick(); + }); + + it('shows loading state when the app is loading', () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('does not display tabs when the first request has not yet been made', () => { + expect(findNavigationTabs().exists()).toBe(false); + }); + + it('does not display buttons', () => { + expect(findNavigationControls().exists()).toBe(false); + }); + }); + + describe('when there are pipelines in the project', () => { + beforeEach(() => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, mockPipelinesResponse); + }); + + describe('when user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + await waitForPromises(); + }); + + it('renders "All" tab with count different from "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 3'); + }); + + it('does not render buttons', () => { + expect(findNavigationControls().exists()).toBe(false); + + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('renders pipelines in a table', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + }); + + describe('when user has permissions', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should set up navigation tabs', () => { + expect(findNavigationTabs().props('tabs')).toEqual([ + { name: 'All', scope: 'all', count: '3', isActive: true }, + { name: 'Finished', scope: 'finished', count: undefined, isActive: false }, + { name: 'Branches', scope: 'branches', isActive: false }, + { name: 'Tags', scope: 'tags', isActive: false }, + ]); + }); + + it('renders "All" tab with count different from "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 3'); + }); + + it('should render other navigation tabs', () => { + expect(findTab('finished').text()).toBe('Finished'); + expect(findTab('branches').text()).toBe('Branches'); + expect(findTab('tags').text()).toBe('Tags'); + }); + + it('shows navigation controls', () => { + expect(findNavigationControls().exists()).toBe(true); + }); + + it('renders Run pipeline link', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + }); + + it('renders CI lint link', () => { + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + }); + + it('renders Clear runner cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('renders pipelines in a table', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + + describe('when user goes to a tab', () => { + const goToTab = (tab) => { + findNavigationTabs().vm.$emit('onChangeTab', tab); + }; + + describe('when the scope in the tab has pipelines', () => { + const mockFinishedPipeline = mockPipelinesResponse.pipelines[0]; + + beforeEach(async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [mockFinishedPipeline], + count: mockPipelinesResponse.count, + }); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + goToTab('finished'); + + await waitForPromises(); + }); + + it('should filter pipelines', () => { + expect(findPipelinesTable().exists()).toBe(true); + + expect(findPipelineUrlLinks()).toHaveLength(1); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFinishedPipeline.id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?scope=finished&page=1`, + ); + }); + + it.each(['all', 'finished', 'branches', 'tags'])('tracks %p tab click', async (scope) => { + goToTab(scope); + + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filter_tabs', { + label: TRACKING_CATEGORIES.tabs, + property: scope, + }); + }); + }); + + describe('when the scope in the tab is empty', () => { + beforeEach(async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'branches', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: mockPipelinesResponse.count, + }); + + goToTab('branches'); + + await waitForPromises(); + }); + + it('should filter pipelines', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?scope=branches&page=1`, + ); + }); + }); + }); + + describe('when user triggers a filtered search', () => { + const mockFilteredPipeline = mockPipelinesResponse.pipelines[1]; + + let expectedParams; + + beforeEach(async () => { + expectedParams = { + page: '1', + scope: 'all', + username: 'root', + ref: 'main', + status: 'pending', + }; + + mock + .onGet(mockPipelinesEndpoint, { + params: expectedParams, + }) + .replyOnce(HTTP_STATUS_OK, { + pipelines: [mockFilteredPipeline], + count: mockPipelinesResponse.count, + }); + + findFilteredSearch().vm.$emit('submit', mockSearch); + + await waitForPromises(); + }); + + it('requests data with query params on filter submit', () => { + expect(mock.history.get[1].params).toEqual(expectedParams); + }); + + it('renders filtered pipelines', () => { + expect(findPipelineUrlLinks()).toHaveLength(1); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all&username=root&ref=main&status=pending`, + ); + }); + }); + + describe('when user changes Show Pipeline ID to Show Pipeline IID', () => { + const mockFilteredPipeline = mockPipelinesResponse.pipelines[0]; + + beforeEach(() => { + gon.current_user_id = 1; + }); + + it('should change the text to Show Pipeline IID', async () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + + await waitForPromises(); + + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.iid}`); + }); + + it('calls mutation to save idType preference', () => { + const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse); + createComponent({ ...defaultProps, mutationMock }); + + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + + expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } }); + }); + + it('captures error when mutation response has errors', async () => { + const mutationMock = jest + .fn() + .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors); + createComponent({ ...defaultProps, mutationMock }); + + findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid'); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); + }); + }); + + describe('when user triggers a filtered search with raw text', () => { + beforeEach(async () => { + findFilteredSearch().vm.$emit('submit', ['rawText']); + + await waitForPromises(); + }); + + it('requests data with query params on filter submit', () => { + expect(mock.history.get[1].params).toEqual({ page: '1', scope: 'all' }); + }); + + it('displays a warning message if raw text search is used', () => { + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: RAW_TEXT_WARNING, + variant: VARIANT_WARNING, + }); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all`, + ); + }); + }); + }); + }); + + describe('when there are multiple pages of pipelines', () => { + const mockPageSize = 2; + const mockPageHeaders = ({ page = 1 } = {}) => { + return { + 'X-PER-PAGE': `${mockPageSize}`, + 'X-PREV-PAGE': `${page - 1}`, + 'X-PAGE': `${page}`, + 'X-NEXT-PAGE': `${page + 1}`, + }; + }; + const [firstPage, secondPage] = chunk(mockPipelinesResponse.pipelines, mockPageSize); + + const goToPage = (page) => { + findTablePagination().findComponent(GlPagination).vm.$emit('input', page); + }; + + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }).reply( + HTTP_STATUS_OK, + { + pipelines: firstPage, + count: mockPipelinesResponse.count, + }, + mockPageHeaders({ page: 1 }), + ); + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '2' } }).reply( + HTTP_STATUS_OK, + { + pipelines: secondPage, + count: mockPipelinesResponse.count, + }, + mockPageHeaders({ page: 2 }), + ); + + createComponent(); + + await waitForPromises(); + }); + + it('shows the first page of pipelines', () => { + expect(findPipelineUrlLinks()).toHaveLength(firstPage.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${firstPage[0].id}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${firstPage[1].id}`); + }); + + it('should not update browser bar', () => { + expect(window.history.pushState).not.toHaveBeenCalled(); + }); + + describe('when user goes to next page', () => { + beforeEach(async () => { + goToPage(2); + await waitForPromises(); + }); + + it('should update page and keep scope the same scope', () => { + expect(findPipelineUrlLinks()).toHaveLength(secondPage.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${secondPage[0].id}`); + }); + + it('should update browser bar', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=2&scope=all`, + ); + }); + + it('should reset page to 1 when filtering pipelines', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=2&scope=all`, + ); + + findFilteredSearch().vm.$emit('submit', [ + { type: 'status', value: { data: 'success', operator: '=' } }, + ]); + + expect(window.history.pushState).toHaveBeenCalledTimes(2); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all&status=success`, + ); + }); + }); + }); + + describe('when pipelines can be polled', () => { + beforeEach(() => { + const emptyResponse = { + pipelines: [], + count: { all: '0' }, + }; + + // Mock no pipelines in the first attempt + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .replyOnce(HTTP_STATUS_OK, emptyResponse, { + 'POLL-INTERVAL': 100, + }); + // Mock pipelines in the next attempt + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, mockPipelinesResponse, { + 'POLL-INTERVAL': 100, + }); + }); + + describe('data is loaded for the first time', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('shows tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + }); + + it('should update page and keep scope the same scope', () => { + expect(findPipelineUrlLinks()).toHaveLength(0); + }); + + describe('data is loaded for a second time', () => { + beforeEach(async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + }); + + it('shows tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + }); + + it('is loading after a time', () => { + expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`); + expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`); + expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`); + }); + }); + }); + }); + + describe('when no pipelines exist', () => { + beforeEach(() => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: { all: '0' }, + }); + }); + + describe('when CI is enabled and user has permissions', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('renders tab with count of "0"', () => { + expect(findNavigationTabs().exists()).toBe(true); + expect(findTab('all').text()).toMatchInterpolatedText('All 0'); + }); + + it('renders Run pipeline link', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + }); + + it('renders CI lint link', () => { + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + }); + + it('renders Clear runner cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('renders empty state', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + + it('renders filtered search', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + + it('renders the pipeline key collapsible box', () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(true); + }); + + it('renders tab empty state finished scope', async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) + .reply(HTTP_STATUS_OK, { + pipelines: [], + count: { all: '0' }, + }); + + findNavigationTabs().vm.$emit('onChangeTab', 'finished'); + + await waitForPromises(); + + expect(findEmptyState().text()).toBe('There are currently no finished pipelines.'); + }); + }); + + describe('when CI is not enabled and user has permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + await waitForPromises(); + }); + + it('renders the CI/CD templates', () => { + expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true); + }); + + it('does not render filtered search', () => { + expect(findFilteredSearch().exists()).toBe(false); + }); + + it('does not render the pipeline key dropdown', () => { + expect(findPipelineKeyCollapsibleBox().exists()).toBe(false); + }); + + it('does not render tabs nor buttons', () => { + expect(findNavigationTabs().exists()).toBe(false); + expect(findTab('all').exists()).toBe(false); + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + }); + + describe('when CI is not enabled and user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + await waitForPromises(); + }); + + it('renders empty state without button to set CI', () => { + expect(findEmptyState().text()).toBe( + 'This project is not currently set up to run pipelines.', + ); + + expect(findEmptyState().findComponent(GlButton).exists()).toBe(false); + }); + + it('does not render tabs or buttons', () => { + expect(findTab('all').exists()).toBe(false); + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + }); + + describe('when CI is enabled and user has no permissions', () => { + beforeEach(() => { + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + + return waitForPromises(); + }); + + it('renders tab with count of "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 0'); + }); + + it('does not render buttons', () => { + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('renders empty state', () => { + expect(findEmptyState().text()).toBe('There are currently no pipelines.'); + }); + }); + }); + + describe('when a pipeline with stages exists', () => { + describe('updates results when a staged is clicked', () => { + let stopMock; + let restartMock; + let cancelMock; + + beforeEach(() => { + mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply( + HTTP_STATUS_OK, + { + pipelines: [mockPipelineWithStages], + count: { all: '1' }, + }, + { + 'POLL-INTERVAL': 100, + }, + ); + + mock + .onGet(mockPipelineWithStages.details.stages[0].dropdown_path) + .reply(HTTP_STATUS_OK, stageReply); + + createComponent(); + + stopMock = jest.spyOn(window, 'clearTimeout'); + restartMock = jest.spyOn(axios, 'get'); + }); + + describe('when a request is being made', () => { + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_OK, mockPipelinesResponse); + + await waitForPromises(); + }); + + it('stops polling, cancels the request, & restarts polling', async () => { + // Mock init a polling cycle + wrapper.vm.poll.options.notificationCallback(true); + + await findStagesDropdownToggle().trigger('click'); + jest.runOnlyPendingTimers(); + + // cancelMock is getting overwritten in pipelines_service.js#L29 + // so we have to spy on it again here + cancelMock = jest.spyOn(axios.CancelToken, 'source'); + + await waitForPromises(); + + expect(cancelMock).toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalledWith( + `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`, + ); + }); + + it('stops polling & restarts polling', async () => { + await findStagesDropdownToggle().trigger('click'); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(cancelMock).not.toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalledWith( + `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`, + ); + }); + }); + }); + }); + + describe('when pipelines cannot be loaded', () => { + beforeEach(() => { + mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, {}); + }); + + describe('when user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); + + await waitForPromises(); + }); + + it('renders tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + expect(findTab('all').text()).toBe('All'); + }); + + it('does not render buttons', () => { + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); + + it('shows error state', () => { + expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.'); + expect(findEmptyState().props('description')).toBe( + 'Try again in a few moments or contact your support team.', + ); + }); + }); + + describe('when user has permissions', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('renders tabs', () => { + expect(findTab('all').text()).toBe('All'); + }); + + it('renders buttons', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCleanCacheButton().text()).toBe('Clear runner caches'); + }); + + it('shows error state', () => { + expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.'); + expect(findEmptyState().props('description')).toBe( + 'Try again in a few moments or contact your support team.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js new file mode 100644 index 00000000000..ea615d85c4b --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js @@ -0,0 +1,142 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import PipelineBranchNameToken from '~/ci/pipelines_page/tokens/pipeline_branch_name_token.vue'; +import { branches, mockBranchesAfterMap } from 'jest/ci/pipeline_details/mock_data'; + +describe('Pipeline Branch Name Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const getBranchSuggestions = () => + findAllFilteredSearchSuggestions().wrappers.map((w) => w.text()); + + const stubs = { + GlFilteredSearchToken: { + template: `<div><slot name="suggestions"></slot></div>`, + }, + }; + + const defaultProps = { + config: { + type: 'ref', + icon: 'branch', + title: 'Branch name', + unique: true, + projectId: '21', + defaultBranchName: null, + disabled: false, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const optionsWithDefaultBranchName = (options) => { + return { + propsData: { + ...defaultProps, + config: { + ...defaultProps.config, + defaultBranchName: 'main', + }, + }, + ...options, + }; + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineBranchNameToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project branches', () => { + expect(Api.branches).toHaveBeenCalled(); + + expect(wrapper.vm.branches).toEqual(mockBranchesAfterMap); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ stubs }, { loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ stubs }, { loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows branches correctly', () => { + it('renders all branches', () => { + createComponent({ stubs }, { branches, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length); + }); + + it('renders only the branch searched for', () => { + const mockBranches = ['main']; + createComponent({ stubs }, { branches: mockBranches, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length); + }); + + it('shows the default branch first if no branch was searched for', async () => { + const mockBranches = [{ name: 'branch-1' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false }); + await nextTick(); + expect(getBranchSuggestions()).toEqual(['main', 'branch-1']); + }); + + it('does not show the default branch if a search term was provided', async () => { + const mockBranches = [{ name: 'branch-1' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName(), { loading: false }); + + findFilteredSearchToken().vm.$emit('input', { data: 'branch-1' }); + await waitForPromises(); + expect(getBranchSuggestions()).toEqual(['branch-1']); + }); + + it('shows the default branch only once if it appears in the results', async () => { + const mockBranches = [{ name: 'main' }]; + jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches }); + + createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false }); + await nextTick(); + expect(getBranchSuggestions()).toEqual(['main']); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js new file mode 100644 index 00000000000..0ea2b641b33 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js @@ -0,0 +1,53 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipelines_page/tokens/constants'; +import { stubComponent } from 'helpers/stub_component'; +import PipelineSourceToken from '~/ci/pipelines_page/tokens/pipeline_source_token.vue'; + +describe('Pipeline Source Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + + const defaultProps = { + config: { + type: 'source', + icon: 'trigger-source', + title: 'Source', + unique: true, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = () => { + wrapper = shallowMount(PipelineSourceToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('shows sources correctly', () => { + it('renders all pipeline sources available', () => { + expect(findAllFilteredSearchSuggestions()).toHaveLength(PIPELINE_SOURCES.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js new file mode 100644 index 00000000000..b8f98666438 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js @@ -0,0 +1,58 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import PipelineStatusToken from '~/ci/pipelines_page/tokens/pipeline_status_token.vue'; +import { + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +describe('Pipeline Status Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findAllGlIcons = () => wrapper.findAllComponents(GlIcon); + + const defaultProps = { + config: { + type: TOKEN_TYPE_STATUS, + icon: 'status', + title: TOKEN_TITLE_STATUS, + unique: true, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = () => { + wrapper = shallowMount(PipelineStatusToken, { + propsData: { + ...defaultProps, + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + describe('shows statuses correctly', () => { + it('renders all pipeline statuses available', () => { + expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.statuses.length); + expect(findAllGlIcons()).toHaveLength(wrapper.vm.statuses.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js new file mode 100644 index 00000000000..d23d9f07df3 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js @@ -0,0 +1,95 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Api from '~/api'; +import PipelineTagNameToken from '~/ci/pipelines_page/tokens/pipeline_tag_name_token.vue'; +import { tags, mockTagsAfterMap } from 'jest/ci/pipeline_details/mock_data'; + +describe('Pipeline Branch Name Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const stubs = { + GlFilteredSearchToken: { + template: `<div><slot name="suggestions"></slot></div>`, + }, + }; + + const defaultProps = { + config: { + type: 'tag', + icon: 'tag', + title: 'Tag name', + unique: true, + projectId: '21', + disabled: false, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = (options, data) => { + wrapper = shallowMount(PipelineTagNameToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags }); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project tags', () => { + expect(Api.tags).toHaveBeenCalled(); + + expect(wrapper.vm.tags).toEqual(mockTagsAfterMap); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ stubs }, { loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ stubs }, { loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows tags correctly', () => { + it('renders all tags', () => { + createComponent({ stubs }, { tags, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(tags.length); + }); + + it('renders only the tag searched for', () => { + const mockTags = ['main-tag']; + createComponent({ stubs }, { tags: mockTags, loading: false }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length); + }); + }); +}); diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js new file mode 100644 index 00000000000..eccb90b0c94 --- /dev/null +++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js @@ -0,0 +1,99 @@ +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; +import Api from '~/api'; +import PipelineTriggerAuthorToken from '~/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue'; +import { users } from 'jest/ci/pipeline_details/mock_data'; + +describe('Pipeline Trigger Author Token', () => { + let wrapper; + + const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + const defaultProps = { + config: { + type: 'username', + icon: 'user', + title: 'Trigger author', + dataType: 'username', + unique: true, + triggerAuthors: users, + }, + value: { + data: '', + }, + cursorPosition: 'start', + }; + + const createComponent = (data) => { + wrapper = shallowMount(PipelineTriggerAuthorToken, { + propsData: { + ...defaultProps, + }, + data() { + return { + ...data, + }; + }, + stubs: { + GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, { + template: `<div><slot name="suggestions"></slot></div>`, + }), + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); + + createComponent(); + }); + + it('passes config correctly', () => { + expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config); + }); + + it('fetches and sets project users', () => { + expect(Api.projectUsers).toHaveBeenCalled(); + + expect(wrapper.vm.users).toEqual(users); + expect(findLoadingIcon().exists()).toBe(false); + }); + + describe('displays loading icon correctly', () => { + it('shows loading icon', () => { + createComponent({ loading: true }); + + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not show loading icon', () => { + createComponent({ loading: false }); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('shows trigger authors correctly', () => { + beforeEach(() => {}); + + it('renders all trigger authors', () => { + createComponent({ users, loading: false }); + + // should have length of all users plus the static 'Any' option + expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1); + }); + + it('renders only the trigger author searched for', () => { + createComponent({ + users: [{ name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }], + loading: false, + }); + + expect(findAllFilteredSearchSuggestions()).toHaveLength(2); + }); + }); +}); |