diff options
Diffstat (limited to 'spec/frontend/ci/pipeline_editor')
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); + }); + }); + }); +}); |