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/pipeline_editor')
-rw-r--r--spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js61
-rw-r--r--spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js158
-rw-r--r--spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js287
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js60
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js26
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js89
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js26
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js27
-rw-r--r--spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js27
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js69
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js115
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js134
-rw-r--r--spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js432
-rw-r--r--spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js126
-rw-r--r--spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js138
-rw-r--r--spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js52
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js53
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js109
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js132
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js109
-rw-r--r--spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js197
-rw-r--r--spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js177
-rw-r--r--spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js54
-rw-r--r--spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js342
-rw-r--r--spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js56
-rw-r--r--spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js43
-rw-r--r--spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js29
-rw-r--r--spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js42
-rw-r--r--spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js200
-rw-r--r--spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js92
-rw-r--r--spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_messages_spec.js149
-rw-r--r--spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js314
-rw-r--r--spec/frontend/ci/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap73
-rw-r--r--spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js52
-rw-r--r--spec/frontend/ci/pipeline_editor/mock_data.js541
-rw-r--r--spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js589
-rw-r--r--spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js330
37 files changed, 5510 insertions, 0 deletions
diff --git a/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js b/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js
new file mode 100644
index 00000000000..b00e1adab63
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js
@@ -0,0 +1,61 @@
+import { within } from '@testing-library/dom';
+import { mount } from '@vue/test-utils';
+import { merge } from 'lodash';
+import { TEST_HOST } from 'helpers/test_constants';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import CodeSnippetAlert from '~/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue';
+import { CODE_SNIPPET_SOURCE_API_FUZZING } from '~/ci/pipeline_editor/components/code_snippet_alert/constants';
+
+const apiFuzzingConfigurationPath = '/namespace/project/-/security/configuration/api_fuzzing';
+
+describe('EE - CodeSnippetAlert', () => {
+ let wrapper;
+
+ const createWrapper = (options) => {
+ wrapper = extendedWrapper(
+ mount(
+ CodeSnippetAlert,
+ merge(
+ {
+ provide: {
+ configurationPaths: {
+ [CODE_SNIPPET_SOURCE_API_FUZZING]: apiFuzzingConfigurationPath,
+ },
+ },
+ propsData: {
+ source: CODE_SNIPPET_SOURCE_API_FUZZING,
+ },
+ },
+ options,
+ ),
+ ),
+ );
+ };
+
+ const withinComponent = () => within(wrapper.element);
+ const findDocsLink = () => withinComponent().getByRole('link', { name: /read documentation/i });
+ const findConfigurationLink = () =>
+ withinComponent().getByRole('link', { name: /Go back to configuration/i });
+
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it("provides a link to the feature's documentation", () => {
+ const docsLink = findDocsLink();
+
+ expect(docsLink).not.toBe(null);
+ expect(docsLink.href).toBe(`${TEST_HOST}/help/user/application_security/api_fuzzing/index`);
+ });
+
+ it("provides a link to the feature's configuration form", () => {
+ const configurationLink = findConfigurationLink();
+
+ expect(configurationLink).not.toBe(null);
+ expect(configurationLink.href).toBe(TEST_HOST + apiFuzzingConfigurationPath);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js
new file mode 100644
index 00000000000..8e1d8081dd8
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js
@@ -0,0 +1,158 @@
+import { nextTick } from 'vue';
+import { GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+
+import CommitForm from '~/ci/pipeline_editor/components/commit/commit_form.vue';
+
+import { mockCommitMessage, mockDefaultBranch } from '../../mock_data';
+
+const scrollIntoViewMock = jest.fn();
+HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
+
+describe('Pipeline Editor | Commit Form', () => {
+ let wrapper;
+
+ const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(CommitForm, {
+ propsData: {
+ defaultMessage: mockCommitMessage,
+ currentBranch: mockDefaultBranch,
+ hasUnsavedChanges: true,
+ isNewCiConfigFile: false,
+ ...props,
+ },
+
+ // attachTo is required for input/submit events
+ attachTo: mountFn === mount ? document.body : null,
+ });
+ };
+
+ const findCommitTextarea = () => wrapper.findComponent(GlFormTextarea);
+ const findBranchInput = () => wrapper.findComponent(GlFormInput);
+ const findNewMrCheckbox = () => wrapper.find('[data-testid="new-mr-checkbox"]');
+ const findSubmitBtn = () => wrapper.find('[type="submit"]');
+ const findCancelBtn = () => wrapper.find('[type="reset"]');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when the form is displayed', () => {
+ beforeEach(async () => {
+ createComponent();
+ });
+
+ it('shows a default commit message', () => {
+ expect(findCommitTextarea().attributes('value')).toBe(mockCommitMessage);
+ });
+
+ it('shows current branch', () => {
+ expect(findBranchInput().attributes('value')).toBe(mockDefaultBranch);
+ });
+
+ it('shows buttons', () => {
+ expect(findSubmitBtn().exists()).toBe(true);
+ expect(findCancelBtn().exists()).toBe(true);
+ });
+
+ it('does not show a new MR checkbox by default', () => {
+ expect(findNewMrCheckbox().exists()).toBe(false);
+ });
+ });
+
+ describe('when buttons are clicked', () => {
+ beforeEach(async () => {
+ createComponent({}, mount);
+ });
+
+ it('emits an event when the form submits', () => {
+ findSubmitBtn().trigger('click');
+
+ expect(wrapper.emitted('submit')[0]).toEqual([
+ {
+ message: mockCommitMessage,
+ sourceBranch: mockDefaultBranch,
+ openMergeRequest: false,
+ },
+ ]);
+ });
+
+ it('emits an event when the form resets', () => {
+ findCancelBtn().trigger('click');
+
+ expect(wrapper.emitted('resetContent')).toHaveLength(1);
+ });
+ });
+
+ describe('submit button', () => {
+ it.each`
+ hasUnsavedChanges | isNewCiConfigFile | isDisabled | btnState
+ ${false} | ${false} | ${true} | ${'disabled'}
+ ${true} | ${false} | ${false} | ${'enabled'}
+ ${true} | ${true} | ${false} | ${'enabled'}
+ ${false} | ${true} | ${false} | ${'enabled'}
+ `(
+ 'is $btnState when hasUnsavedChanges:$hasUnsavedChanges and isNewCiConfigfile:$isNewCiConfigFile',
+ ({ hasUnsavedChanges, isNewCiConfigFile, isDisabled }) => {
+ createComponent({ props: { hasUnsavedChanges, isNewCiConfigFile } });
+
+ if (isDisabled) {
+ expect(findSubmitBtn().attributes('disabled')).toBe('true');
+ } else {
+ expect(findSubmitBtn().attributes('disabled')).toBeUndefined();
+ }
+ },
+ );
+ });
+
+ describe('when user inputs values', () => {
+ const anotherMessage = 'Another commit message';
+ const anotherBranch = 'my-branch';
+
+ beforeEach(() => {
+ createComponent({}, mount);
+
+ findCommitTextarea().setValue(anotherMessage);
+ findBranchInput().setValue(anotherBranch);
+ });
+
+ it('shows a new MR checkbox', () => {
+ expect(findNewMrCheckbox().exists()).toBe(true);
+ });
+
+ it('emits an event with values', async () => {
+ await findNewMrCheckbox().setChecked();
+ await findSubmitBtn().trigger('click');
+
+ expect(wrapper.emitted('submit')[0]).toEqual([
+ {
+ message: anotherMessage,
+ sourceBranch: anotherBranch,
+ openMergeRequest: true,
+ },
+ ]);
+ });
+
+ it('when the commit message is empty, submit button is disabled', async () => {
+ await findCommitTextarea().setValue('');
+
+ expect(findSubmitBtn().attributes('disabled')).toBe('disabled');
+ });
+ });
+
+ describe('when scrollToCommitForm becomes true', () => {
+ beforeEach(async () => {
+ createComponent();
+ wrapper.setProps({ scrollToCommitForm: true });
+ await nextTick();
+ });
+
+ it('scrolls into view', () => {
+ expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' });
+ });
+
+ it('emits "scrolled-to-commit-form"', () => {
+ expect(wrapper.emitted()['scrolled-to-commit-form']).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js
new file mode 100644
index 00000000000..f6e93c55bbb
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js
@@ -0,0 +1,287 @@
+import VueApollo from 'vue-apollo';
+import { GlFormTextarea, GlFormInput, GlLoadingIcon } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import Vue from 'vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import CommitForm from '~/ci/pipeline_editor/components/commit/commit_form.vue';
+import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue';
+import {
+ COMMIT_ACTION_CREATE,
+ COMMIT_ACTION_UPDATE,
+ COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
+} from '~/ci/pipeline_editor/constants';
+import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers';
+import commitCreate from '~/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql';
+import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql';
+import updatePipelineEtag from '~/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql';
+
+import {
+ mockCiConfigPath,
+ mockCiYml,
+ mockCommitCreateResponse,
+ mockCommitCreateResponseNewEtag,
+ mockCommitSha,
+ mockCommitMessage,
+ mockDefaultBranch,
+ mockProjectFullPath,
+} from '../../mock_data';
+
+const mockVariables = {
+ action: COMMIT_ACTION_UPDATE,
+ projectPath: mockProjectFullPath,
+ startBranch: mockDefaultBranch,
+ message: mockCommitMessage,
+ filePath: mockCiConfigPath,
+ content: mockCiYml,
+ lastCommitId: mockCommitSha,
+};
+
+const mockProvide = {
+ ciConfigPath: mockCiConfigPath,
+ projectFullPath: mockProjectFullPath,
+};
+
+describe('Pipeline Editor | Commit section', () => {
+ let wrapper;
+ let mockApollo;
+ const mockMutateCommitData = jest.fn();
+
+ const defaultProps = {
+ ciFileContent: mockCiYml,
+ commitSha: mockCommitSha,
+ hasUnsavedChanges: true,
+ isNewCiConfigFile: false,
+ };
+
+ const createComponent = ({ apolloConfig = {}, props = {}, options = {}, provide = {} } = {}) => {
+ wrapper = mount(CommitSection, {
+ propsData: { ...defaultProps, ...props },
+ provide: { ...mockProvide, ...provide },
+ data() {
+ return {
+ currentBranch: mockDefaultBranch,
+ };
+ },
+ attachTo: document.body,
+ ...apolloConfig,
+ ...options,
+ });
+ };
+
+ const createComponentWithApollo = (options) => {
+ const handlers = [[commitCreate, mockMutateCommitData]];
+ Vue.use(VueApollo);
+ mockApollo = createMockApollo(handlers, resolvers);
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getCurrentBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: mockDefaultBranch,
+ },
+ },
+ },
+ });
+
+ const apolloConfig = {
+ apolloProvider: mockApollo,
+ };
+
+ createComponent({ ...options, apolloConfig });
+ };
+
+ const findCommitForm = () => wrapper.findComponent(CommitForm);
+ const findCommitBtnLoadingIcon = () =>
+ wrapper.find('[type="submit"]').findComponent(GlLoadingIcon);
+
+ const submitCommit = async ({
+ message = mockCommitMessage,
+ branch = mockDefaultBranch,
+ openMergeRequest = false,
+ } = {}) => {
+ await findCommitForm().findComponent(GlFormTextarea).setValue(message);
+ await findCommitForm().findComponent(GlFormInput).setValue(branch);
+ if (openMergeRequest) {
+ await findCommitForm().find('[data-testid="new-mr-checkbox"]').setChecked(openMergeRequest);
+ }
+ await findCommitForm().find('[type="submit"]').trigger('click');
+ await waitForPromises();
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when the user commits a new file', () => {
+ beforeEach(async () => {
+ mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse);
+ createComponentWithApollo({ props: { isNewCiConfigFile: true } });
+ await submitCommit();
+ });
+
+ it('calls the mutation with the CREATE action', () => {
+ expect(mockMutateCommitData).toHaveBeenCalledTimes(1);
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ action: COMMIT_ACTION_CREATE,
+ branch: mockDefaultBranch,
+ });
+ });
+ });
+
+ describe('when the user commits an update to an existing file', () => {
+ beforeEach(async () => {
+ createComponentWithApollo();
+ await submitCommit();
+ });
+
+ it('calls the mutation with the UPDATE action', () => {
+ expect(mockMutateCommitData).toHaveBeenCalledTimes(1);
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ action: COMMIT_ACTION_UPDATE,
+ branch: mockDefaultBranch,
+ });
+ });
+ });
+
+ describe('when the user commits changes to the current branch', () => {
+ beforeEach(async () => {
+ createComponentWithApollo();
+ await submitCommit();
+ });
+
+ it('calls the mutation with the current branch', () => {
+ expect(mockMutateCommitData).toHaveBeenCalledTimes(1);
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ branch: mockDefaultBranch,
+ });
+ });
+
+ it('emits an event to communicate the commit was successful', () => {
+ expect(wrapper.emitted('commit')).toHaveLength(1);
+ expect(wrapper.emitted('commit')[0]).toEqual([{ type: COMMIT_SUCCESS }]);
+ });
+
+ it('emits an event to refetch the commit sha', () => {
+ expect(wrapper.emitted('updateCommitSha')).toHaveLength(1);
+ });
+
+ it('shows no saving state', () => {
+ expect(findCommitBtnLoadingIcon().exists()).toBe(false);
+ });
+
+ it('a second commit submits the latest sha, keeping the form updated', async () => {
+ await submitCommit();
+
+ expect(mockMutateCommitData).toHaveBeenCalledTimes(2);
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ branch: mockDefaultBranch,
+ });
+ });
+ });
+
+ describe('when the user commits changes to a new branch', () => {
+ const newBranch = 'new-branch';
+
+ beforeEach(async () => {
+ createComponentWithApollo();
+ await submitCommit({
+ branch: newBranch,
+ });
+ });
+
+ it('calls the mutation with the new branch', () => {
+ expect(mockMutateCommitData).toHaveBeenCalledWith({
+ ...mockVariables,
+ branch: newBranch,
+ });
+ });
+
+ it('does not emit an event to refetch the commit sha', () => {
+ expect(wrapper.emitted('updateCommitSha')).toBeUndefined();
+ });
+ });
+
+ describe('when the user commits changes to open a new merge request', () => {
+ const newBranch = 'new-branch';
+
+ beforeEach(async () => {
+ mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse);
+ createComponentWithApollo();
+ mockMutateCommitData.mockResolvedValue(mockCommitCreateResponse);
+ await submitCommit({
+ branch: newBranch,
+ openMergeRequest: true,
+ });
+ });
+
+ it('emits a commit event with the right type, sourceBranch and targetBranch', () => {
+ expect(wrapper.emitted('commit')).toHaveLength(1);
+ expect(wrapper.emitted('commit')[0]).toMatchObject([
+ {
+ type: COMMIT_SUCCESS_WITH_REDIRECT,
+ params: { sourceBranch: newBranch, targetBranch: mockDefaultBranch },
+ },
+ ]);
+ });
+ });
+
+ describe('when the commit is ocurring', () => {
+ beforeEach(() => {
+ createComponentWithApollo();
+ });
+
+ it('shows a saving state', async () => {
+ mockMutateCommitData.mockImplementationOnce(() => {
+ expect(findCommitBtnLoadingIcon().exists()).toBe(true);
+ return Promise.resolve();
+ });
+
+ await submitCommit({
+ message: mockCommitMessage,
+ branch: mockDefaultBranch,
+ openMergeRequest: false,
+ });
+ });
+ });
+
+ describe('when the commit returns a different etag path', () => {
+ beforeEach(async () => {
+ createComponentWithApollo();
+ jest.spyOn(wrapper.vm.$apollo, 'mutate');
+ mockMutateCommitData.mockResolvedValue(mockCommitCreateResponseNewEtag);
+ await submitCommit();
+ });
+
+ it('calls the client mutation to update the etag', () => {
+ // 1:Commit submission, 2:etag update, 3:currentBranch update, 4:lastCommit update
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(4);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenNthCalledWith(2, {
+ mutation: updatePipelineEtag,
+ variables: {
+ pipelineEtag: mockCommitCreateResponseNewEtag.data.commitCreate.commitPipelinePath,
+ },
+ });
+ });
+ });
+
+ it('sets listeners on commit form', () => {
+ const handler = jest.fn();
+ createComponent({ options: { listeners: { event: handler } } });
+ findCommitForm().vm.$emit('event');
+ expect(handler).toHaveBeenCalled();
+ });
+
+ it('passes down scroll-to-commit-form prop to commit form', () => {
+ createComponent({ props: { 'scroll-to-commit-form': true } });
+ expect(findCommitForm().props('scrollToCommitForm')).toBe(true);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js
new file mode 100644
index 00000000000..137137ec657
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js
@@ -0,0 +1,60 @@
+import { getByRole } from '@testing-library/dom';
+import { mount } from '@vue/test-utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import FirstPipelineCard from '~/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue';
+import { pipelineEditorTrackingOptions } from '~/ci/pipeline_editor/constants';
+
+describe('First pipeline card', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const createComponent = () => {
+ wrapper = mount(FirstPipelineCard);
+ };
+
+ const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name });
+ const findRunnersLink = () => getLinkByName(/make sure your instance has runners available/i);
+ const findInstructionsList = () => wrapper.find('ol');
+ const findAllInstructions = () => findInstructionsList().findAll('li');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the title', () => {
+ expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title);
+ });
+
+ it('renders the content', () => {
+ expect(findInstructionsList().exists()).toBe(true);
+ expect(findAllInstructions()).toHaveLength(3);
+ });
+
+ it('renders the link', () => {
+ expect(findRunnersLink().href).toBe(wrapper.vm.$options.RUNNER_HELP_URL);
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks runners help page click', async () => {
+ const { label } = pipelineEditorTrackingOptions;
+ const { runners } = pipelineEditorTrackingOptions.actions.helpDrawerLinks;
+
+ await findRunnersLink().click();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, runners, { label });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js
new file mode 100644
index 00000000000..cdce757ce7c
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js
@@ -0,0 +1,26 @@
+import { shallowMount } from '@vue/test-utils';
+import GettingStartedCard from '~/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue';
+
+describe('Getting started card', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(GettingStartedCard);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the title', () => {
+ expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title);
+ });
+
+ it('renders the content', () => {
+ expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js
new file mode 100644
index 00000000000..6909916c3e6
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js
@@ -0,0 +1,89 @@
+import { getByRole } from '@testing-library/dom';
+import { mount } from '@vue/test-utils';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import PipelineConfigReferenceCard from '~/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue';
+import { pipelineEditorTrackingOptions } from '~/ci/pipeline_editor/constants';
+
+describe('Pipeline config reference card', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const defaultProvide = {
+ ciExamplesHelpPagePath: 'help/ci/examples/',
+ ciHelpPagePath: 'help/ci/introduction',
+ needsHelpPagePath: 'help/ci/yaml#needs',
+ ymlHelpPagePath: 'help/ci/yaml',
+ };
+
+ const createComponent = () => {
+ wrapper = mount(PipelineConfigReferenceCard, {
+ provide: {
+ ...defaultProvide,
+ },
+ });
+ };
+
+ const getLinkByName = (name) => getByRole(wrapper.element, 'link', { name });
+ const findCiExamplesLink = () => getLinkByName(/CI\/CD examples and templates/i);
+ const findCiIntroLink = () => getLinkByName(/GitLab CI\/CD concepts/i);
+ const findNeedsLink = () => getLinkByName(/Needs keyword/i);
+ const findYmlSyntaxLink = () => getLinkByName(/.gitlab-ci.yml syntax reference/i);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the title', () => {
+ expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title);
+ });
+
+ it('renders the content', () => {
+ expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph);
+ });
+
+ it('renders the links', () => {
+ expect(findCiExamplesLink().href).toContain(defaultProvide.ciExamplesHelpPagePath);
+ expect(findCiIntroLink().href).toContain(defaultProvide.ciHelpPagePath);
+ expect(findNeedsLink().href).toContain(defaultProvide.needsHelpPagePath);
+ expect(findYmlSyntaxLink().href).toContain(defaultProvide.ymlHelpPagePath);
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ const testTracker = async (element, expectedAction) => {
+ const { label } = pipelineEditorTrackingOptions;
+
+ await element.click();
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, expectedAction, {
+ label,
+ });
+ };
+
+ it('tracks help page links', async () => {
+ const {
+ CI_EXAMPLES_LINK,
+ CI_HELP_LINK,
+ CI_NEEDS_LINK,
+ CI_YAML_LINK,
+ } = pipelineEditorTrackingOptions.actions.helpDrawerLinks;
+
+ testTracker(findCiExamplesLink(), CI_EXAMPLES_LINK);
+ testTracker(findCiIntroLink(), CI_HELP_LINK);
+ testTracker(findNeedsLink(), CI_NEEDS_LINK);
+ testTracker(findYmlSyntaxLink(), CI_YAML_LINK);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js
new file mode 100644
index 00000000000..0c6879020de
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js
@@ -0,0 +1,26 @@
+import { shallowMount } from '@vue/test-utils';
+import VisualizeAndLintCard from '~/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue';
+
+describe('Visual and Lint card', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(VisualizeAndLintCard);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the title', () => {
+ expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.title);
+ });
+
+ it('renders the content', () => {
+ expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.firstParagraph);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
new file mode 100644
index 00000000000..42e372cc1db
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
@@ -0,0 +1,27 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDrawer } from '@gitlab/ui';
+import PipelineEditorDrawer from '~/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
+
+describe('Pipeline editor drawer', () => {
+ let wrapper;
+
+ const findDrawer = () => wrapper.findComponent(GlDrawer);
+
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineEditorDrawer);
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('emits close event when closing the drawer', () => {
+ createComponent();
+
+ expect(wrapper.emitted('close-drawer')).toBeUndefined();
+
+ findDrawer().vm.$emit('close');
+
+ expect(wrapper.emitted('close-drawer')).toHaveLength(1);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js
new file mode 100644
index 00000000000..f510c61ee74
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js
@@ -0,0 +1,27 @@
+import { shallowMount } from '@vue/test-utils';
+import DemoJobPill from '~/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue';
+
+describe('Demo job pill', () => {
+ let wrapper;
+ const jobName = 'my-build-job';
+
+ const createComponent = () => {
+ wrapper = shallowMount(DemoJobPill, {
+ propsData: {
+ jobName,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders the jobName', () => {
+ expect(wrapper.text()).toContain(jobName);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js
new file mode 100644
index 00000000000..2a2bc2547cc
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js
@@ -0,0 +1,69 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import { EDITOR_READY_EVENT } from '~/editor/constants';
+import CiConfigMergedPreview from '~/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue';
+import { mockLintResponse, mockCiConfigPath } from '../../mock_data';
+
+describe('Text editor component', () => {
+ let wrapper;
+
+ const MockSourceEditor = {
+ template: '<div/>',
+ props: ['value', 'fileName', 'editorOptions'],
+ mounted() {
+ this.$emit(EDITOR_READY_EVENT);
+ },
+ };
+
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = shallowMount(CiConfigMergedPreview, {
+ propsData: {
+ ciConfigData: mockLintResponse,
+ ...props,
+ },
+ provide: {
+ ciConfigPath: mockCiConfigPath,
+ },
+ stubs: {
+ SourceEditor: MockSourceEditor,
+ },
+ });
+ };
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findEditor = () => wrapper.findComponent(MockSourceEditor);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when status is valid', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows an information message that the section is not editable', () => {
+ expect(findIcon().exists()).toBe(true);
+ expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.viewOnlyMessage);
+ });
+
+ it('contains an editor', () => {
+ expect(findEditor().exists()).toBe(true);
+ });
+
+ it('editor contains the value provided', () => {
+ expect(findEditor().props('value')).toBe(mockLintResponse.mergedYaml);
+ });
+
+ it('editor is configured for the CI config path', () => {
+ expect(findEditor().props('fileName')).toBe(mockCiConfigPath);
+ });
+
+ it('editor is readonly', () => {
+ expect(findEditor().props('editorOptions')).toMatchObject({
+ readOnly: true,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js
new file mode 100644
index 00000000000..d7f0ce838d6
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js
@@ -0,0 +1,115 @@
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import CiEditorHeader from '~/ci/pipeline_editor/components/editor/ci_editor_header.vue';
+import {
+ pipelineEditorTrackingOptions,
+ TEMPLATE_REPOSITORY_URL,
+} from '~/ci/pipeline_editor/constants';
+
+describe('CI Editor Header', () => {
+ let wrapper;
+ let trackingSpy = null;
+
+ const createComponent = ({ showDrawer = false } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(CiEditorHeader, {
+ propsData: {
+ showDrawer,
+ },
+ }),
+ );
+ };
+
+ const findLinkBtn = () => wrapper.findByTestId('template-repo-link');
+ const findHelpBtn = () => wrapper.findByTestId('drawer-toggle');
+
+ afterEach(() => {
+ wrapper.destroy();
+ unmockTracking();
+ });
+
+ const testTracker = async (element, expectedAction) => {
+ const { label } = pipelineEditorTrackingOptions;
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ await element.vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, expectedAction, {
+ label,
+ });
+ };
+
+ describe('link button', () => {
+ beforeEach(() => {
+ createComponent();
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ it('finds the browse template button', () => {
+ expect(findLinkBtn().exists()).toBe(true);
+ });
+
+ it('contains the link to the template repo', () => {
+ expect(findLinkBtn().attributes('href')).toBe(TEMPLATE_REPOSITORY_URL);
+ });
+
+ it('has the external-link icon', () => {
+ expect(findLinkBtn().props('icon')).toBe('external-link');
+ });
+
+ it('tracks the click on the browse button', async () => {
+ const { browseTemplates } = pipelineEditorTrackingOptions.actions;
+
+ testTracker(findLinkBtn(), browseTemplates);
+ });
+ });
+
+ describe('help button', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('finds the help button', () => {
+ expect(findHelpBtn().exists()).toBe(true);
+ });
+
+ it('has the information-o icon', () => {
+ expect(findHelpBtn().props('icon')).toBe('information-o');
+ });
+
+ describe('when pipeline editor drawer is closed', () => {
+ beforeEach(() => {
+ createComponent({ showDrawer: false });
+ });
+
+ it('emits open drawer event when clicked', () => {
+ expect(wrapper.emitted('open-drawer')).toBeUndefined();
+
+ findHelpBtn().vm.$emit('click');
+
+ expect(wrapper.emitted('open-drawer')).toHaveLength(1);
+ });
+
+ it('tracks open help drawer action', async () => {
+ const { actions } = pipelineEditorTrackingOptions;
+
+ testTracker(findHelpBtn(), actions.openHelpDrawer);
+ });
+ });
+
+ describe('when pipeline editor drawer is open', () => {
+ beforeEach(() => {
+ createComponent({ showDrawer: true });
+ });
+
+ it('emits close drawer event when clicked', () => {
+ expect(wrapper.emitted('close-drawer')).toBeUndefined();
+
+ findHelpBtn().vm.$emit('click');
+
+ expect(wrapper.emitted('close-drawer')).toHaveLength(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js
new file mode 100644
index 00000000000..63e23c41263
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js
@@ -0,0 +1,134 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { EDITOR_READY_EVENT } from '~/editor/constants';
+import { SOURCE_EDITOR_DEBOUNCE } from '~/ci/pipeline_editor/constants';
+import TextEditor from '~/ci/pipeline_editor/components/editor/text_editor.vue';
+import {
+ mockCiConfigPath,
+ mockCiYml,
+ mockCommitSha,
+ mockProjectPath,
+ mockProjectNamespace,
+ mockDefaultBranch,
+} from '../../mock_data';
+
+describe('Pipeline Editor | Text editor component', () => {
+ let wrapper;
+
+ let editorReadyListener;
+ let mockUse;
+ let mockRegisterCiSchema;
+ let mockEditorInstance;
+ let editorInstanceDetail;
+
+ const MockSourceEditor = {
+ template: '<div/>',
+ props: ['value', 'fileName', 'editorOptions', 'debounceValue'],
+ };
+
+ const createComponent = (glFeatures = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(TextEditor, {
+ provide: {
+ projectPath: mockProjectPath,
+ projectNamespace: mockProjectNamespace,
+ ciConfigPath: mockCiConfigPath,
+ defaultBranch: mockDefaultBranch,
+ glFeatures,
+ },
+ propsData: {
+ commitSha: mockCommitSha,
+ },
+ attrs: {
+ value: mockCiYml,
+ },
+ listeners: {
+ [EDITOR_READY_EVENT]: editorReadyListener,
+ },
+ stubs: {
+ SourceEditor: MockSourceEditor,
+ },
+ });
+ };
+
+ const findEditor = () => wrapper.findComponent(MockSourceEditor);
+
+ beforeEach(() => {
+ editorReadyListener = jest.fn();
+ mockUse = jest.fn();
+ mockRegisterCiSchema = jest.fn();
+ mockEditorInstance = {
+ use: mockUse,
+ registerCiSchema: mockRegisterCiSchema,
+ };
+ editorInstanceDetail = {
+ detail: {
+ instance: mockEditorInstance,
+ },
+ };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+
+ mockUse.mockClear();
+ mockRegisterCiSchema.mockClear();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('contains an editor', () => {
+ expect(findEditor().exists()).toBe(true);
+ });
+
+ it('editor contains the value provided', () => {
+ expect(findEditor().props('value')).toBe(mockCiYml);
+ });
+
+ it('editor is configured for the CI config path', () => {
+ expect(findEditor().props('fileName')).toBe(mockCiConfigPath);
+ });
+
+ it('passes down editor configs options', () => {
+ expect(findEditor().props('editorOptions')).toEqual({ quickSuggestions: true });
+ });
+
+ it('passes down editor debounce value', () => {
+ expect(findEditor().props('debounceValue')).toBe(SOURCE_EDITOR_DEBOUNCE);
+ });
+
+ it('bubbles up events', () => {
+ findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail);
+
+ expect(editorReadyListener).toHaveBeenCalled();
+ });
+ });
+
+ describe('CI schema', () => {
+ describe('when `schema_linting` feature flag is on', () => {
+ beforeEach(() => {
+ createComponent({ schemaLinting: true });
+ findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail);
+ });
+
+ it('configures editor with syntax highlight', () => {
+ expect(mockUse).toHaveBeenCalledTimes(1);
+ expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when `schema_linting` feature flag is off', () => {
+ beforeEach(() => {
+ createComponent();
+ findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail);
+ });
+
+ it('does not call the register CI schema function', () => {
+ expect(mockUse).not.toHaveBeenCalled();
+ expect(mockRegisterCiSchema).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js
new file mode 100644
index 00000000000..a26232df58f
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js
@@ -0,0 +1,432 @@
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlInfiniteScroll,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import BranchSwitcher from '~/ci/pipeline_editor/components/file_nav/branch_switcher.vue';
+import { DEFAULT_FAILURE } from '~/ci/pipeline_editor/constants';
+import getAvailableBranchesQuery from '~/ci/pipeline_editor/graphql/queries/available_branches.query.graphql';
+import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql';
+import getLastCommitBranch from '~/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql';
+import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers';
+
+import {
+ mockBranchPaginationLimit,
+ mockDefaultBranch,
+ mockEmptySearchBranches,
+ mockProjectBranches,
+ mockProjectFullPath,
+ mockSearchBranches,
+ mockTotalBranches,
+ mockTotalBranchResults,
+ mockTotalSearchResults,
+} from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('Pipeline editor branch switcher', () => {
+ let wrapper;
+ let mockApollo;
+ let mockAvailableBranchQuery;
+
+ const createComponent = ({
+ currentBranch = mockDefaultBranch,
+ availableBranches = ['main'],
+ isQueryLoading = false,
+ mountFn = shallowMount,
+ options = {},
+ props = {},
+ } = {}) => {
+ wrapper = mountFn(BranchSwitcher, {
+ propsData: {
+ ...props,
+ paginationLimit: mockBranchPaginationLimit,
+ },
+ provide: {
+ projectFullPath: mockProjectFullPath,
+ totalBranches: mockTotalBranches,
+ },
+ mocks: {
+ $apollo: {
+ queries: {
+ availableBranches: {
+ loading: isQueryLoading,
+ },
+ },
+ },
+ },
+ data() {
+ return {
+ availableBranches,
+ currentBranch,
+ };
+ },
+ ...options,
+ });
+ };
+
+ const createComponentWithApollo = ({
+ mountFn = shallowMount,
+ props = {},
+ availableBranches = ['main'],
+ } = {}) => {
+ const handlers = [[getAvailableBranchesQuery, mockAvailableBranchQuery]];
+ mockApollo = createMockApollo(handlers, resolvers);
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getCurrentBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: mockDefaultBranch,
+ },
+ },
+ },
+ });
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getLastCommitBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ lastCommit: {
+ __typename: 'WorkBranch',
+ name: '',
+ },
+ },
+ },
+ });
+
+ createComponent({
+ mountFn,
+ props,
+ availableBranches,
+ options: {
+ localVue,
+ apolloProvider: mockApollo,
+ mocks: {},
+ },
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
+ const findInfiniteScroll = () => wrapper.findComponent(GlInfiniteScroll);
+ const defaultBranchInDropdown = () => findDropdownItems().at(0);
+
+ const setAvailableBranchesMock = (availableBranches) => {
+ mockAvailableBranchQuery.mockResolvedValue(availableBranches);
+ };
+
+ beforeEach(() => {
+ mockAvailableBranchQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const testErrorHandling = () => {
+ expect(wrapper.emitted('showError')).toBeDefined();
+ expect(wrapper.emitted('showError')[0]).toEqual([
+ {
+ reasons: [wrapper.vm.$options.i18n.fetchError],
+ type: DEFAULT_FAILURE,
+ },
+ ]);
+ };
+
+ describe('when querying for the first time', () => {
+ beforeEach(() => {
+ createComponentWithApollo({ availableBranches: [] });
+ });
+
+ it('disables the dropdown', () => {
+ expect(findDropdown().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('after querying', () => {
+ beforeEach(async () => {
+ setAvailableBranchesMock(mockProjectBranches);
+ createComponentWithApollo({ mountFn: mount });
+ await waitForPromises();
+ });
+
+ it('renders search box', () => {
+ expect(findSearchBox().exists()).toBe(true);
+ });
+
+ it('renders list of branches', () => {
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDropdownItems()).toHaveLength(mockTotalBranchResults);
+ });
+
+ it('renders current branch with a check mark', () => {
+ expect(defaultBranchInDropdown().text()).toBe(mockDefaultBranch);
+ expect(defaultBranchInDropdown().props('isChecked')).toBe(true);
+ });
+
+ it('does not render check mark for other branches', () => {
+ const nonDefaultBranch = findDropdownItems().at(1);
+
+ expect(nonDefaultBranch.text()).not.toBe(mockDefaultBranch);
+ expect(nonDefaultBranch.props('isChecked')).toBe(false);
+ });
+ });
+
+ describe('on fetch error', () => {
+ beforeEach(async () => {
+ setAvailableBranchesMock(new Error());
+ createComponentWithApollo({ availableBranches: [] });
+ await waitForPromises();
+ });
+
+ it('does not render dropdown', () => {
+ expect(findDropdown().props('disabled')).toBe(true);
+ });
+
+ it('shows an error message', () => {
+ testErrorHandling();
+ });
+ });
+
+ describe('when switching branches', () => {
+ beforeEach(async () => {
+ jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
+ setAvailableBranchesMock(mockProjectBranches);
+ createComponentWithApollo({ mountFn: mount });
+ await waitForPromises();
+ });
+
+ it('updates session history when selecting a different branch', async () => {
+ const branch = findDropdownItems().at(1);
+ branch.vm.$emit('click');
+ await waitForPromises();
+
+ expect(window.history.pushState).toHaveBeenCalled();
+ expect(window.history.pushState.mock.calls[0][2]).toContain(`?branch_name=${branch.text()}`);
+ });
+
+ it('does not update session history when selecting current branch', async () => {
+ const branch = findDropdownItems().at(0);
+ branch.vm.$emit('click');
+ await waitForPromises();
+
+ expect(branch.text()).toBe(mockDefaultBranch);
+ expect(window.history.pushState).not.toHaveBeenCalled();
+ });
+
+ it('emits the refetchContent event when selecting a different branch', async () => {
+ const branch = findDropdownItems().at(1);
+
+ expect(branch.text()).not.toBe(mockDefaultBranch);
+ expect(wrapper.emitted('refetchContent')).toBeUndefined();
+
+ branch.vm.$emit('click');
+ await waitForPromises();
+
+ expect(wrapper.emitted('refetchContent')).toBeDefined();
+ expect(wrapper.emitted('refetchContent')).toHaveLength(1);
+ });
+
+ it('does not emit the refetchContent event when selecting the current branch', async () => {
+ const branch = findDropdownItems().at(0);
+
+ expect(branch.text()).toBe(mockDefaultBranch);
+ expect(wrapper.emitted('refetchContent')).toBeUndefined();
+
+ branch.vm.$emit('click');
+ await waitForPromises();
+
+ expect(wrapper.emitted('refetchContent')).toBeUndefined();
+ });
+
+ describe('with unsaved changes', () => {
+ beforeEach(async () => {
+ createComponentWithApollo({ mountFn: mount, props: { hasUnsavedChanges: true } });
+ await waitForPromises();
+ });
+
+ it('emits `select-branch` event and does not switch branch', async () => {
+ expect(wrapper.emitted('select-branch')).toBeUndefined();
+
+ const branch = findDropdownItems().at(1);
+ await branch.vm.$emit('click');
+
+ expect(wrapper.emitted('select-branch')).toEqual([[branch.text()]]);
+ expect(wrapper.emitted('refetchContent')).toBeUndefined();
+ });
+ });
+ });
+
+ describe('when searching', () => {
+ beforeEach(async () => {
+ setAvailableBranchesMock(mockProjectBranches);
+ createComponentWithApollo({ mountFn: mount });
+ await waitForPromises();
+ });
+
+ afterEach(() => {
+ mockAvailableBranchQuery.mockClear();
+ });
+
+ it('shows error message on fetch error', async () => {
+ mockAvailableBranchQuery.mockResolvedValue(new Error());
+
+ findSearchBox().vm.$emit('input', 'te');
+ await waitForPromises();
+
+ testErrorHandling();
+ });
+
+ describe('with a search term', () => {
+ beforeEach(async () => {
+ mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches);
+ });
+
+ it('calls query with correct variables', async () => {
+ findSearchBox().vm.$emit('input', 'te');
+ await waitForPromises();
+
+ expect(mockAvailableBranchQuery).toHaveBeenCalledWith({
+ limit: mockTotalBranches, // fetch all branches
+ offset: 0,
+ projectFullPath: mockProjectFullPath,
+ searchPattern: '*te*',
+ });
+ });
+
+ it('fetches new list of branches', async () => {
+ expect(findDropdownItems()).toHaveLength(mockTotalBranchResults);
+
+ findSearchBox().vm.$emit('input', 'te');
+ await waitForPromises();
+
+ expect(findDropdownItems()).toHaveLength(mockTotalSearchResults);
+ });
+
+ it('does not hide dropdown when search result is empty', async () => {
+ mockAvailableBranchQuery.mockResolvedValue(mockEmptySearchBranches);
+ findSearchBox().vm.$emit('input', 'aaaaa');
+ await waitForPromises();
+
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDropdownItems()).toHaveLength(0);
+ });
+ });
+
+ describe('without a search term', () => {
+ beforeEach(async () => {
+ mockAvailableBranchQuery.mockResolvedValue(mockSearchBranches);
+ findSearchBox().vm.$emit('input', 'te');
+ await waitForPromises();
+
+ mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches);
+ });
+
+ it('calls query with correct variables', async () => {
+ findSearchBox().vm.$emit('input', '');
+ await waitForPromises();
+
+ expect(mockAvailableBranchQuery).toHaveBeenCalledWith({
+ limit: mockBranchPaginationLimit, // only fetch first n branches first
+ offset: 0,
+ projectFullPath: mockProjectFullPath,
+ searchPattern: '*',
+ });
+ });
+
+ it('fetches new list of branches', async () => {
+ expect(findDropdownItems()).toHaveLength(mockTotalSearchResults);
+
+ findSearchBox().vm.$emit('input', '');
+ await waitForPromises();
+
+ expect(findDropdownItems()).toHaveLength(mockTotalBranchResults);
+ });
+ });
+ });
+
+ describe('loading icon', () => {
+ it.each`
+ isQueryLoading | isRendered
+ ${true} | ${true}
+ ${false} | ${false}
+ `('checks if query is loading before rendering', ({ isQueryLoading, isRendered }) => {
+ createComponent({ isQueryLoading, mountFn: mount });
+
+ expect(findLoadingIcon().exists()).toBe(isRendered);
+ });
+ });
+
+ describe('when scrolling to the bottom of the list', () => {
+ beforeEach(async () => {
+ setAvailableBranchesMock(mockProjectBranches);
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ afterEach(() => {
+ mockAvailableBranchQuery.mockClear();
+ });
+
+ describe('when search term is empty', () => {
+ it('fetches more branches', async () => {
+ expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(1);
+
+ findInfiniteScroll().vm.$emit('bottomReached');
+ await waitForPromises();
+
+ expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(2);
+ });
+
+ it('calls the query with the correct variables', async () => {
+ findInfiniteScroll().vm.$emit('bottomReached');
+ await waitForPromises();
+
+ expect(mockAvailableBranchQuery).toHaveBeenCalledWith({
+ limit: mockBranchPaginationLimit,
+ offset: mockBranchPaginationLimit, // offset changed
+ projectFullPath: mockProjectFullPath,
+ searchPattern: '*',
+ });
+ });
+
+ it('shows error message on fetch error', async () => {
+ mockAvailableBranchQuery.mockResolvedValue(new Error());
+
+ findInfiniteScroll().vm.$emit('bottomReached');
+ await waitForPromises();
+
+ testErrorHandling();
+ });
+ });
+
+ describe('when search term exists', () => {
+ it('does not fetch more branches', async () => {
+ findSearchBox().vm.$emit('input', 'te');
+ await waitForPromises();
+
+ expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(2);
+ mockAvailableBranchQuery.mockClear();
+
+ findInfiniteScroll().vm.$emit('bottomReached');
+ await waitForPromises();
+
+ expect(mockAvailableBranchQuery).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
new file mode 100644
index 00000000000..907db16913c
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
@@ -0,0 +1,126 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import BranchSwitcher from '~/ci/pipeline_editor/components/file_nav/branch_switcher.vue';
+import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
+import FileTreePopover from '~/ci/pipeline_editor/components/popovers/file_tree_popover.vue';
+import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql';
+import {
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
+} from '~/ci/pipeline_editor/constants';
+
+Vue.use(VueApollo);
+
+describe('Pipeline editor file nav', () => {
+ let wrapper;
+
+ const mockApollo = createMockApollo();
+
+ const createComponent = ({
+ appStatus = EDITOR_APP_STATUS_VALID,
+ isNewCiConfigFile = false,
+ } = {}) => {
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ app: {
+ __typename: 'PipelineEditorApp',
+ status: appStatus,
+ },
+ },
+ });
+
+ wrapper = extendedWrapper(
+ shallowMount(PipelineEditorFileNav, {
+ apolloProvider: mockApollo,
+ propsData: {
+ isNewCiConfigFile,
+ },
+ }),
+ );
+ };
+
+ const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher);
+ const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle');
+ const findPopoverContainer = () => wrapper.findComponent(FileTreePopover);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the branch switcher', () => {
+ expect(findBranchSwitcher().exists()).toBe(true);
+ });
+ });
+
+ describe('file tree', () => {
+ describe('when editor is in the empty state', () => {
+ beforeEach(() => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_EMPTY, isNewCiConfigFile: false });
+ });
+
+ it('does not render the file tree button', () => {
+ expect(findFileTreeBtn().exists()).toBe(false);
+ });
+
+ it('does not render the file tree popover', () => {
+ expect(findPopoverContainer().exists()).toBe(false);
+ });
+ });
+
+ describe('when user is about to create their config file for the first time', () => {
+ beforeEach(() => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_VALID, isNewCiConfigFile: true });
+ });
+
+ it('does not render the file tree button', () => {
+ expect(findFileTreeBtn().exists()).toBe(false);
+ });
+
+ it('does not render the file tree popover', () => {
+ expect(findPopoverContainer().exists()).toBe(false);
+ });
+ });
+
+ describe('when app is in a global loading state', () => {
+ it('renders the file tree button with a loading icon', () => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_LOADING, isNewCiConfigFile: false });
+
+ expect(findFileTreeBtn().exists()).toBe(true);
+ expect(findFileTreeBtn().attributes('loading')).toBe('true');
+ });
+ });
+
+ describe('when editor has a non-empty config file open', () => {
+ beforeEach(() => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_VALID, isNewCiConfigFile: false });
+ });
+
+ it('renders the file tree button', () => {
+ expect(findFileTreeBtn().exists()).toBe(true);
+ expect(findFileTreeBtn().props('icon')).toBe('file-tree');
+ });
+
+ it('renders the file tree popover', () => {
+ expect(findPopoverContainer().exists()).toBe(true);
+ });
+
+ it('file tree button emits toggle-file-tree event', () => {
+ expect(wrapper.emitted('toggle-file-tree')).toBe(undefined);
+
+ findFileTreeBtn().vm.$emit('click');
+
+ expect(wrapper.emitted('toggle-file-tree')).toHaveLength(1);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js
new file mode 100644
index 00000000000..11ba517e0eb
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js
@@ -0,0 +1,138 @@
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { createMockDirective } from 'helpers/vue_mock_directive';
+import PipelineEditorFileTreeContainer from '~/ci/pipeline_editor/components/file_tree/container.vue';
+import PipelineEditorFileTreeItem from '~/ci/pipeline_editor/components/file_tree/file_item.vue';
+import { FILE_TREE_TIP_DISMISSED_KEY } from '~/ci/pipeline_editor/constants';
+import { mockCiConfigPath, mockIncludes, mockIncludesHelpPagePath } from '../../mock_data';
+
+describe('Pipeline editor file nav', () => {
+ let wrapper;
+
+ const createComponent = ({ includes = mockIncludes, stubs } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(PipelineEditorFileTreeContainer, {
+ provide: {
+ ciConfigPath: mockCiConfigPath,
+ includesHelpPagePath: mockIncludesHelpPagePath,
+ },
+ propsData: {
+ includes,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ stubs,
+ }),
+ );
+ };
+
+ const findTip = () => wrapper.findComponent(GlAlert);
+ const findCurrentConfigFilename = () => wrapper.findByTestId('current-config-filename');
+ const fileTreeItems = () => wrapper.findAllComponents(PipelineEditorFileTreeItem);
+
+ afterEach(() => {
+ localStorage.clear();
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent({ stubs: { GlAlert } });
+ });
+
+ it('renders config file as a file item', () => {
+ expect(findCurrentConfigFilename().text()).toBe(mockCiConfigPath);
+ });
+ });
+
+ describe('when includes list is empty', () => {
+ describe('when dismiss state is not saved in local storage', () => {
+ beforeEach(() => {
+ createComponent({
+ includes: [],
+ stubs: { GlAlert },
+ });
+ });
+
+ it('does not render filenames', () => {
+ expect(fileTreeItems().exists()).toBe(false);
+ });
+
+ it('renders alert tip', async () => {
+ expect(findTip().exists()).toBe(true);
+ });
+
+ it('renders learn more link', async () => {
+ expect(findTip().props('secondaryButtonLink')).toBe(mockIncludesHelpPagePath);
+ });
+
+ it('can dismiss the tip', async () => {
+ expect(findTip().exists()).toBe(true);
+
+ findTip().vm.$emit('dismiss');
+ await nextTick();
+
+ expect(findTip().exists()).toBe(false);
+ });
+ });
+
+ describe('when dismiss state is saved in local storage', () => {
+ beforeEach(() => {
+ localStorage.setItem(FILE_TREE_TIP_DISMISSED_KEY, 'true');
+ createComponent({
+ includes: [],
+ stubs: { GlAlert },
+ });
+ });
+
+ it('does not render alert tip', async () => {
+ expect(findTip().exists()).toBe(false);
+ });
+ });
+
+ describe('when component receives new props with includes files', () => {
+ beforeEach(() => {
+ createComponent({ includes: [] });
+ });
+
+ it('hides tip and renders list of files', async () => {
+ expect(findTip().exists()).toBe(true);
+ expect(fileTreeItems()).toHaveLength(0);
+
+ await wrapper.setProps({ includes: mockIncludes });
+
+ expect(findTip().exists()).toBe(false);
+ expect(fileTreeItems()).toHaveLength(mockIncludes.length);
+ });
+ });
+ });
+
+ describe('when there are includes files', () => {
+ beforeEach(() => {
+ createComponent({ stubs: { GlAlert } });
+ });
+
+ it('does not render alert tip', () => {
+ expect(findTip().exists()).toBe(false);
+ });
+
+ it('renders the list of files', () => {
+ expect(fileTreeItems()).toHaveLength(mockIncludes.length);
+ });
+
+ describe('when component receives new props with empty includes', () => {
+ it('shows tip and does not render list of files', async () => {
+ expect(findTip().exists()).toBe(false);
+ expect(fileTreeItems()).toHaveLength(mockIncludes.length);
+
+ await wrapper.setProps({ includes: [] });
+
+ expect(findTip().exists()).toBe(true);
+ expect(fileTreeItems()).toHaveLength(0);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js
new file mode 100644
index 00000000000..bceb741f91c
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js
@@ -0,0 +1,52 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import PipelineEditorFileTreeItem from '~/ci/pipeline_editor/components/file_tree/file_item.vue';
+import { mockIncludesWithBlob, mockDefaultIncludes } from '../../mock_data';
+
+describe('Pipeline editor file nav', () => {
+ let wrapper;
+
+ const createComponent = ({ file = mockDefaultIncludes } = {}) => {
+ wrapper = shallowMount(PipelineEditorFileTreeItem, {
+ propsData: {
+ file,
+ },
+ });
+ };
+
+ const fileIcon = () => wrapper.findComponent(FileIcon);
+ const link = () => wrapper.findComponent(GlLink);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders file icon', () => {
+ expect(fileIcon().exists()).toBe(true);
+ });
+
+ it('renders file name', () => {
+ expect(wrapper.text()).toBe(mockDefaultIncludes.location);
+ });
+
+ it('links to raw path by default', () => {
+ expect(link().attributes('href')).toBe(mockDefaultIncludes.raw);
+ });
+ });
+
+ describe('when file has blob link', () => {
+ beforeEach(() => {
+ createComponent({ file: mockIncludesWithBlob });
+ });
+
+ it('links to blob path', () => {
+ expect(link().attributes('href')).toBe(mockIncludesWithBlob.blob);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js
new file mode 100644
index 00000000000..555b9f29fbf
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js
@@ -0,0 +1,53 @@
+import { shallowMount } from '@vue/test-utils';
+import PipelineEditorHeader from '~/ci/pipeline_editor/components/header/pipeline_editor_header.vue';
+import PipelineStatus from '~/ci/pipeline_editor/components/header/pipeline_status.vue';
+import ValidationSegment from '~/ci/pipeline_editor/components/header/validation_segment.vue';
+
+import { mockCiYml, mockLintResponse } from '../../mock_data';
+
+describe('Pipeline editor header', () => {
+ let wrapper;
+
+ const createComponent = ({ provide = {}, props = {} } = {}) => {
+ wrapper = shallowMount(PipelineEditorHeader, {
+ provide: {
+ ...provide,
+ },
+ propsData: {
+ ciConfigData: mockLintResponse,
+ ciFileContent: mockCiYml,
+ isCiConfigDataLoading: false,
+ isNewCiConfigFile: false,
+ ...props,
+ },
+ });
+ };
+
+ const findPipelineStatus = () => wrapper.findComponent(PipelineStatus);
+ const findValidationSegment = () => wrapper.findComponent(ValidationSegment);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('template', () => {
+ it('hides the pipeline status for new projects without a CI file', () => {
+ createComponent({ props: { isNewCiConfigFile: true } });
+
+ expect(findPipelineStatus().exists()).toBe(false);
+ });
+
+ it('renders the pipeline status when CI file exists', () => {
+ createComponent({ props: { isNewCiConfigFile: false } });
+
+ expect(findPipelineStatus().exists()).toBe(true);
+ });
+
+ it('renders the validation segment', () => {
+ createComponent();
+
+ expect(findValidationSegment().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js
new file mode 100644
index 00000000000..6f28362e478
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js
@@ -0,0 +1,109 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
+import { PIPELINE_FAILURE } from '~/ci/pipeline_editor/constants';
+import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('Pipeline Status', () => {
+ let wrapper;
+ let mockApollo;
+ let mockLinkedPipelinesQuery;
+
+ const createComponent = ({ hasStages = true, options } = {}) => {
+ wrapper = shallowMount(PipelineEditorMiniGraph, {
+ provide: {
+ dataMethod: 'graphql',
+ projectFullPath: mockProjectFullPath,
+ },
+ propsData: {
+ pipeline: mockProjectPipeline({ hasStages }).pipeline,
+ },
+ ...options,
+ });
+ };
+
+ const createComponentWithApollo = (hasStages = true) => {
+ const handlers = [[getLinkedPipelinesQuery, mockLinkedPipelinesQuery]];
+ mockApollo = createMockApollo(handlers);
+
+ createComponent({
+ hasStages,
+ options: {
+ apolloProvider: mockApollo,
+ },
+ });
+ };
+
+ const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph);
+
+ beforeEach(() => {
+ mockLinkedPipelinesQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ mockLinkedPipelinesQuery.mockReset();
+ wrapper.destroy();
+ });
+
+ describe('when there are stages', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders pipeline mini graph', () => {
+ expect(findPipelineMiniGraph().exists()).toBe(true);
+ });
+ });
+
+ describe('when there are no stages', () => {
+ beforeEach(() => {
+ createComponent({ hasStages: false });
+ });
+
+ it('does not render pipeline mini graph', () => {
+ expect(findPipelineMiniGraph().exists()).toBe(false);
+ });
+ });
+
+ describe('when querying upstream and downstream pipelines', () => {
+ describe('when query succeeds', () => {
+ beforeEach(() => {
+ mockLinkedPipelinesQuery.mockResolvedValue(mockLinkedPipelines());
+ createComponentWithApollo();
+ });
+
+ it('should call the query with the correct variables', () => {
+ expect(mockLinkedPipelinesQuery).toHaveBeenCalledTimes(1);
+ expect(mockLinkedPipelinesQuery).toHaveBeenCalledWith({
+ fullPath: mockProjectFullPath,
+ iid: mockProjectPipeline().pipeline.iid,
+ });
+ });
+ });
+
+ describe('when query fails', () => {
+ beforeEach(async () => {
+ mockLinkedPipelinesQuery.mockRejectedValue(new Error());
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ it('should emit an error event when query fails', async () => {
+ expect(wrapper.emitted('showError')).toHaveLength(1);
+ expect(wrapper.emitted('showError')[0]).toEqual([
+ {
+ type: PIPELINE_FAILURE,
+ reasons: [wrapper.vm.$options.i18n.linkedPipelinesFetchError],
+ },
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js
new file mode 100644
index 00000000000..a62c51ffb59
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js
@@ -0,0 +1,132 @@
+import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import PipelineStatus, { i18n } from '~/ci/pipeline_editor/components/header/pipeline_status.vue';
+import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql';
+import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue';
+import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('Pipeline Status', () => {
+ let wrapper;
+ let mockApollo;
+ let mockPipelineQuery;
+
+ const createComponentWithApollo = () => {
+ const handlers = [[getPipelineQuery, mockPipelineQuery]];
+ mockApollo = createMockApollo(handlers);
+
+ wrapper = shallowMount(PipelineStatus, {
+ apolloProvider: mockApollo,
+ propsData: {
+ commitSha: mockCommitSha,
+ },
+ provide: {
+ projectFullPath: mockProjectFullPath,
+ },
+ stubs: { GlLink, GlSprintf },
+ });
+ };
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findPipelineEditorMiniGraph = () => wrapper.findComponent(PipelineEditorMiniGraph);
+ const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]');
+ const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]');
+ const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]');
+ const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]');
+ const findPipelineViewBtn = () => wrapper.find('[data-testid="pipeline-view-btn"]');
+ const findStatusIcon = () => wrapper.find('[data-testid="pipeline-status-icon"]');
+
+ beforeEach(() => {
+ mockPipelineQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ mockPipelineQuery.mockReset();
+ wrapper.destroy();
+ });
+
+ describe('loading icon', () => {
+ it('renders while query is being fetched', () => {
+ createComponentWithApollo();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findPipelineLoadingMsg().text()).toBe(i18n.fetchLoading);
+ });
+
+ it('does not render if query is no longer loading', async () => {
+ createComponentWithApollo();
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('when querying data', () => {
+ describe('when data is set', () => {
+ beforeEach(async () => {
+ mockPipelineQuery.mockResolvedValue({
+ data: { project: mockProjectPipeline() },
+ });
+
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ it('query is called with correct variables', async () => {
+ expect(mockPipelineQuery).toHaveBeenCalledTimes(1);
+ expect(mockPipelineQuery).toHaveBeenCalledWith({
+ fullPath: mockProjectFullPath,
+ sha: mockCommitSha,
+ });
+ });
+
+ it('does not render error', () => {
+ expect(findPipelineErrorMsg().exists()).toBe(false);
+ });
+
+ it('renders pipeline data', () => {
+ const {
+ id,
+ commit: { title },
+ detailedStatus: { detailsPath },
+ } = mockProjectPipeline().pipeline;
+
+ expect(findStatusIcon().exists()).toBe(true);
+ expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`);
+ expect(findPipelineCommit().text()).toBe(`${mockCommitSha}: ${title}`);
+ expect(findPipelineViewBtn().attributes('href')).toBe(detailsPath);
+ });
+
+ it('renders the pipeline mini graph', () => {
+ expect(findPipelineEditorMiniGraph().exists()).toBe(true);
+ });
+ });
+
+ describe('when data cannot be fetched', () => {
+ beforeEach(async () => {
+ mockPipelineQuery.mockRejectedValue(new Error());
+
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ it('renders error', () => {
+ expect(findIcon().attributes('name')).toBe('warning-solid');
+ expect(findPipelineErrorMsg().text()).toBe(i18n.fetchError);
+ });
+
+ it('does not render pipeline data', () => {
+ expect(findStatusIcon().exists()).toBe(false);
+ expect(findPipelineId().exists()).toBe(false);
+ expect(findPipelineCommit().exists()).toBe(false);
+ expect(findPipelineViewBtn().exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js
new file mode 100644
index 00000000000..6f28362e478
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js
@@ -0,0 +1,109 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
+import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
+import { PIPELINE_FAILURE } from '~/ci/pipeline_editor/constants';
+import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('Pipeline Status', () => {
+ let wrapper;
+ let mockApollo;
+ let mockLinkedPipelinesQuery;
+
+ const createComponent = ({ hasStages = true, options } = {}) => {
+ wrapper = shallowMount(PipelineEditorMiniGraph, {
+ provide: {
+ dataMethod: 'graphql',
+ projectFullPath: mockProjectFullPath,
+ },
+ propsData: {
+ pipeline: mockProjectPipeline({ hasStages }).pipeline,
+ },
+ ...options,
+ });
+ };
+
+ const createComponentWithApollo = (hasStages = true) => {
+ const handlers = [[getLinkedPipelinesQuery, mockLinkedPipelinesQuery]];
+ mockApollo = createMockApollo(handlers);
+
+ createComponent({
+ hasStages,
+ options: {
+ apolloProvider: mockApollo,
+ },
+ });
+ };
+
+ const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph);
+
+ beforeEach(() => {
+ mockLinkedPipelinesQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ mockLinkedPipelinesQuery.mockReset();
+ wrapper.destroy();
+ });
+
+ describe('when there are stages', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders pipeline mini graph', () => {
+ expect(findPipelineMiniGraph().exists()).toBe(true);
+ });
+ });
+
+ describe('when there are no stages', () => {
+ beforeEach(() => {
+ createComponent({ hasStages: false });
+ });
+
+ it('does not render pipeline mini graph', () => {
+ expect(findPipelineMiniGraph().exists()).toBe(false);
+ });
+ });
+
+ describe('when querying upstream and downstream pipelines', () => {
+ describe('when query succeeds', () => {
+ beforeEach(() => {
+ mockLinkedPipelinesQuery.mockResolvedValue(mockLinkedPipelines());
+ createComponentWithApollo();
+ });
+
+ it('should call the query with the correct variables', () => {
+ expect(mockLinkedPipelinesQuery).toHaveBeenCalledTimes(1);
+ expect(mockLinkedPipelinesQuery).toHaveBeenCalledWith({
+ fullPath: mockProjectFullPath,
+ iid: mockProjectPipeline().pipeline.iid,
+ });
+ });
+ });
+
+ describe('when query fails', () => {
+ beforeEach(async () => {
+ mockLinkedPipelinesQuery.mockRejectedValue(new Error());
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ it('should emit an error event when query fails', async () => {
+ expect(wrapper.emitted('showError')).toHaveLength(1);
+ expect(wrapper.emitted('showError')[0]).toEqual([
+ {
+ type: PIPELINE_FAILURE,
+ reasons: [wrapper.vm.$options.i18n.linkedPipelinesFetchError],
+ },
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js
new file mode 100644
index 00000000000..0853a6f4ca4
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js
@@ -0,0 +1,197 @@
+import VueApollo from 'vue-apollo';
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import { escape } from 'lodash';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { sprintf } from '~/locale';
+import ValidationSegment, {
+ i18n,
+} from '~/ci/pipeline_editor/components/header/validation_segment.vue';
+import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql';
+import {
+ CI_CONFIG_STATUS_INVALID,
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_INVALID,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_LINT_UNAVAILABLE,
+ EDITOR_APP_STATUS_VALID,
+} from '~/ci/pipeline_editor/constants';
+import {
+ mergeUnwrappedCiConfig,
+ mockCiYml,
+ mockLintUnavailableHelpPagePath,
+ mockYmlHelpPagePath,
+} from '../../mock_data';
+
+Vue.use(VueApollo);
+
+describe('Validation segment component', () => {
+ let wrapper;
+
+ const mockApollo = createMockApollo();
+
+ const createComponent = ({ props = {}, appStatus = EDITOR_APP_STATUS_INVALID }) => {
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ app: {
+ __typename: 'PipelineEditorApp',
+ status: appStatus,
+ },
+ },
+ });
+
+ wrapper = extendedWrapper(
+ shallowMount(ValidationSegment, {
+ apolloProvider: mockApollo,
+ provide: {
+ ymlHelpPagePath: mockYmlHelpPagePath,
+ lintUnavailableHelpPagePath: mockLintUnavailableHelpPagePath,
+ },
+ propsData: {
+ ciConfig: mergeUnwrappedCiConfig(),
+ ciFileContent: mockCiYml,
+ ...props,
+ },
+ }),
+ );
+ };
+
+ const findIcon = () => wrapper.findComponent(GlIcon);
+ const findLearnMoreLink = () => wrapper.findByTestId('learnMoreLink');
+ const findValidationMsg = () => wrapper.findByTestId('validationMsg');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows the loading state', () => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_LOADING });
+
+ expect(wrapper.text()).toBe(i18n.loading);
+ });
+
+ describe('when config is empty', () => {
+ beforeEach(() => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_EMPTY });
+ });
+
+ it('has check icon', () => {
+ expect(findIcon().props('name')).toBe('check');
+ });
+
+ it('shows a message for empty state', () => {
+ expect(findValidationMsg().text()).toBe(i18n.empty);
+ });
+ });
+
+ describe('when config is valid', () => {
+ beforeEach(() => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_VALID });
+ });
+
+ it('has check icon', () => {
+ expect(findIcon().props('name')).toBe('check');
+ });
+
+ it('shows a message for valid state', () => {
+ expect(findValidationMsg().text()).toContain(i18n.valid);
+ });
+
+ it('shows the learn more link', () => {
+ expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath);
+ expect(findLearnMoreLink().text()).toBe(i18n.learnMore);
+ });
+ });
+
+ describe('when config is invalid', () => {
+ beforeEach(() => {
+ createComponent({
+ appStatus: EDITOR_APP_STATUS_INVALID,
+ });
+ });
+
+ it('has warning icon', () => {
+ expect(findIcon().props('name')).toBe('warning-solid');
+ });
+
+ it('has message for invalid state', () => {
+ expect(findValidationMsg().text()).toBe(i18n.invalid);
+ });
+
+ it('shows the learn more link', () => {
+ expect(findLearnMoreLink().attributes('href')).toBe(mockYmlHelpPagePath);
+ expect(findLearnMoreLink().text()).toBe('Learn more');
+ });
+
+ describe('with multiple errors', () => {
+ const firstError = 'First Error';
+ const secondError = 'Second Error';
+
+ beforeEach(() => {
+ createComponent({
+ props: {
+ ciConfig: mergeUnwrappedCiConfig({
+ status: CI_CONFIG_STATUS_INVALID,
+ errors: [firstError, secondError],
+ }),
+ },
+ });
+ });
+ it('shows an invalid state with an error', () => {
+ // Test the error is shown _and_ the string matches
+ expect(findValidationMsg().text()).toContain(firstError);
+ expect(findValidationMsg().text()).toBe(
+ sprintf(i18n.invalidWithReason, { reason: firstError }),
+ );
+ });
+ });
+
+ describe('with XSS inside the error', () => {
+ const evilError = '<script>evil();</script>';
+
+ beforeEach(() => {
+ createComponent({
+ props: {
+ ciConfig: mergeUnwrappedCiConfig({
+ status: CI_CONFIG_STATUS_INVALID,
+ errors: [evilError],
+ }),
+ },
+ });
+ });
+ it('shows an invalid state with an error while preventing XSS', () => {
+ const { innerHTML } = findValidationMsg().element;
+
+ expect(innerHTML).not.toContain(evilError);
+ expect(innerHTML).toContain(escape(evilError));
+ });
+ });
+ });
+
+ describe('when the lint service is unavailable', () => {
+ beforeEach(() => {
+ createComponent({
+ appStatus: EDITOR_APP_STATUS_LINT_UNAVAILABLE,
+ props: {
+ ciConfig: {},
+ },
+ });
+ });
+
+ it('show a message that the service is unavailable', () => {
+ expect(findValidationMsg().text()).toBe(i18n.unavailableValidation);
+ });
+
+ it('shows the time-out icon', () => {
+ expect(findIcon().props('name')).toBe('time-out');
+ });
+
+ it('shows the learn more link', () => {
+ expect(findLearnMoreLink().attributes('href')).toBe(mockLintUnavailableHelpPagePath);
+ expect(findLearnMoreLink().text()).toBe(i18n.learnMore);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js
new file mode 100644
index 00000000000..d43bdec3a33
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js
@@ -0,0 +1,177 @@
+import { GlTableLite, GlLink } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue';
+import { mockJobs, mockErrors, mockWarnings } from '../../mock_data';
+
+describe('CI Lint Results', () => {
+ let wrapper;
+ const defaultProps = {
+ isValid: true,
+ jobs: mockJobs,
+ errors: [],
+ warnings: [],
+ dryRun: false,
+ lintHelpPagePath: '/help',
+ };
+
+ const createComponent = (props = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(CiLintResults, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ const findTable = () => wrapper.findComponent(GlTableLite);
+ const findByTestId = (selector) => () => wrapper.find(`[data-testid="ci-lint-${selector}"]`);
+ const findAllByTestId = (selector) => () =>
+ wrapper.findAll(`[data-testid="ci-lint-${selector}"]`);
+ const findLinkToDoc = () => wrapper.findComponent(GlLink);
+ const findErrors = findByTestId('errors');
+ const findWarnings = findByTestId('warnings');
+ const findStatus = findByTestId('status');
+ const findOnlyExcept = findByTestId('only-except');
+ const findLintParameters = findAllByTestId('parameter');
+ const findLintValues = findAllByTestId('value');
+ const findBeforeScripts = findAllByTestId('before-script');
+ const findScripts = findAllByTestId('script');
+ const findAfterScripts = findAllByTestId('after-script');
+ const filterEmptyScripts = (property) => mockJobs.filter((job) => job[property].length !== 0);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('Empty results', () => {
+ it('renders with no jobs, errors or warnings defined', () => {
+ createComponent({ jobs: undefined, errors: undefined, warnings: undefined }, shallowMount);
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it('renders when job has no properties defined', () => {
+ // job with no attributes such as `tagList` or `environment`
+ const job = {
+ stage: 'Stage Name',
+ name: 'test job',
+ };
+ createComponent({ jobs: [job] }, mount);
+
+ const param = findLintParameters().at(0);
+ const value = findLintValues().at(0);
+
+ expect(param.text()).toBe(`${job.stage} Job - ${job.name}`);
+
+ // This test should be updated once properties of each job are shown
+ // See https://gitlab.com/gitlab-org/gitlab/-/issues/291031
+ expect(value.text()).toBe('');
+ });
+ });
+
+ describe('Invalid results', () => {
+ beforeEach(() => {
+ createComponent({ isValid: false, errors: mockErrors, warnings: mockWarnings }, mount);
+ });
+
+ it('does not display the table', () => {
+ expect(findTable().exists()).toBe(false);
+ });
+
+ it('displays the invalid status', () => {
+ expect(findStatus().text()).toContain(`Status: ${wrapper.vm.$options.incorrect.text}`);
+ expect(findStatus().props('variant')).toBe(wrapper.vm.$options.incorrect.variant);
+ });
+
+ it('contains the link to documentation', () => {
+ expect(findLinkToDoc().text()).toBe('More information');
+ expect(findLinkToDoc().attributes('href')).toBe(defaultProps.lintHelpPagePath);
+ });
+
+ it('displays the error message', () => {
+ const [expectedError] = mockErrors;
+
+ expect(findErrors().text()).toBe(expectedError);
+ });
+
+ it('displays the warning message', () => {
+ const [expectedWarning] = mockWarnings;
+
+ expect(findWarnings().exists()).toBe(true);
+ expect(findWarnings().text()).toContain(expectedWarning);
+ });
+ });
+
+ describe('Valid results with dry run', () => {
+ beforeEach(() => {
+ createComponent({ dryRun: true }, mount);
+ });
+
+ it('displays table', () => {
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it('displays the valid status', () => {
+ expect(findStatus().text()).toContain(wrapper.vm.$options.correct.text);
+ expect(findStatus().props('variant')).toBe(wrapper.vm.$options.correct.variant);
+ });
+
+ it('does not display only/expect values with dry run', () => {
+ expect(findOnlyExcept().exists()).toBe(false);
+ });
+
+ it('contains the link to documentation', () => {
+ expect(findLinkToDoc().text()).toBe('More information');
+ expect(findLinkToDoc().attributes('href')).toBe(defaultProps.lintHelpPagePath);
+ });
+ });
+
+ describe('Lint results', () => {
+ beforeEach(() => {
+ createComponent({}, mount);
+ });
+
+ it('formats parameter value', () => {
+ findLintParameters().wrappers.forEach((job, index) => {
+ const { stage } = mockJobs[index];
+ const { name } = mockJobs[index];
+
+ expect(job.text()).toBe(`${capitalizeFirstCharacter(stage)} Job - ${name}`);
+ });
+ });
+
+ it('only shows before scripts when data is present', () => {
+ expect(findBeforeScripts()).toHaveLength(filterEmptyScripts('beforeScript').length);
+ });
+
+ it('only shows script when data is present', () => {
+ expect(findScripts()).toHaveLength(filterEmptyScripts('script').length);
+ });
+
+ it('only shows after script when data is present', () => {
+ expect(findAfterScripts()).toHaveLength(filterEmptyScripts('afterScript').length);
+ });
+ });
+
+ describe('Hide Alert', () => {
+ it('hides alert on success if hide-alert prop is true', async () => {
+ await createComponent({ dryRun: true, hideAlert: true }, mount);
+
+ expect(findStatus().exists()).toBe(false);
+ });
+
+ it('hides alert on error if hide-alert prop is true', async () => {
+ await createComponent(
+ {
+ hideAlert: true,
+ isValid: false,
+ errors: mockErrors,
+ warnings: mockWarnings,
+ },
+ mount,
+ );
+
+ expect(findStatus().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js
new file mode 100644
index 00000000000..b5e3ea06c2c
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js
@@ -0,0 +1,54 @@
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import CiLintWarnings from '~/ci/pipeline_editor/components/lint/ci_lint_warnings.vue';
+
+const warnings = ['warning 1', 'warning 2', 'warning 3'];
+
+describe('CI lint warnings', () => {
+ let wrapper;
+
+ const createComponent = (limit = 25) => {
+ wrapper = mount(CiLintWarnings, {
+ propsData: {
+ warnings,
+ maxWarnings: limit,
+ },
+ });
+ };
+
+ const findWarningAlert = () => wrapper.findComponent(GlAlert);
+ const findWarnings = () => wrapper.findAll('[data-testid="ci-lint-warning"]');
+ const findWarningMessage = () => trimText(wrapper.findComponent(GlSprintf).text());
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays the warning alert', () => {
+ createComponent();
+
+ expect(findWarningAlert().exists()).toBe(true);
+ });
+
+ it('displays all the warnings', () => {
+ createComponent();
+
+ expect(findWarnings()).toHaveLength(warnings.length);
+ });
+
+ it('shows the correct message when the limit is not passed', () => {
+ createComponent();
+
+ expect(findWarningMessage()).toBe(`${warnings.length} warnings found:`);
+ });
+
+ it('shows the correct message when the limit is passed', () => {
+ const limit = 2;
+
+ createComponent(limit);
+
+ expect(findWarningMessage()).toBe(`${warnings.length} warnings found: showing first ${limit}`);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js
new file mode 100644
index 00000000000..70310cbdb10
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js
@@ -0,0 +1,342 @@
+// TODO
+
+import { GlAlert, GlBadge, GlLoadingIcon, GlTabs } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import Vue, { nextTick } from 'vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import CiConfigMergedPreview from '~/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue';
+import CiValidate from '~/ci/pipeline_editor/components/validate/ci_validate.vue';
+import WalkthroughPopover from '~/ci/pipeline_editor/components/popovers/walkthrough_popover.vue';
+import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue';
+import EditorTab from '~/ci/pipeline_editor/components/ui/editor_tab.vue';
+import {
+ CREATE_TAB,
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_INVALID,
+ EDITOR_APP_STATUS_VALID,
+ TAB_QUERY_PARAM,
+ VALIDATE_TAB,
+ VALIDATE_TAB_BADGE_DISMISSED_KEY,
+} from '~/ci/pipeline_editor/constants';
+import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
+import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql';
+import {
+ mockBlobContentQueryResponse,
+ mockCiLintPath,
+ mockCiYml,
+ mockLintResponse,
+ mockLintResponseWithoutMerged,
+} from '../mock_data';
+
+Vue.use(VueApollo);
+
+Vue.config.ignoredElements = ['gl-emoji'];
+
+describe('Pipeline editor tabs component', () => {
+ let wrapper;
+ const MockTextEditor = {
+ template: '<div />',
+ };
+
+ const createComponent = ({
+ listeners = {},
+ props = {},
+ provide = {},
+ appStatus = EDITOR_APP_STATUS_VALID,
+ mountFn = shallowMount,
+ options = {},
+ } = {}) => {
+ wrapper = mountFn(PipelineEditorTabs, {
+ propsData: {
+ ciConfigData: mockLintResponse,
+ ciFileContent: mockCiYml,
+ currentTab: CREATE_TAB,
+ isNewCiConfigFile: true,
+ showDrawer: false,
+ ...props,
+ },
+ data() {
+ return {
+ appStatus,
+ };
+ },
+ provide: {
+ ciConfigPath: '/path/to/ci-config',
+ ciLintPath: mockCiLintPath,
+ currentBranch: 'main',
+ projectFullPath: '/path/to/project',
+ simulatePipelineHelpPagePath: 'path/to/help/page',
+ validateTabIllustrationPath: 'path/to/svg',
+ ...provide,
+ },
+ stubs: {
+ TextEditor: MockTextEditor,
+ EditorTab,
+ },
+ listeners,
+ ...options,
+ });
+ };
+
+ let mockBlobContentData;
+ let mockApollo;
+
+ const createComponentWithApollo = ({ props, provide = {}, mountFn = shallowMount } = {}) => {
+ const handlers = [[getBlobContent, mockBlobContentData]];
+ mockApollo = createMockApollo(handlers);
+
+ createComponent({
+ props,
+ provide,
+ mountFn,
+ options: {
+ apolloProvider: mockApollo,
+ },
+ });
+ };
+
+ const findEditorTab = () => wrapper.find('[data-testid="editor-tab"]');
+ const findMergedTab = () => wrapper.find('[data-testid="merged-tab"]');
+ const findValidateTab = () => wrapper.find('[data-testid="validate-tab"]');
+ const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findBadge = () => wrapper.findComponent(GlBadge);
+ const findCiValidate = () => wrapper.findComponent(CiValidate);
+ const findGlTabs = () => wrapper.findComponent(GlTabs);
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findPipelineGraph = () => wrapper.findComponent(PipelineGraph);
+ const findTextEditor = () => wrapper.findComponent(MockTextEditor);
+ const findMergedPreview = () => wrapper.findComponent(CiConfigMergedPreview);
+ const findWalkthroughPopover = () => wrapper.findComponent(WalkthroughPopover);
+
+ beforeEach(() => {
+ mockBlobContentData = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('editor tab', () => {
+ it('displays editor only after the tab is mounted', async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ createComponentWithApollo({ mountFn: mount });
+
+ expect(findTextEditor().exists()).toBe(false);
+
+ await nextTick();
+
+ expect(findTextEditor().exists()).toBe(true);
+ expect(findEditorTab().exists()).toBe(true);
+ });
+ });
+
+ describe('visualization tab', () => {
+ describe('while loading', () => {
+ beforeEach(() => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_LOADING });
+ });
+
+ it('displays a loading icon if the lint query is loading', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findPipelineGraph().exists()).toBe(false);
+ });
+ });
+ describe('after loading', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('display the tab and visualization', () => {
+ expect(findVisualizationTab().exists()).toBe(true);
+ expect(findPipelineGraph().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('validate tab', () => {
+ describe('after loading', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays the tab and the validate component', () => {
+ expect(findValidateTab().exists()).toBe(true);
+ expect(findCiValidate().exists()).toBe(true);
+ });
+ });
+
+ describe('NEW badge', () => {
+ describe('default', () => {
+ beforeEach(() => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ createComponentWithApollo({
+ mountFn: mount,
+ props: {
+ currentTab: VALIDATE_TAB,
+ },
+ });
+ });
+
+ it('renders badge by default', () => {
+ expect(findBadge().exists()).toBe(true);
+ expect(findBadge().text()).toBe(wrapper.vm.$options.i18n.new);
+ });
+
+ it('hides badge when moving away from the validate tab', async () => {
+ expect(findBadge().exists()).toBe(true);
+
+ await findEditorTab().vm.$emit('click');
+
+ expect(findBadge().exists()).toBe(false);
+ });
+ });
+
+ describe('if badge has been dismissed before', () => {
+ beforeEach(() => {
+ localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true');
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ createComponentWithApollo({ mountFn: mount });
+ });
+
+ it('does not render badge if it has been dismissed before', () => {
+ expect(findBadge().exists()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('merged tab', () => {
+ describe('while loading', () => {
+ beforeEach(() => {
+ createComponent({ appStatus: EDITOR_APP_STATUS_LOADING });
+ });
+
+ it('displays a loading icon if the lint query is loading', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('when there is a fetch error', () => {
+ beforeEach(() => {
+ createComponent({ props: { ciConfigData: mockLintResponseWithoutMerged } });
+ });
+
+ it('show an error message', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts.loadMergedYaml);
+ });
+
+ it('does not render the `merged_preview` component', () => {
+ expect(findMergedPreview().exists()).toBe(false);
+ });
+ });
+
+ describe('after loading', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('display the tab and the merged preview component', () => {
+ expect(findMergedTab().exists()).toBe(true);
+ expect(findMergedPreview().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('show tab content based on status', () => {
+ it.each`
+ appStatus | editor | viz | validate | merged
+ ${undefined} | ${true} | ${true} | ${true} | ${true}
+ ${EDITOR_APP_STATUS_EMPTY} | ${true} | ${false} | ${true} | ${false}
+ ${EDITOR_APP_STATUS_INVALID} | ${true} | ${false} | ${true} | ${true}
+ ${EDITOR_APP_STATUS_VALID} | ${true} | ${true} | ${true} | ${true}
+ `(
+ 'when status is $appStatus, we show - editor:$editor | viz:$viz | validate:$validate | merged:$merged',
+ ({ appStatus, editor, viz, validate, merged }) => {
+ createComponent({ appStatus });
+
+ expect(findTextEditor().exists()).toBe(editor);
+ expect(findPipelineGraph().exists()).toBe(viz);
+ expect(findValidateTab().exists()).toBe(validate);
+ expect(findMergedPreview().exists()).toBe(merged);
+ },
+ );
+ });
+
+ describe('default tab based on url query param', () => {
+ const gitlabUrl = 'https://gitlab.test/ci/editor/';
+ const matchObject = {
+ hostname: 'gitlab.test',
+ pathname: '/ci/editor/',
+ search: '',
+ };
+
+ it(`is ${CREATE_TAB} if the query param ${TAB_QUERY_PARAM} is not present`, () => {
+ setWindowLocation(gitlabUrl);
+ createComponent();
+
+ expect(window.location).toMatchObject(matchObject);
+ });
+
+ it(`is ${CREATE_TAB} tab if the query param ${TAB_QUERY_PARAM} is invalid`, () => {
+ const queryValue = 'FOO';
+ setWindowLocation(`${gitlabUrl}?${TAB_QUERY_PARAM}=${queryValue}`);
+ createComponent();
+
+ // If the query param remains unchanged, then we have ignored it.
+ expect(window.location).toMatchObject({
+ ...matchObject,
+ search: `?${TAB_QUERY_PARAM}=${queryValue}`,
+ });
+ });
+ });
+
+ describe('glTabs', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('passes the `sync-active-tab-with-query-params` prop', () => {
+ expect(findGlTabs().props('syncActiveTabWithQueryParams')).toBe(true);
+ });
+ });
+
+ describe('pipeline editor walkthrough', () => {
+ describe('when isNewCiConfigFile prop is true (default)', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows walkthrough popover', async () => {
+ expect(findWalkthroughPopover().exists()).toBe(true);
+ });
+ });
+
+ describe('when isNewCiConfigFile prop is false', () => {
+ it('does not show walkthrough popover', async () => {
+ createComponent({ props: { isNewCiConfigFile: false } });
+ expect(findWalkthroughPopover().exists()).toBe(false);
+ });
+ });
+ });
+
+ it('sets listeners on walkthrough popover', async () => {
+ const handler = jest.fn();
+
+ createComponent({
+ listeners: {
+ event: handler,
+ },
+ });
+ await nextTick();
+
+ findWalkthroughPopover().vm.$emit('event');
+
+ expect(handler).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js
new file mode 100644
index 00000000000..63ebfc0559d
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js
@@ -0,0 +1,56 @@
+import { nextTick } from 'vue';
+import { GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import FileTreePopover from '~/ci/pipeline_editor/components/popovers/file_tree_popover.vue';
+import { FILE_TREE_POPOVER_DISMISSED_KEY } from '~/ci/pipeline_editor/constants';
+import { mockIncludesHelpPagePath } from '../../mock_data';
+
+describe('FileTreePopover component', () => {
+ let wrapper;
+
+ const findPopover = () => wrapper.findComponent(GlPopover);
+ const findLink = () => findPopover().findComponent(GlLink);
+
+ const createComponent = ({ stubs } = {}) => {
+ wrapper = shallowMount(FileTreePopover, {
+ provide: {
+ includesHelpPagePath: mockIncludesHelpPagePath,
+ },
+ stubs,
+ });
+ };
+
+ afterEach(() => {
+ localStorage.clear();
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(async () => {
+ createComponent({ stubs: { GlSprintf } });
+ });
+
+ it('renders dismissable popover', async () => {
+ expect(findPopover().exists()).toBe(true);
+
+ findPopover().vm.$emit('close-button-clicked');
+ await nextTick();
+
+ expect(findPopover().exists()).toBe(false);
+ });
+
+ it('renders learn more link', () => {
+ expect(findLink().exists()).toBe(true);
+ expect(findLink().attributes('href')).toBe(mockIncludesHelpPagePath);
+ });
+ });
+
+ describe('when popover has already been dismissed before', () => {
+ it('does not render popover', async () => {
+ localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true');
+ createComponent();
+
+ expect(findPopover().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js
new file mode 100644
index 00000000000..cf0b974081e
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js
@@ -0,0 +1,43 @@
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ValidatePopover from '~/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue';
+import { VALIDATE_TAB_FEEDBACK_URL } from '~/ci/pipeline_editor/constants';
+import { mockSimulatePipelineHelpPagePath } from '../../mock_data';
+
+describe('ValidatePopover component', () => {
+ let wrapper;
+
+ const createComponent = ({ stubs } = {}) => {
+ wrapper = shallowMountExtended(ValidatePopover, {
+ provide: {
+ simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath,
+ },
+ stubs,
+ });
+ };
+
+ const findHelpLink = () => wrapper.findByTestId('help-link');
+ const findFeedbackLink = () => wrapper.findByTestId('feedback-link');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(async () => {
+ createComponent({
+ stubs: { GlLink, GlSprintf },
+ });
+ });
+
+ it('renders help link', () => {
+ expect(findHelpLink().exists()).toBe(true);
+ expect(findHelpLink().attributes('href')).toBe(mockSimulatePipelineHelpPagePath);
+ });
+
+ it('renders feedback link', () => {
+ expect(findFeedbackLink().exists()).toBe(true);
+ expect(findFeedbackLink().attributes('href')).toBe(VALIDATE_TAB_FEEDBACK_URL);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js
new file mode 100644
index 00000000000..ca6033f2ff5
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js
@@ -0,0 +1,29 @@
+import { mount, shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import WalkthroughPopover from '~/ci/pipeline_editor/components/popovers/walkthrough_popover.vue';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+
+Vue.config.ignoredElements = ['gl-emoji'];
+
+describe('WalkthroughPopover component', () => {
+ let wrapper;
+
+ const createComponent = (mountFn = shallowMount) => {
+ return extendedWrapper(mountFn(WalkthroughPopover));
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('CTA button clicked', () => {
+ beforeEach(async () => {
+ wrapper = createComponent(mount);
+ await wrapper.findByTestId('ctaBtn').trigger('click');
+ });
+
+ it('emits "walkthrough-popover-cta-clicked" event', async () => {
+ expect(wrapper.emitted()['walkthrough-popover-cta-clicked']).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js
new file mode 100644
index 00000000000..b22c98e5544
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js
@@ -0,0 +1,42 @@
+import { shallowMount } from '@vue/test-utils';
+import ConfirmDialog from '~/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue';
+
+describe('pipeline_editor/components/ui/confirm_unsaved_changes_dialog', () => {
+ let beforeUnloadEvent;
+ let setDialogContent;
+ let wrapper;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(ConfirmDialog, {
+ propsData,
+ });
+ };
+
+ beforeEach(() => {
+ beforeUnloadEvent = new Event('beforeunload');
+ jest.spyOn(beforeUnloadEvent, 'preventDefault');
+ setDialogContent = jest.spyOn(beforeUnloadEvent, 'returnValue', 'set');
+ });
+
+ afterEach(() => {
+ beforeUnloadEvent.preventDefault.mockRestore();
+ setDialogContent.mockRestore();
+ wrapper.destroy();
+ });
+
+ it('shows confirmation dialog when there are unsaved changes', () => {
+ createComponent({ hasUnsavedChanges: true });
+ window.dispatchEvent(beforeUnloadEvent);
+
+ expect(beforeUnloadEvent.preventDefault).toHaveBeenCalled();
+ expect(setDialogContent).toHaveBeenCalledWith('');
+ });
+
+ it('does not show confirmation dialog when there are no unsaved changes', () => {
+ createComponent({ hasUnsavedChanges: false });
+ window.dispatchEvent(beforeUnloadEvent);
+
+ expect(beforeUnloadEvent.preventDefault).not.toHaveBeenCalled();
+ expect(setDialogContent).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js
new file mode 100644
index 00000000000..a4e7abba7b0
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js
@@ -0,0 +1,200 @@
+import { GlAlert, GlBadge, GlTabs } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import EditorTab from '~/ci/pipeline_editor/components/ui/editor_tab.vue';
+
+const mockContent1 = 'MOCK CONTENT 1';
+const mockContent2 = 'MOCK CONTENT 2';
+
+const MockSourceEditor = {
+ template: '<div>EDITOR</div>',
+};
+
+describe('~/ci/pipeline_editor/components/ui/editor_tab.vue', () => {
+ let wrapper;
+ let mockChildMounted = jest.fn();
+
+ const MockChild = {
+ props: ['content'],
+ template: '<div>{{content}}</div>',
+ mounted() {
+ mockChildMounted(this.content);
+ },
+ };
+
+ const MockTabbedContent = {
+ components: {
+ EditorTab,
+ GlTabs,
+ MockChild,
+ },
+ template: `
+ <gl-tabs>
+ <editor-tab title="Tab 1" :title-link-attributes="{ 'data-testid': 'tab1-btn' }" :lazy="true">
+ <mock-child content="${mockContent1}"/>
+ </editor-tab>
+ <editor-tab title="Tab 2" :title-link-attributes="{ 'data-testid': 'tab2-btn' }" :lazy="true" badge-title="NEW">
+ <mock-child content="${mockContent2}"/>
+ </editor-tab>
+ </gl-tabs>
+ `,
+ };
+
+ const createMockedWrapper = () => {
+ wrapper = mount(MockTabbedContent);
+ };
+
+ const createWrapper = ({ props } = {}) => {
+ wrapper = mount(EditorTab, {
+ propsData: {
+ title: 'Tab 1',
+ ...props,
+ },
+ slots: {
+ default: MockSourceEditor,
+ },
+ });
+ };
+
+ const findSlotComponent = () => wrapper.findComponent(MockSourceEditor);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findBadges = () => wrapper.findAllComponents(GlBadge);
+
+ beforeEach(() => {
+ mockChildMounted = jest.fn();
+ });
+
+ it('tabs are mounted lazily', async () => {
+ createMockedWrapper();
+
+ expect(mockChildMounted).toHaveBeenCalledTimes(0);
+ });
+
+ it('first tab is only mounted after nextTick', async () => {
+ createMockedWrapper();
+
+ await nextTick();
+
+ expect(mockChildMounted).toHaveBeenCalledTimes(1);
+ expect(mockChildMounted).toHaveBeenCalledWith(mockContent1);
+ });
+
+ describe('alerts', () => {
+ describe('unavailable state', () => {
+ beforeEach(() => {
+ createWrapper({ props: { isUnavailable: true } });
+ });
+
+ it('shows the invalid alert when the status is invalid', () => {
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toContain(wrapper.vm.$options.i18n.unavailable);
+ });
+ });
+
+ describe('invalid state', () => {
+ beforeEach(() => {
+ createWrapper({ props: { isInvalid: true } });
+ });
+
+ it('shows the invalid alert when the status is invalid', () => {
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toBe(wrapper.vm.$options.i18n.invalid);
+ });
+ });
+
+ describe('empty state', () => {
+ const text = 'my custom alert message';
+
+ beforeEach(() => {
+ createWrapper({
+ props: { isEmpty: true, emptyMessage: text },
+ });
+ });
+
+ it('displays an empty message', () => {
+ createWrapper({
+ props: { isEmpty: true },
+ });
+
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toBe(
+ 'This tab will be usable when the CI/CD configuration file is populated with valid syntax.',
+ );
+ });
+
+ it('can have a custom empty message', () => {
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(true);
+ expect(alert.text()).toBe(text);
+ });
+ });
+ });
+
+ describe('showing the tab content depending on `isEmpty`, `isUnavailable` and `isInvalid`', () => {
+ it.each`
+ isEmpty | isUnavailable | isInvalid | showSlotComponent | text
+ ${undefined} | ${undefined} | ${undefined} | ${true} | ${'renders'}
+ ${false} | ${false} | ${false} | ${true} | ${'renders'}
+ ${undefined} | ${true} | ${true} | ${false} | ${'hides'}
+ ${true} | ${false} | ${false} | ${false} | ${'hides'}
+ ${false} | ${true} | ${false} | ${false} | ${'hides'}
+ ${false} | ${false} | ${true} | ${false} | ${'hides'}
+ `(
+ '$text the slot component when isEmpty:$isEmpty, isUnavailable:$isUnavailable and isInvalid:$isInvalid',
+ ({ isEmpty, isUnavailable, isInvalid, showSlotComponent }) => {
+ createWrapper({
+ props: { isEmpty, isUnavailable, isInvalid },
+ });
+ expect(findSlotComponent().exists()).toBe(showSlotComponent);
+ expect(findAlert().exists()).toBe(!showSlotComponent);
+ },
+ );
+ });
+
+ describe('user interaction', () => {
+ const clickTab = async (testid) => {
+ wrapper.find(`[data-testid="${testid}"]`).trigger('click');
+ await nextTick();
+ };
+
+ beforeEach(() => {
+ createMockedWrapper();
+ });
+
+ it('mounts a tab once after selecting it', async () => {
+ await clickTab('tab2-btn');
+
+ expect(mockChildMounted).toHaveBeenCalledTimes(2);
+ expect(mockChildMounted).toHaveBeenNthCalledWith(1, mockContent1);
+ expect(mockChildMounted).toHaveBeenNthCalledWith(2, mockContent2);
+ });
+
+ it('mounts each tab once after selecting each', async () => {
+ await clickTab('tab2-btn');
+ await clickTab('tab1-btn');
+ await clickTab('tab2-btn');
+
+ expect(mockChildMounted).toHaveBeenCalledTimes(2);
+ expect(mockChildMounted).toHaveBeenNthCalledWith(1, mockContent1);
+ expect(mockChildMounted).toHaveBeenNthCalledWith(2, mockContent2);
+ });
+ });
+
+ describe('valid state', () => {
+ beforeEach(() => {
+ createMockedWrapper();
+ });
+
+ it('renders correct number of badges', async () => {
+ expect(findBadges()).toHaveLength(1);
+ expect(findBadges().at(0).text()).toBe('NEW');
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js
new file mode 100644
index 00000000000..3c68f74af43
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js
@@ -0,0 +1,92 @@
+import { GlButton, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
+import PipelineEditorEmptyState from '~/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
+
+describe('Pipeline editor empty state', () => {
+ let wrapper;
+ const defaultProvide = {
+ emptyStateIllustrationPath: 'my/svg/path',
+ usesExternalConfig: false,
+ };
+
+ const createComponent = ({ provide } = {}) => {
+ wrapper = shallowMount(PipelineEditorEmptyState, {
+ provide: { ...defaultProvide, ...provide },
+ });
+ };
+
+ const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav);
+ const findSvgImage = () => wrapper.find('img');
+ const findTitle = () => wrapper.find('h1');
+ const findExternalCiInstructions = () => wrapper.find('p');
+ const findConfirmButton = () => wrapper.findComponent(GlButton);
+ const findDescription = () => wrapper.findComponent(GlSprintf);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when project uses an external CI config', () => {
+ beforeEach(() => {
+ createComponent({
+ provide: { usesExternalConfig: true },
+ });
+ });
+
+ it('renders an svg image', () => {
+ expect(findSvgImage().exists()).toBe(true);
+ });
+
+ it('renders the correct title and instructions', () => {
+ expect(findTitle().exists()).toBe(true);
+ expect(findExternalCiInstructions().exists()).toBe(true);
+
+ expect(findExternalCiInstructions().html()).toContain(
+ wrapper.vm.$options.i18n.externalCiInstructions,
+ );
+ expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.externalCiNote);
+ });
+
+ it('does not render the CTA button', () => {
+ expect(findConfirmButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when project uses an accessible CI config', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders an svg image', () => {
+ expect(findSvgImage().exists()).toBe(true);
+ });
+
+ it('renders a title', () => {
+ expect(findTitle().exists()).toBe(true);
+ expect(findTitle().text()).toBe(wrapper.vm.$options.i18n.title);
+ });
+
+ it('renders a description', () => {
+ expect(findDescription().exists()).toBe(true);
+ expect(findDescription().html()).toContain(wrapper.vm.$options.i18n.body);
+ });
+
+ it('renders the file nav', () => {
+ expect(findFileNav().exists()).toBe(true);
+ });
+
+ it('renders a CTA button', () => {
+ expect(findConfirmButton().exists()).toBe(true);
+ expect(findConfirmButton().text()).toBe(wrapper.vm.$options.i18n.btnText);
+ });
+
+ it('emits an event when clicking on the CTA', async () => {
+ const expectedEvent = 'createEmptyConfigFile';
+ expect(wrapper.emitted(expectedEvent)).toBeUndefined();
+
+ await findConfirmButton().vm.$emit('click');
+ expect(wrapper.emitted(expectedEvent)).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_messages_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_messages_spec.js
new file mode 100644
index 00000000000..fdb3be5c690
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_messages_spec.js
@@ -0,0 +1,149 @@
+import { GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import { TEST_HOST } from 'helpers/test_constants';
+import CodeSnippetAlert from '~/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue';
+import { CODE_SNIPPET_SOURCES } from '~/ci/pipeline_editor/components/code_snippet_alert/constants';
+import PipelineEditorMessages from '~/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue';
+import {
+ COMMIT_FAILURE,
+ COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
+ DEFAULT_FAILURE,
+ DEFAULT_SUCCESS,
+ LOAD_FAILURE_UNKNOWN,
+ PIPELINE_FAILURE,
+} from '~/ci/pipeline_editor/constants';
+
+beforeEach(() => {
+ setWindowLocation(TEST_HOST);
+});
+
+describe('Pipeline Editor messages', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(PipelineEditorMessages, {
+ propsData: props,
+ });
+ };
+
+ const findCodeSnippetAlert = () => wrapper.findComponent(CodeSnippetAlert);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ describe('success alert', () => {
+ it('shows a message for successful commit type', () => {
+ createComponent({ successType: COMMIT_SUCCESS, showSuccess: true });
+
+ expect(findAlert().text()).toBe(wrapper.vm.$options.success[COMMIT_SUCCESS]);
+ });
+
+ it('shows a message for successful commit with redirect type', () => {
+ createComponent({ successType: COMMIT_SUCCESS_WITH_REDIRECT, showSuccess: true });
+
+ expect(findAlert().text()).toBe(wrapper.vm.$options.success[COMMIT_SUCCESS_WITH_REDIRECT]);
+ });
+
+ it('does not show alert when there is a successType but visibility is off', () => {
+ createComponent({ successType: COMMIT_SUCCESS, showSuccess: false });
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('shows a success alert with default copy if `showSuccess` is true and the `successType` is not valid,', () => {
+ createComponent({ successType: 'random', showSuccess: true });
+
+ expect(findAlert().text()).toBe(wrapper.vm.$options.success[DEFAULT_SUCCESS]);
+ });
+
+ it('emit `hide-success` event when clicking on the dismiss button', async () => {
+ const expectedEvent = 'hide-success';
+
+ createComponent({ successType: COMMIT_SUCCESS, showSuccess: true });
+ expect(wrapper.emitted(expectedEvent)).not.toBeDefined();
+
+ await findAlert().vm.$emit('dismiss');
+
+ expect(wrapper.emitted(expectedEvent)).toBeDefined();
+ });
+ });
+
+ describe('failure alert', () => {
+ it.each`
+ failureType | message | expectedFailureType
+ ${COMMIT_FAILURE} | ${'failed commit'} | ${COMMIT_FAILURE}
+ ${LOAD_FAILURE_UNKNOWN} | ${'loading failure'} | ${LOAD_FAILURE_UNKNOWN}
+ ${PIPELINE_FAILURE} | ${'pipeline failure'} | ${PIPELINE_FAILURE}
+ ${'random'} | ${'error without a specified type'} | ${DEFAULT_FAILURE}
+ `('shows a message for $message', ({ failureType, expectedFailureType }) => {
+ createComponent({ failureType, showFailure: true });
+
+ expect(findAlert().text()).toBe(wrapper.vm.$options.errors[expectedFailureType]);
+ });
+
+ it('show failure reasons when there are some', () => {
+ const failureReasons = ['There was a problem', 'ouppps'];
+ createComponent({ failureType: COMMIT_FAILURE, failureReasons, showFailure: true });
+
+ expect(wrapper.html()).toContain(failureReasons[0]);
+ expect(wrapper.html()).toContain(failureReasons[1]);
+ });
+
+ it('does not show a message for error with a disabled visibility', () => {
+ createComponent({ failureType: 'random', showFailure: false });
+
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('emit `hide-failure` event when clicking on the dismiss button', async () => {
+ const expectedEvent = 'hide-failure';
+
+ createComponent({ failureType: COMMIT_FAILURE, showFailure: true });
+ expect(wrapper.emitted(expectedEvent)).not.toBeDefined();
+
+ await findAlert().vm.$emit('dismiss');
+
+ expect(wrapper.emitted(expectedEvent)).toBeDefined();
+ });
+ });
+
+ describe('code snippet alert', () => {
+ const setCodeSnippetUrlParam = (value) => {
+ setWindowLocation(`${TEST_HOST}/?code_snippet_copied_from=${value}`);
+ };
+
+ it('does not show by default', () => {
+ createComponent();
+
+ expect(findCodeSnippetAlert().exists()).toBe(false);
+ });
+
+ it.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => {
+ jest.spyOn(window.history, 'replaceState');
+ setCodeSnippetUrlParam(source);
+ createComponent();
+
+ expect(findCodeSnippetAlert().exists()).toBe(true);
+ expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`);
+ });
+
+ it('does not show if URL param is invalid', () => {
+ setCodeSnippetUrlParam('foo_bar');
+ createComponent();
+
+ expect(findCodeSnippetAlert().exists()).toBe(false);
+ });
+
+ it('disappears on dismiss', async () => {
+ setCodeSnippetUrlParam('api_fuzzing');
+ createComponent();
+ const alert = findCodeSnippetAlert();
+
+ expect(alert.exists()).toBe(true);
+
+ await alert.vm.$emit('dismiss');
+
+ expect(alert.exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js
new file mode 100644
index 00000000000..ae25142b455
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js
@@ -0,0 +1,314 @@
+import { GlAlert, GlDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue';
+import CiValidate, { i18n } from '~/ci/pipeline_editor/components/validate/ci_validate.vue';
+import ValidatePipelinePopover from '~/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue';
+import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql';
+import lintCIMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql';
+import { pipelineEditorTrackingOptions } from '~/ci/pipeline_editor/constants';
+import {
+ mockBlobContentQueryResponse,
+ mockCiLintPath,
+ mockCiYml,
+ mockSimulatePipelineHelpPagePath,
+} from '../../mock_data';
+import { mockLintDataError, mockLintDataValid } from '../../../ci_lint/mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('Pipeline Editor Validate Tab', () => {
+ let wrapper;
+ let mockApollo;
+ let mockBlobContentData;
+ let trackingSpy;
+
+ const createComponent = ({
+ props,
+ stubs,
+ options,
+ isBlobLoading = false,
+ isSimulationLoading = false,
+ } = {}) => {
+ wrapper = shallowMountExtended(CiValidate, {
+ propsData: {
+ ciFileContent: mockCiYml,
+ ...props,
+ },
+ provide: {
+ ciConfigPath: '/path/to/ci-config',
+ ciLintPath: mockCiLintPath,
+ currentBranch: 'main',
+ projectFullPath: '/path/to/project',
+ validateTabIllustrationPath: '/path/to/img',
+ simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath,
+ },
+ stubs,
+ mocks: {
+ $apollo: {
+ queries: {
+ initialBlobContent: {
+ loading: isBlobLoading,
+ },
+ },
+ mutations: {
+ lintCiMutation: {
+ loading: isSimulationLoading,
+ },
+ },
+ },
+ },
+ ...options,
+ });
+ };
+
+ const createComponentWithApollo = ({ props, stubs } = {}) => {
+ const handlers = [[getBlobContent, mockBlobContentData]];
+ mockApollo = createMockApollo(handlers);
+
+ createComponent({
+ props,
+ stubs,
+ options: {
+ localVue,
+ apolloProvider: mockApollo,
+ mocks: {},
+ },
+ });
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findCancelBtn = () => wrapper.findByTestId('cancel-simulation');
+ const findContentChangeStatus = () => wrapper.findByTestId('content-status');
+ const findCta = () => wrapper.findByTestId('simulate-pipeline-button');
+ const findDisabledCtaTooltip = () => wrapper.findByTestId('cta-tooltip');
+ const findHelpIcon = () => wrapper.findComponent(GlIcon);
+ const findIllustration = () => wrapper.findByRole('img');
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findPipelineSource = () => wrapper.findComponent(GlDropdown);
+ const findPopover = () => wrapper.findComponent(GlPopover);
+ const findCiLintResults = () => wrapper.findComponent(CiLintResults);
+ const findResultsCta = () => wrapper.findByTestId('resimulate-pipeline-button');
+
+ beforeEach(() => {
+ mockBlobContentData = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('while initial CI content is loading', () => {
+ beforeEach(() => {
+ createComponent({ isBlobLoading: true });
+ });
+
+ it('renders disabled CTA with tooltip', () => {
+ expect(findCta().props('disabled')).toBe(true);
+ expect(findDisabledCtaTooltip().exists()).toBe(true);
+ });
+ });
+
+ describe('after initial CI content is loaded', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await createComponentWithApollo({ stubs: { GlPopover, ValidatePipelinePopover } });
+ });
+
+ it('renders disabled pipeline source dropdown', () => {
+ expect(findPipelineSource().exists()).toBe(true);
+ expect(findPipelineSource().attributes('text')).toBe(i18n.pipelineSourceDefault);
+ expect(findPipelineSource().props('disabled')).toBe(true);
+ });
+
+ it('renders enabled CTA without tooltip', () => {
+ expect(findCta().exists()).toBe(true);
+ expect(findCta().props('disabled')).toBe(false);
+ expect(findDisabledCtaTooltip().exists()).toBe(false);
+ });
+
+ it('popover is set to render when hovering over help icon', () => {
+ expect(findPopover().props('target')).toBe(findHelpIcon().attributes('id'));
+ expect(findPopover().props('triggers')).toBe('hover focus');
+ });
+ });
+
+ describe('simulating the pipeline', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await createComponentWithApollo();
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks the simulation event', () => {
+ const {
+ label,
+ actions: { simulatePipeline },
+ } = pipelineEditorTrackingOptions;
+ findCta().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, simulatePipeline, { label });
+ });
+
+ it('renders loading state while simulation is ongoing', async () => {
+ findCta().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findCancelBtn().exists()).toBe(true);
+ expect(findCta().props('loading')).toBe(true);
+ });
+
+ it('calls mutation with the correct input', async () => {
+ await findCta().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: lintCIMutation,
+ variables: {
+ dry: true,
+ content: mockCiYml,
+ endpoint: mockCiLintPath,
+ },
+ });
+ });
+
+ describe('when results are successful', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
+ await findCta().vm.$emit('click');
+ });
+
+ it('renders success alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().attributes('variant')).toBe('success');
+ expect(findAlert().attributes('title')).toBe(i18n.successAlertTitle);
+ });
+
+ it('does not render content change status or CTA for results page', () => {
+ expect(findContentChangeStatus().exists()).toBe(false);
+ expect(findResultsCta().exists()).toBe(false);
+ });
+
+ it('renders CI lint results with correct props', () => {
+ expect(findCiLintResults().exists()).toBe(true);
+ expect(findCiLintResults().props()).toMatchObject({
+ dryRun: true,
+ hideAlert: true,
+ isValid: true,
+ jobs: mockLintDataValid.data.lintCI.jobs,
+ });
+ });
+ });
+
+ describe('when results have errors', () => {
+ beforeEach(async () => {
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataError);
+ await findCta().vm.$emit('click');
+ });
+
+ it('renders error alert', () => {
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().attributes('variant')).toBe('danger');
+ expect(findAlert().attributes('title')).toBe(i18n.errorAlertTitle);
+ });
+
+ it('renders CI lint results with correct props', () => {
+ expect(findCiLintResults().exists()).toBe(true);
+ expect(findCiLintResults().props()).toMatchObject({
+ dryRun: true,
+ hideAlert: true,
+ isValid: false,
+ errors: mockLintDataError.data.lintCI.errors,
+ warnings: mockLintDataError.data.lintCI.warnings,
+ });
+ });
+ });
+ });
+
+ describe('when CI content has changed after a simulation', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await createComponentWithApollo();
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
+ await findCta().vm.$emit('click');
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('tracks the second simulation event', async () => {
+ const {
+ label,
+ actions: { resimulatePipeline },
+ } = pipelineEditorTrackingOptions;
+
+ await wrapper.setProps({ ciFileContent: 'new yaml content' });
+ findResultsCta().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, resimulatePipeline, { label });
+ });
+
+ it('renders content change status', async () => {
+ await wrapper.setProps({ ciFileContent: 'new yaml content' });
+
+ expect(findContentChangeStatus().exists()).toBe(true);
+ expect(findResultsCta().exists()).toBe(true);
+ });
+
+ it('calls mutation with new content', async () => {
+ await wrapper.setProps({ ciFileContent: 'new yaml content' });
+ await findResultsCta().vm.$emit('click');
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(2);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: lintCIMutation,
+ variables: {
+ dry: true,
+ content: 'new yaml content',
+ endpoint: mockCiLintPath,
+ },
+ });
+ });
+ });
+
+ describe('canceling a simulation', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await createComponentWithApollo();
+ });
+
+ it('returns to init state', async () => {
+ // init state
+ expect(findIllustration().exists()).toBe(true);
+ expect(findCiLintResults().exists()).toBe(false);
+
+ // mutations should have successful results
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid);
+ findCta().vm.$emit('click');
+ await nextTick();
+
+ // cancel before simulation succeeds
+ expect(findCancelBtn().exists()).toBe(true);
+ await findCancelBtn().vm.$emit('click');
+
+ // should still render init state
+ expect(findIllustration().exists()).toBe(true);
+ expect(findCiLintResults().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap b/spec/frontend/ci/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap
new file mode 100644
index 00000000000..75a1354fd29
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`~/ci/pipeline_editor/graphql/resolvers Mutation lintCI lint data is as expected 1`] = `
+Object {
+ "__typename": "CiLintContent",
+ "errors": Array [],
+ "jobs": Array [
+ Object {
+ "__typename": "CiLintJob",
+ "afterScript": Array [
+ "echo 'after script 1",
+ ],
+ "allowFailure": false,
+ "beforeScript": Array [
+ "echo 'before script 1'",
+ ],
+ "environment": "prd",
+ "except": Object {
+ "refs": Array [
+ "main@gitlab-org/gitlab",
+ "/^release/.*$/@gitlab-org/gitlab",
+ ],
+ },
+ "name": "job_1",
+ "only": null,
+ "script": Array [
+ "echo 'script 1'",
+ ],
+ "stage": "test",
+ "tags": Array [
+ "tag 1",
+ ],
+ "when": "on_success",
+ },
+ Object {
+ "__typename": "CiLintJob",
+ "afterScript": Array [
+ "echo 'after script 2",
+ ],
+ "allowFailure": true,
+ "beforeScript": Array [
+ "echo 'before script 2'",
+ ],
+ "environment": "stg",
+ "except": Object {
+ "refs": Array [
+ "main@gitlab-org/gitlab",
+ "/^release/.*$/@gitlab-org/gitlab",
+ ],
+ },
+ "name": "job_2",
+ "only": Object {
+ "__typename": "CiLintJobOnlyPolicy",
+ "refs": Array [
+ "web",
+ "chat",
+ "pushes",
+ ],
+ },
+ "script": Array [
+ "echo 'script 2'",
+ ],
+ "stage": "test",
+ "tags": Array [
+ "tag 2",
+ ],
+ "when": "on_success",
+ },
+ ],
+ "valid": true,
+ "warnings": Array [],
+}
+`;
diff --git a/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js
new file mode 100644
index 00000000000..e54c72a758f
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js
@@ -0,0 +1,52 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import httpStatus from '~/lib/utils/http_status';
+import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers';
+import { mockLintResponse } from '../mock_data';
+
+jest.mock('~/api', () => {
+ return {
+ getRawFile: jest.fn(),
+ };
+});
+
+describe('~/ci/pipeline_editor/graphql/resolvers', () => {
+ describe('Mutation', () => {
+ describe('lintCI', () => {
+ let mock;
+ let result;
+
+ const endpoint = '/ci/lint';
+
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse);
+
+ result = await resolvers.Mutation.lintCI(null, {
+ endpoint,
+ content: 'content',
+ dry_run: true,
+ });
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ /* eslint-disable no-underscore-dangle */
+ it('lint data has correct type names', async () => {
+ expect(result.__typename).toBe('CiLintContent');
+
+ expect(result.jobs[0].__typename).toBe('CiLintJob');
+ expect(result.jobs[1].__typename).toBe('CiLintJob');
+
+ expect(result.jobs[1].only.__typename).toBe('CiLintJobOnlyPolicy');
+ });
+ /* eslint-enable no-underscore-dangle */
+
+ it('lint data is as expected', () => {
+ expect(result).toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js
new file mode 100644
index 00000000000..176dc24f169
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/mock_data.js
@@ -0,0 +1,541 @@
+import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants';
+import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
+
+export const mockProjectNamespace = 'user1';
+export const mockProjectPath = 'project1';
+export const mockProjectFullPath = `${mockProjectNamespace}/${mockProjectPath}`;
+export const mockDefaultBranch = 'main';
+export const mockNewBranch = 'new-branch';
+export const mockNewMergeRequestPath = '/-/merge_requests/new';
+export const mockCiLintPath = '/-/ci/lint';
+export const mockCommitSha = 'aabbccdd';
+export const mockCommitNextSha = 'eeffgghh';
+export const mockIncludesHelpPagePath = '/-/includes/help';
+export const mockLintHelpPagePath = '/-/lint-help';
+export const mockLintUnavailableHelpPagePath = '/-/pipeline-editor/troubleshoot';
+export const mockSimulatePipelineHelpPagePath = '/-/simulate-pipeline-help';
+export const mockYmlHelpPagePath = '/-/yml-help';
+export const mockCommitMessage = 'My commit message';
+
+export const mockCiConfigPath = '.gitlab-ci.yml';
+export const mockCiYml = `
+stages:
+ - test
+ - build
+
+job_test_1:
+ stage: test
+ script:
+ - echo "test 1"
+
+job_test_2:
+ stage: test
+ script:
+ - echo "test 2"
+
+job_build:
+ stage: build
+ script:
+ - echo "build"
+ needs: ["job_test_2"]
+`;
+
+export const mockCiTemplateQueryResponse = {
+ data: {
+ project: {
+ id: 'project-1',
+ ciTemplate: {
+ content: mockCiYml,
+ },
+ },
+ },
+};
+
+export const mockBlobContentQueryResponse = {
+ data: {
+ project: {
+ id: 'project-1',
+ repository: { blobs: { nodes: [{ id: 'blob-1', rawBlob: mockCiYml }] } },
+ },
+ },
+};
+
+export const mockBlobContentQueryResponseNoCiFile = {
+ data: {
+ project: { id: 'project-1', repository: { blobs: { nodes: [] } } },
+ },
+};
+
+export const mockBlobContentQueryResponseEmptyCiFile = {
+ data: {
+ project: { id: 'project-1', repository: { blobs: { nodes: [{ rawBlob: '' }] } } },
+ },
+};
+
+const mockJobFields = {
+ beforeScript: [],
+ afterScript: [],
+ environment: null,
+ allowFailure: false,
+ tags: [],
+ when: 'on_success',
+ only: { refs: ['branches', 'tags'], __typename: 'CiJobLimitType' },
+ except: null,
+ needs: { nodes: [], __typename: 'CiConfigNeedConnection' },
+ __typename: 'CiConfigJob',
+};
+
+export const mockIncludesWithBlob = {
+ location: 'test-include.yml',
+ type: 'local',
+ blob:
+ 'http://gdk.test:3000/root/upstream/-/blob/dd54f00bb3645f8ddce7665d2ffb3864540399cb/test-include.yml',
+ raw:
+ 'http://gdk.test:3000/root/upstream/-/raw/dd54f00bb3645f8ddce7665d2ffb3864540399cb/test-include.yml',
+ __typename: 'CiConfigInclude',
+};
+
+export const mockDefaultIncludes = {
+ location: 'npm.gitlab-ci.yml',
+ type: 'template',
+ blob: null,
+ raw:
+ 'https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/npm.gitlab-ci.yml',
+ __typename: 'CiConfigInclude',
+};
+
+export const mockIncludes = [
+ mockDefaultIncludes,
+ mockIncludesWithBlob,
+ {
+ location: 'a_really_really_long_name_for_includes_file.yml',
+ type: 'local',
+ blob:
+ 'http://gdk.test:3000/root/upstream/-/blob/dd54f00bb3645f8ddce7665d2ffb3864540399cb/a_really_really_long_name_for_includes_file.yml',
+ raw:
+ 'http://gdk.test:3000/root/upstream/-/raw/dd54f00bb3645f8ddce7665d2ffb3864540399cb/a_really_really_long_name_for_includes_file.yml',
+ __typename: 'CiConfigInclude',
+ },
+];
+
+// Mock result of the graphql query at:
+// app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.graphql
+export const mockCiConfigQueryResponse = {
+ data: {
+ ciConfig: {
+ errors: [],
+ includes: mockIncludes,
+ mergedYaml: mockCiYml,
+ status: CI_CONFIG_STATUS_VALID,
+ stages: {
+ __typename: 'CiConfigStageConnection',
+ nodes: [
+ {
+ name: 'test',
+ groups: {
+ nodes: [
+ {
+ id: 'group-1',
+ name: 'job_test_1',
+ size: 1,
+ jobs: {
+ nodes: [
+ {
+ name: 'job_test_1',
+ script: ['echo "test 1"'],
+ ...mockJobFields,
+ },
+ ],
+ __typename: 'CiConfigJobConnection',
+ },
+ __typename: 'CiConfigGroup',
+ },
+ {
+ id: 'group-2',
+ name: 'job_test_2',
+ size: 1,
+ jobs: {
+ nodes: [
+ {
+ name: 'job_test_2',
+ script: ['echo "test 2"'],
+ ...mockJobFields,
+ },
+ ],
+ __typename: 'CiConfigJobConnection',
+ },
+ __typename: 'CiConfigGroup',
+ },
+ ],
+ __typename: 'CiConfigGroupConnection',
+ },
+ __typename: 'CiConfigStage',
+ },
+ {
+ name: 'build',
+ groups: {
+ nodes: [
+ {
+ name: 'job_build',
+ size: 1,
+ jobs: {
+ nodes: [
+ {
+ name: 'job_build',
+ script: ['echo "build"'],
+ ...mockJobFields,
+ },
+ ],
+ __typename: 'CiConfigJobConnection',
+ },
+ __typename: 'CiConfigGroup',
+ },
+ ],
+ __typename: 'CiConfigGroupConnection',
+ },
+ __typename: 'CiConfigStage',
+ },
+ ],
+ },
+ __typename: 'CiConfig',
+ },
+ },
+};
+
+export const mergeUnwrappedCiConfig = (mergedConfig) => {
+ const { ciConfig } = mockCiConfigQueryResponse.data;
+ return {
+ ...ciConfig,
+ stages: unwrapStagesWithNeeds(ciConfig.stages.nodes),
+ ...mergedConfig,
+ };
+};
+
+export const mockCommitShaResults = {
+ data: {
+ project: {
+ id: '1',
+ repository: {
+ tree: {
+ lastCommit: {
+ id: 'commit-1',
+ sha: mockCommitSha,
+ },
+ },
+ },
+ },
+ },
+};
+
+export const mockNewCommitShaResults = {
+ data: {
+ project: {
+ id: '1',
+ repository: {
+ tree: {
+ lastCommit: {
+ id: 'commit-1',
+ sha: 'eeff1122',
+ },
+ },
+ },
+ },
+ },
+};
+
+export const mockEmptyCommitShaResults = {
+ data: {
+ project: {
+ id: '1',
+ repository: {
+ tree: {
+ lastCommit: {
+ id: 'commit-1',
+ sha: '',
+ },
+ },
+ },
+ },
+ },
+};
+
+export const mockProjectBranches = {
+ data: {
+ project: {
+ id: '1',
+ repository: {
+ branchNames: [
+ 'main',
+ 'develop',
+ 'production',
+ 'test',
+ 'better-feature',
+ 'feature-abc',
+ 'update-ci',
+ 'mock-feature',
+ 'test-merge-request',
+ 'staging',
+ ],
+ },
+ },
+ },
+};
+
+export const mockTotalBranchResults =
+ mockProjectBranches.data.project.repository.branchNames.length;
+
+export const mockSearchBranches = {
+ data: {
+ project: {
+ id: '1',
+ repository: {
+ branchNames: ['test', 'better-feature', 'update-ci', 'test-merge-request'],
+ },
+ },
+ },
+};
+
+export const mockTotalSearchResults = mockSearchBranches.data.project.repository.branchNames.length;
+
+export const mockEmptySearchBranches = {
+ data: {
+ project: {
+ id: '1',
+ repository: {
+ branchNames: [],
+ },
+ },
+ },
+};
+
+export const mockBranchPaginationLimit = 10;
+export const mockTotalBranches = 20; // must be greater than mockBranchPaginationLimit to test pagination
+
+export const mockProjectPipeline = ({ hasStages = true } = {}) => {
+ const stages = hasStages
+ ? {
+ edges: [
+ {
+ node: {
+ id: 'gid://gitlab/Ci::Stage/605',
+ name: 'prepare',
+ status: 'success',
+ detailedStatus: {
+ detailsPath: '/root/sample-ci-project/-/pipelines/268#prepare',
+ group: 'success',
+ hasDetails: true,
+ icon: 'status_success',
+ id: 'success-605-605',
+ label: 'passed',
+ text: 'passed',
+ tooltip: 'passed',
+ },
+ },
+ },
+ ],
+ }
+ : null;
+
+ return {
+ id: '1',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/118',
+ iid: '28',
+ shortSha: mockCommitSha,
+ status: 'SUCCESS',
+ commit: {
+ id: 'commit-1',
+ title: 'Update .gitlabe-ci.yml',
+ webPath: '/-/commit/aabbccdd',
+ },
+ detailedStatus: {
+ id: 'status-1',
+ detailsPath: '/root/sample-ci-project/-/pipelines/118',
+ group: 'success',
+ icon: 'status_success',
+ text: 'passed',
+ },
+ stages,
+ },
+ };
+};
+
+export const mockLinkedPipelines = ({ hasDownstream = true, hasUpstream = true } = {}) => {
+ let upstream = null;
+ let downstream = {
+ nodes: [],
+ __typename: 'PipelineConnection',
+ };
+
+ if (hasDownstream) {
+ downstream = {
+ nodes: [
+ {
+ id: 'gid://gitlab/Ci::Pipeline/612',
+ path: '/root/job-log-sections/-/pipelines/612',
+ project: { name: 'job-log-sections', __typename: 'Project' },
+ detailedStatus: {
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ __typename: 'DetailedStatus',
+ },
+ __typename: 'Pipeline',
+ },
+ ],
+ __typename: 'PipelineConnection',
+ };
+ }
+
+ if (hasUpstream) {
+ upstream = {
+ id: 'gid://gitlab/Ci::Pipeline/610',
+ path: '/root/trigger-downstream/-/pipelines/610',
+ project: { name: 'trigger-downstream', __typename: 'Project' },
+ detailedStatus: {
+ group: 'success',
+ icon: 'status_success',
+ label: 'passed',
+ __typename: 'DetailedStatus',
+ },
+ __typename: 'Pipeline',
+ };
+ }
+
+ return {
+ data: {
+ project: {
+ pipeline: {
+ path: '/root/ci-project/-/pipelines/790',
+ downstream,
+ upstream,
+ },
+ __typename: 'Project',
+ },
+ },
+ };
+};
+
+export const mockLintResponse = {
+ valid: true,
+ mergedYaml: mockCiYml,
+ status: CI_CONFIG_STATUS_VALID,
+ errors: [],
+ warnings: [],
+ jobs: [
+ {
+ name: 'job_1',
+ stage: 'test',
+ before_script: ["echo 'before script 1'"],
+ script: ["echo 'script 1'"],
+ after_script: ["echo 'after script 1"],
+ tag_list: ['tag 1'],
+ environment: 'prd',
+ when: 'on_success',
+ allow_failure: false,
+ only: null,
+ except: { refs: ['main@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] },
+ },
+ {
+ name: 'job_2',
+ stage: 'test',
+ before_script: ["echo 'before script 2'"],
+ script: ["echo 'script 2'"],
+ after_script: ["echo 'after script 2"],
+ tag_list: ['tag 2'],
+ environment: 'stg',
+ when: 'on_success',
+ allow_failure: true,
+ only: { refs: ['web', 'chat', 'pushes'] },
+ except: { refs: ['main@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] },
+ },
+ ],
+};
+
+export const mockLintResponseWithoutMerged = {
+ valid: false,
+ status: CI_CONFIG_STATUS_INVALID,
+ errors: ['error'],
+ warnings: [],
+ jobs: [],
+};
+
+export const mockJobs = [
+ {
+ name: 'job_1',
+ stage: 'build',
+ beforeScript: [],
+ script: ["echo 'Building'"],
+ afterScript: [],
+ tagList: [],
+ environment: null,
+ when: 'on_success',
+ allowFailure: true,
+ only: { refs: ['web', 'chat', 'pushes'] },
+ except: null,
+ },
+ {
+ name: 'multi_project_job',
+ stage: 'test',
+ beforeScript: [],
+ script: [],
+ afterScript: [],
+ tagList: [],
+ environment: null,
+ when: 'on_success',
+ allowFailure: false,
+ only: { refs: ['branches', 'tags'] },
+ except: null,
+ },
+ {
+ name: 'job_2',
+ stage: 'test',
+ beforeScript: ["echo 'before script'"],
+ script: ["echo 'script'"],
+ afterScript: ["echo 'after script"],
+ tagList: [],
+ environment: null,
+ when: 'on_success',
+ allowFailure: false,
+ only: { refs: ['branches@gitlab-org/gitlab'] },
+ except: { refs: ['main@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] },
+ },
+];
+
+export const mockErrors = [
+ '"job_1 job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post"',
+];
+
+export const mockWarnings = [
+ '"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"',
+];
+
+export const mockCommitCreateResponse = {
+ data: {
+ commitCreate: {
+ __typename: 'CommitCreatePayload',
+ errors: [],
+ commit: {
+ __typename: 'Commit',
+ id: 'commit-1',
+ sha: mockCommitNextSha,
+ },
+ commitPipelinePath: '',
+ },
+ },
+};
+
+export const mockCommitCreateResponseNewEtag = {
+ data: {
+ commitCreate: {
+ __typename: 'CommitCreatePayload',
+ errors: [],
+ commit: {
+ __typename: 'Commit',
+ id: 'commit-2',
+ sha: mockCommitNextSha,
+ },
+ commitPipelinePath: '/api/graphql:pipelines/sha/550ceace1acd373c84d02bd539cb9d4614f786db',
+ },
+ },
+};
diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
new file mode 100644
index 00000000000..2246d0bbf7e
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js
@@ -0,0 +1,589 @@
+import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+
+import { objectToQuery, redirectTo } from '~/lib/utils/url_utility';
+import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers';
+import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue';
+import PipelineEditorEmptyState from '~/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue';
+import PipelineEditorMessages from '~/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue';
+import PipelineEditorHeader from '~/ci/pipeline_editor/components/header/pipeline_editor_header.vue';
+import ValidationSegment, {
+ i18n as validationSegmenti18n,
+} from '~/ci/pipeline_editor/components/header/validation_segment.vue';
+import {
+ COMMIT_SUCCESS,
+ COMMIT_SUCCESS_WITH_REDIRECT,
+ COMMIT_FAILURE,
+ EDITOR_APP_STATUS_LOADING,
+} from '~/ci/pipeline_editor/constants';
+import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql';
+import getCiConfigData from '~/ci/pipeline_editor/graphql/queries/ci_config.query.graphql';
+import getTemplate from '~/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql';
+import getLatestCommitShaQuery from '~/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql';
+import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql';
+import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql';
+import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql';
+
+import PipelineEditorApp from '~/ci/pipeline_editor/pipeline_editor_app.vue';
+import PipelineEditorHome from '~/ci/pipeline_editor/pipeline_editor_home.vue';
+
+import {
+ mockCiConfigPath,
+ mockCiConfigQueryResponse,
+ mockBlobContentQueryResponse,
+ mockBlobContentQueryResponseNoCiFile,
+ mockCiYml,
+ mockCiTemplateQueryResponse,
+ mockCommitSha,
+ mockCommitShaResults,
+ mockDefaultBranch,
+ mockEmptyCommitShaResults,
+ mockNewCommitShaResults,
+ mockNewMergeRequestPath,
+ mockProjectFullPath,
+} from './mock_data';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ redirectTo: jest.fn(),
+}));
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+const defaultProvide = {
+ ciConfigPath: mockCiConfigPath,
+ defaultBranch: mockDefaultBranch,
+ newMergeRequestPath: mockNewMergeRequestPath,
+ projectFullPath: mockProjectFullPath,
+ usesExternalConfig: false,
+};
+
+describe('Pipeline editor app component', () => {
+ let wrapper;
+
+ let mockApollo;
+ let mockBlobContentData;
+ let mockCiConfigData;
+ let mockGetTemplate;
+ let mockLatestCommitShaQuery;
+ let mockPipelineQuery;
+
+ const createComponent = ({
+ blobLoading = false,
+ options = {},
+ provide = {},
+ stubs = {},
+ } = {}) => {
+ wrapper = shallowMount(PipelineEditorApp, {
+ provide: { ...defaultProvide, ...provide },
+ stubs,
+ mocks: {
+ $apollo: {
+ queries: {
+ initialCiFileContent: {
+ loading: blobLoading,
+ },
+ },
+ },
+ },
+ ...options,
+ });
+ };
+
+ const createComponentWithApollo = async ({
+ provide = {},
+ stubs = {},
+ withUndefinedBranch = false,
+ } = {}) => {
+ const handlers = [
+ [getBlobContent, mockBlobContentData],
+ [getCiConfigData, mockCiConfigData],
+ [getTemplate, mockGetTemplate],
+ [getLatestCommitShaQuery, mockLatestCommitShaQuery],
+ [getPipelineQuery, mockPipelineQuery],
+ ];
+
+ mockApollo = createMockApollo(handlers, resolvers);
+
+ if (!withUndefinedBranch) {
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getCurrentBranch,
+ data: {
+ workBranches: {
+ __typename: 'BranchList',
+ current: {
+ __typename: 'WorkBranch',
+ name: mockDefaultBranch,
+ },
+ },
+ },
+ });
+ }
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getAppStatus,
+ data: {
+ app: {
+ __typename: 'AppData',
+ status: EDITOR_APP_STATUS_LOADING,
+ },
+ },
+ });
+
+ const options = {
+ localVue,
+ mocks: {},
+ apolloProvider: mockApollo,
+ };
+
+ createComponent({ provide, stubs, options });
+
+ return waitForPromises();
+ };
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findEditorHome = () => wrapper.findComponent(PipelineEditorHome);
+ const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState);
+ const findEmptyStateButton = () => findEmptyState().findComponent(GlButton);
+ const findValidationSegment = () => wrapper.findComponent(ValidationSegment);
+
+ beforeEach(() => {
+ mockBlobContentData = jest.fn();
+ mockCiConfigData = jest.fn();
+ mockGetTemplate = jest.fn();
+ mockLatestCommitShaQuery = jest.fn();
+ mockPipelineQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('loading state', () => {
+ it('displays a loading icon if the blob query is loading', () => {
+ createComponent({ blobLoading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findEditorHome().exists()).toBe(false);
+ });
+ });
+
+ describe('skipping queries', () => {
+ describe('when branchName is undefined', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo({ withUndefinedBranch: true });
+ });
+
+ it('does not calls getBlobContent', () => {
+ expect(mockBlobContentData).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when branchName is defined', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo();
+ });
+
+ it('calls getBlobContent', () => {
+ expect(mockBlobContentData).toHaveBeenCalled();
+ });
+ });
+
+ describe('when commit sha is undefined', () => {
+ beforeEach(async () => {
+ mockLatestCommitShaQuery.mockResolvedValue(undefined);
+ await createComponentWithApollo();
+ });
+
+ it('calls getBlobContent', () => {
+ expect(mockBlobContentData).toHaveBeenCalled();
+ });
+
+ it('does not call ciConfigData', () => {
+ expect(mockCiConfigData).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when commit sha is defined', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+ await createComponentWithApollo();
+ });
+
+ it('calls ciConfigData', () => {
+ expect(mockCiConfigData).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when queries are called', () => {
+ beforeEach(() => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse);
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+ });
+
+ describe('when project uses an external CI config file', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo({
+ provide: {
+ usesExternalConfig: true,
+ },
+ });
+ });
+
+ it('shows an empty state and does not show editor home component', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findAlert().exists()).toBe(false);
+ expect(findEditorHome().exists()).toBe(false);
+ });
+ });
+
+ describe('when file exists', () => {
+ beforeEach(async () => {
+ await createComponentWithApollo();
+
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
+ .mockImplementation(jest.fn());
+ });
+
+ it('shows pipeline editor home component', () => {
+ expect(findEditorHome().exists()).toBe(true);
+ });
+
+ it('no error is shown when data is set', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('ci config query is called with correct variables', async () => {
+ expect(mockCiConfigData).toHaveBeenCalledWith({
+ content: mockCiYml,
+ projectPath: mockProjectFullPath,
+ sha: mockCommitSha,
+ });
+ });
+
+ it('does not poll for the commit sha', () => {
+ expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0);
+ });
+ });
+
+ describe('when no CI config file exists', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile);
+ await createComponentWithApollo({
+ stubs: {
+ PipelineEditorEmptyState,
+ },
+ });
+
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
+ .mockImplementation(jest.fn());
+ });
+
+ it('shows an empty state and does not show editor home component', async () => {
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findAlert().exists()).toBe(false);
+ expect(findEditorHome().exists()).toBe(false);
+ });
+
+ it('does not poll for the commit sha', () => {
+ expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(0);
+ });
+
+ describe('because of a fetching error', () => {
+ it('shows a unkown error message', async () => {
+ const loadUnknownFailureText = 'The CI configuration was not loaded, please try again.';
+
+ mockBlobContentData.mockRejectedValueOnce();
+ await createComponentWithApollo({
+ stubs: {
+ PipelineEditorMessages,
+ },
+ });
+
+ expect(findEmptyState().exists()).toBe(false);
+
+ expect(findAlert().text()).toBe(loadUnknownFailureText);
+ expect(findEditorHome().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('with no CI config setup', () => {
+ it('user can click on CTA button to get started', async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile);
+ mockLatestCommitShaQuery.mockResolvedValue(mockEmptyCommitShaResults);
+
+ await createComponentWithApollo({
+ stubs: {
+ PipelineEditorHome,
+ PipelineEditorEmptyState,
+ },
+ });
+
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEditorHome().exists()).toBe(false);
+
+ await findEmptyStateButton().vm.$emit('click');
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findEditorHome().exists()).toBe(true);
+ });
+ });
+
+ describe('when the lint query returns a 500 error', () => {
+ beforeEach(async () => {
+ mockCiConfigData.mockRejectedValueOnce(new Error(500));
+ await createComponentWithApollo({
+ stubs: { PipelineEditorHome, PipelineEditorHeader, ValidationSegment },
+ });
+ });
+
+ it('shows that the lint service is down', () => {
+ expect(findValidationSegment().text()).toContain(
+ validationSegmenti18n.unavailableValidation,
+ );
+ });
+
+ it('does not report an error or scroll to the top', () => {
+ expect(findAlert().exists()).toBe(false);
+ expect(window.scrollTo).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when the user commits', () => {
+ const updateFailureMessage = 'The GitLab CI configuration could not be updated.';
+ const updateSuccessMessage = 'Your changes have been successfully committed.';
+
+ describe('and the commit mutation succeeds', () => {
+ beforeEach(async () => {
+ window.scrollTo = jest.fn();
+ await createComponentWithApollo({ stubs: { PipelineEditorMessages } });
+
+ findEditorHome().vm.$emit('commit', { type: COMMIT_SUCCESS });
+ });
+
+ it('shows a confirmation message', () => {
+ expect(findAlert().text()).toBe(updateSuccessMessage);
+ });
+
+ it('scrolls to the top of the page to bring attention to the confirmation message', () => {
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
+
+ it('polls for commit sha while pipeline data is not yet available for current branch', async () => {
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
+ .mockImplementation(jest.fn());
+
+ // simulate a commit to the current branch
+ findEditorHome().vm.$emit('updateCommitSha');
+ await waitForPromises();
+
+ expect(wrapper.vm.$apollo.queries.commitSha.startPolling).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops polling for commit sha when pipeline data is available for newly committed branch', async () => {
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling')
+ .mockImplementation(jest.fn());
+
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+ await wrapper.vm.$apollo.queries.commitSha.refetch();
+
+ expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops polling for commit sha when pipeline data is available for current branch', async () => {
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'stopPolling')
+ .mockImplementation(jest.fn());
+
+ mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults);
+ findEditorHome().vm.$emit('updateCommitSha');
+ await waitForPromises();
+
+ expect(wrapper.vm.$apollo.queries.commitSha.stopPolling).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when the commit succeeds with a redirect', () => {
+ const newBranch = 'new-branch';
+
+ beforeEach(async () => {
+ await createComponentWithApollo({ stubs: { PipelineEditorMessages } });
+
+ findEditorHome().vm.$emit('commit', {
+ type: COMMIT_SUCCESS_WITH_REDIRECT,
+ params: { sourceBranch: newBranch, targetBranch: mockDefaultBranch },
+ });
+ });
+
+ it('redirects to the merge request page with source and target branches', () => {
+ const branchesQuery = objectToQuery({
+ 'merge_request[source_branch]': newBranch,
+ 'merge_request[target_branch]': mockDefaultBranch,
+ });
+
+ expect(redirectTo).toHaveBeenCalledWith(`${mockNewMergeRequestPath}?${branchesQuery}`);
+ });
+ });
+
+ describe('and the commit mutation fails', () => {
+ const commitFailedReasons = ['Commit failed'];
+
+ beforeEach(async () => {
+ window.scrollTo = jest.fn();
+ await createComponentWithApollo({ stubs: { PipelineEditorMessages } });
+
+ findEditorHome().vm.$emit('showError', {
+ type: COMMIT_FAILURE,
+ reasons: commitFailedReasons,
+ });
+ });
+
+ it('shows an error message', () => {
+ expect(findAlert().text()).toMatchInterpolatedText(
+ `${updateFailureMessage} ${commitFailedReasons[0]}`,
+ );
+ });
+
+ it('scrolls to the top of the page to bring attention to the error message', () => {
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
+ });
+
+ describe('when an unknown error occurs', () => {
+ const unknownReasons = ['Commit failed'];
+
+ beforeEach(async () => {
+ window.scrollTo = jest.fn();
+ await createComponentWithApollo({ stubs: { PipelineEditorMessages } });
+
+ findEditorHome().vm.$emit('showError', {
+ type: COMMIT_FAILURE,
+ reasons: unknownReasons,
+ });
+ });
+
+ it('shows an error message', () => {
+ expect(findAlert().text()).toMatchInterpolatedText(
+ `${updateFailureMessage} ${unknownReasons[0]}`,
+ );
+ });
+
+ it('scrolls to the top of the page to bring attention to the error message', () => {
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
+ });
+ });
+ });
+ });
+
+ describe('when refetching content', () => {
+ beforeEach(() => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse);
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+ });
+
+ it('refetches blob content', async () => {
+ await createComponentWithApollo();
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.initialCiFileContent, 'refetch')
+ .mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(0);
+
+ await wrapper.vm.refetchContent();
+
+ expect(wrapper.vm.$apollo.queries.initialCiFileContent.refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('hides start screen when refetch fetches CI file', async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile);
+ await createComponentWithApollo();
+
+ expect(findEmptyState().exists()).toBe(true);
+ expect(findEditorHome().exists()).toBe(false);
+
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ await wrapper.vm.$apollo.queries.initialCiFileContent.refetch();
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findEditorHome().exists()).toBe(true);
+ });
+ });
+
+ describe('when a template parameter is present in the URL', () => {
+ const originalLocation = window.location.href;
+
+ beforeEach(() => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse);
+ mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse);
+ mockLatestCommitShaQuery.mockResolvedValue(mockCommitShaResults);
+ mockGetTemplate.mockResolvedValue(mockCiTemplateQueryResponse);
+ setWindowLocation('?template=Android');
+ });
+
+ afterEach(() => {
+ setWindowLocation(originalLocation);
+ });
+
+ it('renders the given template', async () => {
+ await createComponentWithApollo({
+ stubs: { PipelineEditorHome, PipelineEditorTabs },
+ });
+
+ expect(mockGetTemplate).toHaveBeenCalledWith({
+ projectPath: mockProjectFullPath,
+ templateName: 'Android',
+ });
+
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findEditorHome().exists()).toBe(true);
+ });
+ });
+
+ describe('when add_new_config_file query param is present', () => {
+ const originalLocation = window.location.href;
+
+ beforeEach(() => {
+ setWindowLocation('?add_new_config_file=true');
+
+ mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse);
+ });
+
+ afterEach(() => {
+ setWindowLocation(originalLocation);
+ });
+
+ describe('when CI config file does not exist', () => {
+ beforeEach(async () => {
+ mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile);
+ mockLatestCommitShaQuery.mockResolvedValue(mockEmptyCommitShaResults);
+ mockGetTemplate.mockResolvedValue(mockCiTemplateQueryResponse);
+
+ await createComponentWithApollo();
+
+ jest
+ .spyOn(wrapper.vm.$apollo.queries.commitSha, 'startPolling')
+ .mockImplementation(jest.fn());
+ });
+
+ it('skips empty state and shows editor home component', () => {
+ expect(findEmptyState().exists()).toBe(false);
+ expect(findEditorHome().exists()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js
new file mode 100644
index 00000000000..621e015e825
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js
@@ -0,0 +1,330 @@
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { GlButton, GlDrawer, GlModal } from '@gitlab/ui';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import setWindowLocation from 'helpers/set_window_location_helper';
+import CiEditorHeader from '~/ci/pipeline_editor/components/editor/ci_editor_header.vue';
+import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue';
+import PipelineEditorDrawer from '~/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
+import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
+import PipelineEditorFileTree from '~/ci/pipeline_editor/components/file_tree/container.vue';
+import BranchSwitcher from '~/ci/pipeline_editor/components/file_nav/branch_switcher.vue';
+import PipelineEditorHeader from '~/ci/pipeline_editor/components/header/pipeline_editor_header.vue';
+import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue';
+import {
+ CREATE_TAB,
+ FILE_TREE_DISPLAY_KEY,
+ VALIDATE_TAB,
+ MERGED_TAB,
+ TABS_INDEX,
+ VISUALIZE_TAB,
+} from '~/ci/pipeline_editor/constants';
+import PipelineEditorHome from '~/ci/pipeline_editor/pipeline_editor_home.vue';
+
+import { mockLintResponse, mockCiYml } from './mock_data';
+
+jest.mock('~/lib/utils/common_utils');
+
+describe('Pipeline editor home wrapper', () => {
+ let wrapper;
+
+ const createComponent = ({ props = {}, glFeatures = {}, data = {}, stubs = {} } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(PipelineEditorHome, {
+ data: () => data,
+ propsData: {
+ ciConfigData: mockLintResponse,
+ ciFileContent: mockCiYml,
+ isCiConfigDataLoading: false,
+ isNewCiConfigFile: false,
+ ...props,
+ },
+ provide: {
+ projectFullPath: '',
+ totalBranches: 19,
+ glFeatures: {
+ ...glFeatures,
+ },
+ },
+ stubs,
+ }),
+ );
+ };
+
+ const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher);
+ const findCommitSection = () => wrapper.findComponent(CommitSection);
+ const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav);
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer);
+ const findPipelineEditorFileTree = () => wrapper.findComponent(PipelineEditorFileTree);
+ const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
+ const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
+ const findFileTreeBtn = () => wrapper.findByTestId('file-tree-toggle');
+ const findHelpBtn = () => wrapper.findByTestId('drawer-toggle');
+
+ afterEach(() => {
+ localStorage.clear();
+ wrapper.destroy();
+ });
+
+ describe('renders', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('shows the file nav', () => {
+ expect(findFileNav().exists()).toBe(true);
+ });
+
+ it('shows the pipeline editor header', () => {
+ expect(findPipelineEditorHeader().exists()).toBe(true);
+ });
+
+ it('shows the pipeline editor tabs', () => {
+ expect(findPipelineEditorTabs().exists()).toBe(true);
+ });
+
+ it('shows the commit section by default', () => {
+ expect(findCommitSection().exists()).toBe(true);
+ });
+ });
+
+ describe('modal when switching branch', () => {
+ describe('when `showSwitchBranchModal` value is false', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('is not visible', () => {
+ expect(findModal().exists()).toBe(false);
+ });
+ });
+ describe('when `showSwitchBranchModal` value is true', () => {
+ beforeEach(() => {
+ createComponent({
+ data: { showSwitchBranchModal: true },
+ stubs: { PipelineEditorFileNav },
+ });
+ });
+
+ it('is visible', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('pass down `shouldLoadNewBranch` to the branch switcher when primary is selected', async () => {
+ expect(findBranchSwitcher().props('shouldLoadNewBranch')).toBe(false);
+
+ await findModal().vm.$emit('primary');
+
+ expect(findBranchSwitcher().props('shouldLoadNewBranch')).toBe(true);
+ });
+
+ it('closes the modal when secondary action is selected', async () => {
+ expect(findModal().exists()).toBe(true);
+
+ await findModal().vm.$emit('secondary');
+
+ expect(findModal().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('commit form toggle', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it.each`
+ tab | shouldShow
+ ${MERGED_TAB} | ${false}
+ ${VISUALIZE_TAB} | ${false}
+ ${VALIDATE_TAB} | ${false}
+ ${CREATE_TAB} | ${true}
+ `(
+ 'when the active tab is $tab the commit form is shown: $shouldShow',
+ async ({ tab, shouldShow }) => {
+ expect(findCommitSection().exists()).toBe(true);
+
+ findPipelineEditorTabs().vm.$emit('set-current-tab', tab);
+
+ await nextTick();
+
+ expect(findCommitSection().isVisible()).toBe(shouldShow);
+ },
+ );
+
+ it('shows the commit form again when coming back to the create tab', async () => {
+ expect(findCommitSection().isVisible()).toBe(true);
+
+ findPipelineEditorTabs().vm.$emit('set-current-tab', MERGED_TAB);
+ await nextTick();
+ expect(findCommitSection().isVisible()).toBe(false);
+
+ findPipelineEditorTabs().vm.$emit('set-current-tab', CREATE_TAB);
+ await nextTick();
+ expect(findCommitSection().isVisible()).toBe(true);
+ });
+
+ describe('rendering with tab params', () => {
+ it.each`
+ tab | shouldShow
+ ${MERGED_TAB} | ${false}
+ ${VISUALIZE_TAB} | ${false}
+ ${VALIDATE_TAB} | ${false}
+ ${CREATE_TAB} | ${true}
+ `(
+ 'when the tab query param is $tab the commit form is shown: $shouldShow',
+ async ({ tab, shouldShow }) => {
+ setWindowLocation(`https://gitlab.test/ci/editor/?tab=${TABS_INDEX[tab]}`);
+ await createComponent({ stubs: { PipelineEditorTabs } });
+
+ expect(findCommitSection().isVisible()).toBe(shouldShow);
+ },
+ );
+ });
+ });
+
+ describe('WalkthroughPopover events', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('when "walkthrough-popover-cta-clicked" is emitted from pipeline editor tabs', () => {
+ it('passes down `scrollToCommitForm=true` to commit section', async () => {
+ expect(findCommitSection().props('scrollToCommitForm')).toBe(false);
+ await findPipelineEditorTabs().vm.$emit('walkthrough-popover-cta-clicked');
+ expect(findCommitSection().props('scrollToCommitForm')).toBe(true);
+ });
+ });
+
+ describe('when "scrolled-to-commit-form" is emitted from commit section', () => {
+ it('passes down `scrollToCommitForm=false` to commit section', async () => {
+ await findPipelineEditorTabs().vm.$emit('walkthrough-popover-cta-clicked');
+ expect(findCommitSection().props('scrollToCommitForm')).toBe(true);
+ await findCommitSection().vm.$emit('scrolled-to-commit-form');
+ expect(findCommitSection().props('scrollToCommitForm')).toBe(false);
+ });
+ });
+ });
+
+ describe('help drawer', () => {
+ const clickHelpBtn = async () => {
+ findHelpBtn().vm.$emit('click');
+ await nextTick();
+ };
+
+ it('hides the drawer by default', () => {
+ createComponent();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(false);
+ });
+
+ it('toggles the drawer on button click', async () => {
+ createComponent({
+ stubs: {
+ CiEditorHeader,
+ GlButton,
+ GlDrawer,
+ PipelineEditorTabs,
+ PipelineEditorDrawer,
+ },
+ });
+
+ await clickHelpBtn();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(true);
+
+ await clickHelpBtn();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(false);
+ });
+
+ it("closes the drawer through the drawer's close button", async () => {
+ createComponent({
+ stubs: {
+ CiEditorHeader,
+ GlButton,
+ GlDrawer,
+ PipelineEditorTabs,
+ PipelineEditorDrawer,
+ },
+ });
+
+ await clickHelpBtn();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(true);
+
+ findPipelineEditorDrawer().findComponent(GlDrawer).vm.$emit('close');
+ await nextTick();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(false);
+ });
+ });
+
+ describe('file tree', () => {
+ const toggleFileTree = async () => {
+ findFileTreeBtn().vm.$emit('click');
+ await nextTick();
+ };
+
+ describe('button toggle', () => {
+ beforeEach(() => {
+ createComponent({
+ stubs: {
+ GlButton,
+ PipelineEditorFileNav,
+ },
+ });
+ });
+
+ it('shows button toggle', () => {
+ expect(findFileTreeBtn().exists()).toBe(true);
+ });
+
+ it('toggles the drawer on button click', async () => {
+ await toggleFileTree();
+
+ expect(findPipelineEditorFileTree().exists()).toBe(true);
+
+ await toggleFileTree();
+
+ expect(findPipelineEditorFileTree().exists()).toBe(false);
+ });
+
+ it('sets the display state in local storage', async () => {
+ await toggleFileTree();
+
+ expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('true');
+
+ await toggleFileTree();
+
+ expect(localStorage.getItem(FILE_TREE_DISPLAY_KEY)).toBe('false');
+ });
+ });
+
+ describe('when file tree display state is saved in local storage', () => {
+ beforeEach(() => {
+ localStorage.setItem(FILE_TREE_DISPLAY_KEY, 'true');
+ createComponent({
+ stubs: { PipelineEditorFileNav },
+ });
+ });
+
+ it('shows the file tree by default', () => {
+ expect(findPipelineEditorFileTree().exists()).toBe(true);
+ });
+ });
+
+ describe('when file tree display state is not saved in local storage', () => {
+ beforeEach(() => {
+ createComponent({
+ stubs: { PipelineEditorFileNav },
+ });
+ });
+
+ it('hides the file tree by default', () => {
+ expect(findPipelineEditorFileTree().exists()).toBe(false);
+ });
+ });
+ });
+});