+ {{ $options.i18n.pipelineCreationFailed }}
@@ -292,7 +521,8 @@ export default {
{{ $options.i18n.runPipelineText }}
@@ -300,20 +530,21 @@ export default {
{{ $options.i18n.runPipelineText }}
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 9f4e3296e72b8a97e681da70ac8608dec710b1e8..d714f78cc0d9370798dff00890d233128f7416b0 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -40,6 +40,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action only: [:show, :diffs, :rapid_diffs, :reports] do
push_frontend_feature_flag(:mr_pipelines_graphql, project)
+ push_frontend_feature_flag(:ci_pipeline_creation_requests_realtime, project)
push_frontend_feature_flag(:notifications_todos_buttons, current_user)
push_frontend_feature_flag(:mr_review_batch_submit, current_user)
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3a1d69f38bc1c73289abdbbce137c98bfe64cbf7..09c267b84ffbc712fd94424ed608192dc7b611e7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -49397,6 +49397,9 @@ msgstr ""
msgid "Pipeline|Pipeline cannot be run."
msgstr ""
+msgid "Pipeline|Pipeline creation failed. Please try again."
+msgstr ""
+
msgid "Pipeline|Pipelines"
msgstr ""
diff --git a/spec/frontend/ci/merge_requests/mock_data.js b/spec/frontend/ci/merge_requests/mock_data.js
index 39c22c7f51cc2c3d20c823d41b5e5fc7e8278763..400749623f82f8ac00709bd0be606dcbaf1e915b 100644
--- a/spec/frontend/ci/merge_requests/mock_data.js
+++ b/spec/frontend/ci/merge_requests/mock_data.js
@@ -1,52 +1,120 @@
-const createMergeRequestPipelines = ({ mergeRequestEventType = 'MERGE_TRAIN', count = 1 } = {}) => {
- const pipelines = [];
-
- for (let i = 0; i < count; i += 1) {
- pipelines.push({
- id: `gid://gitlab/Ci::Pipeline/${i}`,
- iid: `gid://gitlab/Ci::Pipeline/${i + 10}`,
- path: `/project/pipelines/${i}`,
- duration: 1000,
- name: null,
- finishedAt: null,
- configSource: 'REPOSITORY_SOURCE',
- mergeRequestEventType,
- stuck: false,
- failureReason: null,
- yamlErrors: false,
- latest: true,
- retryable: true,
- cancelable: false,
- commit: {
- id: 'gid://gitlab/Ci::Commit/1',
- title:
- "Merge branch '419724-apollo-mr-pipelines-build-pipeline-table-component-2' into 'master' ",
- webPath: '/gitlab-org/gitlab/-/commit/a43ea6d3a453f8e603fb3558024c084c45c0c9e4',
- shortId: 'a43ea6d3',
- authorGravatar:
- 'https://secure.gravatar.com/avatar/295d89332b1f3e65933ee72a5f1a6081dc048333a42a5dd2bb8e81fd45590b30?s=80&d=identicon',
- author: {
- id: '1',
- avatarUrl: '/uploads/-/system/user/avatar/5327378/avatar.png',
- commitEmail: 'rando@gitlab.com',
- name: 'Random User',
- webUrl: 'https://gitlab.com/random_user',
+export const generateMockPipeline = ({
+ id = '123',
+ mergeRequestEventType = 'DETACHED',
+ status = 'SUCCESS',
+} = {}) => ({
+ id: `gid://gitlab/Ci::Pipeline/${id}`,
+ iid: id,
+ path: `/project/pipelines/${id}`,
+ duration: 1000,
+ name: null,
+ createdAt: '2024-01-15T10:29:00Z',
+ finishedAt: '2024-01-15T10:30:00Z',
+ configSource: 'REPOSITORY_SOURCE',
+ mergeRequestEventType,
+ stuck: false,
+ failureReason: null,
+ yamlErrors: false,
+ latest: true,
+ retryable: true,
+ cancelable: false,
+ ref: 'refs/merge-requests/1/head',
+ refPath: 'refs/heads/root-main-patch-56329',
+ refText: '',
+ source: 'merge_request_event',
+ type: 'merge_request',
+ hasManualActions: true,
+ hasScheduledActions: false,
+ commit: {
+ id: 'gid://gitlab/Ci::Commit/1',
+ name: "Merge branch '419724-apollo-mr-pipelines-build-pipeline-table-component-2' into 'master' ",
+ title:
+ "Merge branch '419724-apollo-mr-pipelines-build-pipeline-table-component-2' into 'master' ",
+ webPath: '/gitlab-org/gitlab/-/commit/a43ea6d3a453f8e603fb3558024c084c45c0c9e4',
+ webUrl: '/gitlab-org/gitlab/-/commit/a43ea6d3a453f8e603fb3558024c084c45c0c9e4',
+ shortId: 'a43ea6d3',
+ sha: 'a43ea6d3fc81257b1caeeaceb21b36349110ad54',
+ authorGravatar:
+ 'https://secure.gravatar.com/avatar/295d89332b1f3e65933ee72a5f1a6081dc048333a42a5dd2bb8e81fd45590b30?s=80&d=identicon',
+ author: {
+ id: '1',
+ avatarUrl: '/uploads/-/system/user/avatar/5327378/avatar.png',
+ commitEmail: 'rando@gitlab.com',
+ name: 'Random User',
+ webUrl: 'https://gitlab.com/random_user',
+ webPath: '/random_user',
+ },
+ },
+ detailedStatus: {
+ id: `${status.toLowerCase()}-${id}-${id}`,
+ hasDetails: true,
+ detailsPath: `/gitlab-org/gitlab/-/pipelines/${id}`,
+ label: status.toLowerCase(),
+ name: status,
+ },
+ stages: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Ci::Stage/1949',
+ name: 'build',
+ detailedStatus: {
+ id: 'success-1949-1949',
+ icon: 'status_success',
+ text: 'Passed',
+ detailsPath: `/gitlab-org/gitlab/-/pipelines/${id}#build`,
+ tooltip: 'passed',
},
},
- detailedStatus: {
- id: '1',
- hasDetails: true,
- detailsPath: `/gitlab-org/gitlab/-/pipelines/${i}`,
- label: 'skipped',
- name: 'SKIPPED',
+ {
+ id: 'gid://gitlab/Ci::Stage/1950',
+ name: 'test',
+ detailedStatus: {
+ id: 'success-1950-1950',
+ icon: 'status_success',
+ text: 'Passed',
+ detailsPath: `/gitlab-org/gitlab/-/pipelines/${id}#test`,
+ tooltip: 'passed',
+ },
},
- user: {
- id: 'gid://gitlab/User/1',
- avatar_url: '/uploads/-/system/user/avatar/5327378/avatar.png',
- name: 'Random User',
- path: '/random_user',
+ {
+ id: 'gid://gitlab/Ci::Stage/1951',
+ name: 'deploy',
+ detailedStatus: {
+ id: 'success-1951-1951',
+ icon: 'status_success',
+ text: 'Passed',
+ detailsPath: `/gitlab-org/gitlab/-/pipelines/${id}#deploy`,
+ tooltip: 'passed',
+ },
},
- });
+ ],
+ },
+ mergeRequest: {
+ id: 'gid://gitlab/MergeRequest/1',
+ iid: '1',
+ webPath: '/gitlab-org/gitlab/-/merge_requests/1',
+ title: 'Edit README.md',
+ },
+ project: {
+ id: 'gid://gitlab/Project/1',
+ fullPath: 'gitlab-org/gitlab',
+ },
+ user: {
+ id: 'gid://gitlab/User/1',
+ avatar_url: '/uploads/-/system/user/avatar/5327378/avatar.png',
+ name: 'Random User',
+ path: '/random_user',
+ webPath: '/random_user',
+ },
+});
+
+const createMergeRequestPipelines = ({ mergeRequestEventType = 'MERGE_TRAIN', count = 1 } = {}) => {
+ const pipelines = [];
+
+ for (let i = 0; i < count; i += 1) {
+ pipelines.push(
+ generateMockPipeline({ id: String(i), mergeRequestEventType, status: 'SKIPPED' }),
+ );
}
return {
diff --git a/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js b/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js
index 3a76e27d23f6075670e617194c33b4e6a892acfb..aecd410ce06e6e6b732b90c63115bf5d253c67f0 100644
--- a/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js
+++ b/spec/frontend/commit/pipelines/legacy_pipelines_table_wrapper_spec.js
@@ -19,6 +19,12 @@ import {
import { createAlert } from '~/alert';
import { TOAST_MESSAGE } from '~/ci/pipeline_details/constants';
import axios from '~/lib/utils/axios_utils';
+import getPipelineCreationRequests from '~/ci/merge_requests/graphql/queries/get_pipeline_creation_requests.query.graphql';
+import pipelineCreationRequestsUpdatedSubscription from '~/ci/merge_requests/graphql/subscriptions/pipeline_creation_requests_updated.subscription.graphql';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { generateMockPipeline } from 'jest/ci/merge_requests/mock_data';
+import retryPipelineMutation from '~/ci/pipelines_page/graphql/mutations/retry_pipeline.mutation.graphql';
+import cancelPipelineMutation from '~/ci/pipelines_page/graphql/mutations/cancel_pipeline.mutation.graphql';
Vue.use(VueApollo);
@@ -28,10 +34,56 @@ const $toast = {
jest.mock('~/alert');
+const generateMockPipelineCreationMergeRequest = (requests) => ({
+ id: 'gid://gitlab/MergeRequest/3',
+ iid: '3',
+ title: 'Test MR',
+ webPath: '/test/project/-/merge_requests/3',
+ pipelineCreationRequests: requests,
+});
+
+const generatePipelineCreationRequestsResponse = ({
+ requests = [
+ { status: 'IN_PROGRESS', pipelineId: null, error: null, pipeline: null },
+ {
+ status: 'SUCCEEDED',
+ pipelineId: '123',
+ error: null,
+ pipeline: generateMockPipeline({ id: '123' }),
+ },
+ ],
+} = {}) => ({
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/5',
+ fullPath: 'test/project',
+ mergeRequest: generateMockPipelineCreationMergeRequest(requests),
+ },
+ },
+});
+
+const generatePipelineCreationSubscriptionUpdateResponse = ({
+ requests = [
+ { status: 'IN_PROGRESS', pipelineId: null, error: null, pipeline: null },
+ {
+ status: 'SUCCEEDED',
+ pipelineId: '123',
+ error: null,
+ pipeline: generateMockPipeline({ id: '123' }),
+ },
+ ],
+} = {}) => ({
+ data: {
+ ciPipelineCreationRequestsUpdated: generateMockPipelineCreationMergeRequest(requests),
+ },
+});
+
describe('Pipelines table in Commits and Merge requests', () => {
let wrapper;
let pipeline;
let mock;
+ let getPipelineCreationRequestsHandler;
+ let mockSubscriptionHandler;
const showMock = jest.fn();
const findRunPipelineBtn = () => wrapper.findByTestId('run_pipeline_button');
@@ -45,8 +97,32 @@ describe('Pipelines table in Commits and Merge requests', () => {
const findMrPipelinesDocsLink = () => wrapper.findByTestId('mr-pipelines-docs-link');
const findUserPermissionsDocsLink = () => wrapper.findByTestId('user-permissions-docs-link');
const findPipelinesTable = () => wrapper.findComponent(PipelinesTable);
+ const findSkeletonLoader = () => wrapper.find('.gl-animate-skeleton-loader');
+ const findCreationFailedAlert = () => wrapper.findComponent({ name: 'GlAlert' });
+
+ const createComponent = ({
+ props = {},
+ handlers = [],
+ mountFn = mountExtended,
+ glFeatures = { ciPipelineCreationRequestsRealtime: false },
+ } = {}) => {
+ const requestHandlers = [
+ [getPipelineCreationRequests, getPipelineCreationRequestsHandler],
+ ...handlers,
+ ];
+
+ const subscriptionHandlers = [
+ [pipelineCreationRequestsUpdatedSubscription, mockSubscriptionHandler],
+ ];
+
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ subscriptionHandlers.forEach(([query, handler]) => {
+ apolloProvider.defaultClient.setRequestHandler(query, handler);
+ });
+
+ apolloProvider.defaultClient.clearStore();
- const createComponent = ({ props = {}, mountFn = mountExtended } = {}) => {
wrapper = mountFn(LegacyPipelinesTableWrapper, {
propsData: {
endpoint: 'endpoint.json',
@@ -57,18 +133,28 @@ describe('Pipelines table in Commits and Merge requests', () => {
mocks: {
$toast,
},
+ provide: {
+ glFeatures,
+ },
stubs: {
GlModal: stubComponent(GlModal, {
template: '',
methods: { show: showMock },
}),
},
- apolloProvider: createMockApollo(),
+ apolloProvider,
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
+ getPipelineCreationRequestsHandler = jest
+ .fn()
+ .mockResolvedValue(generatePipelineCreationRequestsResponse());
+
+ mockSubscriptionHandler = jest
+ .fn()
+ .mockResolvedValue(generatePipelineCreationSubscriptionUpdateResponse());
const { pipelines } = fixture;
@@ -240,6 +326,7 @@ describe('Pipelines table in Commits and Merge requests', () => {
isMergeRequestTable: true,
mergeRequestId: 3,
projectId: '5',
+ targetProjectFullPath: 'test/project',
},
});
@@ -256,18 +343,6 @@ describe('Pipelines table in Commits and Merge requests', () => {
await findRunPipelineBtnMobile().trigger('click');
expect(findRunPipelineBtn().props('loading')).toBe(true);
-
- await waitForPromises();
-
- expect(findRunPipelineBtn().props('disabled')).toBe(false);
- });
-
- it('sets isCreatingPipeline to true in pipelines table', async () => {
- expect(findPipelinesTable().props('isCreatingPipeline')).toBe(false);
-
- await findRunPipelineBtn().trigger('click');
-
- expect(findPipelinesTable().props('isCreatingPipeline')).toBe(true);
});
});
@@ -422,6 +497,14 @@ describe('Pipelines table in Commits and Merge requests', () => {
describe('When cancelling a pipeline', () => {
it('sends the cancel action', async () => {
+ const cancelPipelineMutationHandler = jest.fn().mockResolvedValue({
+ data: {
+ pipelineCancel: {
+ errors: [],
+ },
+ },
+ });
+
const cancelablePipeline = {
...pipeline,
cancel_path: '/root/project/-/pipelines/1/cancel',
@@ -431,27 +514,47 @@ describe('Pipelines table in Commits and Merge requests', () => {
},
};
- expect(mock.history.post).toHaveLength(0);
+ createComponent({
+ mountFn: shallowMountExtended,
+ handlers: [[cancelPipelineMutation, cancelPipelineMutationHandler]],
+ });
+
+ await waitForPromises();
findPipelinesTable().vm.$emit('cancel-pipeline', cancelablePipeline);
await waitForPromises();
- expect(mock.history.post).toHaveLength(1);
- expect(mock.history.post[0].url).toContain('cancel.json');
+ expect(cancelPipelineMutationHandler).toHaveBeenCalledWith({
+ id: `gid://gitlab/Ci::Pipeline/${cancelablePipeline.id}`,
+ });
});
});
describe('When retrying a pipeline', () => {
it('sends the retry action', async () => {
- expect(mock.history.post).toHaveLength(0);
+ const retryPipelineMutationHandler = jest.fn().mockResolvedValue({
+ data: {
+ pipelineRetry: {
+ errors: [],
+ },
+ },
+ });
+
+ createComponent({
+ mountFn: shallowMountExtended,
+ handlers: [[retryPipelineMutation, retryPipelineMutationHandler]],
+ });
+
+ await waitForPromises();
findPipelinesTable().vm.$emit('retry-pipeline', pipeline);
await waitForPromises();
- expect(mock.history.post).toHaveLength(1);
- expect(mock.history.post[0].url).toContain('retry.json');
+ expect(retryPipelineMutationHandler).toHaveBeenCalledWith({
+ id: `gid://gitlab/Ci::Pipeline/${pipeline.id}`,
+ });
});
});
@@ -468,4 +571,515 @@ describe('Pipelines table in Commits and Merge requests', () => {
});
});
});
+
+ describe('GraphQL pipeline creation requests', () => {
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
+ beforeEach(() => {
+ mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [
+ {
+ ...pipeline,
+ flags: {
+ ...pipeline.flags,
+ detached_merge_request_pipeline: true,
+ merge_request_pipeline: true,
+ },
+ },
+ ]);
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ describe('with feature flag ci_pipeline_creation_requests_realtime', () => {
+ describe('when feature flag is OFF', () => {
+ it('skips getPipelineCreationRequests query', async () => {
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ });
+
+ await waitForPromises();
+
+ expect(getPipelineCreationRequestsHandler).not.toHaveBeenCalled();
+ });
+
+ it('skips subscription', async () => {
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ });
+
+ await waitForPromises();
+
+ expect(mockSubscriptionHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when feature flag is ON', () => {
+ let glFeatures;
+
+ beforeEach(() => {
+ glFeatures = { ciPipelineCreationRequestsRealtime: true };
+ });
+
+ describe('getPipelineCreationRequests query', () => {
+ it('calls getPipelineCreationRequests query with correct variables', async () => {
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(getPipelineCreationRequestsHandler).toHaveBeenCalledWith({
+ fullPath: 'test/project',
+ mergeRequestIid: '3',
+ });
+ });
+
+ it.each`
+ scenario | isMergeRequestTable | targetProjectFullPath | mergeRequestId
+ ${'not on merge request table'} | ${false} | ${'test/project'} | ${3}
+ ${'mergeRequestId is missing'} | ${true} | ${'test/project'} | ${null}
+ ${'targetProjectFullPath is missing'} | ${true} | ${null} | ${3}
+ `(
+ 'skips query when $scenario',
+ async ({ isMergeRequestTable, targetProjectFullPath, mergeRequestId }) => {
+ createComponent({
+ props: {
+ isMergeRequestTable,
+ targetProjectFullPath,
+ mergeRequestId,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(getPipelineCreationRequestsHandler).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ describe('pipelineCreationRequestsUpdated subscription', () => {
+ it('calls subscription with correct variables', async () => {
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(mockSubscriptionHandler).toHaveBeenCalledWith({
+ mergeRequestId: 'gid://gitlab/MergeRequest/3',
+ });
+ });
+
+ const mockMergeRequestGlobalId = () =>
+ getPipelineCreationRequestsHandler.mockResolvedValue({
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/5',
+ mergeRequest: { id: null, pipelineCreationRequests: [] },
+ },
+ },
+ });
+
+ it.each`
+ scenario | isMergeRequestTable | targetProjectFullPath | mergeRequestId | mockHandlerSetup
+ ${'not on merge request table'} | ${false} | ${'test/project'} | ${3} | ${undefined}
+ ${'mergeRequestId is missing'} | ${true} | ${'test/project'} | ${null} | ${undefined}
+ ${'targetProjectFullPath is missing'} | ${true} | ${null} | ${3} | ${undefined}
+ ${'mergeRequestGlobalId is not available'} | ${true} | ${'test/project'} | ${3} | ${mockMergeRequestGlobalId}
+ `(
+ 'skips subscription when $scenario',
+ async ({
+ isMergeRequestTable,
+ targetProjectFullPath,
+ mergeRequestId,
+ mockHandlerSetup,
+ }) => {
+ if (mockHandlerSetup) {
+ mockHandlerSetup();
+ }
+
+ createComponent({
+ props: {
+ isMergeRequestTable,
+ targetProjectFullPath,
+ mergeRequestId,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(mockSubscriptionHandler).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ describe('Pipeline creation failed alert', () => {
+ it('shows alert when pipeline creation fails', async () => {
+ const failedRequests = [
+ { status: 'FAILED', pipelineId: null, pipeline: null, error: 'Creation failed' },
+ ];
+
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({ requests: failedRequests }),
+ );
+
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(findCreationFailedAlert().exists()).toBe(true);
+ expect(findCreationFailedAlert().text()).toBe(
+ 'Pipeline creation failed. Please try again.',
+ );
+ expect(findCreationFailedAlert().props('variant')).toBe('danger');
+ expect(findCreationFailedAlert().props('dismissible')).toBe(true);
+ });
+
+ it('hides alert when dismissed', async () => {
+ const failedRequests = [
+ { status: 'FAILED', pipelineId: null, pipeline: null, error: 'Creation failed' },
+ ];
+
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({ requests: failedRequests }),
+ );
+
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(findCreationFailedAlert().exists()).toBe(true);
+
+ await findCreationFailedAlert().vm.$emit('dismiss');
+
+ expect(findCreationFailedAlert().exists()).toBe(false);
+ });
+
+ it('shows alert when failure count increases via subscription', async () => {
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(findCreationFailedAlert().exists()).toBe(false);
+
+ const failedRequests = [
+ { status: 'FAILED', pipelineId: null, pipeline: null, error: 'Creation failed' },
+ ];
+
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({ requests: failedRequests }),
+ );
+
+ await wrapper.vm.$apollo.queries.pipelineCreationRequests.refetch();
+ await waitForPromises();
+
+ await nextTick();
+
+ expect(findCreationFailedAlert().exists()).toBe(true);
+ });
+ });
+
+ describe('Run pipeline button', () => {
+ describe('when there are in progress pipeline creation requests', () => {
+ it.each([
+ {
+ buttonType: 'desktop',
+ findRunButton: () => findRunPipelineBtn(),
+ },
+ {
+ buttonType: 'mobile',
+ findRunButton: () => findRunPipelineBtnMobile(),
+ },
+ {
+ buttonType: 'empty state',
+ findRunButton: () => findRunPipelineBtn(),
+ },
+ ])('disables the $buttonType button & enables loading', async ({ findRunButton }) => {
+ const inProgressRequests = [
+ { status: 'IN_PROGRESS', pipelineId: null, pipeline: null, error: null },
+ ];
+
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({ requests: inProgressRequests }),
+ );
+
+ createComponent({
+ props: {
+ canRunPipeline: true,
+ isMergeRequestTable: true,
+ mergeRequestId: 3,
+ projectId: '5',
+ targetProjectFullPath: 'test/project',
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(findRunButton().exists()).toBe(true);
+ expect(findRunButton().props('disabled')).toBe(true);
+ expect(findRunButton().props('loading')).toBe(true);
+ });
+ });
+
+ describe('shows skeleton loader', () => {
+ it('after a small delay when run pipeline button is clicked', async () => {
+ createComponent({
+ props: {
+ canRunPipeline: true,
+ isMergeRequestTable: true,
+ mergeRequestId: 3,
+ projectId: '5',
+ targetProjectFullPath: 'test/project',
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+
+ await findRunPipelineBtn().trigger('click');
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+
+ jest.runAllTimers();
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('when hasInProgressCreationRequests becomes true', async () => {
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({
+ requests: [
+ { status: 'IN_PROGRESS', pipelineId: null, pipeline: null, error: null },
+ ],
+ }),
+ );
+
+ createComponent({
+ props: {
+ canRunPipeline: true,
+ isMergeRequestTable: true,
+ mergeRequestId: 3,
+ projectId: '5',
+ targetProjectFullPath: 'test/project',
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+
+ jest.runAllTimers();
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('debounced pipeline loader', () => {
+ it('shows skeleton loader after debounce delay when run pipeline button is clicked', async () => {
+ createComponent({
+ props: {
+ canRunPipeline: true,
+ isMergeRequestTable: true,
+ mergeRequestId: 3,
+ projectId: '5',
+ targetProjectFullPath: 'test/project',
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+
+ findRunPipelineBtn().trigger('click');
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('stops showing skeleton loader when pipeline creation completes', async () => {
+ const inProgressRequests = [
+ { status: 'IN_PROGRESS', pipelineId: null, pipeline: null, error: null },
+ ];
+ const completedRequests = [
+ {
+ status: 'SUCCEEDED',
+ pipelineId: '123',
+ pipeline: generateMockPipeline({ id: '123' }),
+ error: null,
+ },
+ ];
+
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({ requests: inProgressRequests }),
+ );
+
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({ requests: completedRequests }),
+ );
+
+ mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, []);
+
+ findPipelinesTable().vm.$emit('refresh-pipelines-table');
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('continues showing skeleton loader when there are still in-progress requests', async () => {
+ const inProgressRequests = [
+ { status: 'IN_PROGRESS', pipelineId: null, pipeline: null, error: null },
+ { status: 'IN_PROGRESS', pipelineId: null, pipeline: null, error: null },
+ ];
+
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({ requests: inProgressRequests }),
+ );
+
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+
+ const mixedRequests = [
+ { status: 'IN_PROGRESS', pipelineId: null, pipeline: null, error: null },
+ {
+ status: 'SUCCEEDED',
+ pipelineId: '123',
+ pipeline: generateMockPipeline({ id: '123' }),
+ error: null,
+ },
+ ];
+
+ getPipelineCreationRequestsHandler.mockResolvedValue(
+ generatePipelineCreationRequestsResponse({ requests: mixedRequests }),
+ );
+
+ mock.onGet('endpoint.json').reply(HTTP_STATUS_OK, [
+ {
+ ...pipeline,
+ flags: {
+ ...pipeline.flags,
+ detached_merge_request_pipeline: true,
+ merge_request_pipeline: true,
+ },
+ },
+ ]);
+ findPipelinesTable().vm.$emit('refresh-pipelines-table');
+ await waitForPromises();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('clears timeout on component unmount', async () => {
+ createComponent({
+ props: {
+ isMergeRequestTable: true,
+ targetProjectFullPath: 'test/project',
+ mergeRequestId: 3,
+ },
+ glFeatures,
+ });
+
+ await waitForPromises();
+
+ const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+
+ findRunPipelineBtn().trigger('click');
+
+ wrapper.destroy();
+
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+ });
});