Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/ci/pipelines_page')
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js107
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js133
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js87
-rw-r--r--spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js58
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js254
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js279
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/mock.js78
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js139
-rw-r--r--spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js55
-rw-r--r--spec/frontend/ci/pipelines_page/components/nav_controls_spec.js80
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js164
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js316
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js77
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js27
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js76
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js188
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js64
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js199
-rw-r--r--spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js216
-rw-r--r--spec/frontend/ci/pipelines_page/components/time_ago_spec.js85
-rw-r--r--spec/frontend/ci/pipelines_page/pipelines_spec.js851
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js142
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js53
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js58
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js95
-rw-r--r--spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js99
26 files changed, 3980 insertions, 0 deletions
diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js
new file mode 100644
index 00000000000..980a8be24ea
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/ci_templates_spec.js
@@ -0,0 +1,107 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue';
+
+const pipelineEditorPath = '/-/ci/editor';
+const suggestedCiTemplates = [
+ { name: 'Android', logo: '/assets/illustrations/logos/android.svg' },
+ { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' },
+ { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' },
+];
+
+describe('CI Templates', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const createWrapper = (propsData = {}) => {
+ wrapper = shallowMountExtended(CiTemplates, {
+ provide: {
+ pipelineEditorPath,
+ suggestedCiTemplates,
+ },
+ propsData,
+ });
+ };
+
+ const findTemplateDescription = () => wrapper.findByTestId('template-description');
+ const findTemplateLink = () => wrapper.findByTestId('template-link');
+ const findTemplateNames = () => wrapper.findAllByTestId('template-name');
+ const findTemplateName = () => wrapper.findByTestId('template-name');
+ const findTemplateLogo = () => wrapper.findByTestId('template-logo');
+
+ describe('renders template list', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders all suggested templates', () => {
+ expect(findTemplateNames().length).toBe(3);
+ expect(wrapper.text()).toContain('Android', 'Bash', 'C++');
+ });
+
+ it('has the correct template name', () => {
+ expect(findTemplateName().text()).toBe('Android');
+ });
+
+ it('links to the correct template', () => {
+ expect(findTemplateLink().attributes('href')).toBe(
+ pipelineEditorPath.concat('?template=Android'),
+ );
+ });
+
+ it('has the link button enabled', () => {
+ expect(findTemplateLink().props('disabled')).toBe(false);
+ });
+
+ it('has the description of the template', () => {
+ expect(findTemplateDescription().text()).toBe(
+ 'Continuous integration and deployment template to test and deploy your Android project.',
+ );
+ });
+
+ it('has the right logo of the template', () => {
+ expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg');
+ });
+ });
+
+ describe('filtering the templates', () => {
+ beforeEach(() => {
+ createWrapper({ filterTemplates: ['Bash'] });
+ });
+
+ it('renders only the filtered templates', () => {
+ expect(findTemplateNames()).toHaveLength(1);
+ expect(findTemplateName().text()).toBe('Bash');
+ });
+ });
+
+ describe('disabling the templates', () => {
+ beforeEach(() => {
+ createWrapper({ disabled: true });
+ });
+
+ it('has the link button disabled', () => {
+ expect(findTemplateLink().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ createWrapper();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('sends an event when template is clicked', () => {
+ findTemplateLink().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
+ label: 'Android',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js
new file mode 100644
index 00000000000..8620d41886e
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/ios_templates_spec.js
@@ -0,0 +1,133 @@
+import '~/commons';
+import { nextTick } from 'vue';
+import { GlPopover, GlButton } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import IosTemplates from '~/ci/pipelines_page/components/empty_state/ios_templates.vue';
+import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue';
+
+const pipelineEditorPath = '/-/ci/editor';
+const registrationToken = 'SECRET_TOKEN';
+const iOSTemplateName = 'iOS-Fastlane';
+
+describe('iOS Templates', () => {
+ let wrapper;
+
+ const createWrapper = (providedPropsData = {}) => {
+ return shallowMountExtended(IosTemplates, {
+ provide: {
+ pipelineEditorPath,
+ iosRunnersAvailable: true,
+ ...providedPropsData,
+ },
+ propsData: {
+ registrationToken,
+ },
+ stubs: {
+ GlButton,
+ },
+ });
+ };
+
+ const findIosTemplate = () => wrapper.findComponent(CiTemplates);
+ const findRunnerInstructionsModal = () => wrapper.findComponent(RunnerInstructionsModal);
+ const findRunnerInstructionsPopover = () => wrapper.findComponent(GlPopover);
+ const findRunnerSetupTodoEmoji = () => wrapper.findByTestId('runner-setup-marked-todo');
+ const findRunnerSetupCompletedEmoji = () => wrapper.findByTestId('runner-setup-marked-completed');
+ const findSetupRunnerLink = () => wrapper.findByText('Set up a runner');
+ const configurePipelineLink = () => wrapper.findByTestId('configure-pipeline-link');
+
+ describe('when ios runners are not available', () => {
+ beforeEach(() => {
+ wrapper = createWrapper({ iosRunnersAvailable: false });
+ });
+
+ describe('the runner setup section', () => {
+ it('marks the section as todo', () => {
+ expect(findRunnerSetupTodoEmoji().isVisible()).toBe(true);
+ expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(false);
+ });
+
+ it('renders the setup runner link', () => {
+ expect(findSetupRunnerLink().exists()).toBe(true);
+ });
+
+ it('renders the runner instructions modal with a popover once clicked', async () => {
+ findSetupRunnerLink().element.parentElement.click();
+
+ await nextTick();
+
+ expect(findRunnerInstructionsModal().exists()).toBe(true);
+ expect(findRunnerInstructionsModal().props('registrationToken')).toBe(registrationToken);
+ expect(findRunnerInstructionsModal().props('defaultPlatformName')).toBe('osx');
+
+ findRunnerInstructionsModal().vm.$emit('shown');
+
+ await nextTick();
+
+ expect(findRunnerInstructionsPopover().exists()).toBe(true);
+ });
+ });
+
+ describe('the configure pipeline section', () => {
+ it('has a disabled link button', () => {
+ expect(configurePipelineLink().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('the ios-Fastlane template', () => {
+ it('renders the template', () => {
+ expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]);
+ });
+
+ it('has a disabled link button', () => {
+ expect(findIosTemplate().props('disabled')).toBe(true);
+ });
+ });
+ });
+
+ describe('when ios runners are available', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+
+ describe('the runner setup section', () => {
+ it('marks the section as completed', () => {
+ expect(findRunnerSetupTodoEmoji().isVisible()).toBe(false);
+ expect(findRunnerSetupCompletedEmoji().isVisible()).toBe(true);
+ });
+
+ it('does not render the setup runner link', () => {
+ expect(findSetupRunnerLink().exists()).toBe(false);
+ });
+ });
+
+ describe('the configure pipeline section', () => {
+ it('has an enabled link button', () => {
+ expect(configurePipelineLink().props('disabled')).toBe(false);
+ });
+
+ it('links to the pipeline editor with the right template', () => {
+ expect(configurePipelineLink().attributes('href')).toBe(
+ `${pipelineEditorPath}?template=${iOSTemplateName}`,
+ );
+ });
+ });
+
+ describe('the ios-Fastlane template', () => {
+ it('renders the template', () => {
+ expect(findIosTemplate().props('filterTemplates')).toStrictEqual([iOSTemplateName]);
+ });
+
+ it('has an enabled link button', () => {
+ expect(findIosTemplate().props('disabled')).toBe(false);
+ });
+
+ it('links to the pipeline editor with the right template', () => {
+ expect(configurePipelineLink().attributes('href')).toBe(
+ `${pipelineEditorPath}?template=${iOSTemplateName}`,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js
new file mode 100644
index 00000000000..0c42723f753
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/no_ci_empty_state_spec.js
@@ -0,0 +1,87 @@
+import '~/commons';
+import { shallowMount } from '@vue/test-utils';
+import { GlEmptyState } from '@gitlab/ui';
+import { stubExperiments } from 'helpers/experimentation_helper';
+import EmptyState from '~/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
+import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue';
+import IosTemplates from '~/ci/pipelines_page/components/empty_state/ios_templates.vue';
+
+describe('Pipelines Empty State', () => {
+ let wrapper;
+
+ const findIllustration = () => wrapper.find('img');
+ const findButton = () => wrapper.find('a');
+ const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates);
+ const iosTemplates = () => wrapper.findComponent(IosTemplates);
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMount(EmptyState, {
+ provide: {
+ pipelineEditorPath: '',
+ suggestedCiTemplates: [],
+ anyRunnersAvailable: true,
+ ciRunnerSettingsPath: '',
+ },
+ propsData: {
+ emptyStateSvgPath: 'foo.svg',
+ canSetCi: true,
+ ...props,
+ },
+ stubs: {
+ GlEmptyState,
+ GitlabExperiment,
+ },
+ });
+ };
+
+ describe('when user can configure CI', () => {
+ describe('when the ios_specific_templates experiment is active', () => {
+ beforeEach(() => {
+ stubExperiments({ ios_specific_templates: 'candidate' });
+ createWrapper();
+ });
+
+ it('should render the iOS templates', () => {
+ expect(iosTemplates().exists()).toBe(true);
+ });
+
+ it('should not render the CI/CD templates', () => {
+ expect(pipelinesCiTemplates().exists()).toBe(false);
+ });
+ });
+
+ describe('when the ios_specific_templates experiment is inactive', () => {
+ beforeEach(() => {
+ stubExperiments({ ios_specific_templates: 'control' });
+ createWrapper();
+ });
+
+ it('should render the CI/CD templates', () => {
+ expect(pipelinesCiTemplates().exists()).toBe(true);
+ });
+
+ it('should not render the iOS templates', () => {
+ expect(iosTemplates().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when user cannot configure CI', () => {
+ beforeEach(() => {
+ createWrapper({ canSetCi: false });
+ });
+
+ it('should render empty state SVG', () => {
+ expect(findIllustration().attributes('src')).toBe('foo.svg');
+ });
+
+ it('should render empty state header', () => {
+ expect(wrapper.text()).toBe('This project is not currently set up to run pipelines.');
+ });
+
+ it('should not render a link', () => {
+ expect(findButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js
new file mode 100644
index 00000000000..fbef4aa08eb
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/empty_state/pipelines_ci_templates_spec.js
@@ -0,0 +1,58 @@
+import '~/commons';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue';
+import CiTemplates from '~/ci/pipelines_page/components/empty_state/ci_templates.vue';
+
+const pipelineEditorPath = '/-/ci/editor';
+
+describe('Pipelines CI Templates', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const createWrapper = (propsData = {}, stubs = {}) => {
+ return shallowMountExtended(PipelinesCiTemplates, {
+ provide: {
+ pipelineEditorPath,
+ ...propsData,
+ },
+ stubs,
+ });
+ };
+
+ const findTestTemplateLink = () => wrapper.findByTestId('test-template-link');
+ const findCiTemplates = () => wrapper.findComponent(CiTemplates);
+
+ describe('templates', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+
+ it('renders test template and Ci templates', () => {
+ expect(findTestTemplateLink().attributes('href')).toBe(
+ pipelineEditorPath.concat('?template=Getting-Started'),
+ );
+ expect(findCiTemplates().exists()).toBe(true);
+ });
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('sends an event when Getting-Started template is clicked', () => {
+ findTestTemplateLink().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
+ label: 'Getting-Started',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js
new file mode 100644
index 00000000000..6967a369338
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_job_details_spec.js
@@ -0,0 +1,254 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlIcon, GlLink } from '@gitlab/ui';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue';
+import RetryMrFailedJobMutation from '~/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql';
+import { BRIDGE_KIND } from '~/ci/pipeline_details/graph/constants';
+import { job } from './mock';
+
+Vue.use(VueApollo);
+jest.mock('~/alert');
+
+const createFakeEvent = () => ({ stopPropagation: jest.fn() });
+
+describe('FailedJobDetails component', () => {
+ let wrapper;
+ let mockRetryResponse;
+
+ const retrySuccessResponse = {
+ data: {
+ jobRetry: {
+ errors: [],
+ },
+ },
+ };
+
+ const defaultProps = {
+ job,
+ };
+
+ const createComponent = ({ props = {} } = {}) => {
+ const handlers = [[RetryMrFailedJobMutation, mockRetryResponse]];
+
+ wrapper = shallowMountExtended(FailedJobDetails, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ apolloProvider: createMockApollo(handlers),
+ });
+ };
+
+ const findArrowIcon = () => wrapper.findComponent(GlIcon);
+ const findJobId = () => wrapper.findComponent(GlLink);
+ const findJobLog = () => wrapper.findByTestId('job-log');
+ const findJobName = () => wrapper.findByText(defaultProps.job.name);
+ const findRetryButton = () => wrapper.findByLabelText('Retry');
+ const findRow = () => wrapper.findByTestId('widget-row');
+ const findStageName = () => wrapper.findByText(defaultProps.job.stage.name);
+
+ beforeEach(() => {
+ mockRetryResponse = jest.fn();
+ mockRetryResponse.mockResolvedValue(retrySuccessResponse);
+ });
+
+ describe('ui', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the job name', () => {
+ expect(findJobName().exists()).toBe(true);
+ });
+
+ it('renders the stage name', () => {
+ expect(findStageName().exists()).toBe(true);
+ });
+
+ it('renders the job id as a link', () => {
+ const jobId = getIdFromGraphQLId(defaultProps.job.id);
+
+ expect(findJobId().exists()).toBe(true);
+ expect(findJobId().text()).toContain(String(jobId));
+ });
+
+ it('does not renders the job lob', () => {
+ expect(findJobLog().exists()).toBe(false);
+ });
+ });
+
+ describe('Retry action', () => {
+ describe('when the job is not retryable', () => {
+ beforeEach(() => {
+ createComponent({ props: { job: { ...job, retryable: false } } });
+ });
+
+ it('disables the retry button', () => {
+ expect(findRetryButton().props().disabled).toBe(true);
+ });
+ });
+
+ describe('when the job is a bridge', () => {
+ beforeEach(() => {
+ createComponent({ props: { job: { ...job, kind: BRIDGE_KIND } } });
+ });
+
+ it('disables the retry button', () => {
+ expect(findRetryButton().props().disabled).toBe(true);
+ });
+ });
+
+ describe('when the job is retryable', () => {
+ describe('and user has permission to update the build', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('enables the retry button', () => {
+ expect(findRetryButton().props().disabled).toBe(false);
+ });
+
+ describe('when clicking on the retry button', () => {
+ it('passes the loading state to the button', async () => {
+ await findRetryButton().vm.$emit('click', createFakeEvent());
+
+ expect(findRetryButton().props().loading).toBe(true);
+ });
+
+ describe('and it succeeds', () => {
+ beforeEach(async () => {
+ findRetryButton().vm.$emit('click', createFakeEvent());
+ await waitForPromises();
+ });
+
+ it('is no longer loading', () => {
+ expect(findRetryButton().props().loading).toBe(false);
+ });
+
+ it('calls the retry mutation', () => {
+ expect(mockRetryResponse).toHaveBeenCalled();
+ expect(mockRetryResponse).toHaveBeenCalledWith({
+ id: job.id,
+ });
+ });
+
+ it('emits the `retried-job` event', () => {
+ expect(wrapper.emitted('job-retried')).toStrictEqual([[job.name]]);
+ });
+ });
+
+ describe('and it fails', () => {
+ const customErrorMsg = 'Custom error message from API';
+
+ beforeEach(async () => {
+ mockRetryResponse.mockResolvedValue({
+ data: { jobRetry: { errors: [customErrorMsg] } },
+ });
+ findRetryButton().vm.$emit('click', createFakeEvent());
+
+ await waitForPromises();
+ });
+
+ it('shows an error message', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: customErrorMsg });
+ });
+
+ it('does not emits the `refetch-jobs` event', () => {
+ expect(wrapper.emitted('refetch-jobs')).toBeUndefined();
+ });
+ });
+ });
+ });
+
+ describe('and user does not have permission to update the build', () => {
+ beforeEach(() => {
+ createComponent({
+ props: { job: { ...job, retryable: true, userPermissions: { updateBuild: false } } },
+ });
+ });
+
+ it('disables the retry button', () => {
+ expect(findRetryButton().props().disabled).toBe(true);
+ });
+ });
+ });
+ });
+
+ describe('Job log', () => {
+ describe('without permissions', () => {
+ beforeEach(async () => {
+ createComponent({ props: { job: { ...job, userPermissions: { readBuild: false } } } });
+ await findRow().trigger('click');
+ });
+
+ it('does not renders the received html of the job log', () => {
+ expect(findJobLog().html()).not.toContain(defaultProps.job.trace.htmlSummary);
+ });
+
+ it('shows a permission error message', () => {
+ expect(findJobLog().text()).toBe("You do not have permission to read this job's log.");
+ });
+ });
+
+ describe('with permissions', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('when clicking on the row', () => {
+ beforeEach(async () => {
+ await findRow().trigger('click');
+ });
+
+ describe('while collapsed', () => {
+ it('expands the job log', () => {
+ expect(findJobLog().exists()).toBe(true);
+ });
+
+ it('renders the down arrow', () => {
+ expect(findArrowIcon().props().name).toBe('chevron-down');
+ });
+
+ it('renders the received html of the job log', () => {
+ expect(findJobLog().html()).toContain(defaultProps.job.trace.htmlSummary);
+ });
+ });
+
+ describe('while expanded', () => {
+ it('collapes the job log', async () => {
+ expect(findJobLog().exists()).toBe(true);
+
+ await findRow().trigger('click');
+
+ expect(findJobLog().exists()).toBe(false);
+ });
+
+ it('renders the right arrow', async () => {
+ expect(findArrowIcon().props().name).toBe('chevron-down');
+
+ await findRow().trigger('click');
+
+ expect(findArrowIcon().props().name).toBe('chevron-right');
+ });
+ });
+ });
+
+ describe('when clicking on a link element within the row', () => {
+ it('does not expands/collapse the job log', async () => {
+ expect(findJobLog().exists()).toBe(false);
+ expect(findArrowIcon().props().name).toBe('chevron-right');
+
+ await findJobId().vm.$emit('click');
+
+ expect(findJobLog().exists()).toBe(false);
+ expect(findArrowIcon().props().name).toBe('chevron-right');
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js
new file mode 100644
index 00000000000..af075b02b64
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/failed_jobs_list_spec.js
@@ -0,0 +1,279 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+
+import { GlLoadingIcon, GlToast } from '@gitlab/ui';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
+import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue';
+import FailedJobDetails from '~/ci/pipelines_page/components/failure_widget/failed_job_details.vue';
+import * as utils from '~/ci/pipelines_page/components/failure_widget/utils';
+import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql';
+import { failedJobsMock, failedJobsMock2, failedJobsMockEmpty, activeFailedJobsMock } from './mock';
+
+Vue.use(VueApollo);
+Vue.use(GlToast);
+
+jest.mock('~/alert');
+
+describe('FailedJobsList component', () => {
+ let wrapper;
+ let mockFailedJobsResponse;
+ const showToast = jest.fn();
+
+ const defaultProps = {
+ failedJobsCount: 0,
+ graphqlResourceEtag: 'api/graphql',
+ isPipelineActive: false,
+ pipelineIid: 1,
+ projectPath: 'namespace/project/',
+ };
+
+ const defaultProvide = {
+ graphqlPath: 'api/graphql',
+ };
+
+ const createComponent = ({ props = {}, provide } = {}) => {
+ const handlers = [[getPipelineFailedJobs, mockFailedJobsResponse]];
+ const mockApollo = createMockApollo(handlers);
+
+ wrapper = shallowMountExtended(FailedJobsList, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ apolloProvider: mockApollo,
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
+ });
+ };
+
+ const findAllHeaders = () => wrapper.findAllByTestId('header');
+ const findFailedJobRows = () => wrapper.findAllComponents(FailedJobDetails);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findNoFailedJobsText = () => wrapper.findByText('No failed jobs in this pipeline 🎉');
+
+ beforeEach(() => {
+ mockFailedJobsResponse = jest.fn();
+ });
+
+ describe('on mount', () => {
+ beforeEach(() => {
+ mockFailedJobsResponse.mockResolvedValue(failedJobsMock);
+ createComponent();
+ });
+
+ it('fires the graphql query', () => {
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
+ expect(mockFailedJobsResponse).toHaveBeenCalledWith({
+ fullPath: defaultProps.projectPath,
+ pipelineIid: defaultProps.pipelineIid,
+ });
+ });
+ });
+
+ describe('when loading failed jobs', () => {
+ beforeEach(() => {
+ mockFailedJobsResponse.mockResolvedValue(failedJobsMock);
+ createComponent();
+ });
+
+ it('shows a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('when failed jobs have loaded', () => {
+ beforeEach(async () => {
+ mockFailedJobsResponse.mockResolvedValue(failedJobsMock);
+ jest.spyOn(utils, 'sortJobsByStatus');
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('does not renders a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders table column', () => {
+ expect(findAllHeaders()).toHaveLength(3);
+ });
+
+ it('shows the list of failed jobs', () => {
+ expect(findFailedJobRows()).toHaveLength(
+ failedJobsMock.data.project.pipeline.jobs.nodes.length,
+ );
+ });
+
+ it('does not renders the empty state', () => {
+ expect(findNoFailedJobsText().exists()).toBe(false);
+ });
+
+ it('calls sortJobsByStatus', () => {
+ expect(utils.sortJobsByStatus).toHaveBeenCalledWith(
+ failedJobsMock.data.project.pipeline.jobs.nodes,
+ );
+ });
+ });
+
+ describe('when there are no failed jobs', () => {
+ beforeEach(async () => {
+ mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty);
+ jest.spyOn(utils, 'sortJobsByStatus');
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('renders the empty state', () => {
+ expect(findNoFailedJobsText().exists()).toBe(true);
+ });
+ });
+
+ describe('polling', () => {
+ it.each`
+ isGraphqlActive | text
+ ${true} | ${'polls'}
+ ${false} | ${'does not poll'}
+ `(`$text when isGraphqlActive: $isGraphqlActive`, async ({ isGraphqlActive }) => {
+ const defaultCount = 2;
+ const newCount = 1;
+
+ const expectedCount = isGraphqlActive ? newCount : defaultCount;
+ const expectedCallCount = isGraphqlActive ? 2 : 1;
+ const mockResponse = isGraphqlActive ? activeFailedJobsMock : failedJobsMock;
+
+ // Second result is to simulate polling with a different response
+ mockFailedJobsResponse.mockResolvedValueOnce(mockResponse);
+ mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2);
+
+ createComponent();
+ await waitForPromises();
+
+ // Initially, we get the first response which is always the default
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
+ expect(findFailedJobRows()).toHaveLength(defaultCount);
+
+ jest.advanceTimersByTime(10000);
+ await waitForPromises();
+
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(expectedCallCount);
+ expect(findFailedJobRows()).toHaveLength(expectedCount);
+ });
+ });
+
+ describe('when a REST action occurs', () => {
+ beforeEach(() => {
+ // Second result is to simulate polling with a different response
+ mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock);
+ mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2);
+ });
+
+ it.each([true, false])('triggers a refetch of the jobs count', async (isPipelineActive) => {
+ const defaultCount = 2;
+ const newCount = 1;
+
+ createComponent({ props: { isPipelineActive } });
+ await waitForPromises();
+
+ // Initially, we get the first response which is always the default
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
+ expect(findFailedJobRows()).toHaveLength(defaultCount);
+
+ wrapper.setProps({ isPipelineActive: !isPipelineActive });
+ await waitForPromises();
+
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2);
+ expect(findFailedJobRows()).toHaveLength(newCount);
+ });
+ });
+
+ describe('When the job count changes from REST', () => {
+ beforeEach(() => {
+ mockFailedJobsResponse.mockResolvedValue(failedJobsMockEmpty);
+
+ createComponent();
+ });
+
+ describe('and the count is the same', () => {
+ it('does not re-fetch the query', async () => {
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
+
+ await wrapper.setProps({ failedJobsCount: 0 });
+
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('and the count is different', () => {
+ it('re-fetches the query', async () => {
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(1);
+
+ await wrapper.setProps({ failedJobsCount: 10 });
+
+ expect(mockFailedJobsResponse).toHaveBeenCalledTimes(2);
+ });
+ });
+ });
+
+ describe('when an error occurs loading jobs', () => {
+ const errorMessage = "We couldn't fetch jobs for you because you are not qualified";
+
+ beforeEach(async () => {
+ mockFailedJobsResponse.mockRejectedValue({ message: errorMessage });
+
+ createComponent();
+
+ await waitForPromises();
+ });
+ it('does not renders a loading icon', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('calls create Alert with the error message and danger variant', () => {
+ expect(createAlert).toHaveBeenCalledWith({ message: errorMessage, variant: 'danger' });
+ });
+ });
+
+ describe('when `refetch-jobs` job is fired from the widget', () => {
+ beforeEach(async () => {
+ mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock);
+ mockFailedJobsResponse.mockResolvedValueOnce(failedJobsMock2);
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('refetches all failed jobs', async () => {
+ expect(findFailedJobRows()).not.toHaveLength(
+ failedJobsMock2.data.project.pipeline.jobs.nodes.length,
+ );
+
+ await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name');
+ await waitForPromises();
+
+ expect(findFailedJobRows()).toHaveLength(
+ failedJobsMock2.data.project.pipeline.jobs.nodes.length,
+ );
+ });
+
+ it('shows a toast message', async () => {
+ await findFailedJobRows().at(0).vm.$emit('job-retried', 'job-name');
+ await waitForPromises();
+
+ expect(showToast).toHaveBeenCalledWith('job-name job is being retried');
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js b/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js
new file mode 100644
index 00000000000..318d787a984
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/mock.js
@@ -0,0 +1,78 @@
+export const job = {
+ id: 'gid://gitlab/Ci::Build/5241',
+ allowFailure: false,
+ detailedStatus: {
+ id: 'status',
+ detailsPath: '/jobs/5241',
+ action: {
+ id: 'action',
+ path: '/retry',
+ icon: 'retry',
+ },
+ group: 'running',
+ icon: 'status_running_icon',
+ },
+ name: 'job-name',
+ retried: false,
+ retryable: true,
+ kind: 'BUILD',
+ stage: {
+ id: '1',
+ name: 'build',
+ },
+ trace: {
+ htmlSummary: '<h1>Hello</h1>',
+ },
+ userPermissions: {
+ readBuild: true,
+ updateBuild: true,
+ },
+};
+
+export const allowedToFailJob = {
+ ...job,
+ id: 'gid://gitlab/Ci::Build/5242',
+ allowFailure: true,
+};
+
+export const createFailedJobsMockCount = ({ count = 4, active = false } = {}) => {
+ return {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ id: 'gid://gitlab/Pipeline/20',
+ active,
+ jobs: {
+ count,
+ },
+ },
+ },
+ },
+ };
+};
+
+const createFailedJobsMock = (nodes, active = false) => {
+ return {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ active,
+ id: 'gid://gitlab/Pipeline/20',
+ jobs: {
+ count: nodes.length,
+ nodes,
+ },
+ },
+ },
+ },
+ };
+};
+
+export const failedJobsMock = createFailedJobsMock([allowedToFailJob, job]);
+export const failedJobsMockEmpty = createFailedJobsMock([]);
+
+export const activeFailedJobsMock = createFailedJobsMock([allowedToFailJob, job], true);
+
+export const failedJobsMock2 = createFailedJobsMock([job]);
diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js
new file mode 100644
index 00000000000..e52b62feb23
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget_spec.js
@@ -0,0 +1,139 @@
+import { GlButton, GlCard, GlIcon, GlPopover } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue';
+import FailedJobsList from '~/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue';
+
+jest.mock('~/alert');
+
+describe('PipelineFailedJobsWidget component', () => {
+ let wrapper;
+
+ const defaultProps = {
+ failedJobsCount: 4,
+ isPipelineActive: false,
+ pipelineIid: 1,
+ pipelinePath: '/pipelines/1',
+ projectPath: 'namespace/project/',
+ };
+
+ const defaultProvide = {
+ fullPath: 'namespace/project/',
+ };
+
+ const createComponent = ({ props = {}, provide = {} } = {}) => {
+ wrapper = shallowMountExtended(PipelineFailedJobsWidget, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ stubs: { GlCard },
+ });
+ };
+
+ const findFailedJobsCard = () => wrapper.findByTestId('failed-jobs-card');
+ const findFailedJobsButton = () => wrapper.findComponent(GlButton);
+ const findFailedJobsList = () => wrapper.findAllComponents(FailedJobsList);
+ const findInfoIcon = () => wrapper.findComponent(GlIcon);
+ const findInfoPopover = () => wrapper.findComponent(GlPopover);
+
+ describe('when there are no failed jobs', () => {
+ beforeEach(() => {
+ createComponent({ props: { failedJobsCount: 0 } });
+ });
+
+ it('renders the show failed jobs button with a count of 0', () => {
+ expect(findFailedJobsButton().exists()).toBe(true);
+ expect(findFailedJobsButton().text()).toBe('Failed jobs (0)');
+ });
+ });
+
+ describe('when there are failed jobs', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the show failed jobs button with correct count', () => {
+ expect(findFailedJobsButton().exists()).toBe(true);
+ expect(findFailedJobsButton().text()).toBe(`Failed jobs (${defaultProps.failedJobsCount})`);
+ });
+
+ it('renders the info icon', () => {
+ expect(findInfoIcon().exists()).toBe(true);
+ });
+
+ it('renders the info popover', () => {
+ expect(findInfoPopover().exists()).toBe(true);
+ });
+
+ it('does not render the failed jobs widget', () => {
+ expect(findFailedJobsList().exists()).toBe(false);
+ });
+ });
+
+ describe('when the job button is clicked', () => {
+ beforeEach(async () => {
+ createComponent();
+ await findFailedJobsButton().vm.$emit('click');
+ });
+
+ it('renders the failed jobs widget', () => {
+ expect(findFailedJobsList().exists()).toBe(true);
+ });
+
+ it('removes the CSS border classes', () => {
+ expect(findFailedJobsCard().attributes('class')).not.toContain(
+ 'gl-border-white gl-hover-border-gray-100',
+ );
+ });
+ });
+
+ describe('when the job details are not expanded', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('has the CSS border classes', () => {
+ expect(findFailedJobsCard().attributes('class')).toContain(
+ 'gl-border-white gl-hover-border-gray-100',
+ );
+ });
+ });
+
+ describe('when the job count changes', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('from the prop', () => {
+ it('updates the job count', async () => {
+ const newJobCount = 12;
+
+ expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount));
+
+ await wrapper.setProps({ failedJobsCount: newJobCount });
+
+ expect(findFailedJobsButton().text()).toContain(String(newJobCount));
+ });
+ });
+
+ describe('from the event', () => {
+ beforeEach(async () => {
+ await findFailedJobsButton().vm.$emit('click');
+ });
+
+ it('updates the job count', async () => {
+ const newJobCount = 12;
+
+ expect(findFailedJobsButton().text()).toContain(String(defaultProps.failedJobsCount));
+
+ await findFailedJobsList().at(0).vm.$emit('failed-jobs-count', newJobCount);
+
+ expect(findFailedJobsButton().text()).toContain(String(newJobCount));
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js b/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js
new file mode 100644
index 00000000000..5755cd846ac
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/failure_widget/utils_spec.js
@@ -0,0 +1,55 @@
+import { isFailedJob, sortJobsByStatus } from '~/ci/pipelines_page/components/failure_widget/utils';
+
+describe('isFailedJob', () => {
+ describe('when the job argument is undefined', () => {
+ it('returns false', () => {
+ expect(isFailedJob()).toBe(false);
+ });
+ });
+
+ describe('when the job is of status `failed`', () => {
+ it('returns false', () => {
+ expect(isFailedJob({ detailedStatus: { group: 'success' } })).toBe(false);
+ });
+ });
+
+ describe('when the job status is `failed`', () => {
+ it('returns true', () => {
+ expect(isFailedJob({ detailedStatus: { group: 'failed' } })).toBe(true);
+ });
+ });
+});
+
+describe('sortJobsByStatus', () => {
+ describe('when the arg is undefined', () => {
+ it('returns an empty array', () => {
+ expect(sortJobsByStatus()).toEqual([]);
+ });
+ });
+
+ describe('when receiving an empty array', () => {
+ it('returns an empty array', () => {
+ expect(sortJobsByStatus([])).toEqual([]);
+ });
+ });
+
+ describe('when reciving a list of jobs', () => {
+ const jobArr = [
+ { detailedStatus: { group: 'failed' } },
+ { detailedStatus: { group: 'allowed_to_fail' } },
+ { detailedStatus: { group: 'failed' } },
+ { detailedStatus: { group: 'success' } },
+ ];
+
+ const expectedResult = [
+ { detailedStatus: { group: 'failed' } },
+ { detailedStatus: { group: 'failed' } },
+ { detailedStatus: { group: 'allowed_to_fail' } },
+ { detailedStatus: { group: 'success' } },
+ ];
+
+ it('sorts failed jobs first', () => {
+ expect(sortJobsByStatus(jobArr)).toEqual(expectedResult);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js b/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js
new file mode 100644
index 00000000000..f4858ac27ea
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/nav_controls_spec.js
@@ -0,0 +1,80 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import NavControls from '~/ci/pipelines_page/components/nav_controls.vue';
+
+describe('Pipelines Nav Controls', () => {
+ let wrapper;
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(NavControls, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button');
+ const findCiLintButton = () => wrapper.findByTestId('ci-lint-button');
+ const findClearCacheButton = () => wrapper.findByTestId('clear-cache-button');
+
+ it('should render link to create a new pipeline', () => {
+ const mockData = {
+ newPipelinePath: 'foo',
+ ciLintPath: 'foo',
+ resetCachePath: 'foo',
+ };
+
+ createComponent(mockData);
+
+ const runPipelineButton = findRunPipelineButton();
+ expect(runPipelineButton.text()).toContain('Run pipeline');
+ expect(runPipelineButton.attributes('href')).toBe(mockData.newPipelinePath);
+ });
+
+ it('should not render link to create pipeline if no path is provided', () => {
+ const mockData = {
+ helpPagePath: 'foo',
+ ciLintPath: 'foo',
+ resetCachePath: 'foo',
+ };
+
+ createComponent(mockData);
+
+ expect(findRunPipelineButton().exists()).toBe(false);
+ });
+
+ it('should render link for CI lint', () => {
+ const mockData = {
+ newPipelinePath: 'foo',
+ helpPagePath: 'foo',
+ ciLintPath: 'foo',
+ resetCachePath: 'foo',
+ };
+
+ createComponent(mockData);
+ const ciLintButton = findCiLintButton();
+
+ expect(ciLintButton.text()).toContain('CI lint');
+ expect(ciLintButton.attributes('href')).toBe(mockData.ciLintPath);
+ });
+
+ describe('Reset Runners Cache', () => {
+ beforeEach(() => {
+ const mockData = {
+ newPipelinePath: 'foo',
+ ciLintPath: 'foo',
+ resetCachePath: 'foo',
+ };
+ createComponent(mockData);
+ });
+
+ it('should render button for resetting runner caches', () => {
+ expect(findClearCacheButton().text()).toContain('Clear runner caches');
+ });
+
+ it('should emit postAction event when reset runner cache button is clicked', () => {
+ findClearCacheButton().vm.$emit('click');
+
+ expect(wrapper.emitted('resetRunnersCache')).toEqual([['foo']]);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js
new file mode 100644
index 00000000000..b5c9a3030e0
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_labels_spec.js
@@ -0,0 +1,164 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { trimText } from 'helpers/text_helper';
+import PipelineLabelsComponent from '~/ci/pipelines_page/components/pipeline_labels.vue';
+import { mockPipeline } from 'jest/ci/pipeline_details/mock_data';
+
+const projectPath = 'test/test';
+
+describe('Pipeline label component', () => {
+ let wrapper;
+
+ const findScheduledTag = () => wrapper.findByTestId('pipeline-url-scheduled');
+ const findLatestTag = () => wrapper.findByTestId('pipeline-url-latest');
+ const findYamlTag = () => wrapper.findByTestId('pipeline-url-yaml');
+ const findStuckTag = () => wrapper.findByTestId('pipeline-url-stuck');
+ const findAutoDevopsTag = () => wrapper.findByTestId('pipeline-url-autodevops');
+ const findAutoDevopsTagLink = () => wrapper.findByTestId('pipeline-url-autodevops-link');
+ const findDetachedTag = () => wrapper.findByTestId('pipeline-url-detached');
+ const findFailureTag = () => wrapper.findByTestId('pipeline-url-failure');
+ const findForkTag = () => wrapper.findByTestId('pipeline-url-fork');
+ const findTrainTag = () => wrapper.findByTestId('pipeline-url-train');
+
+ const defaultProps = mockPipeline(projectPath);
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(PipelineLabelsComponent, {
+ propsData: { ...defaultProps, ...props },
+ provide: {
+ targetProjectFullPath: projectPath,
+ },
+ });
+ };
+
+ it('should not render tags when flags are not set', () => {
+ createComponent();
+
+ expect(findStuckTag().exists()).toBe(false);
+ expect(findLatestTag().exists()).toBe(false);
+ expect(findYamlTag().exists()).toBe(false);
+ expect(findAutoDevopsTag().exists()).toBe(false);
+ expect(findFailureTag().exists()).toBe(false);
+ expect(findScheduledTag().exists()).toBe(false);
+ expect(findForkTag().exists()).toBe(false);
+ expect(findTrainTag().exists()).toBe(false);
+ });
+
+ it('should render the stuck tag when flag is provided', () => {
+ const stuckPipeline = defaultProps.pipeline;
+ stuckPipeline.flags.stuck = true;
+
+ createComponent({
+ ...stuckPipeline.pipeline,
+ });
+
+ expect(findStuckTag().text()).toContain('stuck');
+ });
+
+ it('should render latest tag when flag is provided', () => {
+ const latestPipeline = defaultProps.pipeline;
+ latestPipeline.flags.latest = true;
+
+ createComponent({
+ ...latestPipeline,
+ });
+
+ expect(findLatestTag().text()).toContain('latest');
+ });
+
+ it('should render a yaml badge when it is invalid', () => {
+ const yamlPipeline = defaultProps.pipeline;
+ yamlPipeline.flags.yaml_errors = true;
+
+ createComponent({
+ ...yamlPipeline,
+ });
+
+ expect(findYamlTag().text()).toContain('yaml invalid');
+ });
+
+ it('should render an autodevops badge when flag is provided', () => {
+ const autoDevopsPipeline = defaultProps.pipeline;
+ autoDevopsPipeline.flags.auto_devops = true;
+
+ createComponent({
+ ...autoDevopsPipeline,
+ });
+
+ expect(trimText(findAutoDevopsTag().text())).toBe('Auto DevOps');
+
+ expect(findAutoDevopsTagLink().attributes()).toMatchObject({
+ href: '/help/topics/autodevops/index.md',
+ target: '_blank',
+ });
+ });
+
+ it('should render a detached badge when flag is provided', () => {
+ const detachedMRPipeline = defaultProps.pipeline;
+ detachedMRPipeline.flags.detached_merge_request_pipeline = true;
+
+ createComponent({
+ ...detachedMRPipeline,
+ });
+
+ expect(findDetachedTag().text()).toBe('merge request');
+ });
+
+ it('should render error badge when pipeline has a failure reason set', () => {
+ const failedPipeline = defaultProps.pipeline;
+ failedPipeline.flags.failure_reason = true;
+ failedPipeline.failure_reason = 'some reason';
+
+ createComponent({
+ ...failedPipeline,
+ });
+
+ expect(findFailureTag().text()).toContain('error');
+ expect(findFailureTag().attributes('title')).toContain('some reason');
+ });
+
+ it('should render scheduled badge when pipeline was triggered by a schedule', () => {
+ const scheduledPipeline = defaultProps.pipeline;
+ scheduledPipeline.source = 'schedule';
+
+ createComponent({
+ ...scheduledPipeline,
+ });
+
+ expect(findScheduledTag().exists()).toBe(true);
+ expect(findScheduledTag().text()).toContain('Scheduled');
+ });
+
+ it('should render the fork badge when the pipeline was run in a fork', () => {
+ const forkedPipeline = defaultProps.pipeline;
+ forkedPipeline.project.full_path = '/test/forked';
+
+ createComponent({
+ ...forkedPipeline,
+ });
+
+ expect(findForkTag().exists()).toBe(true);
+ expect(findForkTag().text()).toBe('fork');
+ });
+
+ it('should render the train badge when the pipeline is a merge train pipeline', () => {
+ const mergeTrainPipeline = defaultProps.pipeline;
+ mergeTrainPipeline.flags.merge_train_pipeline = true;
+
+ createComponent({
+ ...mergeTrainPipeline,
+ });
+
+ expect(findTrainTag().text()).toBe('merge train');
+ });
+
+ it('should not render the train badge when the pipeline is not a merge train pipeline', () => {
+ const mergeTrainPipeline = defaultProps.pipeline;
+ mergeTrainPipeline.flags.merge_train_pipeline = false;
+
+ createComponent({
+ ...mergeTrainPipeline,
+ });
+
+ expect(findTrainTag().exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js
new file mode 100644
index 00000000000..7ae21db8815
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_multi_actions_spec.js
@@ -0,0 +1,316 @@
+import { nextTick } from 'vue';
+import {
+ GlAlert,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlSprintf,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { stubComponent } from 'helpers/stub_component';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import PipelineMultiActions, {
+ i18n,
+} from '~/ci/pipelines_page/components/pipeline_multi_actions.vue';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
+
+describe('Pipeline Multi Actions Dropdown', () => {
+ let wrapper;
+ let mockAxios;
+
+ const artifacts = [
+ {
+ name: 'job my-artifact',
+ path: '/download/path',
+ },
+ {
+ name: 'job-2 my-artifact-2',
+ path: '/download/path-two',
+ },
+ ];
+ const newArtifacts = [
+ {
+ name: 'job-3 my-new-artifact',
+ path: '/new/download/path',
+ },
+ {
+ name: 'job-4 my-new-artifact-2',
+ path: '/new/download/path-two',
+ },
+ {
+ name: 'job-5 my-new-artifact-3',
+ path: '/new/download/path-three',
+ },
+ ];
+ const artifactItemTestId = 'artifact-item';
+ const artifactsEndpointPlaceholder = ':pipeline_artifacts_id';
+ const artifactsEndpoint = `endpoint/${artifactsEndpointPlaceholder}/artifacts.json`;
+ const pipelineId = 108;
+
+ const createComponent = () => {
+ wrapper = extendedWrapper(
+ shallowMount(PipelineMultiActions, {
+ provide: {
+ artifactsEndpoint,
+ artifactsEndpointPlaceholder,
+ },
+ propsData: {
+ pipelineId,
+ },
+ stubs: {
+ GlAlert,
+ GlSprintf,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlSearchBoxByType: stubComponent(GlSearchBoxByType),
+ },
+ }),
+ );
+ };
+
+ const findAlert = () => wrapper.findByTestId('artifacts-fetch-error');
+ const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findFirstArtifactItem = () => wrapper.findByTestId(artifactItemTestId);
+ const findAllArtifactItemsData = () =>
+ findDropdown()
+ .props('items')
+ .map(({ text, href }) => ({
+ name: text,
+ path: href,
+ }));
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findEmptyMessage = () => wrapper.findByTestId('artifacts-empty-message');
+ const findWarning = () => wrapper.findByTestId('artifacts-fetch-warning');
+ const changePipelineId = (newId) => wrapper.setProps({ pipelineId: newId });
+
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mockAxios.restore();
+ });
+
+ it('should render the dropdown', () => {
+ createComponent();
+
+ expect(findDropdown().exists()).toBe(true);
+ });
+
+ describe('Artifacts', () => {
+ const endpoint = artifactsEndpoint.replace(artifactsEndpointPlaceholder, pipelineId);
+
+ describe('while loading artifacts', () => {
+ beforeEach(() => {
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts });
+ });
+
+ it('should render a loading spinner and no empty message', async () => {
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findEmptyMessage().exists()).toBe(false);
+ });
+ });
+
+ describe('artifacts loaded successfully', () => {
+ describe('artifacts exist', () => {
+ beforeEach(async () => {
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts });
+
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+ await waitForPromises();
+ });
+
+ it('should fetch artifacts and show search box on dropdown click', () => {
+ expect(mockAxios.history.get).toHaveLength(1);
+ expect(findSearchBox().exists()).toBe(true);
+ });
+
+ it('should focus the search box when opened with artifacts', () => {
+ findDropdown().vm.$emit('shown');
+
+ expect(findSearchBox().attributes('autofocus')).not.toBe(undefined);
+ });
+
+ it('should clear searchQuery when dropdown is closed', async () => {
+ findDropdown().vm.$emit('shown');
+ findSearchBox().vm.$emit('input', 'job-2');
+ await waitForPromises();
+
+ expect(findSearchBox().vm.value).toBe('job-2');
+
+ findDropdown().vm.$emit('hidden');
+ await waitForPromises();
+
+ expect(findSearchBox().vm.value).toBe('');
+ });
+
+ it('should render all the provided artifacts when search query is empty', async () => {
+ findSearchBox().vm.$emit('input', '');
+ await waitForPromises();
+
+ expect(findAllArtifactItemsData()).toEqual(
+ artifacts.map(({ name, path }) => ({ name, path })),
+ );
+ expect(findEmptyMessage().exists()).toBe(false);
+ });
+
+ it('should render filtered artifacts when search query is not empty', async () => {
+ findSearchBox().vm.$emit('input', 'job-2');
+ await waitForPromises();
+
+ expect(findAllArtifactItemsData()).toEqual([
+ {
+ name: 'job-2 my-artifact-2',
+ path: '/download/path-two',
+ },
+ ]);
+ expect(findEmptyMessage().exists()).toBe(false);
+ });
+
+ it('should render the correct artifact name and path', () => {
+ expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path);
+ expect(findFirstArtifactItem().text()).toBe(artifacts[0].name);
+ });
+
+ describe('when opened again with new artifacts', () => {
+ describe('with a successful refetch', () => {
+ beforeEach(async () => {
+ mockAxios.resetHistory();
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: newArtifacts });
+
+ findDropdown().vm.$emit('shown');
+ await nextTick();
+ });
+
+ it('should hide list and render a loading spinner on dropdown click', () => {
+ expect(findAllArtifactItemsData()).toHaveLength(0);
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('should not render warning or empty message while loading', () => {
+ expect(findEmptyMessage().exists()).toBe(false);
+ expect(findWarning().exists()).toBe(false);
+ });
+
+ it('should render the correct new list', async () => {
+ await waitForPromises();
+
+ expect(findAllArtifactItemsData()).toEqual(newArtifacts);
+ });
+ });
+
+ describe('with a failing refetch', () => {
+ beforeEach(async () => {
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ findDropdown().vm.$emit('shown');
+ await waitForPromises();
+ });
+
+ it('should render warning', () => {
+ expect(findWarning().text()).toBe(i18n.artifactsFetchWarningMessage);
+ });
+
+ it('should render old list', () => {
+ expect(findAllArtifactItemsData()).toEqual(artifacts);
+ });
+ });
+ });
+
+ describe('pipeline id has changed', () => {
+ const newEndpoint = artifactsEndpoint.replace(
+ artifactsEndpointPlaceholder,
+ pipelineId + 1,
+ );
+
+ beforeEach(() => {
+ changePipelineId(pipelineId + 1);
+ });
+
+ describe('followed by a failing request', () => {
+ beforeEach(async () => {
+ mockAxios.onGet(newEndpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ findDropdown().vm.$emit('shown');
+ await waitForPromises();
+ });
+
+ it('should render error message and no warning', () => {
+ expect(findWarning().exists()).toBe(false);
+ expect(findAlert().text()).toBe(i18n.artifactsFetchErrorMessage);
+ });
+
+ it('should clear list', () => {
+ expect(findAllArtifactItemsData()).toHaveLength(0);
+ });
+ });
+ });
+ });
+
+ describe('artifacts list is empty', () => {
+ beforeEach(() => {
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_OK, { artifacts: [] });
+ });
+
+ it('should render empty message and no search box when no artifacts are found', async () => {
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+ await waitForPromises();
+
+ expect(findEmptyMessage().exists()).toBe(true);
+ expect(findSearchBox().exists()).toBe(false);
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mockAxios.onGet(endpoint).replyOnce(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ });
+
+ it('should render an error message', async () => {
+ createComponent();
+ findDropdown().vm.$emit('shown');
+ await waitForPromises();
+
+ const error = findAlert();
+ expect(error.exists()).toBe(true);
+ expect(error.text()).toBe(i18n.artifactsFetchErrorMessage);
+ });
+ });
+ });
+
+ describe('tracking', () => {
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks artifacts dropdown click', () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_artifacts_dropdown', {
+ label: TRACKING_CATEGORIES.table,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js
new file mode 100644
index 00000000000..d2eab64b317
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_operations_spec.js
@@ -0,0 +1,77 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue';
+import PipelineMultiActions from '~/ci/pipelines_page/components/pipeline_multi_actions.vue';
+import PipelineOperations from '~/ci/pipelines_page/components/pipeline_operations.vue';
+import eventHub from '~/ci/event_hub';
+
+describe('Pipeline operations', () => {
+ let wrapper;
+
+ const defaultProps = {
+ pipeline: {
+ id: 329,
+ iid: 234,
+ details: {
+ has_manual_actions: true,
+ has_scheduled_actions: false,
+ },
+ flags: {
+ retryable: true,
+ cancelable: true,
+ },
+ cancel_path: '/root/ci-project/-/pipelines/329/cancel',
+ retry_path: '/root/ci-project/-/pipelines/329/retry',
+ },
+ };
+
+ const createComponent = (props = defaultProps) => {
+ wrapper = shallowMountExtended(PipelineOperations, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ const findManualActions = () => wrapper.findComponent(PipelinesManualActions);
+ const findMultiActions = () => wrapper.findComponent(PipelineMultiActions);
+ const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button');
+ const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button');
+
+ it('should display pipeline manual actions', () => {
+ createComponent();
+
+ expect(findManualActions().exists()).toBe(true);
+ });
+
+ it('should display pipeline multi actions', () => {
+ createComponent();
+
+ expect(findMultiActions().exists()).toBe(true);
+ });
+
+ describe('events', () => {
+ beforeEach(() => {
+ createComponent();
+
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ it('should emit retryPipeline event', () => {
+ findRetryBtn().vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ 'retryPipeline',
+ defaultProps.pipeline.retry_path,
+ );
+ });
+
+ it('should emit openConfirmationModal event', () => {
+ findCancelBtn().vm.$emit('click');
+
+ expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', {
+ pipeline: defaultProps.pipeline,
+ endpoint: defaultProps.pipeline.cancel_path,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js
new file mode 100644
index 00000000000..4d78a923542
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_stop_modal_spec.js
@@ -0,0 +1,27 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlSprintf } from '@gitlab/ui';
+import { mockPipelineHeader } from 'jest/ci/pipeline_details/mock_data';
+import PipelineStopModal from '~/ci/pipelines_page/components/pipeline_stop_modal.vue';
+
+describe('PipelineStopModal', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineStopModal, {
+ propsData: {
+ pipeline: mockPipelineHeader,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should render "stop pipeline" warning', () => {
+ expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`);
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js
new file mode 100644
index 00000000000..cb04171f031
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_triggerer_spec.js
@@ -0,0 +1,76 @@
+import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import pipelineTriggerer from '~/ci/pipelines_page/components/pipeline_triggerer.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+
+describe('Pipelines Triggerer', () => {
+ let wrapper;
+
+ const mockData = {
+ pipeline: {
+ user: {
+ name: 'foo',
+ avatar_url: '/avatar',
+ path: '/path',
+ },
+ },
+ };
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(pipelineTriggerer, {
+ propsData: {
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ });
+ };
+
+ const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
+ const findAvatar = () => wrapper.findComponent(GlAvatar);
+ const findTriggerer = () => wrapper.findByText('API');
+
+ describe('when user was a triggerer', () => {
+ beforeEach(() => {
+ createComponent(mockData);
+ });
+
+ it('should render pipeline triggerer table cell', () => {
+ expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true);
+ });
+
+ it('should render only user avatar', () => {
+ expect(findAvatarLink().exists()).toBe(true);
+ expect(findTriggerer().exists()).toBe(false);
+ });
+
+ it('should set correct props on avatar link component', () => {
+ expect(findAvatarLink().attributes()).toMatchObject({
+ title: mockData.pipeline.user.name,
+ href: mockData.pipeline.user.path,
+ });
+ });
+
+ it('should add tooltip to avatar link', () => {
+ const tooltip = getBinding(findAvatarLink().element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ });
+
+ it('should set correct props on avatar component', () => {
+ expect(findAvatar().attributes().src).toBe(mockData.pipeline.user.avatar_url);
+ });
+ });
+
+ describe('when API was a triggerer', () => {
+ beforeEach(() => {
+ createComponent({ pipeline: {} });
+ });
+
+ it('should render label only', () => {
+ expect(findAvatarLink().exists()).toBe(false);
+ expect(findTriggerer().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js
new file mode 100644
index 00000000000..0ee22dda826
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js
@@ -0,0 +1,188 @@
+import { merge } from 'lodash';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import PipelineUrlComponent from '~/ci/pipelines_page/components/pipeline_url.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
+import {
+ mockPipeline,
+ mockPipelineBranch,
+ mockPipelineTag,
+} from 'jest/ci/pipeline_details/mock_data';
+
+const projectPath = 'test/test';
+
+describe('Pipeline Url Component', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const findTableCell = () => wrapper.findByTestId('pipeline-url-table-cell');
+ const findPipelineUrlLink = () => wrapper.findByTestId('pipeline-url-link');
+ const findRefName = () => wrapper.findByTestId('merge-request-ref');
+ const findCommitShortSha = () => wrapper.findByTestId('commit-short-sha');
+ const findCommitIcon = () => wrapper.findByTestId('commit-icon');
+ const findCommitIconType = () => wrapper.findByTestId('commit-icon-type');
+ const findCommitRefName = () => wrapper.findByTestId('commit-ref-name');
+
+ const findCommitTitleContainer = () => wrapper.findByTestId('commit-title-container');
+ const findPipelineNameContainer = () => wrapper.findByTestId('pipeline-name-container');
+ const findCommitTitle = (commitWrapper) => commitWrapper.find('[data-testid="commit-title"]');
+
+ const defaultProps = { ...mockPipeline(projectPath), refClass: 'gl-text-black' };
+
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(PipelineUrlComponent, {
+ propsData: { ...defaultProps, ...props },
+ provide: {
+ targetProjectFullPath: projectPath,
+ },
+ });
+ };
+
+ it('should render pipeline url table cell', () => {
+ createComponent();
+
+ expect(findTableCell().exists()).toBe(true);
+ });
+
+ it('should render a link the provided path and id', () => {
+ createComponent();
+
+ expect(findPipelineUrlLink().attributes('href')).toBe('foo');
+
+ expect(findPipelineUrlLink().text()).toBe('#1');
+ });
+
+ it('should render the pipeline name instead of commit title', () => {
+ createComponent(merge(mockPipeline(projectPath), { pipeline: { name: 'Build pipeline' } }));
+
+ expect(findCommitTitleContainer().exists()).toBe(false);
+ expect(findPipelineNameContainer().exists()).toBe(true);
+ expect(findRefName().exists()).toBe(true);
+ expect(findCommitShortSha().exists()).toBe(true);
+ });
+
+ it('should render the commit title when pipeline has no name', () => {
+ createComponent();
+
+ const commitWrapper = findCommitTitleContainer();
+
+ expect(findCommitTitle(commitWrapper).exists()).toBe(true);
+ expect(findRefName().exists()).toBe(true);
+ expect(findCommitShortSha().exists()).toBe(true);
+ expect(findPipelineNameContainer().exists()).toBe(false);
+ });
+
+ it('should pass the refClass prop to merge request link', () => {
+ createComponent();
+
+ expect(findRefName().classes()).toContain(defaultProps.refClass);
+ });
+
+ it('should pass the refClass prop to the commit ref name link', () => {
+ createComponent(mockPipelineBranch());
+
+ expect(findCommitRefName().classes()).toContain(defaultProps.refClass);
+ });
+
+ describe('commit user avatar', () => {
+ it('renders when commit author exists', () => {
+ const pipelineBranch = mockPipelineBranch();
+ const { avatar_url: imgSrc, name, path } = pipelineBranch.pipeline.commit.author;
+ createComponent(pipelineBranch);
+
+ const component = wrapper.findComponent(UserAvatarLink);
+ expect(component.exists()).toBe(true);
+ expect(component.props()).toMatchObject({
+ imgSize: 16,
+ imgSrc,
+ imgAlt: name,
+ linkHref: path,
+ tooltipText: name,
+ });
+ });
+
+ it('does not render when commit author does not exist', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(UserAvatarLink).exists()).toBe(false);
+ });
+ });
+
+ it('should render commit icon tooltip', () => {
+ createComponent();
+
+ expect(findCommitIcon().attributes('title')).toBe('Commit');
+ });
+
+ it.each`
+ pipeline | expectedTitle
+ ${mockPipelineTag()} | ${'Tag'}
+ ${mockPipelineBranch()} | ${'Branch'}
+ ${mockPipeline()} | ${'Merge Request'}
+ `('should render tooltip $expectedTitle for commit icon type', ({ pipeline, expectedTitle }) => {
+ createComponent(pipeline);
+
+ expect(findCommitIconType().attributes('title')).toBe(expectedTitle);
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks pipeline id click', () => {
+ createComponent();
+
+ findPipelineUrlLink().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_pipeline_id', {
+ label: TRACKING_CATEGORIES.table,
+ });
+ });
+
+ it('tracks merge request ref click', () => {
+ createComponent();
+
+ findRefName().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_mr_ref', {
+ label: TRACKING_CATEGORIES.table,
+ });
+ });
+
+ it('tracks commit ref name click', () => {
+ createComponent(mockPipelineBranch());
+
+ findCommitRefName().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_name', {
+ label: TRACKING_CATEGORIES.table,
+ });
+ });
+
+ it('tracks commit title click', () => {
+ createComponent(merge(mockPipelineBranch(), { pipeline: { name: null } }));
+
+ findCommitTitle(findCommitTitleContainer()).vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_title', {
+ label: TRACKING_CATEGORIES.table,
+ });
+ });
+
+ it('tracks commit short sha click', () => {
+ createComponent(mockPipelineBranch());
+
+ findCommitShortSha().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_commit_sha', {
+ label: TRACKING_CATEGORIES.table,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js
new file mode 100644
index 00000000000..557403b3de9
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipelines_artifacts_spec.js
@@ -0,0 +1,64 @@
+import {
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
+ GlSprintf,
+} from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue';
+
+describe('Pipelines Artifacts dropdown', () => {
+ let wrapper;
+
+ const artifacts = [
+ {
+ name: 'job my-artifact',
+ path: '/download/path',
+ },
+ {
+ name: 'job-2 my-artifact-2',
+ path: '/download/path-two',
+ },
+ ];
+ const pipelineId = 108;
+
+ const createComponent = ({ mockArtifacts = artifacts } = {}) => {
+ wrapper = shallowMount(PipelineArtifacts, {
+ propsData: {
+ pipelineId,
+ artifacts: mockArtifacts,
+ },
+ stubs: {
+ GlSprintf,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownItem,
+ GlDisclosureDropdownGroup,
+ },
+ });
+ };
+
+ const findGlDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
+ const findFirstGlDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
+
+ it('should render a dropdown with all the provided artifacts', () => {
+ createComponent();
+
+ const [{ items }] = findGlDropdown().props('items');
+ expect(items).toHaveLength(artifacts.length);
+ });
+
+ it('should render a link with the provided path', () => {
+ createComponent();
+
+ expect(findFirstGlDropdownItem().props('item').href).toBe(artifacts[0].path);
+ expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name);
+ });
+
+ describe('with no artifacts', () => {
+ it('should not render the dropdown', () => {
+ createComponent({ mockArtifacts: [] });
+
+ expect(findGlDropdown().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js
new file mode 100644
index 00000000000..4cd85b86e31
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipelines_filtered_search_spec.js
@@ -0,0 +1,199 @@
+import { GlFilteredSearch } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { nextTick } from 'vue';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import Api from '~/api';
+import axios from '~/lib/utils/axios_utils';
+import PipelinesFilteredSearch from '~/ci/pipelines_page/components/pipelines_filtered_search.vue';
+import {
+ FILTERED_SEARCH_TERM,
+ OPERATORS_IS,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
+import { users, mockSearch, branches, tags } from 'jest/ci/pipeline_details/mock_data';
+
+describe('Pipelines filtered search', () => {
+ let wrapper;
+ let mock;
+
+ const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
+ const getSearchToken = (type) =>
+ findFilteredSearch()
+ .props('availableTokens')
+ .find((token) => token.type === type);
+ const findBranchToken = () => getSearchToken('ref');
+ const findTagToken = () => getSearchToken('tag');
+ const findUserToken = () => getSearchToken('username');
+ const findStatusToken = () => getSearchToken('status');
+ const findSourceToken = () => getSearchToken('source');
+
+ const createComponent = (params = {}) => {
+ wrapper = mount(PipelinesFilteredSearch, {
+ propsData: {
+ projectId: '21',
+ defaultBranchName: 'main',
+ params,
+ },
+ attachTo: document.body,
+ });
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
+ jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags });
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('displays UI elements', () => {
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+
+ it('displays search tokens', () => {
+ expect(findUserToken()).toMatchObject({
+ type: 'username',
+ icon: 'user',
+ title: 'Trigger author',
+ unique: true,
+ projectId: '21',
+ operators: OPERATORS_IS,
+ });
+
+ expect(findBranchToken()).toMatchObject({
+ type: 'ref',
+ icon: 'branch',
+ title: 'Branch name',
+ unique: true,
+ projectId: '21',
+ defaultBranchName: 'main',
+ operators: OPERATORS_IS,
+ });
+
+ expect(findSourceToken()).toMatchObject({
+ type: 'source',
+ icon: 'trigger-source',
+ title: 'Source',
+ unique: true,
+ operators: OPERATORS_IS,
+ });
+
+ expect(findStatusToken()).toMatchObject({
+ type: 'status',
+ icon: 'status',
+ title: 'Status',
+ unique: true,
+ operators: OPERATORS_IS,
+ });
+
+ expect(findTagToken()).toMatchObject({
+ type: 'tag',
+ icon: 'tag',
+ title: 'Tag name',
+ unique: true,
+ operators: OPERATORS_IS,
+ });
+ });
+
+ it('emits filterPipelines on submit with correct filter', () => {
+ findFilteredSearch().vm.$emit('submit', mockSearch);
+
+ expect(wrapper.emitted('filterPipelines')).toHaveLength(1);
+ expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]);
+ });
+
+ it('disables tag name token when branch name token is active', async () => {
+ findFilteredSearch().vm.$emit('input', [
+ { type: 'ref', value: { data: 'branch-1', operator: '=' } },
+ { type: FILTERED_SEARCH_TERM, value: { data: '' } },
+ ]);
+
+ await nextTick();
+ expect(findBranchToken().disabled).toBe(false);
+ expect(findTagToken().disabled).toBe(true);
+ });
+
+ it('disables branch name token when tag name token is active', async () => {
+ findFilteredSearch().vm.$emit('input', [
+ { type: 'tag', value: { data: 'tag-1', operator: '=' } },
+ { type: FILTERED_SEARCH_TERM, value: { data: '' } },
+ ]);
+
+ await nextTick();
+ expect(findBranchToken().disabled).toBe(true);
+ expect(findTagToken().disabled).toBe(false);
+ });
+
+ it('resets tokens disabled state on clear', async () => {
+ findFilteredSearch().vm.$emit('clearInput');
+
+ await nextTick();
+ expect(findBranchToken().disabled).toBe(false);
+ expect(findTagToken().disabled).toBe(false);
+ });
+
+ it('resets tokens disabled state when clearing tokens by backspace', async () => {
+ findFilteredSearch().vm.$emit('input', [{ type: FILTERED_SEARCH_TERM, value: { data: '' } }]);
+
+ await nextTick();
+ expect(findBranchToken().disabled).toBe(false);
+ expect(findTagToken().disabled).toBe(false);
+ });
+
+ describe('Url query params', () => {
+ const params = {
+ username: 'deja.green',
+ ref: 'main',
+ };
+
+ beforeEach(() => {
+ createComponent(params);
+ });
+
+ it('sets default value if url query params', () => {
+ const expectedValueProp = [
+ {
+ type: 'username',
+ value: {
+ data: params.username,
+ operator: '=',
+ },
+ },
+ {
+ type: 'ref',
+ value: {
+ data: params.ref,
+ operator: '=',
+ },
+ },
+ { type: FILTERED_SEARCH_TERM, value: { data: '' } },
+ ];
+
+ expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp);
+ expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length);
+ });
+ });
+
+ describe('tracking', () => {
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks filtered search click', () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ findFilteredSearch().vm.$emit('submit', mockSearch);
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filtered_search', {
+ label: TRACKING_CATEGORIES.search,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js b/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js
new file mode 100644
index 00000000000..a24e136f1ff
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/pipelines_manual_actions_spec.js
@@ -0,0 +1,216 @@
+import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import mockPipelineActionsQueryResponse from 'test_fixtures/graphql/pipelines/get_pipeline_actions.query.graphql.json';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import PipelinesManualActions from '~/ci/pipelines_page/components/pipelines_manual_actions.vue';
+import getPipelineActionsQuery from '~/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql';
+import { TRACKING_CATEGORIES } from '~/ci/constants';
+import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+
+Vue.use(VueApollo);
+
+jest.mock('~/alert');
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+
+describe('Pipeline manual actions', () => {
+ let wrapper;
+ let mock;
+
+ const queryHandler = jest.fn().mockResolvedValue(mockPipelineActionsQueryResponse);
+ const {
+ data: {
+ project: {
+ pipeline: {
+ jobs: { nodes },
+ },
+ },
+ },
+ } = mockPipelineActionsQueryResponse;
+
+ const mockPath = nodes[2].playPath;
+
+ const createComponent = (limit = 50) => {
+ wrapper = shallowMountExtended(PipelinesManualActions, {
+ provide: {
+ fullPath: 'root/ci-project',
+ manualActionsLimit: limit,
+ },
+ propsData: {
+ iid: 100,
+ },
+ stubs: {
+ GlDropdown,
+ },
+ apolloProvider: createMockApollo([[getPipelineActionsQuery, queryHandler]]),
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findLimitMessage = () => wrapper.findByTestId('limit-reached-msg');
+
+ it('skips calling query on mount', () => {
+ createComponent();
+
+ expect(queryHandler).not.toHaveBeenCalled();
+ });
+
+ describe('loading', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+ });
+
+ it('display loading state while actions are being fetched', () => {
+ expect(findAllDropdownItems().at(0).text()).toBe('Loading...');
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findAllDropdownItems()).toHaveLength(1);
+ });
+ });
+
+ describe('loaded', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+
+ await waitForPromises();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ confirmAction.mockReset();
+ });
+
+ it('displays dropdown with the provided actions', () => {
+ expect(findAllDropdownItems()).toHaveLength(3);
+ });
+
+ it("displays a disabled action when it's not playable", () => {
+ expect(findAllDropdownItems().at(0).attributes('disabled')).toBeDefined();
+ });
+
+ describe('on action click', () => {
+ it('makes a request and toggles the loading state', async () => {
+ mock.onPost(mockPath).reply(HTTP_STATUS_OK);
+
+ findAllDropdownItems().at(1).vm.$emit('click');
+
+ await nextTick();
+
+ expect(findDropdown().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findDropdown().props('loading')).toBe(false);
+ });
+
+ it('makes a failed request and toggles the loading state', async () => {
+ mock.onPost(mockPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+
+ findAllDropdownItems().at(1).vm.$emit('click');
+
+ await nextTick();
+
+ expect(findDropdown().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findDropdown().props('loading')).toBe(false);
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('tracking', () => {
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks manual actions click', () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ findDropdown().vm.$emit('shown');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', {
+ label: TRACKING_CATEGORIES.table,
+ });
+ });
+ });
+
+ describe('scheduled jobs', () => {
+ beforeEach(() => {
+ jest
+ .spyOn(Date, 'now')
+ .mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime());
+ });
+
+ it('makes post request after confirming', async () => {
+ mock.onPost(mockPath).reply(HTTP_STATUS_OK);
+
+ confirmAction.mockResolvedValueOnce(true);
+
+ findAllDropdownItems().at(2).vm.$emit('click');
+
+ expect(confirmAction).toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(mock.history.post).toHaveLength(1);
+ });
+
+ it('does not make post request if confirmation is cancelled', async () => {
+ mock.onPost(mockPath).reply(HTTP_STATUS_OK);
+
+ confirmAction.mockResolvedValueOnce(false);
+
+ findAllDropdownItems().at(2).vm.$emit('click');
+
+ expect(confirmAction).toHaveBeenCalled();
+
+ await waitForPromises();
+
+ expect(mock.history.post).toHaveLength(0);
+ });
+
+ it('displays the remaining time in the dropdown', () => {
+ expect(findAllCountdowns().at(0).props('endDateString')).toBe(nodes[2].scheduledAt);
+ });
+ });
+ });
+
+ describe('limit message', () => {
+ it('limit message does not show', async () => {
+ createComponent();
+
+ findDropdown().vm.$emit('shown');
+
+ await waitForPromises();
+
+ expect(findLimitMessage().exists()).toBe(false);
+ });
+
+ it('limit message does show', async () => {
+ createComponent(3);
+
+ findDropdown().vm.$emit('shown');
+
+ await waitForPromises();
+
+ expect(findLimitMessage().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/components/time_ago_spec.js b/spec/frontend/ci/pipelines_page/components/time_ago_spec.js
new file mode 100644
index 00000000000..f7203f8d1b4
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/components/time_ago_spec.js
@@ -0,0 +1,85 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import TimeAgo from '~/ci/pipelines_page/components/time_ago.vue';
+
+describe('Timeago component', () => {
+ let wrapper;
+
+ const defaultProps = { duration: 0, finished_at: '' };
+
+ const createComponent = (props = defaultProps, extraProps) => {
+ wrapper = extendedWrapper(
+ shallowMount(TimeAgo, {
+ propsData: {
+ pipeline: {
+ details: {
+ ...props,
+ },
+ },
+ ...extraProps,
+ },
+ data() {
+ return {
+ iconTimerSvg: `<svg></svg>`,
+ };
+ },
+ }),
+ );
+ };
+
+ const duration = () => wrapper.find('.duration');
+ const finishedAt = () => wrapper.find('.finished-at');
+ const findCalendarIcon = () => wrapper.findByTestId('calendar-icon');
+
+ describe('with duration', () => {
+ beforeEach(() => {
+ createComponent({ duration: 10, finished_at: '' });
+ });
+
+ it('should render duration and timer svg', () => {
+ const icon = duration().findComponent(GlIcon);
+
+ expect(duration().exists()).toBe(true);
+ expect(icon.props('name')).toBe('timer');
+ });
+ });
+
+ describe('without duration', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should not render duration and timer svg', () => {
+ expect(duration().exists()).toBe(false);
+ });
+ });
+
+ describe('with finishedTime', () => {
+ it('should render time', () => {
+ createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' });
+
+ const time = finishedAt().find('time');
+
+ expect(finishedAt().exists()).toBe(true);
+ expect(time.exists()).toBe(true);
+ });
+
+ it('should display calendar icon', () => {
+ createComponent({ duration: 0, finished_at: '2017-04-26T12:40:23.277Z' });
+
+ expect(findCalendarIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('without finishedTime', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should not render time and calendar icon', () => {
+ expect(finishedAt().exists()).toBe(false);
+ expect(findCalendarIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/pipelines_spec.js b/spec/frontend/ci/pipelines_page/pipelines_spec.js
new file mode 100644
index 00000000000..5d1f431e57c
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/pipelines_spec.js
@@ -0,0 +1,851 @@
+import '~/commons';
+import {
+ GlButton,
+ GlEmptyState,
+ GlFilteredSearch,
+ GlLoadingIcon,
+ GlPagination,
+ GlCollapsibleListbox,
+} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { mount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import { chunk } from 'lodash';
+import { nextTick } from 'vue';
+import mockPipelinesResponse from 'test_fixtures/pipelines/pipelines.json';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+import { mockTracking } from 'helpers/tracking_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import Api from '~/api';
+import { createAlert, VARIANT_WARNING } from '~/alert';
+import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
+import NavigationControls from '~/ci/pipelines_page/components/nav_controls.vue';
+import PipelinesComponent from '~/ci/pipelines_page/pipelines.vue';
+import PipelinesCiTemplates from '~/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue';
+import PipelinesTableComponent from '~/ci/common/pipelines_table.vue';
+import { RAW_TEXT_WARNING, TRACKING_CATEGORIES } from '~/ci/constants';
+import Store from '~/ci/pipeline_details/stores/pipelines_store';
+import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import {
+ setIdTypePreferenceMutationResponse,
+ setIdTypePreferenceMutationResponseWithErrors,
+} from 'jest/issues/list/mock_data';
+
+import { stageReply } from 'jest/ci/pipeline_mini_graph/mock_data';
+import { users, mockSearch, branches } from '../pipeline_details/mock_data';
+
+jest.mock('@sentry/browser');
+jest.mock('~/alert');
+
+const mockProjectPath = 'twitter/flight';
+const mockProjectId = '21';
+const mockDefaultBranchName = 'main';
+const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`;
+const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id);
+const mockPipelineWithStages = mockPipelinesResponse.pipelines.find(
+ (p) => p.details.stages && p.details.stages.length,
+);
+
+describe('Pipelines', () => {
+ let wrapper;
+ let mockApollo;
+ let mock;
+ let trackingSpy;
+
+ const paths = {
+ emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
+ errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
+ noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
+ ciLintPath: '/ci/lint',
+ resetCachePath: `${mockProjectPath}/settings/ci_cd/reset_cache`,
+ newPipelinePath: `${mockProjectPath}/pipelines/new`,
+
+ ciRunnerSettingsPath: `${mockProjectPath}/-/settings/ci_cd#js-runners-settings`,
+ };
+
+ const noPermissions = {
+ emptyStateSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
+ errorStateSvgPath: '/assets/illustrations/pipelines_failed.svg',
+ noPipelinesSvgPath: '/assets/illustrations/empty-state/empty-pipeline-md.svg',
+ };
+
+ const defaultProps = {
+ hasGitlabCi: true,
+ canCreatePipeline: true,
+ ...paths,
+ };
+
+ const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findNavigationTabs = () => wrapper.findComponent(NavigationTabs);
+ const findNavigationControls = () => wrapper.findComponent(NavigationControls);
+ const findPipelinesTable = () => wrapper.findComponent(PipelinesTableComponent);
+ const findTablePagination = () => wrapper.findComponent(TablePagination);
+ const findPipelineKeyCollapsibleBoxVue = () => wrapper.findComponent(GlCollapsibleListbox);
+
+ const findTab = (tab) => wrapper.findByTestId(`pipelines-tab-${tab}`);
+ const findPipelineKeyCollapsibleBox = () => wrapper.findByTestId('pipeline-key-collapsible-box');
+ const findRunPipelineButton = () => wrapper.findByTestId('run-pipeline-button');
+ const findCiLintButton = () => wrapper.findByTestId('ci-lint-button');
+ const findCleanCacheButton = () => wrapper.findByTestId('clear-cache-button');
+ const findStagesDropdownToggle = () =>
+ wrapper.find('[data-testid="mini-pipeline-graph-dropdown"] .dropdown-toggle');
+ const findPipelineUrlLinks = () => wrapper.findAll('[data-testid="pipeline-url-link"]');
+
+ const createComponent = (props = defaultProps) => {
+ const { mutationMock, ...restProps } = props;
+ mockApollo = createMockApollo([[setSortPreferenceMutation, mutationMock]]);
+
+ wrapper = extendedWrapper(
+ mount(PipelinesComponent, {
+ provide: {
+ pipelineEditorPath: '',
+ suggestedCiTemplates: [],
+ ciRunnerSettingsPath: paths.ciRunnerSettingsPath,
+ anyRunnersAvailable: true,
+ },
+ propsData: {
+ store: new Store(),
+ projectId: mockProjectId,
+ defaultBranchName: mockDefaultBranchName,
+ endpoint: mockPipelinesEndpoint,
+ params: {},
+ ...restProps,
+ },
+ apolloProvider: mockApollo,
+ }),
+ );
+ };
+
+ beforeEach(() => {
+ setWindowLocation(TEST_HOST);
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ jest.spyOn(window.history, 'pushState');
+ jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
+ });
+
+ afterEach(() => {
+ mock.reset();
+ mockApollo = null;
+ window.history.pushState.mockReset();
+ });
+
+ describe('when pipelines are not yet loaded', () => {
+ beforeEach(async () => {
+ createComponent();
+ await nextTick();
+ });
+
+ it('shows loading state when the app is loading', () => {
+ expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('does not display tabs when the first request has not yet been made', () => {
+ expect(findNavigationTabs().exists()).toBe(false);
+ });
+
+ it('does not display buttons', () => {
+ expect(findNavigationControls().exists()).toBe(false);
+ });
+ });
+
+ describe('when there are pipelines in the project', () => {
+ beforeEach(() => {
+ mock
+ .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } })
+ .reply(HTTP_STATUS_OK, mockPipelinesResponse);
+ });
+
+ describe('when user has no permissions', () => {
+ beforeEach(async () => {
+ createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions });
+ await waitForPromises();
+ });
+
+ it('renders "All" tab with count different from "0"', () => {
+ expect(findTab('all').text()).toMatchInterpolatedText('All 3');
+ });
+
+ it('does not render buttons', () => {
+ expect(findNavigationControls().exists()).toBe(false);
+
+ expect(findRunPipelineButton().exists()).toBe(false);
+ expect(findCiLintButton().exists()).toBe(false);
+ expect(findCleanCacheButton().exists()).toBe(false);
+ });
+
+ it('renders pipelines in a table', () => {
+ expect(findPipelinesTable().exists()).toBe(true);
+
+ expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`);
+ expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`);
+ expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`);
+ });
+ });
+
+ describe('when user has permissions', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('should set up navigation tabs', () => {
+ expect(findNavigationTabs().props('tabs')).toEqual([
+ { name: 'All', scope: 'all', count: '3', isActive: true },
+ { name: 'Finished', scope: 'finished', count: undefined, isActive: false },
+ { name: 'Branches', scope: 'branches', isActive: false },
+ { name: 'Tags', scope: 'tags', isActive: false },
+ ]);
+ });
+
+ it('renders "All" tab with count different from "0"', () => {
+ expect(findTab('all').text()).toMatchInterpolatedText('All 3');
+ });
+
+ it('should render other navigation tabs', () => {
+ expect(findTab('finished').text()).toBe('Finished');
+ expect(findTab('branches').text()).toBe('Branches');
+ expect(findTab('tags').text()).toBe('Tags');
+ });
+
+ it('shows navigation controls', () => {
+ expect(findNavigationControls().exists()).toBe(true);
+ });
+
+ it('renders Run pipeline link', () => {
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
+ });
+
+ it('renders CI lint link', () => {
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
+ });
+
+ it('renders Clear runner cache button', () => {
+ expect(findCleanCacheButton().text()).toBe('Clear runner caches');
+ });
+
+ it('renders pipelines in a table', () => {
+ expect(findPipelinesTable().exists()).toBe(true);
+
+ expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`);
+ expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`);
+ expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`);
+ });
+
+ describe('when user goes to a tab', () => {
+ const goToTab = (tab) => {
+ findNavigationTabs().vm.$emit('onChangeTab', tab);
+ };
+
+ describe('when the scope in the tab has pipelines', () => {
+ const mockFinishedPipeline = mockPipelinesResponse.pipelines[0];
+
+ beforeEach(async () => {
+ mock
+ .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } })
+ .reply(HTTP_STATUS_OK, {
+ pipelines: [mockFinishedPipeline],
+ count: mockPipelinesResponse.count,
+ });
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ goToTab('finished');
+
+ await waitForPromises();
+ });
+
+ it('should filter pipelines', () => {
+ expect(findPipelinesTable().exists()).toBe(true);
+
+ expect(findPipelineUrlLinks()).toHaveLength(1);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFinishedPipeline.id}`);
+ });
+
+ it('should update browser bar', () => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ `${window.location.pathname}?scope=finished&page=1`,
+ );
+ });
+
+ it.each(['all', 'finished', 'branches', 'tags'])('tracks %p tab click', async (scope) => {
+ goToTab(scope);
+
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_filter_tabs', {
+ label: TRACKING_CATEGORIES.tabs,
+ property: scope,
+ });
+ });
+ });
+
+ describe('when the scope in the tab is empty', () => {
+ beforeEach(async () => {
+ mock
+ .onGet(mockPipelinesEndpoint, { params: { scope: 'branches', page: '1' } })
+ .reply(HTTP_STATUS_OK, {
+ pipelines: [],
+ count: mockPipelinesResponse.count,
+ });
+
+ goToTab('branches');
+
+ await waitForPromises();
+ });
+
+ it('should filter pipelines', () => {
+ expect(findEmptyState().text()).toBe('There are currently no pipelines.');
+ });
+
+ it('should update browser bar', () => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ `${window.location.pathname}?scope=branches&page=1`,
+ );
+ });
+ });
+ });
+
+ describe('when user triggers a filtered search', () => {
+ const mockFilteredPipeline = mockPipelinesResponse.pipelines[1];
+
+ let expectedParams;
+
+ beforeEach(async () => {
+ expectedParams = {
+ page: '1',
+ scope: 'all',
+ username: 'root',
+ ref: 'main',
+ status: 'pending',
+ };
+
+ mock
+ .onGet(mockPipelinesEndpoint, {
+ params: expectedParams,
+ })
+ .replyOnce(HTTP_STATUS_OK, {
+ pipelines: [mockFilteredPipeline],
+ count: mockPipelinesResponse.count,
+ });
+
+ findFilteredSearch().vm.$emit('submit', mockSearch);
+
+ await waitForPromises();
+ });
+
+ it('requests data with query params on filter submit', () => {
+ expect(mock.history.get[1].params).toEqual(expectedParams);
+ });
+
+ it('renders filtered pipelines', () => {
+ expect(findPipelineUrlLinks()).toHaveLength(1);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`);
+ });
+
+ it('should update browser bar', () => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ `${window.location.pathname}?page=1&scope=all&username=root&ref=main&status=pending`,
+ );
+ });
+ });
+
+ describe('when user changes Show Pipeline ID to Show Pipeline IID', () => {
+ const mockFilteredPipeline = mockPipelinesResponse.pipelines[0];
+
+ beforeEach(() => {
+ gon.current_user_id = 1;
+ });
+
+ it('should change the text to Show Pipeline IID', async () => {
+ expect(findPipelineKeyCollapsibleBox().exists()).toBe(true);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.id}`);
+ findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid');
+
+ await waitForPromises();
+
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockFilteredPipeline.iid}`);
+ });
+
+ it('calls mutation to save idType preference', () => {
+ const mutationMock = jest.fn().mockResolvedValue(setIdTypePreferenceMutationResponse);
+ createComponent({ ...defaultProps, mutationMock });
+
+ findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid');
+
+ expect(mutationMock).toHaveBeenCalledWith({ input: { visibilityPipelineIdType: 'IID' } });
+ });
+
+ it('captures error when mutation response has errors', async () => {
+ const mutationMock = jest
+ .fn()
+ .mockResolvedValue(setIdTypePreferenceMutationResponseWithErrors);
+ createComponent({ ...defaultProps, mutationMock });
+
+ findPipelineKeyCollapsibleBoxVue().vm.$emit('select', 'iid');
+ await waitForPromises();
+
+ expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!'));
+ });
+ });
+
+ describe('when user triggers a filtered search with raw text', () => {
+ beforeEach(async () => {
+ findFilteredSearch().vm.$emit('submit', ['rawText']);
+
+ await waitForPromises();
+ });
+
+ it('requests data with query params on filter submit', () => {
+ expect(mock.history.get[1].params).toEqual({ page: '1', scope: 'all' });
+ });
+
+ it('displays a warning message if raw text search is used', () => {
+ expect(createAlert).toHaveBeenCalledTimes(1);
+ expect(createAlert).toHaveBeenCalledWith({
+ message: RAW_TEXT_WARNING,
+ variant: VARIANT_WARNING,
+ });
+ });
+
+ it('should update browser bar', () => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ `${window.location.pathname}?page=1&scope=all`,
+ );
+ });
+ });
+ });
+ });
+
+ describe('when there are multiple pages of pipelines', () => {
+ const mockPageSize = 2;
+ const mockPageHeaders = ({ page = 1 } = {}) => {
+ return {
+ 'X-PER-PAGE': `${mockPageSize}`,
+ 'X-PREV-PAGE': `${page - 1}`,
+ 'X-PAGE': `${page}`,
+ 'X-NEXT-PAGE': `${page + 1}`,
+ };
+ };
+ const [firstPage, secondPage] = chunk(mockPipelinesResponse.pipelines, mockPageSize);
+
+ const goToPage = (page) => {
+ findTablePagination().findComponent(GlPagination).vm.$emit('input', page);
+ };
+
+ beforeEach(async () => {
+ mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } }).reply(
+ HTTP_STATUS_OK,
+ {
+ pipelines: firstPage,
+ count: mockPipelinesResponse.count,
+ },
+ mockPageHeaders({ page: 1 }),
+ );
+ mock.onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '2' } }).reply(
+ HTTP_STATUS_OK,
+ {
+ pipelines: secondPage,
+ count: mockPipelinesResponse.count,
+ },
+ mockPageHeaders({ page: 2 }),
+ );
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('shows the first page of pipelines', () => {
+ expect(findPipelineUrlLinks()).toHaveLength(firstPage.length);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${firstPage[0].id}`);
+ expect(findPipelineUrlLinks().at(1).text()).toBe(`#${firstPage[1].id}`);
+ });
+
+ it('should not update browser bar', () => {
+ expect(window.history.pushState).not.toHaveBeenCalled();
+ });
+
+ describe('when user goes to next page', () => {
+ beforeEach(async () => {
+ goToPage(2);
+ await waitForPromises();
+ });
+
+ it('should update page and keep scope the same scope', () => {
+ expect(findPipelineUrlLinks()).toHaveLength(secondPage.length);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${secondPage[0].id}`);
+ });
+
+ it('should update browser bar', () => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ `${window.location.pathname}?page=2&scope=all`,
+ );
+ });
+
+ it('should reset page to 1 when filtering pipelines', () => {
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ `${window.location.pathname}?page=2&scope=all`,
+ );
+
+ findFilteredSearch().vm.$emit('submit', [
+ { type: 'status', value: { data: 'success', operator: '=' } },
+ ]);
+
+ expect(window.history.pushState).toHaveBeenCalledTimes(2);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ `${window.location.pathname}?page=1&scope=all&status=success`,
+ );
+ });
+ });
+ });
+
+ describe('when pipelines can be polled', () => {
+ beforeEach(() => {
+ const emptyResponse = {
+ pipelines: [],
+ count: { all: '0' },
+ };
+
+ // Mock no pipelines in the first attempt
+ mock
+ .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } })
+ .replyOnce(HTTP_STATUS_OK, emptyResponse, {
+ 'POLL-INTERVAL': 100,
+ });
+ // Mock pipelines in the next attempt
+ mock
+ .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } })
+ .reply(HTTP_STATUS_OK, mockPipelinesResponse, {
+ 'POLL-INTERVAL': 100,
+ });
+ });
+
+ describe('data is loaded for the first time', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('shows tabs', () => {
+ expect(findNavigationTabs().exists()).toBe(true);
+ });
+
+ it('should update page and keep scope the same scope', () => {
+ expect(findPipelineUrlLinks()).toHaveLength(0);
+ });
+
+ describe('data is loaded for a second time', () => {
+ beforeEach(async () => {
+ jest.runOnlyPendingTimers();
+ await waitForPromises();
+ });
+
+ it('shows tabs', () => {
+ expect(findNavigationTabs().exists()).toBe(true);
+ });
+
+ it('is loading after a time', () => {
+ expect(findPipelineUrlLinks()).toHaveLength(mockPipelinesIds.length);
+ expect(findPipelineUrlLinks().at(0).text()).toBe(`#${mockPipelinesIds[0]}`);
+ expect(findPipelineUrlLinks().at(1).text()).toBe(`#${mockPipelinesIds[1]}`);
+ expect(findPipelineUrlLinks().at(2).text()).toBe(`#${mockPipelinesIds[2]}`);
+ });
+ });
+ });
+ });
+
+ describe('when no pipelines exist', () => {
+ beforeEach(() => {
+ mock
+ .onGet(mockPipelinesEndpoint, { params: { scope: 'all', page: '1' } })
+ .reply(HTTP_STATUS_OK, {
+ pipelines: [],
+ count: { all: '0' },
+ });
+ });
+
+ describe('when CI is enabled and user has permissions', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('renders tab with count of "0"', () => {
+ expect(findNavigationTabs().exists()).toBe(true);
+ expect(findTab('all').text()).toMatchInterpolatedText('All 0');
+ });
+
+ it('renders Run pipeline link', () => {
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
+ });
+
+ it('renders CI lint link', () => {
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
+ });
+
+ it('renders Clear runner cache button', () => {
+ expect(findCleanCacheButton().text()).toBe('Clear runner caches');
+ });
+
+ it('renders empty state', () => {
+ expect(findEmptyState().text()).toBe('There are currently no pipelines.');
+ });
+
+ it('renders filtered search', () => {
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+
+ it('renders the pipeline key collapsible box', () => {
+ expect(findPipelineKeyCollapsibleBox().exists()).toBe(true);
+ });
+
+ it('renders tab empty state finished scope', async () => {
+ mock
+ .onGet(mockPipelinesEndpoint, { params: { scope: 'finished', page: '1' } })
+ .reply(HTTP_STATUS_OK, {
+ pipelines: [],
+ count: { all: '0' },
+ });
+
+ findNavigationTabs().vm.$emit('onChangeTab', 'finished');
+
+ await waitForPromises();
+
+ expect(findEmptyState().text()).toBe('There are currently no finished pipelines.');
+ });
+ });
+
+ describe('when CI is not enabled and user has permissions', () => {
+ beforeEach(async () => {
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths });
+ await waitForPromises();
+ });
+
+ it('renders the CI/CD templates', () => {
+ expect(wrapper.findComponent(PipelinesCiTemplates).exists()).toBe(true);
+ });
+
+ it('does not render filtered search', () => {
+ expect(findFilteredSearch().exists()).toBe(false);
+ });
+
+ it('does not render the pipeline key dropdown', () => {
+ expect(findPipelineKeyCollapsibleBox().exists()).toBe(false);
+ });
+
+ it('does not render tabs nor buttons', () => {
+ expect(findNavigationTabs().exists()).toBe(false);
+ expect(findTab('all').exists()).toBe(false);
+ expect(findRunPipelineButton().exists()).toBe(false);
+ expect(findCiLintButton().exists()).toBe(false);
+ expect(findCleanCacheButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when CI is not enabled and user has no permissions', () => {
+ beforeEach(async () => {
+ createComponent({ hasGitlabCi: false, canCreatePipeline: false, ...noPermissions });
+ await waitForPromises();
+ });
+
+ it('renders empty state without button to set CI', () => {
+ expect(findEmptyState().text()).toBe(
+ 'This project is not currently set up to run pipelines.',
+ );
+
+ expect(findEmptyState().findComponent(GlButton).exists()).toBe(false);
+ });
+
+ it('does not render tabs or buttons', () => {
+ expect(findTab('all').exists()).toBe(false);
+ expect(findRunPipelineButton().exists()).toBe(false);
+ expect(findCiLintButton().exists()).toBe(false);
+ expect(findCleanCacheButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when CI is enabled and user has no permissions', () => {
+ beforeEach(() => {
+ createComponent({ hasGitlabCi: true, canCreatePipeline: false, ...noPermissions });
+
+ return waitForPromises();
+ });
+
+ it('renders tab with count of "0"', () => {
+ expect(findTab('all').text()).toMatchInterpolatedText('All 0');
+ });
+
+ it('does not render buttons', () => {
+ expect(findRunPipelineButton().exists()).toBe(false);
+ expect(findCiLintButton().exists()).toBe(false);
+ expect(findCleanCacheButton().exists()).toBe(false);
+ });
+
+ it('renders empty state', () => {
+ expect(findEmptyState().text()).toBe('There are currently no pipelines.');
+ });
+ });
+ });
+
+ describe('when a pipeline with stages exists', () => {
+ describe('updates results when a staged is clicked', () => {
+ let stopMock;
+ let restartMock;
+ let cancelMock;
+
+ beforeEach(() => {
+ mock.onGet(mockPipelinesEndpoint, { scope: 'all', page: '1' }).reply(
+ HTTP_STATUS_OK,
+ {
+ pipelines: [mockPipelineWithStages],
+ count: { all: '1' },
+ },
+ {
+ 'POLL-INTERVAL': 100,
+ },
+ );
+
+ mock
+ .onGet(mockPipelineWithStages.details.stages[0].dropdown_path)
+ .reply(HTTP_STATUS_OK, stageReply);
+
+ createComponent();
+
+ stopMock = jest.spyOn(window, 'clearTimeout');
+ restartMock = jest.spyOn(axios, 'get');
+ });
+
+ describe('when a request is being made', () => {
+ beforeEach(async () => {
+ mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_OK, mockPipelinesResponse);
+
+ await waitForPromises();
+ });
+
+ it('stops polling, cancels the request, & restarts polling', async () => {
+ // Mock init a polling cycle
+ wrapper.vm.poll.options.notificationCallback(true);
+
+ await findStagesDropdownToggle().trigger('click');
+ jest.runOnlyPendingTimers();
+
+ // cancelMock is getting overwritten in pipelines_service.js#L29
+ // so we have to spy on it again here
+ cancelMock = jest.spyOn(axios.CancelToken, 'source');
+
+ await waitForPromises();
+
+ expect(cancelMock).toHaveBeenCalled();
+ expect(stopMock).toHaveBeenCalled();
+ expect(restartMock).toHaveBeenCalledWith(
+ `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`,
+ );
+ });
+
+ it('stops polling & restarts polling', async () => {
+ await findStagesDropdownToggle().trigger('click');
+ jest.runOnlyPendingTimers();
+ await waitForPromises();
+
+ expect(cancelMock).not.toHaveBeenCalled();
+ expect(stopMock).toHaveBeenCalled();
+ expect(restartMock).toHaveBeenCalledWith(
+ `${mockPipelinesResponse.pipelines[0].path}/stage.json?stage=build`,
+ );
+ });
+ });
+ });
+ });
+
+ describe('when pipelines cannot be loaded', () => {
+ beforeEach(() => {
+ mock.onGet(mockPipelinesEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR, {});
+ });
+
+ describe('when user has no permissions', () => {
+ beforeEach(async () => {
+ createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...noPermissions });
+
+ await waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(findNavigationTabs().exists()).toBe(true);
+ expect(findTab('all').text()).toBe('All');
+ });
+
+ it('does not render buttons', () => {
+ expect(findRunPipelineButton().exists()).toBe(false);
+ expect(findCiLintButton().exists()).toBe(false);
+ expect(findCleanCacheButton().exists()).toBe(false);
+ });
+
+ it('shows error state', () => {
+ expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.');
+ expect(findEmptyState().props('description')).toBe(
+ 'Try again in a few moments or contact your support team.',
+ );
+ });
+ });
+
+ describe('when user has permissions', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('renders tabs', () => {
+ expect(findTab('all').text()).toBe('All');
+ });
+
+ it('renders buttons', () => {
+ expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath);
+
+ expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath);
+ expect(findCleanCacheButton().text()).toBe('Clear runner caches');
+ });
+
+ it('shows error state', () => {
+ expect(findEmptyState().props('title')).toBe('There was an error fetching the pipelines.');
+ expect(findEmptyState().props('description')).toBe(
+ 'Try again in a few moments or contact your support team.',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js
new file mode 100644
index 00000000000..ea615d85c4b
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_branch_name_token_spec.js
@@ -0,0 +1,142 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import Api from '~/api';
+import PipelineBranchNameToken from '~/ci/pipelines_page/tokens/pipeline_branch_name_token.vue';
+import { branches, mockBranchesAfterMap } from 'jest/ci/pipeline_details/mock_data';
+
+describe('Pipeline Branch Name Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () =>
+ wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const getBranchSuggestions = () =>
+ findAllFilteredSearchSuggestions().wrappers.map((w) => w.text());
+
+ const stubs = {
+ GlFilteredSearchToken: {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ },
+ };
+
+ const defaultProps = {
+ config: {
+ type: 'ref',
+ icon: 'branch',
+ title: 'Branch name',
+ unique: true,
+ projectId: '21',
+ defaultBranchName: null,
+ disabled: false,
+ },
+ value: {
+ data: '',
+ },
+ cursorPosition: 'start',
+ };
+
+ const optionsWithDefaultBranchName = (options) => {
+ return {
+ propsData: {
+ ...defaultProps,
+ config: {
+ ...defaultProps.config,
+ defaultBranchName: 'main',
+ },
+ },
+ ...options,
+ };
+ };
+
+ const createComponent = (options, data) => {
+ wrapper = shallowMount(PipelineBranchNameToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
+
+ createComponent();
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ it('fetches and sets project branches', () => {
+ expect(Api.branches).toHaveBeenCalled();
+
+ expect(wrapper.vm.branches).toEqual(mockBranchesAfterMap);
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ describe('displays loading icon correctly', () => {
+ it('shows loading icon', () => {
+ createComponent({ stubs }, { loading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not show loading icon', () => {
+ createComponent({ stubs }, { loading: false });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('shows branches correctly', () => {
+ it('renders all branches', () => {
+ createComponent({ stubs }, { branches, loading: false });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length);
+ });
+
+ it('renders only the branch searched for', () => {
+ const mockBranches = ['main'];
+ createComponent({ stubs }, { branches: mockBranches, loading: false });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length);
+ });
+
+ it('shows the default branch first if no branch was searched for', async () => {
+ const mockBranches = [{ name: 'branch-1' }];
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches });
+
+ createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false });
+ await nextTick();
+ expect(getBranchSuggestions()).toEqual(['main', 'branch-1']);
+ });
+
+ it('does not show the default branch if a search term was provided', async () => {
+ const mockBranches = [{ name: 'branch-1' }];
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches });
+
+ createComponent(optionsWithDefaultBranchName(), { loading: false });
+
+ findFilteredSearchToken().vm.$emit('input', { data: 'branch-1' });
+ await waitForPromises();
+ expect(getBranchSuggestions()).toEqual(['branch-1']);
+ });
+
+ it('shows the default branch only once if it appears in the results', async () => {
+ const mockBranches = [{ name: 'main' }];
+ jest.spyOn(Api, 'branches').mockResolvedValue({ data: mockBranches });
+
+ createComponent(optionsWithDefaultBranchName({ stubs }), { loading: false });
+ await nextTick();
+ expect(getBranchSuggestions()).toEqual(['main']);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js
new file mode 100644
index 00000000000..0ea2b641b33
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_source_token_spec.js
@@ -0,0 +1,53 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipelines_page/tokens/constants';
+import { stubComponent } from 'helpers/stub_component';
+import PipelineSourceToken from '~/ci/pipelines_page/tokens/pipeline_source_token.vue';
+
+describe('Pipeline Source Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () =>
+ wrapper.findAllComponents(GlFilteredSearchSuggestion);
+
+ const defaultProps = {
+ config: {
+ type: 'source',
+ icon: 'trigger-source',
+ title: 'Source',
+ unique: true,
+ },
+ value: {
+ data: '',
+ },
+ cursorPosition: 'start',
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineSourceToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ stubs: {
+ GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ }),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ describe('shows sources correctly', () => {
+ it('renders all pipeline sources available', () => {
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(PIPELINE_SOURCES.length);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js
new file mode 100644
index 00000000000..b8f98666438
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_status_token_spec.js
@@ -0,0 +1,58 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
+import PipelineStatusToken from '~/ci/pipelines_page/tokens/pipeline_status_token.vue';
+import {
+ TOKEN_TITLE_STATUS,
+ TOKEN_TYPE_STATUS,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+
+describe('Pipeline Status Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () =>
+ wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const findAllGlIcons = () => wrapper.findAllComponents(GlIcon);
+
+ const defaultProps = {
+ config: {
+ type: TOKEN_TYPE_STATUS,
+ icon: 'status',
+ title: TOKEN_TITLE_STATUS,
+ unique: true,
+ },
+ value: {
+ data: '',
+ },
+ cursorPosition: 'start',
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineStatusToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ stubs: {
+ GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ }),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ describe('shows statuses correctly', () => {
+ it('renders all pipeline statuses available', () => {
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.statuses.length);
+ expect(findAllGlIcons()).toHaveLength(wrapper.vm.statuses.length);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js
new file mode 100644
index 00000000000..d23d9f07df3
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_tag_name_token_spec.js
@@ -0,0 +1,95 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Api from '~/api';
+import PipelineTagNameToken from '~/ci/pipelines_page/tokens/pipeline_tag_name_token.vue';
+import { tags, mockTagsAfterMap } from 'jest/ci/pipeline_details/mock_data';
+
+describe('Pipeline Branch Name Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () =>
+ wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const stubs = {
+ GlFilteredSearchToken: {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ },
+ };
+
+ const defaultProps = {
+ config: {
+ type: 'tag',
+ icon: 'tag',
+ title: 'Tag name',
+ unique: true,
+ projectId: '21',
+ disabled: false,
+ },
+ value: {
+ data: '',
+ },
+ cursorPosition: 'start',
+ };
+
+ const createComponent = (options, data) => {
+ wrapper = shallowMount(PipelineTagNameToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags });
+
+ createComponent();
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ it('fetches and sets project tags', () => {
+ expect(Api.tags).toHaveBeenCalled();
+
+ expect(wrapper.vm.tags).toEqual(mockTagsAfterMap);
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ describe('displays loading icon correctly', () => {
+ it('shows loading icon', () => {
+ createComponent({ stubs }, { loading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not show loading icon', () => {
+ createComponent({ stubs }, { loading: false });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('shows tags correctly', () => {
+ it('renders all tags', () => {
+ createComponent({ stubs }, { tags, loading: false });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(tags.length);
+ });
+
+ it('renders only the tag searched for', () => {
+ const mockTags = ['main-tag'];
+ createComponent({ stubs }, { tags: mockTags, loading: false });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.length);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js
new file mode 100644
index 00000000000..eccb90b0c94
--- /dev/null
+++ b/spec/frontend/ci/pipelines_page/tokens/pipeline_trigger_author_token_spec.js
@@ -0,0 +1,99 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
+import Api from '~/api';
+import PipelineTriggerAuthorToken from '~/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue';
+import { users } from 'jest/ci/pipeline_details/mock_data';
+
+describe('Pipeline Trigger Author Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () =>
+ wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const defaultProps = {
+ config: {
+ type: 'username',
+ icon: 'user',
+ title: 'Trigger author',
+ dataType: 'username',
+ unique: true,
+ triggerAuthors: users,
+ },
+ value: {
+ data: '',
+ },
+ cursorPosition: 'start',
+ };
+
+ const createComponent = (data) => {
+ wrapper = shallowMount(PipelineTriggerAuthorToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ data() {
+ return {
+ ...data,
+ };
+ },
+ stubs: {
+ GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ }),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
+
+ createComponent();
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ it('fetches and sets project users', () => {
+ expect(Api.projectUsers).toHaveBeenCalled();
+
+ expect(wrapper.vm.users).toEqual(users);
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ describe('displays loading icon correctly', () => {
+ it('shows loading icon', () => {
+ createComponent({ loading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not show loading icon', () => {
+ createComponent({ loading: false });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('shows trigger authors correctly', () => {
+ beforeEach(() => {});
+
+ it('renders all trigger authors', () => {
+ createComponent({ users, loading: false });
+
+ // should have length of all users plus the static 'Any' option
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1);
+ });
+
+ it('renders only the trigger author searched for', () => {
+ createComponent({
+ users: [{ name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }],
+ loading: false,
+ });
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(2);
+ });
+ });
+});