diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 17:22:11 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 17:22:11 +0300 |
commit | 0c872e02b2c822e3397515ec324051ff540f0cd5 (patch) | |
tree | ce2fb6ce7030e4dad0f4118d21ab6453e5938cdd /spec/frontend/ci | |
parent | f7e05a6853b12f02911494c4b3fe53d9540d74fc (diff) |
Add latest changes from gitlab-org/gitlab@15-7-stable-eev15.7.0-rc42
Diffstat (limited to 'spec/frontend/ci')
74 files changed, 7728 insertions, 53 deletions
diff --git a/spec/frontend/ci/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js new file mode 100644 index 00000000000..d4f588a0e09 --- /dev/null +++ b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js @@ -0,0 +1,118 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import CiLint from '~/ci/ci_lint/components/ci_lint.vue'; +import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCIMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; +import { mockLintDataValid } from '../mock_data'; + +describe('CI Lint', () => { + let wrapper; + + const endpoint = '/namespace/project/-/ci/lint'; + const content = + "test_job:\n stage: build\n script: echo 'Building'\n only:\n - web\n - chat\n - pushes\n allow_failure: true "; + const mockMutate = jest.fn().mockResolvedValue(mockLintDataValid); + + const createComponent = () => { + wrapper = shallowMount(CiLint, { + data() { + return { + content, + }; + }, + propsData: { + endpoint, + pipelineSimulationHelpPagePath: '/help/ci/lint#pipeline-simulation', + lintHelpPagePath: '/help/ci/lint#anchor', + }, + mocks: { + $apollo: { + mutate: mockMutate, + }, + }, + }); + }; + + const findEditor = () => wrapper.findComponent(SourceEditor); + const findAlert = () => wrapper.findComponent(GlAlert); + const findCiLintResults = () => wrapper.findComponent(CiLintResults); + const findValidateBtn = () => wrapper.find('[data-testid="ci-lint-validate"]'); + const findClearBtn = () => wrapper.find('[data-testid="ci-lint-clear"]'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + mockMutate.mockClear(); + wrapper.destroy(); + }); + + it('displays the editor', () => { + expect(findEditor().exists()).toBe(true); + }); + + it('validate action calls mutation correctly', () => { + findValidateBtn().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: lintCIMutation, + variables: { content, dry: false, endpoint }, + }); + }); + + it('validate action calls mutation with dry run', async () => { + const dryRunEnabled = true; + + // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details + // eslint-disable-next-line no-restricted-syntax + await wrapper.setData({ dryRun: dryRunEnabled }); + + findValidateBtn().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: lintCIMutation, + variables: { content, dry: dryRunEnabled, endpoint }, + }); + }); + + it('validation displays results', async () => { + findValidateBtn().vm.$emit('click'); + + await nextTick(); + + expect(findValidateBtn().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findCiLintResults().exists()).toBe(true); + expect(findValidateBtn().props('loading')).toBe(false); + }); + + it('validation displays error', async () => { + mockMutate.mockRejectedValue('Error!'); + + findValidateBtn().vm.$emit('click'); + + await nextTick(); + + expect(findValidateBtn().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findCiLintResults().exists()).toBe(false); + expect(findAlert().text()).toBe('Error!'); + expect(findValidateBtn().props('loading')).toBe(false); + }); + + it('content is cleared on clear action', async () => { + expect(findEditor().props('value')).toBe(content); + + await findClearBtn().vm.$emit('click'); + + expect(findEditor().props('value')).toBe(''); + }); +}); diff --git a/spec/frontend/ci/ci_lint/mock_data.js b/spec/frontend/ci/ci_lint/mock_data.js new file mode 100644 index 00000000000..05582470dfa --- /dev/null +++ b/spec/frontend/ci/ci_lint/mock_data.js @@ -0,0 +1,23 @@ +import { mockJobs } from 'jest/ci/pipeline_editor/mock_data'; + +export const mockLintDataError = { + data: { + lintCI: { + errors: ['Error message'], + warnings: ['Warning message'], + valid: false, + jobs: mockJobs, + }, + }, +}; + +export const mockLintDataValid = { + data: { + lintCI: { + errors: [], + warnings: [], + valid: true, + jobs: mockJobs, + }, + }, +}; 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); + }); + }); + }); +}); diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js index e5d9b378a42..639c2dbef4c 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js @@ -1,25 +1,160 @@ -import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import { GlForm } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; import PipelineSchedulesForm from '~/ci/pipeline_schedules/components/pipeline_schedules_form.vue'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; +import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; +import { timezoneDataFixture } from '../../../vue_shared/components/timezone_dropdown/helpers'; describe('Pipeline schedules form', () => { let wrapper; + const defaultBranch = 'main'; + const projectId = '1'; + const cron = ''; + const dailyLimit = ''; - const createComponent = () => { - wrapper = shallowMount(PipelineSchedulesForm); + const createComponent = (mountFn = shallowMountExtended, stubs = {}) => { + wrapper = mountFn(PipelineSchedulesForm, { + propsData: { + timezoneData: timezoneDataFixture, + refParam: 'master', + }, + provide: { + fullPath: 'gitlab-org/gitlab', + projectId, + defaultBranch, + cron, + cronTimezone: '', + dailyLimit, + settingsLink: '', + }, + stubs, + }); }; const findForm = () => wrapper.findComponent(GlForm); + const findDescription = () => wrapper.findByTestId('schedule-description'); + const findIntervalComponent = () => wrapper.findComponent(IntervalPatternInput); + const findTimezoneDropdown = () => wrapper.findComponent(TimezoneDropdown); + const findRefSelector = () => wrapper.findComponent(RefSelector); + const findSubmitButton = () => wrapper.findByTestId('schedule-submit-button'); + const findCancelButton = () => wrapper.findByTestId('schedule-cancel-button'); + // Variables + const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); + const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); + const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); beforeEach(() => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); + describe('Form elements', () => { + it('displays form', () => { + expect(findForm().exists()).toBe(true); + }); + + it('displays the description input', () => { + expect(findDescription().exists()).toBe(true); + }); + + it('displays the interval pattern component', () => { + const intervalPattern = findIntervalComponent(); + + expect(intervalPattern.exists()).toBe(true); + expect(intervalPattern.props()).toMatchObject({ + initialCronInterval: cron, + dailyLimit, + sendNativeErrors: false, + }); + }); + + it('displays the Timezone dropdown', () => { + const timezoneDropdown = findTimezoneDropdown(); + + expect(timezoneDropdown.exists()).toBe(true); + expect(timezoneDropdown.props()).toMatchObject({ + value: '', + name: 'schedule-timezone', + timezoneData: timezoneDataFixture, + }); + }); + + it('displays the branch/tag selector', () => { + const refSelector = findRefSelector(); + + expect(refSelector.exists()).toBe(true); + expect(refSelector.props()).toMatchObject({ + enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], + value: defaultBranch, + projectId, + translations: { dropdownHeader: 'Select target branch or tag' }, + useSymbolicRefNames: true, + state: true, + name: '', + }); + }); + + it('displays the submit and cancel buttons', () => { + expect(findSubmitButton().exists()).toBe(true); + expect(findCancelButton().exists()).toBe(true); + }); }); - it('displays form', () => { - expect(findForm().exists()).toBe(true); + describe('CI variables', () => { + let mock; + + const addVariableToForm = () => { + const input = findKeyInputs().at(0); + input.element.value = 'test_var_2'; + input.trigger('change'); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + createComponent(mountExtended); + }); + + afterEach(() => { + mock.restore(); + }); + + it('creates blank variable on input change event', async () => { + expect(findVariableRows()).toHaveLength(1); + + addVariableToForm(); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(2); + expect(findKeyInputs().at(1).element.value).toBe(''); + expect(findValueInputs().at(1).element.value).toBe(''); + }); + + it('does not display remove icon for last row', async () => { + addVariableToForm(); + + await nextTick(); + + expect(findRemoveIcons()).toHaveLength(1); + }); + + it('removes ci variable row on remove icon button click', async () => { + addVariableToForm(); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(2); + + findRemoveIcons().at(0).trigger('click'); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(1); + }); }); }); diff --git a/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js new file mode 100644 index 00000000000..5ca4b25da9b --- /dev/null +++ b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js @@ -0,0 +1,102 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import component from '~/ci/reports/codequality_report/components/codequality_issue_body.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants'; + +describe('code quality issue body issue body', () => { + let wrapper; + + const findSeverityIcon = () => wrapper.findByTestId('codequality-severity-icon'); + const findGlIcon = () => wrapper.findComponent(GlIcon); + + const codequalityIssue = { + name: + 'rubygem-rest-client: session fixation vulnerability via Set-Cookie headers in 30x redirection responses', + path: 'Gemfile.lock', + severity: 'normal', + type: 'Issue', + urlPath: '/Gemfile.lock#L22', + }; + + const createComponent = (initialStatus, issue = codequalityIssue) => { + wrapper = extendedWrapper( + shallowMount(component, { + propsData: { + issue, + status: initialStatus, + }, + }), + ); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('severity rating', () => { + it.each` + severity | iconClass | iconName + ${'INFO'} | ${'text-primary-400'} | ${'severity-info'} + ${'MINOR'} | ${'text-warning-200'} | ${'severity-low'} + ${'CRITICAL'} | ${'text-danger-600'} | ${'severity-high'} + ${'BLOCKER'} | ${'text-danger-800'} | ${'severity-critical'} + ${'UNKNOWN'} | ${'text-secondary-400'} | ${'severity-unknown'} + ${'INVALID'} | ${'text-secondary-400'} | ${'severity-unknown'} + ${'info'} | ${'text-primary-400'} | ${'severity-info'} + ${'minor'} | ${'text-warning-200'} | ${'severity-low'} + ${'major'} | ${'text-warning-400'} | ${'severity-medium'} + ${'critical'} | ${'text-danger-600'} | ${'severity-high'} + ${'blocker'} | ${'text-danger-800'} | ${'severity-critical'} + ${'unknown'} | ${'text-secondary-400'} | ${'severity-unknown'} + ${'invalid'} | ${'text-secondary-400'} | ${'severity-unknown'} + ${undefined} | ${'text-secondary-400'} | ${'severity-unknown'} + `( + 'renders correct icon for "$severity" severity rating', + ({ severity, iconClass, iconName }) => { + createComponent(STATUS_FAILED, { + ...codequalityIssue, + severity, + }); + const icon = findGlIcon(); + + expect(findSeverityIcon().classes()).toContain(iconClass); + expect(icon.exists()).toBe(true); + expect(icon.props('name')).toBe(iconName); + }, + ); + }); + + describe('with success', () => { + it('renders fixed label', () => { + createComponent(STATUS_SUCCESS); + + expect(wrapper.text()).toContain('Fixed'); + }); + }); + + describe('without success', () => { + it('does not render fixed label', () => { + createComponent(STATUS_FAILED); + + expect(wrapper.text()).not.toContain('Fixed'); + }); + }); + + describe('name', () => { + it('renders name', () => { + createComponent(STATUS_NEUTRAL); + + expect(wrapper.text()).toContain(codequalityIssue.name); + }); + }); + + describe('path', () => { + it('renders the report-link path using the correct code quality issue', () => { + createComponent(STATUS_NEUTRAL); + + expect(wrapper.find('report-link-stub').props('issue')).toBe(codequalityIssue); + }); + }); +}); diff --git a/spec/frontend/ci/reports/codequality_report/mock_data.js b/spec/frontend/ci/reports/codequality_report/mock_data.js new file mode 100644 index 00000000000..2c994116db6 --- /dev/null +++ b/spec/frontend/ci/reports/codequality_report/mock_data.js @@ -0,0 +1,49 @@ +export const reportIssues = { + status: 'failed', + new_errors: [ + { + description: + 'Method `long_if` has a Cognitive Complexity of 10 (exceeds 5 allowed). Consider refactoring.', + severity: 'minor', + file_path: 'codequality.rb', + line: 5, + }, + ], + resolved_errors: [ + { + description: 'Insecure Dependency', + severity: 'major', + file_path: 'lib/six.rb', + line: 22, + }, + ], + existing_errors: [], + summary: { total: 3, resolved: 0, errored: 3 }, +}; + +export const parsedReportIssues = { + newIssues: [ + { + description: + 'Method `long_if` has a Cognitive Complexity of 10 (exceeds 5 allowed). Consider refactoring.', + file_path: 'codequality.rb', + line: 5, + name: + 'Method `long_if` has a Cognitive Complexity of 10 (exceeds 5 allowed). Consider refactoring.', + path: 'codequality.rb', + severity: 'minor', + urlPath: 'null/codequality.rb#L5', + }, + ], + resolvedIssues: [ + { + description: 'Insecure Dependency', + file_path: 'lib/six.rb', + line: 22, + name: 'Insecure Dependency', + path: 'lib/six.rb', + severity: 'major', + urlPath: 'null/lib/six.rb#L22', + }, + ], +}; diff --git a/spec/frontend/ci/reports/codequality_report/store/actions_spec.js b/spec/frontend/ci/reports/codequality_report/store/actions_spec.js new file mode 100644 index 00000000000..88628210793 --- /dev/null +++ b/spec/frontend/ci/reports/codequality_report/store/actions_spec.js @@ -0,0 +1,185 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import createStore from '~/ci/reports/codequality_report/store'; +import * as actions from '~/ci/reports/codequality_report/store/actions'; +import * as types from '~/ci/reports/codequality_report/store/mutation_types'; +import { STATUS_NOT_FOUND } from '~/ci/reports/constants'; +import { reportIssues, parsedReportIssues } from '../mock_data'; + +const pollInterval = 123; +const pollIntervalHeader = { + 'Poll-Interval': pollInterval, +}; + +describe('Codequality Reports actions', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('setPaths', () => { + it('should commit SET_PATHS mutation', () => { + const paths = { + baseBlobPath: 'baseBlobPath', + headBlobPath: 'headBlobPath', + reportsPath: 'reportsPath', + }; + + return testAction( + actions.setPaths, + paths, + localState, + [{ type: types.SET_PATHS, payload: paths }], + [], + ); + }); + }); + + describe('fetchReports', () => { + const endpoint = `${TEST_HOST}/codequality_reports.json`; + let mock; + + beforeEach(() => { + localState.reportsPath = endpoint; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('on success', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', () => { + mock.onGet(endpoint).reply(200, reportIssues); + + return testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [ + { + payload: parsedReportIssues, + type: 'receiveReportsSuccess', + }, + ], + ); + }); + }); + + describe('on error', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { + mock.onGet(endpoint).reply(500); + + return testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [{ type: 'receiveReportsError', payload: expect.any(Error) }], + ); + }); + }); + + describe('when base report is not found', () => { + it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => { + const data = { status: STATUS_NOT_FOUND }; + mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, data); + + return testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [{ type: 'receiveReportsError', payload: data }], + ); + }); + }); + + describe('while waiting for report results', () => { + it('continues polling until it receives data', () => { + mock + .onGet(endpoint) + .replyOnce(204, undefined, pollIntervalHeader) + .onGet(endpoint) + .reply(200, reportIssues); + + return Promise.all([ + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [ + { + payload: parsedReportIssues, + type: 'receiveReportsSuccess', + }, + ], + ), + axios + // wait for initial NO_CONTENT response to be fulfilled + .waitForAll() + .then(() => { + jest.advanceTimersByTime(pollInterval); + }), + ]); + }); + + it('continues polling until it receives an error', () => { + mock + .onGet(endpoint) + .replyOnce(204, undefined, pollIntervalHeader) + .onGet(endpoint) + .reply(500); + + return Promise.all([ + testAction( + actions.fetchReports, + null, + localState, + [{ type: types.REQUEST_REPORTS }], + [{ type: 'receiveReportsError', payload: expect.any(Error) }], + ), + axios + // wait for initial NO_CONTENT response to be fulfilled + .waitForAll() + .then(() => { + jest.advanceTimersByTime(pollInterval); + }), + ]); + }); + }); + }); + + describe('receiveReportsSuccess', () => { + it('commits RECEIVE_REPORTS_SUCCESS', () => { + const data = { issues: [] }; + + return testAction( + actions.receiveReportsSuccess, + data, + localState, + [{ type: types.RECEIVE_REPORTS_SUCCESS, payload: data }], + [], + ); + }); + }); + + describe('receiveReportsError', () => { + it('commits RECEIVE_REPORTS_ERROR', () => { + return testAction( + actions.receiveReportsError, + null, + localState, + [{ type: types.RECEIVE_REPORTS_ERROR, payload: null }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/ci/reports/codequality_report/store/getters_spec.js b/spec/frontend/ci/reports/codequality_report/store/getters_spec.js new file mode 100644 index 00000000000..f4505204f67 --- /dev/null +++ b/spec/frontend/ci/reports/codequality_report/store/getters_spec.js @@ -0,0 +1,94 @@ +import createStore from '~/ci/reports/codequality_report/store'; +import * as getters from '~/ci/reports/codequality_report/store/getters'; +import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '~/ci/reports/constants'; + +describe('Codequality reports store getters', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('hasCodequalityIssues', () => { + describe('when there are issues', () => { + it('returns true', () => { + localState.newIssues = [{ reason: 'repetitive code' }]; + localState.resolvedIssues = []; + + expect(getters.hasCodequalityIssues(localState)).toEqual(true); + + localState.newIssues = []; + localState.resolvedIssues = [{ reason: 'repetitive code' }]; + + expect(getters.hasCodequalityIssues(localState)).toEqual(true); + }); + }); + + describe('when there are no issues', () => { + it('returns false when there are no issues', () => { + expect(getters.hasCodequalityIssues(localState)).toEqual(false); + }); + }); + }); + + describe('codequalityStatus', () => { + describe('when loading', () => { + it('returns loading status', () => { + localState.isLoading = true; + + expect(getters.codequalityStatus(localState)).toEqual(LOADING); + }); + }); + + describe('on error', () => { + it('returns error status', () => { + localState.hasError = true; + + expect(getters.codequalityStatus(localState)).toEqual(ERROR); + }); + }); + + describe('when successfully loaded', () => { + it('returns error status', () => { + expect(getters.codequalityStatus(localState)).toEqual(SUCCESS); + }); + }); + }); + + describe('codequalityText', () => { + it.each` + resolvedIssues | newIssues | expectedText + ${0} | ${0} | ${'No changes to code quality'} + ${0} | ${1} | ${'Code quality degraded due to 1 new issue'} + ${2} | ${0} | ${'Code quality improved due to 2 resolved issues'} + ${1} | ${2} | ${'Code quality scanning detected 3 changes in merged results'} + `( + 'returns a summary containing $resolvedIssues resolved issues and $newIssues new issues', + ({ newIssues, resolvedIssues, expectedText }) => { + localState.newIssues = new Array(newIssues).fill({ reason: 'Repetitive code' }); + localState.resolvedIssues = new Array(resolvedIssues).fill({ reason: 'Repetitive code' }); + + expect(getters.codequalityText(localState)).toEqual(expectedText); + }, + ); + }); + + describe('codequalityPopover', () => { + describe('when base report is not available', () => { + it('returns a popover with a documentation link', () => { + localState.status = STATUS_NOT_FOUND; + localState.helpPath = 'codequality_help.html'; + + expect(getters.codequalityPopover(localState).title).toEqual( + 'Base pipeline codequality artifact not found', + ); + expect(getters.codequalityPopover(localState).content).toContain( + 'Learn more about codequality reports', + 'href="codequality_help.html"', + ); + }); + }); + }); +}); diff --git a/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js b/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js new file mode 100644 index 00000000000..22ff86b1040 --- /dev/null +++ b/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js @@ -0,0 +1,100 @@ +import createStore from '~/ci/reports/codequality_report/store'; +import mutations from '~/ci/reports/codequality_report/store/mutations'; +import { STATUS_NOT_FOUND } from '~/ci/reports/constants'; + +describe('Codequality Reports mutations', () => { + let localState; + let localStore; + + beforeEach(() => { + localStore = createStore(); + localState = localStore.state; + }); + + describe('SET_PATHS', () => { + it('sets paths to given values', () => { + const baseBlobPath = 'base/blob/path/'; + const headBlobPath = 'head/blob/path/'; + const reportsPath = 'reports.json'; + const helpPath = 'help.html'; + + mutations.SET_PATHS(localState, { + baseBlobPath, + headBlobPath, + reportsPath, + helpPath, + }); + + expect(localState.baseBlobPath).toEqual(baseBlobPath); + expect(localState.headBlobPath).toEqual(headBlobPath); + expect(localState.reportsPath).toEqual(reportsPath); + expect(localState.helpPath).toEqual(helpPath); + }); + }); + + describe('REQUEST_REPORTS', () => { + it('sets isLoading to true', () => { + mutations.REQUEST_REPORTS(localState); + + expect(localState.isLoading).toEqual(true); + }); + }); + + describe('RECEIVE_REPORTS_SUCCESS', () => { + it('sets isLoading to false', () => { + mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); + + expect(localState.isLoading).toEqual(false); + }); + + it('sets hasError to false', () => { + mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); + + expect(localState.hasError).toEqual(false); + }); + + it('clears status and statusReason', () => { + mutations.RECEIVE_REPORTS_SUCCESS(localState, {}); + + expect(localState.status).toEqual(''); + expect(localState.statusReason).toEqual(''); + }); + + it('sets newIssues and resolvedIssues from response data', () => { + const data = { newIssues: [{ id: 1 }], resolvedIssues: [{ id: 2 }] }; + mutations.RECEIVE_REPORTS_SUCCESS(localState, data); + + expect(localState.newIssues).toEqual(data.newIssues); + expect(localState.resolvedIssues).toEqual(data.resolvedIssues); + }); + }); + + describe('RECEIVE_REPORTS_ERROR', () => { + it('sets isLoading to false', () => { + mutations.RECEIVE_REPORTS_ERROR(localState); + + expect(localState.isLoading).toEqual(false); + }); + + it('sets hasError to true', () => { + mutations.RECEIVE_REPORTS_ERROR(localState); + + expect(localState.hasError).toEqual(true); + }); + + it('sets status based on error object', () => { + const error = { status: STATUS_NOT_FOUND }; + mutations.RECEIVE_REPORTS_ERROR(localState, error); + + expect(localState.status).toEqual(error.status); + }); + + it('sets statusReason to string from error response data', () => { + const data = { status_reason: 'This merge request does not have codequality reports' }; + const error = { response: { data } }; + mutations.RECEIVE_REPORTS_ERROR(localState, error); + + expect(localState.statusReason).toEqual(data.status_reason); + }); + }); +}); diff --git a/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js b/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js new file mode 100644 index 00000000000..f7d82d2b662 --- /dev/null +++ b/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js @@ -0,0 +1,86 @@ +import { reportIssues, parsedReportIssues } from 'jest/ci/reports/codequality_report/mock_data'; +import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; + +describe('Codequality report store utils', () => { + let result; + + describe('parseCodeclimateMetrics', () => { + it('should parse the issues from backend codequality diff', () => { + [result] = parseCodeclimateMetrics(reportIssues.new_errors, 'path'); + + expect(result.name).toEqual(parsedReportIssues.newIssues[0].name); + expect(result.path).toEqual(parsedReportIssues.newIssues[0].path); + expect(result.line).toEqual(parsedReportIssues.newIssues[0].line); + }); + + describe('when an issue has no location or path', () => { + const issue = { description: 'Insecure Dependency' }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + }); + }); + + describe('when an issue has a non-nested path', () => { + const issue = { description: 'Insecure Dependency', path: 'Gemfile.lock' }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + }); + }); + + describe('when an issue has a path but no line', () => { + const issue = { description: 'Insecure Dependency', location: { path: 'Gemfile.lock' } }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + expect(result.path).toEqual(issue.location.path); + expect(result.urlPath).toEqual(`path/${issue.location.path}`); + }); + }); + + describe('when an issue has a line nested in positions', () => { + const issue = { + description: 'Insecure Dependency', + location: { + path: 'Gemfile.lock', + positions: { begin: { line: 84 } }, + }, + }; + + beforeEach(() => { + [result] = parseCodeclimateMetrics([issue], 'path'); + }); + + it('is parsed', () => { + expect(result.name).toEqual(issue.description); + expect(result.path).toEqual(issue.location.path); + expect(result.urlPath).toEqual( + `path/${issue.location.path}#L${issue.location.positions.begin.line}`, + ); + }); + }); + + describe('with an empty issue array', () => { + beforeEach(() => { + result = parseCodeclimateMetrics([], 'path'); + }); + + it('returns an empty array', () => { + expect(result).toEqual([]); + }); + }); + }); +}); diff --git a/spec/frontend/ci/reports/components/__snapshots__/grouped_issues_list_spec.js.snap b/spec/frontend/ci/reports/components/__snapshots__/grouped_issues_list_spec.js.snap new file mode 100644 index 00000000000..311a67a3e31 --- /dev/null +++ b/spec/frontend/ci/reports/components/__snapshots__/grouped_issues_list_spec.js.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Grouped Issues List renders a smart virtual list with the correct props 1`] = ` +Object { + "length": 4, + "remain": 20, + "rtag": "div", + "size": 32, + "wclass": "report-block-list", + "wtag": "ul", +} +`; + +exports[`Grouped Issues List with data renders a report item with the correct props 1`] = ` +Object { + "component": "CodequalityIssueBody", + "iconComponent": "IssueStatusIcon", + "isNew": false, + "issue": Object { + "name": "foo", + }, + "showReportSectionStatusIcon": false, + "status": "none", + "statusIconSize": 24, +} +`; diff --git a/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap b/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap new file mode 100644 index 00000000000..b5a4cb42463 --- /dev/null +++ b/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IssueStatusIcon renders "failed" state correctly 1`] = ` +<div + class="report-block-list-icon failed" +> + <gl-icon-stub + data-qa-selector="status_failed_icon" + name="status_failed_borderless" + size="24" + /> +</div> +`; + +exports[`IssueStatusIcon renders "neutral" state correctly 1`] = ` +<div + class="report-block-list-icon neutral" +> + <gl-icon-stub + data-qa-selector="status_neutral_icon" + name="dash" + size="24" + /> +</div> +`; + +exports[`IssueStatusIcon renders "success" state correctly 1`] = ` +<div + class="report-block-list-icon success" +> + <gl-icon-stub + data-qa-selector="status_success_icon" + name="status_success_borderless" + size="24" + /> +</div> +`; diff --git a/spec/frontend/ci/reports/components/grouped_issues_list_spec.js b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js new file mode 100644 index 00000000000..3e4adfc7794 --- /dev/null +++ b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js @@ -0,0 +1,87 @@ +import { shallowMount } from '@vue/test-utils'; +import GroupedIssuesList from '~/ci/reports/components/grouped_issues_list.vue'; +import ReportItem from '~/ci/reports/components/report_item.vue'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; + +describe('Grouped Issues List', () => { + let wrapper; + + const createComponent = ({ propsData = {}, stubs = {} } = {}) => { + wrapper = shallowMount(GroupedIssuesList, { + propsData, + stubs, + }); + }; + + const findHeading = (groupName) => wrapper.find(`[data-testid="${groupName}Heading"`); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a smart virtual list with the correct props', () => { + createComponent({ + propsData: { + resolvedIssues: [{ name: 'foo' }], + unresolvedIssues: [{ name: 'bar' }], + }, + stubs: { + SmartVirtualList, + }, + }); + + expect(wrapper.findComponent(SmartVirtualList).props()).toMatchSnapshot(); + }); + + describe('without data', () => { + beforeEach(() => { + createComponent(); + }); + + it.each(['unresolved', 'resolved'])('does not a render a header for %s issues', (issueName) => { + expect(findHeading(issueName).exists()).toBe(false); + }); + + it.each(['resolved', 'unresolved'])('does not render report items for %s issues', () => { + expect(wrapper.findComponent(ReportItem).exists()).toBe(false); + }); + }); + + describe('with data', () => { + it.each` + givenIssues | givenHeading | groupName + ${[{ name: 'foo issue' }]} | ${'Foo Heading'} | ${'resolved'} + ${[{ name: 'bar issue' }]} | ${'Bar Heading'} | ${'unresolved'} + `('renders the heading for $groupName issues', ({ givenIssues, givenHeading, groupName }) => { + createComponent({ + propsData: { [`${groupName}Issues`]: givenIssues, [`${groupName}Heading`]: givenHeading }, + }); + + expect(findHeading(groupName).text()).toBe(givenHeading); + }); + + it.each(['resolved', 'unresolved'])('renders all %s issues', (issueName) => { + const issues = [{ name: 'foo' }, { name: 'bar' }]; + + createComponent({ + propsData: { [`${issueName}Issues`]: issues }, + }); + + expect(wrapper.findAllComponents(ReportItem)).toHaveLength(issues.length); + }); + + it('renders a report item with the correct props', () => { + createComponent({ + propsData: { + resolvedIssues: [{ name: 'foo' }], + component: 'CodequalityIssueBody', + }, + stubs: { + ReportItem, + }, + }); + + expect(wrapper.findComponent(ReportItem).props()).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/ci/reports/components/issue_status_icon_spec.js b/spec/frontend/ci/reports/components/issue_status_icon_spec.js new file mode 100644 index 00000000000..fb13d4407e2 --- /dev/null +++ b/spec/frontend/ci/reports/components/issue_status_icon_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import ReportItem from '~/ci/reports/components/issue_status_icon.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants'; + +describe('IssueStatusIcon', () => { + let wrapper; + + const createComponent = ({ status }) => { + wrapper = shallowMount(ReportItem, { + propsData: { + status, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each([STATUS_SUCCESS, STATUS_NEUTRAL, STATUS_FAILED])( + 'renders "%s" state correctly', + (status) => { + createComponent({ status }); + + expect(wrapper.element).toMatchSnapshot(); + }, + ); +}); diff --git a/spec/frontend/ci/reports/components/report_item_spec.js b/spec/frontend/ci/reports/components/report_item_spec.js new file mode 100644 index 00000000000..d835d549531 --- /dev/null +++ b/spec/frontend/ci/reports/components/report_item_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import { componentNames } from '~/ci/reports/components/issue_body'; +import IssueStatusIcon from '~/ci/reports/components/issue_status_icon.vue'; +import ReportItem from '~/ci/reports/components/report_item.vue'; +import { STATUS_SUCCESS } from '~/ci/reports/constants'; + +describe('ReportItem', () => { + describe('showReportSectionStatusIcon', () => { + it('does not render CI Status Icon when showReportSectionStatusIcon is false', () => { + const wrapper = shallowMount(ReportItem, { + propsData: { + issue: { foo: 'bar' }, + component: componentNames.CodequalityIssueBody, + status: STATUS_SUCCESS, + showReportSectionStatusIcon: false, + }, + }); + + expect(wrapper.findComponent(IssueStatusIcon).exists()).toBe(false); + }); + + it('shows status icon when unspecified', () => { + const wrapper = shallowMount(ReportItem, { + propsData: { + issue: { foo: 'bar' }, + component: componentNames.CodequalityIssueBody, + status: STATUS_SUCCESS, + }, + }); + + expect(wrapper.findComponent(IssueStatusIcon).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ci/reports/components/report_link_spec.js b/spec/frontend/ci/reports/components/report_link_spec.js new file mode 100644 index 00000000000..ba541ba0303 --- /dev/null +++ b/spec/frontend/ci/reports/components/report_link_spec.js @@ -0,0 +1,56 @@ +import { shallowMount } from '@vue/test-utils'; +import ReportLink from '~/ci/reports/components/report_link.vue'; + +describe('app/assets/javascripts/ci/reports/components/report_link.vue', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + const defaultProps = { + issue: {}, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ReportLink, { + propsData: { ...defaultProps, ...props }, + }); + }; + + describe('When an issue prop has a $urlPath property', () => { + it('render a link that will take the user to the $urlPath', () => { + createComponent({ issue: { path: 'Gemfile.lock', urlPath: '/Gemfile.lock' } }); + + expect(wrapper.text()).toContain('in'); + expect(wrapper.find('a').attributes('href')).toBe('/Gemfile.lock'); + expect(wrapper.find('a').text()).toContain('Gemfile.lock'); + }); + }); + + describe('When an issue prop has no $urlPath property', () => { + it('does not render link', () => { + createComponent({ issue: { path: 'Gemfile.lock' } }); + + expect(wrapper.find('a').exists()).toBe(false); + expect(wrapper.text()).toContain('in'); + expect(wrapper.text()).toContain('Gemfile.lock'); + }); + }); + + describe('When an issue prop has a $line property', () => { + it('render a line number', () => { + createComponent({ issue: { path: 'Gemfile.lock', urlPath: '/Gemfile.lock', line: 22 } }); + + expect(wrapper.find('a').text()).toContain('Gemfile.lock:22'); + }); + }); + + describe('When an issue prop does not have a $line property', () => { + it('does not render a line number', () => { + createComponent({ issue: { urlPath: '/Gemfile.lock' } }); + + expect(wrapper.find('a').text()).not.toContain(':22'); + }); + }); +}); diff --git a/spec/frontend/ci/reports/components/report_section_spec.js b/spec/frontend/ci/reports/components/report_section_spec.js new file mode 100644 index 00000000000..f032b210184 --- /dev/null +++ b/spec/frontend/ci/reports/components/report_section_spec.js @@ -0,0 +1,285 @@ +import { GlButton } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import ReportItem from '~/ci/reports/components/report_item.vue'; +import ReportSection from '~/ci/reports/components/report_section.vue'; + +describe('ReportSection component', () => { + let wrapper; + + const findExpandButton = () => wrapper.findComponent(GlButton); + const findPopover = () => wrapper.findComponent(HelpPopover); + const findReportSection = () => wrapper.find('.js-report-section-container'); + const expectExpandButtonOpen = () => + expect(findExpandButton().props('icon')).toBe('chevron-lg-up'); + const expectExpandButtonClosed = () => + expect(findExpandButton().props('icon')).toBe('chevron-lg-down'); + + const resolvedIssues = [ + { + name: 'Insecure Dependency', + fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5', + path: 'Gemfile.lock', + line: 12, + urlPath: 'foo/Gemfile.lock', + }, + ]; + + const defaultProps = { + component: '', + status: 'SUCCESS', + loadingText: 'Loading Code Quality report', + errorText: 'foo', + successText: 'Code quality improved on 1 point and degraded on 1 point', + resolvedIssues, + hasIssues: false, + alwaysOpen: false, + }; + + const createComponent = ({ props = {}, data = {}, slots = {} } = {}) => { + wrapper = mountExtended(ReportSection, { + propsData: { + ...defaultProps, + ...props, + }, + data() { + return data; + }, + slots, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('isCollapsible', () => { + const testMatrix = [ + { hasIssues: false, alwaysOpen: false, isCollapsible: false }, + { hasIssues: false, alwaysOpen: true, isCollapsible: false }, + { hasIssues: true, alwaysOpen: false, isCollapsible: true }, + { hasIssues: true, alwaysOpen: true, isCollapsible: false }, + ]; + + testMatrix.forEach(({ hasIssues, alwaysOpen, isCollapsible }) => { + const issues = hasIssues ? 'has issues' : 'has no issues'; + const open = alwaysOpen ? 'is always open' : 'is not always open'; + + it(`is ${isCollapsible}, if the report ${issues} and ${open}`, () => { + createComponent({ props: { hasIssues, alwaysOpen } }); + + expect(wrapper.vm.isCollapsible).toBe(isCollapsible); + }); + }); + }); + + describe('isExpanded', () => { + const testMatrix = [ + { isCollapsed: false, alwaysOpen: false, isExpanded: true }, + { isCollapsed: false, alwaysOpen: true, isExpanded: true }, + { isCollapsed: true, alwaysOpen: false, isExpanded: false }, + { isCollapsed: true, alwaysOpen: true, isExpanded: true }, + ]; + + testMatrix.forEach(({ isCollapsed, alwaysOpen, isExpanded }) => { + const issues = isCollapsed ? 'is collapsed' : 'is not collapsed'; + const open = alwaysOpen ? 'is always open' : 'is not always open'; + + it(`is ${isExpanded}, if the report ${issues} and ${open}`, () => { + createComponent({ props: { alwaysOpen }, data: { isCollapsed } }); + + expect(wrapper.vm.isExpanded).toBe(isExpanded); + }); + }); + }); + }); + + describe('when it is loading', () => { + it('should render loading indicator', () => { + createComponent({ + props: { + component: '', + status: 'LOADING', + loadingText: 'Loading Code Quality report', + errorText: 'foo', + successText: 'Code quality improved on 1 point and degraded on 1 point', + hasIssues: false, + }, + }); + + expect(wrapper.text()).toBe('Loading Code Quality report'); + }); + }); + + describe('with success status', () => { + it('should render provided data', () => { + createComponent({ props: { hasIssues: true } }); + + expect(wrapper.find('.js-code-text').text()).toBe( + 'Code quality improved on 1 point and degraded on 1 point', + ); + expect(wrapper.findAllComponents(ReportItem)).toHaveLength(resolvedIssues.length); + }); + + describe('toggleCollapsed', () => { + it('toggles issues', async () => { + createComponent({ props: { hasIssues: true } }); + + await findExpandButton().trigger('click'); + + expect(findReportSection().isVisible()).toBe(true); + expectExpandButtonOpen(); + + await findExpandButton().trigger('click'); + + expect(findReportSection().isVisible()).toBe(false); + expectExpandButtonClosed(); + }); + + it('is always expanded, if always-open is set to true', () => { + createComponent({ props: { hasIssues: true, alwaysOpen: true } }); + + expect(findReportSection().isVisible()).toBe(true); + expect(findExpandButton().exists()).toBe(false); + }); + }); + }); + + describe('snowplow events', () => { + it('does emit an event on issue toggle if the shouldEmitToggleEvent prop does exist', () => { + createComponent({ props: { hasIssues: true, shouldEmitToggleEvent: true } }); + + expect(wrapper.emitted('toggleEvent')).toBeUndefined(); + + findExpandButton().trigger('click'); + + expect(wrapper.emitted('toggleEvent')).toEqual([[]]); + }); + + it('does not emit an event on issue toggle if the shouldEmitToggleEvent prop does not exist', () => { + createComponent({ props: { hasIssues: true } }); + + expect(wrapper.emitted('toggleEvent')).toBeUndefined(); + + findExpandButton().trigger('click'); + + expect(wrapper.emitted('toggleEvent')).toBeUndefined(); + }); + + it('does not emit an event if always-open is set to true', () => { + createComponent({ + props: { alwaysOpen: true, hasIssues: true, shouldEmitToggleEvent: true }, + }); + + expect(wrapper.emitted('toggleEvent')).toBeUndefined(); + }); + }); + + describe('with failed request', () => { + it('should render error indicator', () => { + createComponent({ + props: { + component: '', + status: 'ERROR', + loadingText: 'Loading Code Quality report', + errorText: 'Failed to load Code Quality report', + successText: 'Code quality improved on 1 point and degraded on 1 point', + hasIssues: false, + }, + }); + + expect(wrapper.text()).toBe('Failed to load Code Quality report'); + }); + }); + + describe('with action buttons passed to the slot', () => { + beforeEach(() => { + createComponent({ + props: { + status: 'SUCCESS', + successText: 'success', + hasIssues: true, + }, + slots: { + 'action-buttons': ['Action!'], + }, + }); + }); + + it('should render the passed button', () => { + expect(wrapper.text()).toContain('Action!'); + }); + + it('should still render the expand/collapse button', () => { + expectExpandButtonClosed(); + }); + }); + + describe('Success and Error slots', () => { + const createComponentWithSlots = (status) => { + createComponent({ + props: { + status, + hasIssues: true, + }, + slots: { + success: ['This is a success'], + loading: ['This is loading'], + error: ['This is an error'], + }, + }); + }; + + it('only renders success slot when status is "SUCCESS"', () => { + createComponentWithSlots('SUCCESS'); + + expect(wrapper.text()).toContain('This is a success'); + expect(wrapper.text()).not.toContain('This is an error'); + expect(wrapper.text()).not.toContain('This is loading'); + }); + + it('only renders error slot when status is "ERROR"', () => { + createComponentWithSlots('ERROR'); + + expect(wrapper.text()).toContain('This is an error'); + expect(wrapper.text()).not.toContain('This is a success'); + expect(wrapper.text()).not.toContain('This is loading'); + }); + + it('only renders loading slot when status is "LOADING"', () => { + createComponentWithSlots('LOADING'); + + expect(wrapper.text()).toContain('This is loading'); + expect(wrapper.text()).not.toContain('This is an error'); + expect(wrapper.text()).not.toContain('This is a success'); + }); + }); + + describe('help popover', () => { + describe('when popover options are defined', () => { + const options = { + title: 'foo', + content: 'bar', + }; + + beforeEach(() => { + createComponent({ props: { popoverOptions: options } }); + }); + + it('popover is shown with options', () => { + expect(findPopover().props('options')).toEqual(options); + }); + }); + + describe('when popover options are not defined', () => { + beforeEach(() => { + createComponent({ props: { popoverOptions: {} } }); + }); + + it('popover is not shown', () => { + expect(findPopover().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/ci/reports/components/summary_row_spec.js b/spec/frontend/ci/reports/components/summary_row_spec.js new file mode 100644 index 00000000000..fb2ae5371d5 --- /dev/null +++ b/spec/frontend/ci/reports/components/summary_row_spec.js @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import SummaryRow from '~/ci/reports/components/summary_row.vue'; + +describe('Summary row', () => { + let wrapper; + + const summary = 'SAST detected 1 new vulnerability and 1 fixed vulnerability'; + const popoverOptions = { + title: 'Static Application Security Testing (SAST)', + content: '<a>Learn more about SAST</a>', + }; + const statusIcon = 'warning'; + + const createComponent = ({ props = {}, slots = {} } = {}) => { + wrapper = extendedWrapper( + mount(SummaryRow, { + propsData: { + summary, + popoverOptions, + statusIcon, + ...props, + }, + slots, + }), + ); + }; + + const findSummary = () => wrapper.findByTestId('summary-row-description'); + const findStatusIcon = () => wrapper.findByTestId('summary-row-icon'); + const findHelpPopover = () => wrapper.findComponent(HelpPopover); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders provided summary', () => { + createComponent(); + expect(findSummary().text()).toContain(summary); + }); + + it('renders provided icon', () => { + createComponent(); + expect(findStatusIcon().classes()).toContain('js-ci-status-icon-warning'); + }); + + it('renders help popover if popoverOptions are provided', () => { + createComponent(); + expect(findHelpPopover().props('options')).toEqual(popoverOptions); + }); + + it('does not render help popover if popoverOptions are not provided', () => { + createComponent({ props: { popoverOptions: null } }); + expect(findHelpPopover().exists()).toBe(false); + }); + + describe('summary slot', () => { + it('replaces the summary prop', () => { + const summarySlotContent = 'Summary slot content'; + createComponent({ slots: { summary: summarySlotContent } }); + + expect(wrapper.text()).not.toContain(summary); + expect(findSummary().text()).toContain(summarySlotContent); + }); + }); +}); diff --git a/spec/frontend/ci/reports/mock_data/mock_data.js b/spec/frontend/ci/reports/mock_data/mock_data.js new file mode 100644 index 00000000000..2599b0ac365 --- /dev/null +++ b/spec/frontend/ci/reports/mock_data/mock_data.js @@ -0,0 +1,38 @@ +export const failedIssue = { + result: 'failure', + name: 'Test#sum when a is 1 and b is 2 returns summary', + execution_time: 0.009411, + status: 'failed', + system_output: + "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'", + recent_failures: { + count: 3, + base_branch: 'main', + }, +}; + +export const successIssue = { + result: 'success', + name: 'Test#sum when a is 1 and b is 2 returns summary', + execution_time: 0.009411, + status: 'success', + system_output: null, + recent_failures: null, +}; + +export const failedReport = { + summary: { total: 11, resolved: 0, errored: 2, failed: 0 }, + suites: [ + { + name: 'rspec:pg', + status: 'error', + summary: { total: 0, resolved: 0, errored: 0, failed: 0 }, + new_failures: [], + resolved_failures: [], + existing_failures: [], + new_errors: [], + resolved_errors: [], + existing_errors: [], + }, + ], +}; diff --git a/spec/frontend/ci/reports/mock_data/new_and_fixed_failures_report.json b/spec/frontend/ci/reports/mock_data/new_and_fixed_failures_report.json new file mode 100644 index 00000000000..9018ad5e4cf --- /dev/null +++ b/spec/frontend/ci/reports/mock_data/new_and_fixed_failures_report.json @@ -0,0 +1,70 @@ +{ + "status": "failed", + "summary": { + "total": 11, + "resolved": 2, + "errored": 0, + "failed": 2 + }, + "suites": [ + { + "name": "rspec:pg", + "status": "failed", + "summary": { + "total": 8, + "resolved": 2, + "errored": 0, + "failed": 1 + }, + "new_failures": [ + { + "status": "failed", + "name": "Test#subtract when a is 2 and b is 1 returns correct result", + "execution_time": 0.00908, + "system_output": "Failure/Error: is_expected.to eq(1)\n\n expected: 1\n got: 3\n\n (compared using ==)\n./spec/test_spec.rb:43:in `block (4 levels) in <top (required)>'" + } + ], + "resolved_failures": [ + { + "status": "success", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.000318, + "system_output": null + }, + { + "status": "success", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000074, + "system_output": null + } + ], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "status": "failed", + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 1 + }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [ + { + "status": "failed", + "name": "sumTest", + "execution_time": 0.004, + "system_output": "junit.framework.AssertionFailedError: expected:<3> but was:<-1>\n\tat CalculatorTest.sumTest(Unknown Source)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)\n\tat java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\n" + } + ], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +}
\ No newline at end of file diff --git a/spec/frontend/ci/reports/mock_data/new_errors_report.json b/spec/frontend/ci/reports/mock_data/new_errors_report.json new file mode 100644 index 00000000000..d3fb570c327 --- /dev/null +++ b/spec/frontend/ci/reports/mock_data/new_errors_report.json @@ -0,0 +1,53 @@ +{ + "summary": { + "total": 11, + "resolved": 0, + "errored": 2, + "failed": 0 + }, + "suites": [ + { + "name": "karma", + "summary": { + "total": 3, + "resolved": 0, + "errored": 2, + "failed": 0 + }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [ + { + "result": "error", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.009411, + "system_output": "Failed: Error in render: 'TypeError: Cannot read property 'status' of undefined'" + }, + { + "result": "error", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000162, + "system_output": "Failed: Error in render: 'TypeError: Cannot read property 'length' of undefined'" + } + ], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "rspec:pg", + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 0 + }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +}
\ No newline at end of file diff --git a/spec/frontend/ci/reports/mock_data/new_failures_report.json b/spec/frontend/ci/reports/mock_data/new_failures_report.json new file mode 100644 index 00000000000..03a875b7636 --- /dev/null +++ b/spec/frontend/ci/reports/mock_data/new_failures_report.json @@ -0,0 +1,55 @@ +{ + "summary": { + "total": 11, + "resolved": 0, + "errored": 0, + "failed": 2 + }, + "suites": [ + { + "name": "rspec:pg", + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 2 + }, + "new_failures": [ + { + "result": "failure", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "file": "spec/file_1.rb", + "execution_time": 0.009411, + "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'" + }, + { + "result": "failure", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "file": "spec/file_2.rb", + "execution_time": 0.000162, + "system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'" + } + ], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 0 + }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +}
\ No newline at end of file diff --git a/spec/frontend/ci/reports/mock_data/new_failures_with_null_files_report.json b/spec/frontend/ci/reports/mock_data/new_failures_with_null_files_report.json new file mode 100644 index 00000000000..00a35a3d0a7 --- /dev/null +++ b/spec/frontend/ci/reports/mock_data/new_failures_with_null_files_report.json @@ -0,0 +1,55 @@ +{ + "summary": { + "total": 11, + "resolved": 0, + "errored": 0, + "failed": 2 + }, + "suites": [ + { + "name": "rspec:pg", + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 2 + }, + "new_failures": [ + { + "result": "failure", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "file": null, + "execution_time": 0.009411, + "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'" + }, + { + "result": "failure", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "file": null, + "execution_time": 0.000162, + "system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'" + } + ], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 0 + }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +}
\ No newline at end of file diff --git a/spec/frontend/ci/reports/mock_data/no_failures_report.json b/spec/frontend/ci/reports/mock_data/no_failures_report.json new file mode 100644 index 00000000000..a48a206208d --- /dev/null +++ b/spec/frontend/ci/reports/mock_data/no_failures_report.json @@ -0,0 +1,43 @@ +{ + "status": "success", + "summary": { + "total": 11, + "resolved": 0, + "errored": 0, + "failed": 0 + }, + "suites": [ + { + "name": "rspec:pg", + "status": "success", + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 0 + }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "status": "success", + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 0 + }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +}
\ No newline at end of file diff --git a/spec/frontend/ci/reports/mock_data/recent_failures_report.json b/spec/frontend/ci/reports/mock_data/recent_failures_report.json new file mode 100644 index 00000000000..f4fc2d2e927 --- /dev/null +++ b/spec/frontend/ci/reports/mock_data/recent_failures_report.json @@ -0,0 +1,70 @@ +{ + "summary": { + "total": 11, + "resolved": 0, + "errored": 0, + "failed": 3, + "recentlyFailed": 2 + }, + "suites": [ + { + "name": "rspec:pg", + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 2, + "recentlyFailed": 1 + }, + "new_failures": [ + { + "result": "failure", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.009411, + "system_output": "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'", + "recent_failures": { + "count": 8, + "base_branch": "main" + } + }, + { + "result": "failure", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000162, + "system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'" + } + ], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + }, + { + "name": "java ant", + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 1, + "recentlyFailed": 1 + }, + "new_failures": [ + { + "result": "failure", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000562, + "recent_failures": { + "count": 3, + "base_branch": "main" + } + } + ], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +}
\ No newline at end of file diff --git a/spec/frontend/ci/reports/mock_data/resolved_failures.json b/spec/frontend/ci/reports/mock_data/resolved_failures.json new file mode 100644 index 00000000000..15012fb027d --- /dev/null +++ b/spec/frontend/ci/reports/mock_data/resolved_failures.json @@ -0,0 +1,73 @@ +{ + "status": "success", + "summary": { + "total": 11, + "resolved": 4, + "errored": 0, + "failed": 0 + }, + "suites": [ + { + "name": "rspec:pg", + "status": "success", + "summary": { + "total": 8, + "resolved": 4, + "errored": 0, + "failed": 0 + }, + "new_failures": [], + "resolved_failures": [ + { + "status": "success", + "name": "Test#sum when a is 1 and b is 2 returns summary", + "execution_time": 0.000411, + "system_output": null, + "stack_trace": null + }, + { + "status": "success", + "name": "Test#sum when a is 100 and b is 200 returns summary", + "execution_time": 0.000076, + "system_output": null, + "stack_trace": null + } + ], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [ + { + "status": "success", + "name": "Test#sum when a is 4 and b is 4 returns summary", + "execution_time": 0.00342, + "system_output": null, + "stack_trace": null + }, + { + "status": "success", + "name": "Test#sum when a is 40 and b is 400 returns summary", + "execution_time": 0.0000231, + "system_output": null, + "stack_trace": null + } + ], + "existing_errors": [] + }, + { + "name": "java ant", + "status": "success", + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 0 + }, + "new_failures": [], + "resolved_failures": [], + "existing_failures": [], + "new_errors": [], + "resolved_errors": [], + "existing_errors": [] + } + ] +}
\ No newline at end of file diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js index 7081bc57467..e233268b756 100644 --- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import { GlTab, GlTabs } from '@gitlab/ui'; +import VueRouter from 'vue-router'; import VueApollo from 'vue-apollo'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -33,12 +35,15 @@ const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; const mockRunnersPath = '/admin/runners'; Vue.use(VueApollo); +Vue.use(VueRouter); describe('AdminRunnerShowApp', () => { let wrapper; let mockRunnerQuery; const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findTabs = () => wrapper.findComponent(GlTabs); + const findTabAt = (i) => wrapper.findAllComponents(GlTab).at(i); const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); @@ -113,6 +118,16 @@ describe('AdminRunnerShowApp', () => { expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); }); + it.each(['#/', '#/unknown-tab'])('shows details when location hash is `%s`', async (hash) => { + setWindowLocation(hash); + + await createComponent({ mountFn: mountExtended }); + + expect(findTabs().props('value')).toBe(0); + expect(findRunnerDetails().exists()).toBe(true); + expect(findRunnersJobs().exists()).toBe(false); + }); + describe('when runner cannot be updated', () => { beforeEach(async () => { mockRunnerQueryResult({ @@ -226,7 +241,7 @@ describe('AdminRunnerShowApp', () => { }); }); - describe('Jobs tab', () => { + describe('When showing jobs', () => { const stubs = { GlTab, GlTabs, @@ -245,6 +260,17 @@ describe('AdminRunnerShowApp', () => { expect(findRunnersJobs().exists()).toBe(false); }); + it('when URL hash links to jobs tab', async () => { + mockRunnerQueryResult(); + setWindowLocation('#/jobs'); + + await createComponent({ mountFn: mountExtended }); + + expect(findTabs().props('value')).toBe(1); + expect(findRunnerDetails().exists()).toBe(false); + expect(findRunnersJobs().exists()).toBe(true); + }); + it('without a job count, shows no jobs count', async () => { mockRunnerQueryResult({ jobCount: null }); @@ -260,7 +286,28 @@ describe('AdminRunnerShowApp', () => { await createComponent({ stubs }); expect(findJobCountBadge().text()).toBe('3'); - expect(findRunnersJobs().props('runner')).toEqual({ ...mockRunner, ...runner }); + }); + }); + + describe('When navigating to another tab', () => { + let routerPush; + + beforeEach(async () => { + mockRunnerQueryResult(); + + await createComponent({ mountFn: mountExtended }); + + routerPush = jest.spyOn(wrapper.vm.$router, 'push').mockImplementation(() => {}); + }); + + it('navigates to details', () => { + findTabAt(0).vm.$emit('click'); + expect(routerPush).toHaveBeenLastCalledWith({ name: 'details' }); + }); + + it('navigates to job', () => { + findTabAt(1).vm.$emit('click'); + expect(routerPush).toHaveBeenLastCalledWith({ name: 'jobs' }); }); }); }); diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js index 9778a6fe66c..9084ecdb4cc 100644 --- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -25,6 +25,7 @@ import RunnerStats from '~/ci/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/ci/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; +import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue'; import { ADMIN_FILTERED_SEARCH_NAMESPACE, @@ -77,7 +78,9 @@ jest.mock('~/lib/utils/url_utility', () => ({ Vue.use(VueApollo); Vue.use(GlToast); -const COUNT_QUERIES = 7; // 4 tabs + 3 status queries +const STATUS_COUNT_QUERIES = 3; +const TAB_COUNT_QUERIES = 4; +const COUNT_QUERIES = TAB_COUNT_QUERIES + STATUS_COUNT_QUERIES; describe('AdminRunnersApp', () => { let wrapper; @@ -170,6 +173,29 @@ describe('AdminRunnersApp', () => { }); }); + describe('does not show total runner counts when total is 0', () => { + beforeEach(async () => { + mockRunnersCountHandler.mockResolvedValue({ + data: { + runners: { + count: 0, + ...runnersCountData.runners, + }, + }, + }); + + await createComponent({ mountFn: mountExtended }); + }); + + it('fetches only tab counts', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(TAB_COUNT_QUERIES); + }); + + it('does not shows counters', () => { + expect(findRunnerStats().text()).toBe(''); + }); + }); + it('shows the runners list', async () => { await createComponent(); @@ -252,6 +278,15 @@ describe('AdminRunnersApp', () => { expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); }); + it('Shows job status and links to jobs', () => { + const badge = wrapper + .find('tr [data-testid="td-summary"]') + .findComponent(RunnerJobStatusBadge); + + expect(badge.props('jobStatus')).toBe(mockRunners[0].jobExecutionStatus); + expect(badge.attributes('href')).toBe(`http://localhost/admin/runners/${id}#/jobs`); + }); + it('When runner is paused or unpaused, some data is refetched', async () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); diff --git a/spec/frontend/ci/runner/components/cells/runner_stacked_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js index 4aa354f9b62..10280c77303 100644 --- a/spec/frontend/ci/runner/components/cells/runner_stacked_summary_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js @@ -1,12 +1,18 @@ import { __ } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import RunnerStackedSummaryCell from '~/ci/runner/components/cells/runner_stacked_summary_cell.vue'; +import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; +import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue'; import RunnerSummaryField from '~/ci/runner/components/cells/runner_summary_field.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { INSTANCE_TYPE, I18N_INSTANCE_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; +import { + INSTANCE_TYPE, + I18N_INSTANCE_TYPE, + PROJECT_TYPE, + I18N_NO_DESCRIPTION, +} from '~/ci/runner/constants'; import { allRunnersData } from '../../mock_data'; @@ -16,13 +22,14 @@ describe('RunnerTypeCell', () => { let wrapper; const findLockIcon = () => wrapper.findByTestId('lock-icon'); + const findRunnerJobStatusBadge = () => wrapper.findComponent(RunnerJobStatusBadge); const findRunnerTags = () => wrapper.findComponent(RunnerTags); const findRunnerSummaryField = (icon) => wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon) .wrappers[0]; const createComponent = (runner, options) => { - wrapper = mountExtended(RunnerStackedSummaryCell, { + wrapper = mountExtended(RunnerSummaryCell, { propsData: { runner: { ...mockRunner, @@ -80,6 +87,18 @@ describe('RunnerTypeCell', () => { expect(wrapper.text()).toContain(mockRunner.description); }); + it('Displays the no runner description', () => { + createComponent({ + description: null, + }); + + expect(wrapper.text()).toContain(I18N_NO_DESCRIPTION); + }); + + it('Displays job execution status', () => { + expect(findRunnerJobStatusBadge().props('jobStatus')).toBe(mockRunner.jobExecutionStatus); + }); + it('Displays last contact', () => { createComponent({ contactedAt: '2022-01-02', @@ -147,14 +166,14 @@ describe('RunnerTypeCell', () => { expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']); }); - it('Displays a custom slot', () => { + it.each(['runner-name', 'runner-job-status-badge'])('Displays a custom "%s" slot', (slotName) => { const slotContent = 'My custom runner name'; createComponent( {}, { slots: { - 'runner-name': slotContent, + [slotName]: slotContent, }, }, ); diff --git a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js index 496c144083e..408750e646f 100644 --- a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js @@ -13,6 +13,7 @@ import { DEFAULT_SORT, CONTACTED_DESC, } from '~/ci/runner/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -34,7 +35,7 @@ describe('RunnerList', () => { const mockOtherSort = CONTACTED_DESC; const mockFilters = [ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, - { type: 'filtered-search-term', value: { data: '' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, ]; const expectToHaveLastEmittedInput = (value) => { diff --git a/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js new file mode 100644 index 00000000000..015bebf40e3 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js @@ -0,0 +1,51 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue'; +import { + I18N_JOB_STATUS_RUNNING, + I18N_JOB_STATUS_IDLE, + JOB_STATUS_RUNNING, + JOB_STATUS_IDLE, +} from '~/ci/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + + const createComponent = ({ props, ...options } = {}) => { + wrapper = shallowMount(RunnerJobStatusBadge, { + propsData: { + ...props, + }, + ...options, + }); + }; + + it.each` + jobStatus | classes | text + ${JOB_STATUS_RUNNING} | ${['gl-mr-3', 'gl-bg-transparent!', 'gl-text-blue-600!', 'gl-border', 'gl-border-blue-600!']} | ${I18N_JOB_STATUS_RUNNING} + ${JOB_STATUS_IDLE} | ${['gl-mr-3', 'gl-bg-transparent!', 'gl-text-gray-700!', 'gl-border', 'gl-border-gray-500!']} | ${I18N_JOB_STATUS_IDLE} + `( + 'renders $jobStatus job status with "$text" text and styles', + ({ jobStatus, classes, text }) => { + createComponent({ props: { jobStatus } }); + + expect(findBadge().props()).toMatchObject({ size: 'sm', variant: 'muted' }); + expect(findBadge().classes().sort()).toEqual(classes.sort()); + expect(findBadge().text()).toBe(text); + }, + ); + + it('does not render an unknown status', () => { + createComponent({ props: { jobStatus: 'UNKNOWN_STATUS' } }); + + expect(wrapper.html()).toBe(''); + }); + + it('adds arbitrary attributes', () => { + createComponent({ props: { jobStatus: JOB_STATUS_RUNNING }, attrs: { href: '/url' } }); + + expect(findBadge().attributes('href')).toBe('/url'); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js index d53a0ce8f4f..1267d045623 100644 --- a/spec/frontend/ci/runner/components/runner_list_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -188,6 +188,21 @@ describe('RunnerList', () => { expect(findCell({ fieldKey: 'summary' }).text()).toContain(`Summary: ${mockRunners[0].id}`); }); + it('Render #runner-job-status-badge slot in "summary" cell', () => { + createComponent( + { + scopedSlots: { + 'runner-job-status-badge': ({ runner }) => `Job status ${runner.jobExecutionStatus}`, + }, + }, + mountExtended, + ); + + expect(findCell({ fieldKey: 'summary' }).text()).toContain( + `Job status ${mockRunners[0].jobExecutionStatus}`, + ); + }); + it('Render #runner-actions-cell slot in "actions" cell', () => { createComponent( { diff --git a/spec/frontend/ci/runner/components/runner_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_status_badge_spec.js index 7d3064c2aef..45b410df2d4 100644 --- a/spec/frontend/ci/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_status_badge_spec.js @@ -37,12 +37,12 @@ describe('RunnerTypeBadge', () => { }; beforeEach(() => { - jest.useFakeTimers('modern'); + jest.useFakeTimers({ legacyFakeTimers: false }); jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); }); afterEach(() => { - jest.useFakeTimers('legacy'); + jest.useFakeTimers({ legacyFakeTimers: true }); wrapper.destroy(); }); diff --git a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js index d3c7ea50f9d..3dce5a509ca 100644 --- a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js @@ -7,7 +7,7 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import TagToken, { TAG_SUGGESTIONS_PATH } from '~/ci/runner/components/search_tokens/tag_token.vue'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { getRecentlyUsedSuggestions } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; jest.mock('~/flash'); @@ -42,7 +42,7 @@ const mockTagTokenConfig = { type: 'tag', token: TagToken, recentSuggestionsStorageKey: mockStorageKey, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; describe('TagToken', () => { diff --git a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js index daebf3df050..3d45674d106 100644 --- a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js +++ b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js @@ -16,6 +16,23 @@ describe('RunnerStats', () => { const findSingleStats = () => wrapper.findAllComponents(RunnerSingleStat); + const RunnerCountStub = { + props: ['variables'], + render() { + // return a count for each status + const mockCounts = { + undefined: 6, // no status returns "all" + [STATUS_ONLINE]: 3, + [STATUS_OFFLINE]: 2, + [STATUS_STALE]: 1, + }; + + return this.$scopedSlots.default({ + count: mockCounts[this.variables.status], + }); + }, + }; + const createComponent = ({ props = {}, mountFn = shallowMount, ...options } = {}) => { wrapper = mountFn(RunnerStats, { propsData: { @@ -23,6 +40,9 @@ describe('RunnerStats', () => { variables: {}, ...props, }, + stubs: { + RunnerCount: RunnerCountStub, + }, ...options, }); }; @@ -32,24 +52,8 @@ describe('RunnerStats', () => { }); it('Displays all the stats', () => { - const mockCounts = { - [STATUS_ONLINE]: 3, - [STATUS_OFFLINE]: 2, - [STATUS_STALE]: 1, - }; - createComponent({ mountFn: mount, - stubs: { - RunnerCount: { - props: ['variables'], - render() { - return this.$scopedSlots.default({ - count: mockCounts[this.variables.status], - }); - }, - }, - }, }); const text = wrapper.text(); @@ -78,4 +82,21 @@ describe('RunnerStats', () => { expect(stat.props('variables')).toMatchObject(mockVariables); }); }); + + it('Does not display counts when total is 0', () => { + createComponent({ + mountFn: mount, + stubs: { + RunnerCount: { + render() { + return this.$scopedSlots.default({ + count: 0, + }); + }, + }, + }, + }); + + expect(wrapper.html()).toBe(''); + }); }); diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js index c3493b3c9fd..1e5bb828dbf 100644 --- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -448,13 +448,15 @@ describe('GroupRunnersApp', () => { it('navigates to the next page', async () => { await findRunnerPaginationNext().trigger('click'); - expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ - groupFullPath: mockGroupFullPath, - membership: MEMBERSHIP_DESCENDANTS, - sort: CREATED_DESC, - first: RUNNER_PAGE_SIZE, - after: pageInfo.endCursor, - }); + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + groupFullPath: mockGroupFullPath, + membership: MEMBERSHIP_DESCENDANTS, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: pageInfo.endCursor, + }), + ); }); }); diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js index eff5abc21b5..525756ed513 100644 --- a/spec/frontend/ci/runner/mock_data.js +++ b/spec/frontend/ci/runner/mock_data.js @@ -18,6 +18,7 @@ import groupRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/grou import groupRunnersCountData from 'test_fixtures/graphql/ci/runner/list/group_runners_count.query.graphql.json'; import { DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE } from '~/ci/runner/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; const emptyPageInfo = { __typename: 'PageInfo', @@ -73,7 +74,7 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'something' }, }, ], @@ -95,11 +96,11 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'something' }, }, { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'else' }, }, ], diff --git a/spec/frontend/ci/runner/runner_search_utils_spec.js b/spec/frontend/ci/runner/runner_search_utils_spec.js index 1db8fa1829b..f64b89d47fd 100644 --- a/spec/frontend/ci/runner/runner_search_utils_spec.js +++ b/spec/frontend/ci/runner/runner_search_utils_spec.js @@ -6,6 +6,7 @@ import { fromSearchToVariables, isSearchFiltered, } from 'ee_else_ce/ci/runner/runner_search_utils'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { mockSearchExamples } from './mock_data'; describe('search_params.js', () => { @@ -48,8 +49,8 @@ describe('search_params.js', () => { it('When search params appear as array, they are concatenated', () => { expect(fromUrlQueryToSearch('?search[]=my&search[]=text').filters).toEqual([ - { type: 'filtered-search-term', value: { data: 'my' } }, - { type: 'filtered-search-term', value: { data: 'text' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'my' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'text' } }, ]); }); }); @@ -64,12 +65,13 @@ describe('search_params.js', () => { it.each([ 'http://test.host/?status[]=ACTIVE', 'http://test.host/?runner_type[]=INSTANCE_TYPE', + 'http://test.host/?paused[]=true', 'http://test.host/?search=my_text', - ])('When a filter is removed, it is removed from the URL', (initalUrl) => { + ])('When a filter is removed, it is removed from the URL', (initialUrl) => { const search = { filters: [], sort: 'CREATED_DESC' }; const expectedUrl = `http://test.host/`; - expect(fromSearchToUrl(search, initalUrl)).toBe(expectedUrl); + expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); }); it('When unrelated search parameter is present, it does not get removed', () => { @@ -93,7 +95,7 @@ describe('search_params.js', () => { fromSearchToVariables({ filters: [ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: '' }, }, ], @@ -106,11 +108,11 @@ describe('search_params.js', () => { fromSearchToVariables({ filters: [ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'something' }, }, { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: '' }, }, ], |