diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-02-18 10:34:06 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-02-18 10:34:06 +0000 |
commit | 859a6fb938bb9ee2a317c46dfa4fcc1af49608f0 (patch) | |
tree | d7f2700abe6b4ffcb2dcfc80631b2d87d0609239 /spec/frontend/pipelines | |
parent | 446d496a6d000c73a304be52587cd9bbc7493136 (diff) | |
download | gitlab-ce-859a6fb938bb9ee2a317c46dfa4fcc1af49608f0.tar.gz |
Add latest changes from gitlab-org/gitlab@13-9-stable-eev13.9.0-rc42
Diffstat (limited to 'spec/frontend/pipelines')
39 files changed, 1459 insertions, 900 deletions
diff --git a/spec/frontend/pipelines/blank_state_spec.js b/spec/frontend/pipelines/blank_state_spec.js index c09d9232569..5dcf3d267ed 100644 --- a/spec/frontend/pipelines/blank_state_spec.js +++ b/spec/frontend/pipelines/blank_state_spec.js @@ -1,25 +1,20 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/pipelines/components/pipelines_list/blank_state.vue'; +import { getByText } from '@testing-library/dom'; +import { mount } from '@vue/test-utils'; +import BlankState from '~/pipelines/components/pipelines_list/blank_state.vue'; describe('Pipelines Blank State', () => { - let vm; - let Component; - - beforeEach(() => { - Component = Vue.extend(component); - - vm = mountComponent(Component, { + const wrapper = mount(BlankState, { + propsData: { svgPath: 'foo', message: 'Blank State', - }); + }, }); it('should render svg', () => { - expect(vm.$el.querySelector('.svg-content img').getAttribute('src')).toEqual('foo'); + expect(wrapper.find('.svg-content img').attributes('src')).toEqual('foo'); }); it('should render message', () => { - expect(vm.$el.querySelector('h4').textContent.trim()).toEqual('Blank State'); + expect(getByText(wrapper.element, /Blank State/i)).toBeTruthy(); }); }); diff --git a/spec/frontend/pipelines/components/dag/dag_annotations_spec.js b/spec/frontend/pipelines/components/dag/dag_annotations_spec.js index 80807c0b330..1941a7f2777 100644 --- a/spec/frontend/pipelines/components/dag/dag_annotations_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_annotations_spec.js @@ -1,5 +1,5 @@ -import { shallowMount, mount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; import { singleNote, multiNote } from './mock_data'; diff --git a/spec/frontend/pipelines/components/dag/dag_graph_spec.js b/spec/frontend/pipelines/components/dag/dag_graph_spec.js index ccfb2ae7cee..4619548d1bb 100644 --- a/spec/frontend/pipelines/components/dag/dag_graph_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_graph_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; -import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants'; -import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions'; +import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import { createSankey } from '~/pipelines/components/dag/drawing_utils'; +import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions'; import { removeOrphanNodes } from '~/pipelines/components/parsing_utils'; import { parsedData } from './mock_data'; diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js index f6195e30e44..14030930657 100644 --- a/spec/frontend/pipelines/components/dag/dag_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_spec.js @@ -1,10 +1,10 @@ -import { mount, shallowMount } from '@vue/test-utils'; import { GlAlert, GlEmptyState } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/pipelines/components/dag/constants'; import Dag from '~/pipelines/components/dag/dag.vue'; -import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; +import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; -import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/pipelines/components/dag/constants'; import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/pipelines/constants'; import { mockParsedGraphQLNodes, diff --git a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js index 5d3f680a57c..84ff83883b7 100644 --- a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js +++ b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js @@ -1,3 +1,4 @@ +import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { createNodeDict, makeLinksFromNodes, @@ -7,7 +8,6 @@ import { getMaxNodes, } from '~/pipelines/components/parsing_utils'; -import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { mockParsedGraphQLNodes } from './mock_data'; describe('DAG visualization parsing utilities', () => { diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index 00fe9e784b3..e43aa2a02f5 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -1,6 +1,6 @@ +import { GlFilteredSearch } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { GlFilteredSearch } from '@gitlab/ui'; import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import PipelinesFilteredSearch from '~/pipelines/components/pipelines_list/pipelines_filtered_search.vue'; diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 7e42a3b5ae9..3ebedc9ac87 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,7 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { withGonExperiment } from 'helpers/experimentation_helper'; import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; -import Tracking from '~/tracking'; describe('Pipelines Empty State', () => { let wrapper; @@ -40,104 +38,15 @@ describe('Pipelines Empty State', () => { expect(findGetStartedButton().attributes('href')).toBe('foo'); }); - describe('when in control group', () => { - it('should render empty state information', () => { - expect(findInfoText()).toContain( - 'Continuous Integration can help catch bugs by running your tests automatically', - 'while Continuous Deployment can help you deliver code to your product environment', - ); - }); - - it('should render a button', () => { - expect(findGetStartedButton().text()).toBe('Get started with Pipelines'); - }); - }); - - describe('when in experiment group', () => { - withGonExperiment('pipelinesEmptyState'); - - beforeEach(() => { - createWrapper(); - }); - - it('should render empty state information', () => { - expect(findInfoText()).toContain( - 'GitLab CI/CD can automatically build, test, and deploy your code. Let GitLab take care of time', - 'consuming tasks, so you can spend more time creating', - ); - }); - - it('should render button text', () => { - expect(findGetStartedButton().text()).toBe('Get started with CI/CD'); - }); + it('should render empty state information', () => { + expect(findInfoText()).toContain( + 'GitLab CI/CD can automatically build, test, and deploy your code. Let GitLab take care of time', + 'consuming tasks, so you can spend more time creating', + ); }); - describe('tracking', () => { - let origGon; - - describe('when data is set', () => { - beforeEach(() => { - jest.spyOn(Tracking, 'event').mockImplementation(() => {}); - origGon = window.gon; - - window.gon = { - tracking_data: { - category: 'Growth::Activation::Experiment::PipelinesEmptyState', - value: 1, - property: 'experimental_group', - label: 'label', - }, - }; - createWrapper(); - }); - - afterEach(() => { - window.gon = origGon; - }); - - it('tracks when mounted', () => { - expect(Tracking.event).toHaveBeenCalledWith( - 'Growth::Activation::Experiment::PipelinesEmptyState', - 'viewed', - { - value: 1, - label: 'label', - property: 'experimental_group', - }, - ); - }); - - it('tracks when button is clicked', () => { - findGetStartedButton().vm.$emit('click'); - - expect(Tracking.event).toHaveBeenCalledWith( - 'Growth::Activation::Experiment::PipelinesEmptyState', - 'documentation_clicked', - { - value: 1, - label: 'label', - property: 'experimental_group', - }, - ); - }); - }); - - describe('when no data is defined', () => { - beforeEach(() => { - jest.spyOn(Tracking, 'event').mockImplementation(() => {}); - - createWrapper(); - }); - - it('does not track on view', () => { - expect(Tracking.event).not.toHaveBeenCalled(); - }); - - it('does not track when button is clicked', () => { - findGetStartedButton().vm.$emit('click'); - expect(Tracking.event).not.toHaveBeenCalled(); - }); - }); + it('should render button text', () => { + expect(findGetStartedButton().text()).toBe('Get started with CI/CD'); }); }); }); diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js index 95d96e127c6..6a7018fa1e5 100644 --- a/spec/frontend/pipelines/graph/action_component_spec.js +++ b/spec/frontend/pipelines/graph/action_component_spec.js @@ -1,5 +1,5 @@ -import { mount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; diff --git a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js index 840b1f8baf5..a955572a481 100644 --- a/spec/frontend/pipelines/graph/graph_component_legacy_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_legacy_spec.js @@ -1,14 +1,14 @@ -import { nextTick } from 'vue'; -import { mount } from '@vue/test-utils'; import { GlLoadingIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { setHTMLFixture } from 'helpers/fixtures'; -import PipelineStore from '~/pipelines/stores/pipeline_store'; import GraphComponentLegacy from '~/pipelines/components/graph/graph_component_legacy.vue'; -import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue'; import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; -import graphJSON from './mock_data_legacy'; -import linkedPipelineJSON from './linked_pipelines_mock_data'; +import StageColumnComponentLegacy from '~/pipelines/components/graph/stage_column_component_legacy.vue'; import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; +import PipelineStore from '~/pipelines/stores/pipeline_store'; +import linkedPipelineJSON from './linked_pipelines_mock_data'; +import graphJSON from './mock_data_legacy'; describe('graph component', () => { let store; diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index cfc3b7af282..3e8d4ba314c 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,10 +1,10 @@ import { mount, shallowMount } from '@vue/test-utils'; +import { GRAPHQL } from '~/pipelines/components/graph/constants'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; -import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import JobItem from '~/pipelines/components/graph/job_item.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; +import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; -import { GRAPHQL } from '~/pipelines/components/graph/constants'; import { generateResponse, mockPipelineResponse, @@ -22,6 +22,13 @@ describe('graph component', () => { pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), }; + const defaultData = { + measurements: { + width: 800, + height: 800, + }, + }; + const createComponent = ({ data = {}, mountFn = shallowMount, @@ -34,7 +41,10 @@ describe('graph component', () => { ...props, }, data() { - return { ...data }; + return { + ...defaultData, + ...data, + }; }, provide: { dataMethod: GRAPHQL, diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 54593c527cb..202365ecd35 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -1,11 +1,11 @@ +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { shallowMount } from '@vue/test-utils'; -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; -import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; +import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue'; import { mockPipelineResponse } from './mock_data'; const defaultProvide = { diff --git a/spec/frontend/pipelines/graph/job_name_component_spec.js b/spec/frontend/pipelines/graph/job_name_component_spec.js index f0aa646b8d7..658b5be87d4 100644 --- a/spec/frontend/pipelines/graph/job_name_component_spec.js +++ b/spec/frontend/pipelines/graph/job_name_component_spec.js @@ -1,7 +1,6 @@ import { mount } from '@vue/test-utils'; -import ciIcon from '~/vue_shared/components/ci_icon.vue'; - import jobNameComponent from '~/pipelines/components/graph/job_name_component.vue'; +import ciIcon from '~/vue_shared/components/ci_icon.vue'; describe('job name component', () => { let wrapper; diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index fb005d628a9..96f2cd1e371 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -1,9 +1,10 @@ -import { mount } from '@vue/test-utils'; import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; import mockData from './linked_pipelines_mock_data'; -import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants'; const mockPipeline = mockData.triggered[0]; const validTriggeredPipelineId = mockPipeline.project.id; @@ -212,11 +213,11 @@ describe('Linked pipeline', () => { expect(wrapper.emitted().pipelineClicked).toBeTruthy(); }); - it('should emit `bv::hide::tooltip` to close the tooltip', () => { + it(`should emit ${BV_HIDE_TOOLTIP} to close the tooltip`, () => { jest.spyOn(wrapper.vm.$root, '$emit'); findButton().trigger('click'); - expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual(['bv::hide::tooltip']); + expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual([BV_HIDE_TOOLTIP]); }); it('should emit downstreamHovered with job name on mouseover', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js index b6c700c65d2..200e3f48401 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_legacy_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; -import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; import { UPSTREAM } from '~/pipelines/components/graph/constants'; +import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; +import LinkedPipelinesColumnLegacy from '~/pipelines/components/graph/linked_pipelines_column_legacy.vue'; import mockData from './linked_pipelines_mock_data'; describe('Linked Pipelines Column', () => { diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js index 6db152f2607..8f01accccc1 100644 --- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js @@ -1,11 +1,11 @@ -import VueApollo from 'vue-apollo'; import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import { DOWNSTREAM, GRAPHQL, UPSTREAM } from '~/pipelines/components/graph/constants'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; -import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; -import { DOWNSTREAM, GRAPHQL } from '~/pipelines/components/graph/constants'; +import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import { LOAD_FAILURE } from '~/pipelines/constants'; import { mockPipelineResponse, @@ -17,7 +17,7 @@ const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse); describe('Linked Pipelines Column', () => { const defaultProps = { - columnTitle: 'Upstream', + columnTitle: 'Downstream', linkedPipelines: processedPipeline.downstream, type: DOWNSTREAM, }; @@ -45,14 +45,15 @@ describe('Linked Pipelines Column', () => { }); }; - const createComponentWithApollo = ( + const createComponentWithApollo = ({ mountFn = shallowMount, getPipelineDetailsHandler = jest.fn().mockResolvedValue(wrappedPipelineReturn), - ) => { + props = {}, + } = {}) => { const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]]; const apolloProvider = createMockApollo(requestHandlers); - createComponent({ apolloProvider, mountFn }); + createComponent({ apolloProvider, mountFn, props }); }; afterEach(() => { @@ -86,34 +87,90 @@ describe('Linked Pipelines Column', () => { await wrapper.vm.$nextTick(); }; - describe('when successful', () => { - beforeEach(() => { - createComponentWithApollo(mount); + describe('downstream', () => { + describe('when successful', () => { + beforeEach(() => { + createComponentWithApollo({ mountFn: mount }); + }); + + it('toggles the pipeline visibility', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().exists()).toBe(true); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); }); - it('toggles the pipeline visibility', async () => { - expect(findPipelineGraph().exists()).toBe(false); - await clickExpandButtonAndAwaitTimers(); - expect(findPipelineGraph().exists()).toBe(true); - await clickExpandButton(); - expect(findPipelineGraph().exists()).toBe(false); + describe('on error', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + }); + + it('emits the error', async () => { + await clickExpandButton(); + expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]); + }); + + it('does not show the pipeline', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().exists()).toBe(false); + }); }); }); - describe('on error', () => { - beforeEach(() => { - createComponentWithApollo(mount, jest.fn().mockRejectedValue(new Error('GraphQL error'))); - }); - - it('emits the error', async () => { - await clickExpandButton(); - expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]); + describe('upstream', () => { + const upstreamProps = { + columnTitle: 'Upstream', + /* + Because the IDs need to match to work, rather + than make new mock data, we are representing + the upstream pipeline with the downstream data. + */ + linkedPipelines: processedPipeline.downstream, + type: UPSTREAM, + }; + + describe('when successful', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + props: upstreamProps, + }); + }); + + it('toggles the pipeline visibility', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().exists()).toBe(true); + await clickExpandButton(); + expect(findPipelineGraph().exists()).toBe(false); + }); }); - it('does not show the pipeline', async () => { - expect(findPipelineGraph().exists()).toBe(false); - await clickExpandButtonAndAwaitTimers(); - expect(findPipelineGraph().exists()).toBe(false); + describe('on error', () => { + beforeEach(() => { + createComponentWithApollo({ + mountFn: mount, + getPipelineDetailsHandler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + props: upstreamProps, + }); + }); + + it('emits the error', async () => { + await clickExpandButton(); + expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]); + }); + + it('does not show the pipeline', async () => { + expect(findPipelineGraph().exists()).toBe(false); + await clickExpandButtonAndAwaitTimers(); + expect(findPipelineGraph().exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js index 202e25ccda3..16dc70a63a5 100644 --- a/spec/frontend/pipelines/graph/stage_column_component_spec.js +++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js @@ -68,6 +68,10 @@ describe('stage column component', () => { it('should render the provided groups', () => { expect(findAllStageColumnGroups().length).toBe(mockGroups.length); }); + + it('should emit updateMeasurements event on mount', () => { + expect(wrapper.emitted().updateMeasurements).toHaveLength(1); + }); }); describe('when job notifies action is complete', () => { diff --git a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap new file mode 100644 index 00000000000..cf2b66dea5f --- /dev/null +++ b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Links Inner component with a large number of needs matches snapshot and has expected path 1`] = ` +"<div class=\\"gl-display-flex gl-relative\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> + <path d=\\"M202,118L42,118C72,118,72,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M202,118L52,118C82,118,82,148,112,148\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M222,138L62,138C92,138,92,158,122,158\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M212,128L72,128C102,128,102,168,132,168\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M232,148L82,148C112,148,112,178,142,178\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + </svg> </div>" +`; + +exports[`Links Inner component with a parallel need matches snapshot and has expected path 1`] = ` +"<div class=\\"gl-display-flex gl-relative\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> + <path d=\\"M192,108L22,108C52,108,52,118,82,118\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + </svg> </div>" +`; + +exports[`Links Inner component with one need matches snapshot and has expected path 1`] = ` +"<div class=\\"gl-display-flex gl-relative\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> + <path d=\\"M202,118L42,118C72,118,72,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + </svg> </div>" +`; diff --git a/spec/frontend/pipelines/graph_shared/links_inner_spec.js b/spec/frontend/pipelines/graph_shared/links_inner_spec.js new file mode 100644 index 00000000000..6cabe2bc8a7 --- /dev/null +++ b/spec/frontend/pipelines/graph_shared/links_inner_spec.js @@ -0,0 +1,197 @@ +import { shallowMount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; +import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; +import { createJobsHash } from '~/pipelines/utils'; +import { + jobRect, + largePipelineData, + parallelNeedData, + pipelineData, + pipelineDataWithNoNeeds, + rootRect, +} from '../pipeline_graph/mock_data'; + +describe('Links Inner component', () => { + const containerId = 'pipeline-graph-container'; + const defaultProps = { + containerId, + containerMeasurements: { width: 1019, height: 445 }, + pipelineId: 1, + pipelineData: [], + }; + let wrapper; + + const createComponent = (props) => { + wrapper = shallowMount(LinksInner, { + propsData: { ...defaultProps, ...props }, + }); + }; + + const findLinkSvg = () => wrapper.find('#link-svg'); + const findAllLinksPath = () => findLinkSvg().findAll('path'); + + // We create fixture so that each job has an empty div that represent + // the JobPill in the DOM. Each `JobPill` would have different coordinates, + // so we increment their coordinates on each iteration to simulat different positions. + const setFixtures = ({ stages }) => { + const jobs = createJobsHash(stages); + const arrayOfJobs = Object.keys(jobs); + + const linksHtmlElements = arrayOfJobs.map((job) => { + return `<div id=${job}-${defaultProps.pipelineId} />`; + }); + + setHTMLFixture(`<div id="${containerId}">${linksHtmlElements.join(' ')}</div>`); + + // We are mocking the clientRect data of each job and the container ID. + jest + .spyOn(document.getElementById(containerId), 'getBoundingClientRect') + .mockImplementation(() => rootRect); + + arrayOfJobs.forEach((job, index) => { + jest + .spyOn( + document.getElementById(`${job}-${defaultProps.pipelineId}`), + 'getBoundingClientRect', + ) + .mockImplementation(() => { + const newValue = 10 * index; + const { left, right, top, bottom, x, y } = jobRect; + return { + ...jobRect, + left: left + newValue, + right: right + newValue, + top: top + newValue, + bottom: bottom + newValue, + x: x + newValue, + y: y + newValue, + }; + }); + }); + }; + + afterEach(() => { + jest.restoreAllMocks(); + wrapper.destroy(); + wrapper = null; + }); + + describe('basic SVG creation', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders an SVG of the right size', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findLinkSvg().attributes('width')).toBe( + `${defaultProps.containerMeasurements.width}px`, + ); + expect(findLinkSvg().attributes('height')).toBe( + `${defaultProps.containerMeasurements.height}px`, + ); + }); + }); + + describe('no pipeline data', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the component', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findAllLinksPath()).toHaveLength(0); + }); + }); + + describe('pipeline data with no needs', () => { + beforeEach(() => { + createComponent({ pipelineData: pipelineDataWithNoNeeds.stages }); + }); + + it('renders no links', () => { + expect(findLinkSvg().exists()).toBe(true); + expect(findAllLinksPath()).toHaveLength(0); + }); + }); + + describe('with one need', () => { + beforeEach(() => { + setFixtures(pipelineData); + createComponent({ pipelineData: pipelineData.stages }); + }); + + it('renders one link', () => { + expect(findAllLinksPath()).toHaveLength(1); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with a parallel need', () => { + beforeEach(() => { + setFixtures(parallelNeedData); + createComponent({ pipelineData: parallelNeedData.stages }); + }); + + it('renders only one link for all the same parallel jobs', () => { + expect(findAllLinksPath()).toHaveLength(1); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('with a large number of needs', () => { + beforeEach(() => { + setFixtures(largePipelineData); + createComponent({ pipelineData: largePipelineData.stages }); + }); + + it('renders the correct number of links', () => { + expect(findAllLinksPath()).toHaveLength(5); + }); + + it('path does not contain NaN values', () => { + expect(wrapper.html()).not.toContain('NaN'); + }); + + it('matches snapshot and has expected path', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + }); + + describe('interactions', () => { + beforeEach(() => { + setFixtures(largePipelineData); + createComponent({ pipelineData: largePipelineData.stages }); + }); + + it('highlight needs on hover', async () => { + const firstLink = findAllLinksPath().at(0); + + const defaultColorClass = 'gl-stroke-gray-200'; + const hoverColorClass = 'gl-stroke-blue-400'; + + expect(firstLink.classes(defaultColorClass)).toBe(true); + expect(firstLink.classes(hoverColorClass)).toBe(false); + + // Because there is a watcher, we need to set the props after the component + // has mounted. + await wrapper.setProps({ highlightedJob: 'test_1' }); + + expect(firstLink.classes(defaultColorClass)).toBe(false); + expect(firstLink.classes(hoverColorClass)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pipelines/shared/links_layer_spec.js b/spec/frontend/pipelines/graph_shared/links_layer_spec.js index 9ef5233dbce..0ff8583fbff 100644 --- a/spec/frontend/pipelines/shared/links_layer_spec.js +++ b/spec/frontend/pipelines/graph_shared/links_layer_spec.js @@ -1,7 +1,7 @@ -import { mount, shallowMount } from '@vue/test-utils'; import { GlAlert, GlButton } from '@gitlab/ui'; -import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; +import { mount, shallowMount } from '@vue/test-utils'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; +import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import { generateResponse, mockPipelineResponse } from '../graph/mock_data'; describe('links layer component', () => { diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index 03e385e3cc8..57d846c53c8 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -1,15 +1,15 @@ -import { shallowMount } from '@vue/test-utils'; import { GlModal, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import HeaderComponent from '~/pipelines/components/header_component.vue'; +import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; +import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql'; +import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; import { mockCancelledPipelineHeader, mockFailedPipelineHeader, mockRunningPipelineHeader, mockSuccessfulPipelineHeader, } from './mock_data'; -import HeaderComponent from '~/pipelines/components/header_component.vue'; -import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql'; -import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; -import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; describe('Pipeline details header', () => { let wrapper; diff --git a/spec/frontend/pipelines/legacy_header_component_spec.js b/spec/frontend/pipelines/legacy_header_component_spec.js deleted file mode 100644 index fb7feb8898a..00000000000 --- a/spec/frontend/pipelines/legacy_header_component_spec.js +++ /dev/null @@ -1,116 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; -import LegacyHeaderComponent from '~/pipelines/components/legacy_header_component.vue'; -import CiHeader from '~/vue_shared/components/header_ci_component.vue'; -import eventHub from '~/pipelines/event_hub'; - -describe('Pipeline details header', () => { - let wrapper; - let glModalDirective; - - const threeWeeksAgo = new Date(); - threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); - - const findDeleteModal = () => wrapper.find(GlModal); - - const defaultProps = { - pipeline: { - details: { - status: { - group: 'failed', - icon: 'status_failed', - label: 'failed', - text: 'failed', - details_path: 'path', - }, - }, - id: 123, - created_at: threeWeeksAgo.toISOString(), - user: { - web_url: 'path', - name: 'Foo', - username: 'foobar', - email: 'foo@bar.com', - avatar_url: 'link', - }, - retry_path: 'retry', - cancel_path: 'cancel', - delete_path: 'delete', - }, - isLoading: false, - }; - - const createComponent = (props = {}) => { - glModalDirective = jest.fn(); - - wrapper = shallowMount(LegacyHeaderComponent, { - propsData: { - ...props, - }, - directives: { - glModal: { - bind(el, { value }) { - glModalDirective(value); - }, - }, - }, - }); - }; - - beforeEach(() => { - jest.spyOn(eventHub, '$emit'); - - createComponent(defaultProps); - }); - - afterEach(() => { - eventHub.$off(); - - wrapper.destroy(); - wrapper = null; - }); - - it('should render provided pipeline info', () => { - expect(wrapper.find(CiHeader).props()).toMatchObject({ - status: defaultProps.pipeline.details.status, - itemId: defaultProps.pipeline.id, - time: defaultProps.pipeline.created_at, - user: defaultProps.pipeline.user, - }); - }); - - describe('action buttons', () => { - it('should not trigger eventHub when nothing happens', () => { - expect(eventHub.$emit).not.toHaveBeenCalled(); - }); - - it('should call postAction when retry button action is clicked', () => { - wrapper.find('[data-testid="retryButton"]').vm.$emit('click'); - - expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry'); - }); - - it('should call postAction when cancel button action is clicked', () => { - wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click'); - - expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel'); - }); - - it('does not show delete modal', () => { - expect(findDeleteModal()).not.toBeVisible(); - }); - - describe('when delete button action is clicked', () => { - it('displays delete modal', () => { - expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID); - expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID); - }); - - it('should call delete when modal is submitted', () => { - findDeleteModal().vm.$emit('ok'); - - expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete'); - }); - }); - }); -}); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index 7d1a7a79c7f..339aac9f349 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -1,5 +1,3 @@ -import { createUniqueLinkId } from '~/pipelines/components/graph_shared/drawing_utils'; - export const yamlString = `stages: - empty - build @@ -41,10 +39,28 @@ deploy_a: script: echo hello `; -const jobId1 = createUniqueLinkId('build', 'build_1'); -const jobId2 = createUniqueLinkId('test', 'test_1'); -const jobId3 = createUniqueLinkId('test', 'test_2'); -const jobId4 = createUniqueLinkId('deploy', 'deploy_1'); +export const pipelineDataWithNoNeeds = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + ], + }, + ], +}; export const pipelineData = { stages: [ @@ -54,7 +70,6 @@ export const pipelineData = { { name: 'build_1', jobs: [{ script: 'echo hello', stage: 'build' }], - id: jobId1, }, ], }, @@ -64,12 +79,10 @@ export const pipelineData = { { name: 'test_1', jobs: [{ script: 'yarn test', stage: 'test' }], - id: jobId2, }, { name: 'test_2', jobs: [{ script: 'yarn karma', stage: 'test' }], - id: jobId3, }, ], }, @@ -79,7 +92,86 @@ export const pipelineData = { { name: 'deploy_1', jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_1'] }], - id: jobId4, + }, + ], + }, + ], +}; + +export const parallelNeedData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + parallel: 3, + jobs: [ + { script: 'echo hello', stage: 'build', name: 'build_1 1/3' }, + { script: 'echo hello', stage: 'build', name: 'build_1 2/3' }, + { script: 'echo hello', stage: 'build', name: 'build_1 3/3' }, + ], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test', needs: ['build_1'] }], + }, + ], + }, + ], +}; + +export const largePipelineData = { + stages: [ + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + { + name: 'build_2', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + { + name: 'build_3', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test', needs: ['build_2'] }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test', needs: ['build_2'] }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_1'] }], + }, + { + name: 'deploy_2', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['build_3'] }], + }, + { + name: 'deploy_3', + jobs: [{ script: 'yarn magick', stage: 'deploy', needs: ['test_2'] }], }, ], }, @@ -94,9 +186,30 @@ export const singleStageData = { { name: 'build_1', jobs: [{ script: 'echo hello', stage: 'build' }], - id: jobId1, }, ], }, ], }; + +export const rootRect = { + bottom: 463, + height: 271, + left: 236, + right: 1252, + top: 192, + width: 1016, + x: 236, + y: 192, +}; + +export const jobRect = { + bottom: 312, + height: 24, + left: 308, + right: 428, + top: 288, + width: 120, + x: 308, + y: 288, +}; diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index b6b0a964383..718667fcc73 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -1,11 +1,11 @@ -import { shallowMount } from '@vue/test-utils'; import { GlAlert } from '@gitlab/ui'; -import { pipelineData, singleStageData } from './mock_data'; +import { shallowMount } from '@vue/test-utils'; import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; -import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants'; +import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; -import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; +import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants'; +import { pipelineData, singleStageData } from './mock_data'; describe('pipeline graph component', () => { const defaultProps = { pipelineData }; diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js index ad8136890e6..467a97d95c7 100644 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import pipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; describe('Pipelines Triggerer', () => { let wrapper; diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js index 47315bd42e6..44c9def99cc 100644 --- a/spec/frontend/pipelines/pipeline_url_spec.js +++ b/spec/frontend/pipelines/pipeline_url_spec.js @@ -1,6 +1,6 @@ +import { shallowMount } from '@vue/test-utils'; import $ from 'jquery'; import { trimText } from 'helpers/text_helper'; -import { shallowMount } from '@vue/test-utils'; import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue'; $.fn.popover = () => {}; @@ -17,6 +17,7 @@ describe('Pipeline Url Component', () => { const findStuckTag = () => wrapper.find('[data-testid="pipeline-url-stuck"]'); const findDetachedTag = () => wrapper.find('[data-testid="pipeline-url-detached"]'); const findForkTag = () => wrapper.find('[data-testid="pipeline-url-fork"]'); + const findTrainTag = () => wrapper.find('[data-testid="pipeline-url-train"]'); const defaultProps = { pipeline: { @@ -141,6 +142,7 @@ describe('Pipeline Url Component', () => { expect(findScheduledTag().exists()).toBe(true); expect(findScheduledTag().text()).toContain('Scheduled'); }); + it('should render the fork badge when the pipeline was run in a fork', () => { createComponent({ pipeline: { @@ -152,4 +154,28 @@ describe('Pipeline Url Component', () => { expect(findForkTag().exists()).toBe(true); expect(findForkTag().text()).toBe('fork'); }); + + it('should render the train badge when the pipeline is a merge train pipeline', () => { + createComponent({ + pipeline: { + flags: { + merge_train_pipeline: true, + }, + }, + }); + + expect(findTrainTag().text()).toContain('train'); + }); + + it('should not render the train badge when the pipeline is not a merge train pipeline', () => { + createComponent({ + pipeline: { + flags: { + merge_train_pipeline: false, + }, + }, + }); + + expect(findTrainTag().exists()).toBe(false); + }); }); diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js index 69c1b7ce43d..1e6c9e50a7e 100644 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ b/spec/frontend/pipelines/pipelines_actions_spec.js @@ -1,25 +1,29 @@ -import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { TEST_HOST } from 'spec/test_constants'; -import { GlButton } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; +import { TEST_HOST } from 'spec/test_constants'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +jest.mock('~/flash'); + describe('Pipelines Actions dropdown', () => { let wrapper; let mock; - const createComponent = (actions = []) => { - wrapper = shallowMount(PipelinesActions, { + const createComponent = (props, mountFn = shallowMount) => { + wrapper = mountFn(PipelinesActions, { propsData: { - actions, + ...props, }, }); }; - const findAllDropdownItems = () => wrapper.findAll(GlButton); + const findDropdown = () => wrapper.find(GlDropdown); + const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); const findAllCountdowns = () => wrapper.findAll(GlCountdown); beforeEach(() => { @@ -47,7 +51,7 @@ describe('Pipelines Actions dropdown', () => { ]; beforeEach(() => { - createComponent(mockActions); + createComponent({ actions: mockActions }); }); it('renders a dropdown with the provided actions', () => { @@ -59,16 +63,33 @@ describe('Pipelines Actions dropdown', () => { }); describe('on click', () => { - it('makes a request and toggles the loading state', () => { + beforeEach(() => { + createComponent({ actions: mockActions }, mount); + }); + + it('makes a request and toggles the loading state', async () => { mock.onPost(mockActions.path).reply(200); - wrapper.find(GlButton).vm.$emit('click'); + findAllDropdownItems().at(0).vm.$emit('click'); + + await wrapper.vm.$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(mockActions.path).reply(500); - expect(wrapper.vm.isLoading).toBe(true); + findAllDropdownItems().at(0).vm.$emit('click'); - return waitForPromises().then(() => { - expect(wrapper.vm.isLoading).toBe(false); - }); + await wrapper.vm.$nextTick(); + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + expect(findDropdown().props('loading')).toBe(false); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); }); @@ -89,10 +110,10 @@ describe('Pipelines Actions dropdown', () => { beforeEach(() => { jest.spyOn(Date, 'now').mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); - createComponent([scheduledJobAction, expiredJobAction]); + createComponent({ actions: [scheduledJobAction, expiredJobAction] }); }); - it('makes post request after confirming', () => { + it('makes post request after confirming', async () => { mock.onPost(scheduledJobAction.path).reply(200); jest.spyOn(window, 'confirm').mockReturnValue(true); @@ -100,19 +121,22 @@ describe('Pipelines Actions dropdown', () => { expect(window.confirm).toHaveBeenCalled(); - return waitForPromises().then(() => { - expect(mock.history.post.length).toBe(1); - }); + await waitForPromises(); + + expect(mock.history.post).toHaveLength(1); }); - it('does not make post request if confirmation is cancelled', () => { + it('does not make post request if confirmation is cancelled', async () => { mock.onPost(scheduledJobAction.path).reply(200); jest.spyOn(window, 'confirm').mockReturnValue(false); findAllDropdownItems().at(0).vm.$emit('click'); expect(window.confirm).toHaveBeenCalled(); - expect(mock.history.post.length).toBe(0); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(0); }); it('displays the remaining time in the dropdown', () => { diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index 4f4c15fd4cc..f077833ae16 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -1,5 +1,5 @@ -import { mount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; describe('Pipelines Artifacts dropdown', () => { diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 5d82669b0b8..811303a5624 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -1,49 +1,50 @@ -import { nextTick } from 'vue'; +import { GlFilteredSearch, GlButton, GlLoadingIcon, GlPagination } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { chunk } from 'lodash'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { GlFilteredSearch, GlButton, GlLoadingIcon } from '@gitlab/ui'; import Api from '~/api'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; - -import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; -import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; import BlankState from '~/pipelines/components/pipelines_list/blank_state.vue'; -import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; - +import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; +import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; +import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; +import { RAW_TEXT_WARNING } from '~/pipelines/constants'; import Store from '~/pipelines/stores/pipelines_store'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; + import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data'; -import { RAW_TEXT_WARNING } from '~/pipelines/constants'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); -describe('Pipelines', () => { - const jsonFixtureName = 'pipelines/pipelines.json'; +const mockProjectPath = 'twitter/flight'; +const mockProjectId = '21'; +const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`; +const mockPipelinesResponse = getJSONFixture('pipelines/pipelines.json'); +const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id); - preloadFixtures(jsonFixtureName); - - let pipelines; +describe('Pipelines', () => { let wrapper; let mock; + let origWindowLocation; const paths = { - endpoint: 'twitter/flight/pipelines.json', autoDevopsHelpPath: '/help/topics/autodevops/index.md', helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg', noPipelinesSvgPath: '/assets/illustrations/pipelines_pending.svg', ciLintPath: '/ci/lint', - resetCachePath: '/twitter/flight/settings/ci_cd/reset_cache', - newPipelinePath: '/twitter/flight/pipelines/new', + resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`, + newPipelinePath: `${mockProjectPath}/pipelines/new`, }; const noPermissions = { - endpoint: 'twitter/flight/pipelines.json', autoDevopsHelpPath: '/help/topics/autodevops/index.md', helpPagePath: '/help/ci/quick_start/README', emptyStateSvgPath: '/assets/illustrations/pipelines_empty.svg', @@ -57,101 +58,140 @@ describe('Pipelines', () => { ...paths, }; - const findFilteredSearch = () => wrapper.find(GlFilteredSearch); - const findByTestId = (id) => wrapper.find(`[data-testid="${id}"]`); - const findNavigationTabs = () => wrapper.find(NavigationTabs); - const findNavigationControls = () => wrapper.find(NavigationControls); - const findTab = (tab) => findByTestId(`pipelines-tab-${tab}`); - - const findRunPipelineButton = () => findByTestId('run-pipeline-button'); - const findCiLintButton = () => findByTestId('ci-lint-button'); - const findCleanCacheButton = () => findByTestId('clear-cache-button'); - - const findEmptyState = () => wrapper.find(EmptyState); - const findBlankState = () => wrapper.find(BlankState); - const findStagesDropdown = () => wrapper.find('.js-builds-dropdown-button'); - - const findTablePagination = () => wrapper.find(TablePagination); + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findNavigationTabs = () => wrapper.findComponent(NavigationTabs); + const findNavigationControls = () => wrapper.findComponent(NavigationControls); + const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent); + const findEmptyState = () => wrapper.findComponent(EmptyState); + const findBlankState = () => wrapper.findComponent(BlankState); + const findTablePagination = () => wrapper.findComponent(TablePagination); + + const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`); + const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button'); + const findCiLintButton = () => wrapper.findByTestId('ci-lint-button'); + const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button'); + const findStagesDropdown = () => wrapper.findByTestId('mini-pipeline-graph-dropdown-toggle'); + const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]'); const createComponent = (props = defaultProps) => { - wrapper = mount(PipelinesComponent, { - propsData: { - store: new Store(), - projectId: '21', - params: {}, - ...props, - }, - }); + wrapper = extendedWrapper( + mount(PipelinesComponent, { + propsData: { + store: new Store(), + projectId: mockProjectId, + endpoint: mockPipelinesEndpoint, + params: {}, + ...props, + }, + }), + ); }; - beforeEach(() => { + beforeAll(() => { + origWindowLocation = window.location; delete window.location; + window.location = { search: '' }; + }); + + afterAll(() => { + window.location = origWindowLocation; }); beforeEach(() => { - window.location = { search: '' }; mock = new MockAdapter(axios); - pipelines = getJSONFixture(jsonFixtureName); + jest.spyOn(window.history, 'pushState'); jest.spyOn(Api, 'projectUsers').mockResolvedValue(users); jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches }); }); afterEach(() => { wrapper.destroy(); - mock.restore(); + mock.reset(); + window.history.pushState.mockReset(); }); - describe('With permission', () => { - describe('With pipelines in main tab', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); - createComponent(); - return waitForPromises(); - }); + describe('when pipelines are not yet loaded', () => { + beforeEach(async () => { + createComponent(); + await nextTick(); + }); - it('renders tabs', () => { - expect(findTab('all').text()).toContain('All'); - }); + it('shows loading state when the app is loading', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); - it('renders Run Pipeline link', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + 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(200, mockPipelinesResponse); + }); + + describe('when user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + await waitForPromises(); }); - it('renders CI Lint link', () => { - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + it('renders "All" tab with count different from "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 3'); }); - it('renders Clear Runner Cache button', () => { - expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); + 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 table', () => { - expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength( - pipelines.pipelines.length + 1, - ); + 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('Without pipelines on main tab with CI', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(200, { - pipelines: [], - count: { - all: 0, - pending: 0, - running: 0, - finished: 0, - }, - }); - + describe('when user has permissions', () => { + beforeEach(async () => { createComponent(); + await waitForPromises(); + }); - return 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 tabs', () => { - expect(findTab('all').text()).toContain('All'); + 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', () => { @@ -166,549 +206,513 @@ describe('Pipelines', () => { expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); }); - it('renders tab empty state', () => { - expect(findBlankState().text()).toBe('There are currently no pipelines.'); - }); - - it('renders tab empty state finished scope', () => { - wrapper.vm.scope = 'finished'; + it('renders pipelines in a table', () => { + expect(findPipelinesTable().exists()).toBe(true); - return nextTick().then(() => { - expect(findBlankState().text()).toBe('There are currently no finished pipelines.'); - }); + 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('Without pipelines nor CI', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(200, { - pipelines: [], - count: { - all: 0, - pending: 0, - running: 0, - finished: 0, - }, - }); - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + describe('when user goes to a tab', () => { + const goToTab = (tab) => { + findNavigationTabs().vm.$emit('onChangeTab', tab); + }; - return waitForPromises(); - }); + describe('when the scope in the tab has pipelines', () => { + const mockFinishedPipeline = mockPipelinesResponse.pipelines[0]; - it('renders empty state', () => { - expect(findEmptyState().find('h4').text()).toBe('Build with confidence'); - expect(findEmptyState().find(GlButton).attributes('href')).toBe(paths.helpPagePath); - }); + beforeEach(async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }) + .reply(200, { + pipelines: [mockFinishedPipeline], + count: mockPipelinesResponse.count, + }); - it('does not render tabs nor buttons', () => { - expect(findTab('all').exists()).toBe(false); - expect(findRunPipelineButton().exists()).toBeFalsy(); - expect(findCiLintButton().exists()).toBeFalsy(); - expect(findCleanCacheButton().exists()).toBeFalsy(); - }); - }); + goToTab('finished'); - describe('When API returns error', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(500, {}); - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + await waitForPromises(); + }); - return waitForPromises(); - }); + it('should filter pipelines', async () => { + expect(findPipelinesTable().exists()).toBe(true); - it('renders tabs', () => { - expect(findTab('all').text()).toContain('All'); - }); + expect(findPipelineUrlLinks()).toHaveLength(1); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFinishedPipeline.id}`); + }); - it('renders buttons', () => { - expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + 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`, + ); + }); + }); - expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); - expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); - }); + describe('when the scope in the tab is empty', () => { + beforeEach(async () => { + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'branches', page: '1' } }) + .reply(200, { + pipelines: [], + count: mockPipelinesResponse.count, + }); - it('renders error state', () => { - expect(findBlankState().text()).toContain('There was an error fetching the pipelines.'); - }); - }); - }); + goToTab('branches'); - describe('Without permission', () => { - describe('With pipelines in main tab', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + await waitForPromises(); + }); - createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + it('should filter pipelines', async () => { + expect(findBlankState().text()).toBe('There are currently no pipelines.'); + }); - return waitForPromises(); + 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`, + ); + }); + }); }); - it('renders tabs', () => { - expect(findTab('all').text()).toContain('All'); - }); + describe('when user triggers a filtered search', () => { + const mockFilteredPipeline = mockPipelinesResponse.pipelines[1]; - it('does not render buttons', () => { - expect(findRunPipelineButton().exists()).toBeFalsy(); - expect(findCiLintButton().exists()).toBeFalsy(); - expect(findCleanCacheButton().exists()).toBeFalsy(); - }); + let expectedParams; - it('renders pipelines table', () => { - expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength( - pipelines.pipelines.length + 1, - ); - }); - }); + beforeEach(async () => { + expectedParams = { + page: '1', + scope: 'all', + username: 'root', + ref: 'master', + status: 'pending', + }; + + mock + .onGet(mockPipelinesEndpoint, { + params: expectedParams, + }) + .replyOnce(200, { + pipelines: [mockFilteredPipeline], + count: mockPipelinesResponse.count, + }); - describe('Without pipelines on main tab with CI', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(200, { - pipelines: [], - count: { - all: 0, - pending: 0, - running: 0, - finished: 0, - }, + findFilteredSearch().vm.$emit('submit', mockSearch); + + await waitForPromises(); }); - createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); + it('requests data with query params on filter submit', async () => { + expect(mock.history.get[1].params).toEqual(expectedParams); + }); - return waitForPromises(); - }); + it('renders filtered pipelines', async () => { + expect(findPipelineUrlLinks()).toHaveLength(1); + expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`); + }); - it('renders tabs', () => { - expect(findTab('all').text()).toContain('All'); + 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=master&status=pending`, + ); + }); }); - it('does not render buttons', () => { - expect(findRunPipelineButton().exists()).toBeFalsy(); - expect(findCiLintButton().exists()).toBeFalsy(); - expect(findCleanCacheButton().exists()).toBeFalsy(); - }); + describe('when user triggers a filtered search with raw text', () => { + beforeEach(async () => { + findFilteredSearch().vm.$emit('submit', ['rawText']); - it('renders tab empty state', () => { - expect(wrapper.find('.empty-state h4').text()).toBe('There are currently no pipelines.'); - }); - }); + await waitForPromises(); + }); - describe('Without pipelines nor CI', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(200, { - pipelines: [], - count: { - all: 0, - pending: 0, - running: 0, - finished: 0, - }, + it('requests data with query params on filter submit', async () => { + expect(mock.history.get[1].params).toEqual({ page: '1', scope: 'all' }); }); - createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + it('displays a warning message if raw text search is used', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith(RAW_TEXT_WARNING, 'warning'); + }); - return waitForPromises(); + 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`, + ); + }); }); + }); + }); - it('renders empty state without button to set CI', () => { - expect(findEmptyState().text()).toBe( - 'This project is not currently set up to run pipelines.', - ); + 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().find(GlPagination).vm.$emit('input', page); + }; + + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }).reply( + 200, + { + pipelines: firstPage, + count: mockPipelinesResponse.count, + }, + mockPageHeaders({ page: 1 }), + ); + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '2' } }).reply( + 200, + { + pipelines: secondPage, + count: mockPipelinesResponse.count, + }, + mockPageHeaders({ page: 2 }), + ); - expect(findEmptyState().find(GlButton).exists()).toBeFalsy(); - }); + createComponent(); - it('does not render tabs or buttons', () => { - expect(findTab('all').exists()).toBe(false); - expect(findRunPipelineButton().exists()).toBeFalsy(); - expect(findCiLintButton().exists()).toBeFalsy(); - expect(findCleanCacheButton().exists()).toBeFalsy(); - }); + await waitForPromises(); }); - describe('When API returns error', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(500, {}); - - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); + 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}`); + }); - return waitForPromises(); - }); + it('should not update browser bar', () => { + expect(window.history.pushState).not.toHaveBeenCalled(); + }); - it('renders tabs', () => { - expect(findTab('all').text()).toContain('All'); + describe('when user goes to next page', () => { + beforeEach(async () => { + goToPage(2); + await waitForPromises(); }); - it('does not renders buttons', () => { - expect(findRunPipelineButton().exists()).toBeFalsy(); - expect(findCiLintButton().exists()).toBeFalsy(); - expect(findCleanCacheButton().exists()).toBeFalsy(); + 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('renders error state', () => { - expect(wrapper.find('.empty-state').text()).toContain( - 'There was an error fetching the pipelines.', + 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`, ); }); }); }); - describe('successful request', () => { - describe('with pipelines', () => { - beforeEach(() => { - mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + 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(200, emptyResponse, { + 'POLL-INTERVAL': 100, + }); + // Mock pipelines in the next attempt + mock + .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }) + .reply(200, mockPipelinesResponse, { + 'POLL-INTERVAL': 100, + }); + }); + + describe('data is loaded for the first time', () => { + beforeEach(async () => { createComponent(); - return waitForPromises(); + await waitForPromises(); }); - it('should render table', () => { - expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength( - pipelines.pipelines.length + 1, - ); + it('shows tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); }); - 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('should update page and keep scope the same scope', () => { + expect(findPipelineUrlLinks()).toHaveLength(0); }); - it('should render navigation tabs', () => { - expect(findTab('all').html()).toContain('All'); - expect(findTab('finished').text()).toContain('Finished'); - expect(findTab('branches').text()).toContain('Branches'); - expect(findTab('tags').text()).toContain('Tags'); - }); - - it('should make an API request when using tabs', () => { - createComponent({ hasGitlabCi: true, canCreatePipeline: true, ...paths }); - jest.spyOn(wrapper.vm.service, 'getPipelines'); - - return waitForPromises().then(() => { - findTab('finished').trigger('click'); - - expect(wrapper.vm.service.getPipelines).toHaveBeenCalledWith({ - scope: 'finished', - page: '1', - }); + describe('data is loaded for a second time', () => { + beforeEach(async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); }); - }); - - describe('with pagination', () => { - it('should make an API request when using pagination', () => { - createComponent({ hasGitlabCi: true, canCreatePipeline: true, ...paths }); - jest.spyOn(wrapper.vm.service, 'getPipelines'); - return waitForPromises() - .then(() => { - // Mock pagination - wrapper.vm.store.state.pageInfo = { - page: 1, - total: 10, - perPage: 2, - nextPage: 2, - totalPages: 5, - }; + it('shows tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + }); - return nextTick(); - }) - .then(() => { - wrapper.find('.next-page-item').trigger('click'); - expect(wrapper.vm.service.getPipelines).toHaveBeenCalledWith({ - scope: 'all', - page: '2', - }); - }); + it('is loading after a time', async () => { + 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('User Interaction', () => { - let updateContentMock; - + describe('when no pipelines exist', () => { beforeEach(() => { - jest.spyOn(window.history, 'pushState').mockImplementation(() => null); - }); - - beforeEach(() => { - mock.onGet(paths.endpoint).reply(200, pipelines); - createComponent(); - - updateContentMock = jest.spyOn(wrapper.vm, 'updateContent'); - - return waitForPromises(); + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }).reply(200, { + pipelines: [], + count: { all: '0' }, + }); }); - describe('when user changes tabs', () => { - it('should set page to 1', () => { - findNavigationTabs().vm.$emit('onChangeTab', 'running'); + describe('when CI is enabled and user has permissions', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); - expect(updateContentMock).toHaveBeenCalledWith({ scope: 'running', page: '1' }); + it('renders tab with count of "0"', () => { + expect(findNavigationTabs().exists()).toBe(true); + expect(findTab('all').text()).toMatchInterpolatedText('All 0'); }); - }); - describe('when user changes page', () => { - it('should update page and keep scope', () => { - findTablePagination().vm.change(4); + it('renders Run Pipeline link', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); + }); - expect(updateContentMock).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4' }); + it('renders CI Lint link', () => { + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); }); - }); - describe('updates results when a staged is clicked', () => { - beforeEach(() => { - const copyPipeline = { ...pipelineWithStages }; - copyPipeline.id += 1; - mock - .onGet('twitter/flight/pipelines.json') - .reply( - 200, - { - pipelines: [pipelineWithStages], - count: { - all: 1, - finished: 1, - pending: 0, - running: 0, - }, - }, - { - 'POLL-INTERVAL': 100, - }, - ) - .onGet(pipelineWithStages.details.stages[0].dropdown_path) - .reply(200, stageReply); + it('renders Clear Runner Cache button', () => { + expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); + }); - createComponent(); + it('renders empty state', () => { + expect(findBlankState().text()).toBe('There are currently no pipelines.'); }); - describe('when a request is being made', () => { - it('stops polling, cancels the request, & restarts polling', () => { - const stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); - const restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); - const cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel'); - mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); - - return waitForPromises() - .then(() => { - wrapper.vm.isMakingRequest = true; - findStagesDropdown().trigger('click'); - }) - .then(() => { - expect(cancelMock).toHaveBeenCalled(); - expect(stopMock).toHaveBeenCalled(); - expect(restartMock).toHaveBeenCalled(); - }); + it('renders tab empty state finished scope', async () => { + mock.onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } }).reply(200, { + pipelines: [], + count: { all: '0' }, }); - }); - describe('when no request is being made', () => { - it('stops polling & restarts polling', () => { - const stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); - const restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); - mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + findNavigationTabs().vm.$emit('onChangeTab', 'finished'); - return waitForPromises() - .then(() => { - findStagesDropdown().trigger('click'); - expect(stopMock).toHaveBeenCalled(); - }) - .then(() => { - expect(restartMock).toHaveBeenCalled(); - }); - }); - }); - }); - }); + await waitForPromises(); - describe('Rendered content', () => { - beforeEach(() => { - createComponent(); + expect(findBlankState().text()).toBe('There are currently no finished pipelines.'); + }); }); - describe('displays different content', () => { - it('shows loading state when the app is loading', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + describe('when CI is not enabled and user has permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); + await waitForPromises(); }); - it('shows error state when app has error', () => { - wrapper.vm.hasError = true; - wrapper.vm.isLoading = false; - - return nextTick().then(() => { - expect(findBlankState().props('message')).toBe( - 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', - ); - }); + it('renders empty state', () => { + expect(findEmptyState().find('[data-testid="header-text"]').text()).toBe( + 'Build with confidence', + ); + expect(findEmptyState().find('[data-testid="info-text"]').text()).toContain( + 'GitLab CI/CD can automatically build, test, and deploy your code.', + ); + expect(findEmptyState().find(GlButton).text()).toBe('Get started with CI/CD'); + expect(findEmptyState().find(GlButton).attributes('href')).toBe(paths.helpPagePath); }); - it('shows table list when app has pipelines', () => { - wrapper.vm.isLoading = false; - wrapper.vm.hasError = false; - wrapper.vm.state.pipelines = pipelines.pipelines; - - return nextTick().then(() => { - expect(wrapper.find(PipelinesTableComponent).exists()).toBe(true); - }); + 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); }); + }); - it('shows empty tab when app does not have pipelines but project has pipelines', () => { - wrapper.vm.state.count.all = 10; - wrapper.vm.isLoading = false; - - return nextTick().then(() => { - expect(findBlankState().exists()).toBe(true); - expect(findBlankState().props('message')).toBe('There are currently no pipelines.'); - }); + describe('when CI is not enabled and user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions }); + await waitForPromises(); }); - it('shows empty tab when project has CI', () => { - wrapper.vm.isLoading = false; + it('renders empty state without button to set CI', () => { + expect(findEmptyState().text()).toBe( + 'This project is not currently set up to run pipelines.', + ); - return nextTick().then(() => { - expect(findBlankState().exists()).toBe(true); - expect(findBlankState().props('message')).toBe('There are currently no pipelines.'); - }); + expect(findEmptyState().find(GlButton).exists()).toBe(false); }); - it('shows empty state when project does not have pipelines nor CI', () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); - - wrapper.vm.isLoading = false; - - return nextTick().then(() => { - expect(wrapper.find(EmptyState).exists()).toBe(true); - }); + 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('displays tabs', () => { - it('returns true when state is loading & has already made the first request', () => { - wrapper.vm.isLoading = true; - wrapper.vm.hasMadeRequest = true; + describe('when CI is enabled and user has no permissions', () => { + beforeEach(() => { + createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions }); - return nextTick().then(() => { - expect(findNavigationTabs().exists()).toBe(true); - }); + return waitForPromises(); }); - it('returns true when state is tableList & has already made the first request', () => { - wrapper.vm.isLoading = false; - wrapper.vm.state.pipelines = pipelines.pipelines; - wrapper.vm.hasMadeRequest = true; - - return nextTick().then(() => { - expect(findNavigationTabs().exists()).toBe(true); - }); + it('renders tab with count of "0"', () => { + expect(findTab('all').text()).toMatchInterpolatedText('All 0'); }); - it('returns true when state is error & has already made the first request', () => { - wrapper.vm.isLoading = false; - wrapper.vm.hasError = true; - wrapper.vm.hasMadeRequest = true; + it('does not render buttons', () => { + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); - return nextTick().then(() => { - expect(findNavigationTabs().exists()).toBe(true); - }); + it('renders empty state', () => { + expect(findBlankState().text()).toBe('There are currently no pipelines.'); }); + }); + }); - it('returns true when state is empty tab & has already made the first request', () => { - wrapper.vm.isLoading = false; - wrapper.vm.state.count.all = 10; - wrapper.vm.hasMadeRequest = true; + describe('when a pipeline with stages exists', () => { + describe('updates results when a staged is clicked', () => { + let stopMock; + let restartMock; + let cancelMock; - return nextTick().then(() => { - expect(findNavigationTabs().exists()).toBe(true); - }); - }); + beforeEach(() => { + mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply( + 200, + { + pipelines: [pipelineWithStages], + count: { all: '1' }, + }, + { + 'POLL-INTERVAL': 100, + }, + ); + mock.onGet(pipelineWithStages.details.stages[0].dropdown_path).reply(200, stageReply); - it('returns false when has not made first request', () => { - wrapper.vm.hasMadeRequest = false; + createComponent(); - return nextTick().then(() => { - expect(findNavigationTabs().exists()).toBe(false); - }); + stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); + restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); + cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel'); }); - it('returns false when state is empty state', () => { - createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); - - wrapper.vm.isLoading = false; - wrapper.vm.hasMadeRequest = true; + describe('when a request is being made', () => { + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint).reply(200, mockPipelinesResponse); - return nextTick().then(() => { - expect(findNavigationTabs().exists()).toBe(false); + await waitForPromises(); }); - }); - }); - describe('displays buttons', () => { - it('returns true when it has paths & has made the first request', () => { - wrapper.vm.hasMadeRequest = true; + it('stops polling, cancels the request, & restarts polling', async () => { + // Mock init a polling cycle + wrapper.vm.poll.options.notificationCallback(true); + + findStagesDropdown().trigger('click'); - return nextTick().then(() => { - expect(findNavigationControls().exists()).toBe(true); + await waitForPromises(); + + expect(cancelMock).toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalled(); }); - }); - it('returns false when it has not made the first request', () => { - wrapper.vm.hasMadeRequest = false; + it('stops polling & restarts polling', async () => { + findStagesDropdown().trigger('click'); - return nextTick().then(() => { - expect(findNavigationControls().exists()).toBe(false); + expect(cancelMock).not.toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalled(); }); }); }); }); - describe('Pipeline filters', () => { - let updateContentMock; - - beforeEach(() => { - mock.onGet(paths.endpoint).reply(200, pipelines); - createComponent(); + describe('when pipelines cannot be loaded', () => { + beforeEach(async () => { + mock.onGet(mockPipelinesEndpoint).reply(500, {}); + }); - updateContentMock = jest.spyOn(wrapper.vm, 'updateContent'); + describe('when user has no permissions', () => { + beforeEach(async () => { + createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions }); - return waitForPromises(); - }); + await waitForPromises(); + }); - it('updates request data and query params on filter submit', async () => { - const expectedQueryParams = { - page: '1', - scope: 'all', - username: 'root', - ref: 'master', - status: 'pending', - }; + it('renders tabs', () => { + expect(findNavigationTabs().exists()).toBe(true); + expect(findTab('all').text()).toBe('All'); + }); - findFilteredSearch().vm.$emit('submit', mockSearch); - await nextTick(); + it('does not render buttons', () => { + expect(findRunPipelineButton().exists()).toBe(false); + expect(findCiLintButton().exists()).toBe(false); + expect(findCleanCacheButton().exists()).toBe(false); + }); - expect(wrapper.vm.requestData).toEqual(expectedQueryParams); - expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams); + it('shows error state', () => { + expect(findBlankState().text()).toBe( + 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', + ); + }); }); - it('does not add query params if raw text search is used', async () => { - const expectedQueryParams = { page: '1', scope: 'all' }; + describe('when user has permissions', () => { + beforeEach(async () => { + createComponent(); - findFilteredSearch().vm.$emit('submit', ['rawText']); - await nextTick(); + await waitForPromises(); + }); - expect(wrapper.vm.requestData).toEqual(expectedQueryParams); - expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams); - }); + it('renders tabs', () => { + expect(findTab('all').text()).toBe('All'); + }); + + it('renders buttons', () => { + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); - it('displays a warning message if raw text search is used', () => { - findFilteredSearch().vm.$emit('submit', ['rawText']); + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); + }); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(RAW_TEXT_WARNING, 'warning'); + it('shows error state', () => { + expect(findBlankState().text()).toBe( + 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', + ); + }); }); }); }); diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js index 9cdd24b2ab5..660651547fc 100644 --- a/spec/frontend/pipelines/pipelines_table_row_spec.js +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -155,7 +155,9 @@ describe('Pipelines Table Row', () => { it('should render an icon for each stage', () => { expect( - wrapper.findAll('.table-section:nth-child(4) .js-builds-dropdown-button').length, + wrapper.findAll( + '.table-section:nth-child(4) [data-testid="mini-pipeline-graph-dropdown-toggle"]', + ).length, ).toEqual(pipeline.details.stages.length); }); }); diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js index e4782a1dab1..87b43558252 100644 --- a/spec/frontend/pipelines/stage_spec.js +++ b/spec/frontend/pipelines/stage_spec.js @@ -1,6 +1,8 @@ import 'bootstrap/js/dist/dropdown'; +import { GlDropdown } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import StageComponent from '~/pipelines/components/pipelines_list/stage.vue'; import eventHub from '~/pipelines/event_hub'; @@ -9,6 +11,7 @@ import { stageReply } from './mock_data'; describe('Pipelines stage component', () => { let wrapper; let mock; + let glFeatures; const defaultProps = { stage: { @@ -22,8 +25,6 @@ describe('Pipelines stage component', () => { updateDropdown: false, }; - const isDropdownOpen = () => wrapper.classes('show'); - const createComponent = (props = {}) => { wrapper = mount(StageComponent, { attachTo: document.body, @@ -31,110 +32,265 @@ describe('Pipelines stage component', () => { ...defaultProps, ...props, }, + provide: { + glFeatures, + }, }); }; beforeEach(() => { mock = new MockAdapter(axios); + jest.spyOn(eventHub, '$emit'); + glFeatures = {}; }); afterEach(() => { wrapper.destroy(); wrapper = null; + eventHub.$emit.mockRestore(); mock.restore(); }); - describe('default', () => { - beforeEach(() => { - createComponent(); + describe('when ci_mini_pipeline_gl_dropdown feature flag is disabled', () => { + const isDropdownOpen = () => wrapper.classes('show'); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render a dropdown with the status icon', () => { + expect(wrapper.attributes('class')).toEqual('dropdown'); + expect(wrapper.find('svg').exists()).toBe(true); + expect(wrapper.find('button').attributes('data-toggle')).toEqual('dropdown'); + }); }); - it('should render a dropdown with the status icon', () => { - expect(wrapper.attributes('class')).toEqual('dropdown'); - expect(wrapper.find('svg').exists()).toBe(true); - expect(wrapper.find('button').attributes('data-toggle')).toEqual('dropdown'); + describe('with successful request', () => { + beforeEach(() => { + mock.onGet('path.json').reply(200, stageReply); + createComponent(); + }); + + it('should render the received data and emit `clickedDropdown` event', async () => { + wrapper.find('button').trigger('click'); + + await axios.waitForAll(); + expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( + stageReply.latest_statuses[0].name, + ); + + expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + }); }); - }); - describe('with successful request', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); + it('when request fails should close the dropdown', async () => { + mock.onGet('path.json').reply(500); createComponent(); - }); + wrapper.find({ ref: 'dropdown' }).trigger('click'); - it('should render the received data and emit `clickedDropdown` event', async () => { - jest.spyOn(eventHub, '$emit'); - wrapper.find('button').trigger('click'); + expect(isDropdownOpen()).toBe(true); + wrapper.find('button').trigger('click'); await axios.waitForAll(); - expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( - stageReply.latest_statuses[0].name, - ); - expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + expect(isDropdownOpen()).toBe(false); }); - }); - it('when request fails should close the dropdown', async () => { - mock.onGet('path.json').reply(500); - createComponent(); - wrapper.find({ ref: 'dropdown' }).trigger('click'); - expect(isDropdownOpen()).toBe(true); + describe('update endpoint correctly', () => { + beforeEach(() => { + const copyStage = { ...stageReply }; + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(200, copyStage); + createComponent({ + stage: { + status: { + group: 'running', + icon: 'status_running', + title: 'running', + }, + dropdown_path: 'bar.json', + }, + }); + return axios.waitForAll(); + }); + + it('should update the stage to request the new endpoint provided', async () => { + wrapper.find('button').trigger('click'); + await axios.waitForAll(); + + expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( + 'this is the updated content', + ); + }); + }); + + describe('pipelineActionRequestComplete', () => { + beforeEach(() => { + mock.onGet('path.json').reply(200, stageReply); + mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); + }); + + const clickCiAction = async () => { + wrapper.find('button').trigger('click'); + await axios.waitForAll(); + + wrapper.find('.js-ci-action').trigger('click'); + await axios.waitForAll(); + }; + + describe('within pipeline table', () => { + it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => { + createComponent({ type: 'PIPELINES_TABLE' }); + + await clickCiAction(); + + expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); + }); + }); + + describe('in MR widget', () => { + beforeEach(() => { + jest.spyOn($.fn, 'dropdown'); + }); - wrapper.find('button').trigger('click'); - await axios.waitForAll(); + it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => { + createComponent(); - expect(isDropdownOpen()).toBe(false); + await clickCiAction(); + + expect($.fn.dropdown).toHaveBeenCalledWith('toggle'); + }); + }); + }); }); - describe('update endpoint correctly', () => { + describe('when ci_mini_pipeline_gl_dropdown feature flag is enabled', () => { + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownToggle = () => wrapper.find('button.gl-dropdown-toggle'); + const findDropdownMenu = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); + const findCiActionBtn = () => wrapper.find('.js-ci-action'); + + const openGlDropdown = () => { + findDropdownToggle().trigger('click'); + return new Promise((resolve) => { + wrapper.vm.$root.$on('bv::dropdown::show', resolve); + }); + }; + beforeEach(() => { - const copyStage = { ...stageReply }; - copyStage.latest_statuses[0].name = 'this is the updated content'; - mock.onGet('bar.json').reply(200, copyStage); - createComponent({ - stage: { - status: { - group: 'running', - icon: 'status_running', - title: 'running', - }, - dropdown_path: 'bar.json', - }, + glFeatures = { ciMiniPipelineGlDropdown: true }; + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('should render a dropdown with the status icon', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdownToggle().classes('gl-dropdown-toggle')).toEqual(true); + expect(wrapper.find('[data-testid="status_success_borderless-icon"]').exists()).toBe(true); }); - return axios.waitForAll(); }); - it('should update the stage to request the new endpoint provided', async () => { - wrapper.find('button').trigger('click'); + describe('with successful request', () => { + beforeEach(() => { + mock.onGet('path.json').reply(200, stageReply); + createComponent(); + }); + + it('should render the received data and emit `clickedDropdown` event', async () => { + await openGlDropdown(); + await axios.waitForAll(); + + expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); + expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + }); + }); + + it('when request fails should close the dropdown', async () => { + mock.onGet('path.json').reply(500); + + createComponent(); + + await openGlDropdown(); await axios.waitForAll(); - expect(wrapper.find('.js-builds-dropdown-container ul').text()).toContain( - 'this is the updated content', - ); + expect(findDropdown().classes('show')).toBe(false); }); - }); - describe('pipelineActionRequestComplete', () => { - beforeEach(() => { - mock.onGet('path.json').reply(200, stageReply); - mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); + describe('update endpoint correctly', () => { + beforeEach(async () => { + const copyStage = { ...stageReply }; + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(200, copyStage); + createComponent({ + stage: { + status: { + group: 'running', + icon: 'status_running', + title: 'running', + }, + dropdown_path: 'bar.json', + }, + }); + await axios.waitForAll(); + }); - createComponent({ type: 'PIPELINES_TABLE' }); + it('should update the stage to request the new endpoint provided', async () => { + await openGlDropdown(); + await axios.waitForAll(); + + expect(findDropdownMenu().text()).toContain('this is the updated content'); + }); }); - describe('within pipeline table', () => { - it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => { - jest.spyOn(eventHub, '$emit'); + describe('pipelineActionRequestComplete', () => { + beforeEach(() => { + mock.onGet('path.json').reply(200, stageReply); + mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); + }); - wrapper.find('button').trigger('click'); + const clickCiAction = async () => { + await openGlDropdown(); await axios.waitForAll(); - wrapper.find('.js-ci-action').trigger('click'); + findCiActionBtn().trigger('click'); await axios.waitForAll(); + }; + + describe('within pipeline table', () => { + beforeEach(() => { + createComponent({ type: 'PIPELINES_TABLE' }); + }); + + it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => { + await clickCiAction(); + + expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); + }); + }); + + describe('in MR widget', () => { + beforeEach(() => { + jest.spyOn($.fn, 'dropdown'); + createComponent(); + }); + + it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => { + const hidden = jest.fn(); + + wrapper.vm.$root.$on('bv::dropdown::hide', hidden); + + expect(hidden).toHaveBeenCalledTimes(0); + + await clickCiAction(); - expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); + expect(hidden).toHaveBeenCalledTimes(1); + }); }); }); }); diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index f7ff36c0a46..6258b08dfbb 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -2,10 +2,10 @@ import MockAdapter from 'axios-mock-adapter'; import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/pipelines/stores/test_reports/actions'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash.js'); @@ -16,7 +16,7 @@ describe('Actions TestReports Store', () => { const testReports = getJSONFixture('pipelines/test_report.json'); const summary = { total_count: 1 }; - const suiteEndpoint = `${TEST_HOST}/tests/:suite_name.json`; + const suiteEndpoint = `${TEST_HOST}/tests/suite.json`; const summaryEndpoint = `${TEST_HOST}/test_reports/summary.json`; const defaultState = { suiteEndpoint, @@ -69,9 +69,8 @@ describe('Actions TestReports Store', () => { beforeEach(() => { const buildIds = [1]; testReports.test_suites[0].build_ids = buildIds; - const endpoint = suiteEndpoint.replace(':suite_name', testReports.test_suites[0].name); mock - .onGet(endpoint, { params: { build_ids: buildIds } }) + .onGet(suiteEndpoint, { params: { build_ids: buildIds } }) .replyOnce(200, testReports.test_suites[0], {}); }); diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js index 7382a6beefa..f8298fdaba5 100644 --- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -1,6 +1,10 @@ import { getJSONFixture } from 'helpers/fixtures'; import * as getters from '~/pipelines/stores/test_reports/getters'; -import { iconForTestStatus, formattedTime } from '~/pipelines/stores/test_reports/utils'; +import { + iconForTestStatus, + formatFilePath, + formattedTime, +} from '~/pipelines/stores/test_reports/utils'; describe('Getters TestReports Store', () => { let state; @@ -8,6 +12,7 @@ describe('Getters TestReports Store', () => { const testReports = getJSONFixture('pipelines/test_report.json'); const defaultState = { + blobPath: '/test/blob/path', testReports, selectedSuiteIndex: 0, pageInfo: { @@ -17,6 +22,7 @@ describe('Getters TestReports Store', () => { }; const emptyState = { + blobPath: '', testReports: {}, selectedSuite: null, pageInfo: { @@ -74,6 +80,7 @@ describe('Getters TestReports Store', () => { const expected = testReports.test_suites[0].test_cases .map((x) => ({ ...x, + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, formattedTime: formattedTime(x.execution_time), icon: iconForTestStatus(x.status), })) @@ -87,6 +94,70 @@ describe('Getters TestReports Store', () => { expect(getters.getSuiteTests(state)).toEqual([]); }); + + describe('when a test case classname property is null', () => { + it('should return an empty string value for the classname property', () => { + const testCases = testReports.test_suites[0].test_cases; + setupState({ + ...defaultState, + testReports: { + ...testReports, + test_suites: [ + { + test_cases: testCases.map((testCase) => ({ + ...testCase, + classname: null, + })), + }, + ], + }, + }); + + const expected = testCases + .map((x) => ({ + ...x, + classname: '', + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); + + expect(getters.getSuiteTests(state)).toEqual(expected); + }); + }); + + describe('when a test case name property is null', () => { + it('should return an empty string value for the name property', () => { + const testCases = testReports.test_suites[0].test_cases; + setupState({ + ...defaultState, + testReports: { + ...testReports, + test_suites: [ + { + test_cases: testCases.map((testCase) => ({ + ...testCase, + name: null, + })), + }, + ], + }, + }); + + const expected = testCases + .map((x) => ({ + ...x, + name: '', + filePath: `${state.blobPath}/${formatFilePath(x.file)}`, + formattedTime: formattedTime(x.execution_time), + icon: iconForTestStatus(x.status), + })) + .slice(0, state.pageInfo.perPage); + + expect(getters.getSuiteTests(state)).toEqual(expected); + }); + }); }); describe('getSuiteTestCount', () => { diff --git a/spec/frontend/pipelines/test_reports/stores/utils_spec.js b/spec/frontend/pipelines/test_reports/stores/utils_spec.js index 7e632d099fc..703fe69026c 100644 --- a/spec/frontend/pipelines/test_reports/stores/utils_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/utils_spec.js @@ -1,6 +1,20 @@ -import { formattedTime } from '~/pipelines/stores/test_reports/utils'; +import { formatFilePath, formattedTime } from '~/pipelines/stores/test_reports/utils'; describe('Test reports utils', () => { + describe('formatFilePath', () => { + it.each` + file | expected + ${'./test.js'} | ${'test.js'} + ${'/test.js'} | ${'test.js'} + ${'.//////////////test.js'} | ${'test.js'} + ${'test.js'} | ${'test.js'} + ${'mock/path./test.js'} | ${'mock/path./test.js'} + ${'./mock/path./test.js'} | ${'mock/path./test.js'} + `('should format $file to be $expected', ({ file, expected }) => { + expect(formatFilePath(file)).toBe(expected); + }); + }); + describe('formattedTime', () => { describe('when time is smaller than a second', () => { it('should return time in milliseconds fixed to 2 decimals', () => { diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js index bfb8b43778d..e866586a2c3 100644 --- a/spec/frontend/pipelines/test_reports/test_case_details_spec.js +++ b/spec/frontend/pipelines/test_reports/test_case_details_spec.js @@ -1,5 +1,5 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlModal } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; @@ -11,12 +11,17 @@ describe('Test case details', () => { classname: 'spec.test_spec', name: 'Test#something cool', formattedTime: '10.04ms', + recent_failures: { + count: 2, + base_branch: 'master', + }, system_output: 'Line 42 is broken', }; const findModal = () => wrapper.find(GlModal); const findName = () => wrapper.find('[data-testid="test-case-name"]'); const findDuration = () => wrapper.find('[data-testid="test-case-duration"]'); + const findRecentFailures = () => wrapper.find('[data-testid="test-case-recent-failures"]'); const findSystemOutput = () => wrapper.find('[data-testid="test-case-trace"]'); const createComponent = (testCase = {}) => { @@ -56,6 +61,36 @@ describe('Test case details', () => { }); }); + describe('when test case has recent failures', () => { + describe('has only 1 recent failure', () => { + it('renders the recent failure', () => { + createComponent({ recent_failures: { ...defaultTestCase.recent_failures, count: 1 } }); + + expect(findRecentFailures().text()).toContain( + `Failed 1 time in ${defaultTestCase.recent_failures.base_branch} in the last 14 days`, + ); + }); + }); + + describe('has more than 1 recent failure', () => { + it('renders the recent failures', () => { + createComponent(); + + expect(findRecentFailures().text()).toContain( + `Failed ${defaultTestCase.recent_failures.count} times in ${defaultTestCase.recent_failures.base_branch} in the last 14 days`, + ); + }); + }); + }); + + describe('when test case does not have recent failures', () => { + it('does not render the recent failures', () => { + createComponent({ recent_failures: null }); + + expect(findRecentFailures().exists()).toBe(false); + }); + }); + describe('when test case has system output', () => { it('renders the test case system output', () => { createComponent(); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js index c8ab18b9086..da5763ddf8e 100644 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -1,6 +1,6 @@ -import Vuex from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; import { getJSONFixture } from 'helpers/fixtures'; import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; import TestSummary from '~/pipelines/components/test_reports/test_summary.vue'; diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index b8fd056610b..a87145cc557 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,10 +1,11 @@ -import Vuex from 'vuex'; +import { GlButton, GlFriendlyWrap, GlLink, GlPagination } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; import { getJSONFixture } from 'helpers/fixtures'; -import { GlButton, GlFriendlyWrap, GlPagination } from '@gitlab/ui'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; -import * as getters from '~/pipelines/stores/test_reports/getters'; import { TestStatus } from '~/pipelines/constants'; +import * as getters from '~/pipelines/stores/test_reports/getters'; +import { formatFilePath } from '~/pipelines/stores/test_reports/utils'; import skippedTestCases from './mock_data'; const localVue = createLocalVue(); @@ -20,15 +21,18 @@ describe('Test reports suite table', () => { testSuite.test_cases = [...testSuite.test_cases, ...skippedTestCases]; const testCases = testSuite.test_cases; + const blobPath = '/test/blob/path'; const noCasesMessage = () => wrapper.find('.js-no-test-cases'); const allCaseRows = () => wrapper.findAll('.js-case-row'); const findCaseRowAtIndex = (index) => wrapper.findAll('.js-case-row').at(index); + const findLinkForRow = (row) => row.find(GlLink); const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); const createComponent = (suite = testSuite, perPage = 20) => { store = new Vuex.Store({ state: { + blobPath, testReports: { test_suites: [suite], }, @@ -64,7 +68,7 @@ describe('Test reports suite table', () => { beforeEach(() => createComponent()); it('renders the correct number of rows', () => { - expect(allCaseRows().length).toBe(testCases.length); + expect(allCaseRows()).toHaveLength(testCases.length); }); it.each([ @@ -82,9 +86,13 @@ describe('Test reports suite table', () => { it('renders the file name for the test with a copy button', () => { const { file } = testCases[0]; + const relativeFile = formatFilePath(file); + const filePath = `${blobPath}/${relativeFile}`; const row = findCaseRowAtIndex(0); + const fileLink = findLinkForRow(row); const button = row.find(GlButton); + expect(fileLink.attributes('href')).toBe(filePath); expect(row.text()).toContain(file); expect(button.exists()).toBe(true); expect(button.attributes('data-clipboard-text')).toBe(file); @@ -106,4 +114,32 @@ describe('Test reports suite table', () => { expect(wrapper.find(GlPagination).exists()).toBe(true); }); }); + + describe('when a test case classname property is null', () => { + it('still renders all test cases', () => { + createComponent({ + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + classname: null, + })), + }); + + expect(allCaseRows()).toHaveLength(testCases.length); + }); + }); + + describe('when a test case name property is null', () => { + it('still renders all test cases', () => { + createComponent({ + ...testSuite, + test_cases: testSuite.test_cases.map((testCase) => ({ + ...testCase, + name: null, + })), + }); + + expect(allCaseRows()).toHaveLength(testCases.length); + }); + }); }); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js index b585536ae09..892a3742fea 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js @@ -1,5 +1,5 @@ -import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; import { getJSONFixture } from 'helpers/fixtures'; import SummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; import * as getters from '~/pipelines/stores/test_reports/getters'; diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index b7bc8d08a0f..55a19ef5165 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -1,5 +1,5 @@ -import { shallowMount } from '@vue/test-utils'; import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue'; describe('Timeago component', () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js index 371ba5a4f9b..7ddbbb3b005 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -1,6 +1,6 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; -import { stubComponent } from 'helpers/stub_component'; import { shallowMount } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; import Api from '~/api'; import PipelineTriggerAuthorToken from '~/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue'; import { users } from '../mock_data'; |