diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 08:43:02 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 08:43:02 +0000 |
commit | d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb (patch) | |
tree | 2341ef426af70ad1e289c38036737e04b0aa5007 /spec/frontend | |
parent | d6e514dd13db8947884cd58fe2a9c2a063400a9b (diff) | |
download | gitlab-ce-d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb.tar.gz |
Add latest changes from gitlab-org/gitlab@14-4-stable-eev14.4.0-rc42
Diffstat (limited to 'spec/frontend')
393 files changed, 9417 insertions, 5159 deletions
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml index 145e6c8961a..e12c4e5e820 100644 --- a/spec/frontend/.eslintrc.yml +++ b/spec/frontend/.eslintrc.yml @@ -12,7 +12,6 @@ settings: jest: jestConfigFile: 'jest.config.js' globals: - getJSONFixture: false loadFixtures: false setFixtures: false rules: @@ -26,4 +25,9 @@ rules: - off "@gitlab/no-global-event-off": - off - + import/no-unresolved: + - error + # The test fixtures and graphql schema are dynamically generated in CI + # during the `frontend-fixtures` and `graphql-schema-dump` jobs. + # They may not be present during linting. + - ignore: ['^test_fixtures\/', 'tmp/tests/graphql/gitlab_schema.graphql'] diff --git a/spec/frontend/__helpers__/fixtures.js b/spec/frontend/__helpers__/fixtures.js index 4b86724df93..d8054d32fae 100644 --- a/spec/frontend/__helpers__/fixtures.js +++ b/spec/frontend/__helpers__/fixtures.js @@ -20,6 +20,11 @@ Did you run bin/rake frontend:fixtures?`, return fs.readFileSync(absolutePath, 'utf8'); } +/** + * @deprecated Use `import` to load a JSON fixture instead. + * See https://docs.gitlab.com/ee/development/testing_guide/frontend_testing.html#use-fixtures, + * https://gitlab.com/gitlab-org/gitlab/-/issues/339346. + */ export const getJSONFixture = (relativePath) => JSON.parse(getFixture(relativePath)); export const resetHTMLFixture = () => { diff --git a/spec/frontend/__helpers__/flush_promises.js b/spec/frontend/__helpers__/flush_promises.js new file mode 100644 index 00000000000..5287a060753 --- /dev/null +++ b/spec/frontend/__helpers__/flush_promises.js @@ -0,0 +1,3 @@ +export default function flushPromises() { + return new Promise(setImmediate); +} diff --git a/spec/frontend/access_tokens/components/projects_token_selector_spec.js b/spec/frontend/access_tokens/components/projects_token_selector_spec.js index 09f52fe9a5f..40aaf16d41f 100644 --- a/spec/frontend/access_tokens/components/projects_token_selector_spec.js +++ b/spec/frontend/access_tokens/components/projects_token_selector_spec.js @@ -11,7 +11,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { getJSONFixture } from 'helpers/fixtures'; +import getProjectsQueryResponse from 'test_fixtures/graphql/projects/access_tokens/get_projects.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -20,9 +20,6 @@ import getProjectsQuery from '~/access_tokens/graphql/queries/get_projects.query import { getIdFromGraphQLId } from '~/graphql_shared/utils'; describe('ProjectsTokenSelector', () => { - const getProjectsQueryResponse = getJSONFixture( - 'graphql/projects/access_tokens/get_projects.query.graphql.json', - ); const getProjectsQueryResponsePage2 = produce( getProjectsQueryResponse, (getProjectsQueryResponseDraft) => { diff --git a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js index 2832de98769..e7a20ae114c 100644 --- a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js +++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js @@ -1,12 +1,12 @@ import { GlModal, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import AddReviewItemsModal from '~/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue'; import * as actions from '~/add_context_commits_modal/store/actions'; import mutations from '~/add_context_commits_modal/store/mutations'; import defaultState from '~/add_context_commits_modal/store/state'; -import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -18,7 +18,7 @@ describe('AddContextCommitsModal', () => { const removeContextCommits = jest.fn(); const resetModalState = jest.fn(); const searchCommits = jest.fn(); - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; const createWrapper = (props = {}) => { store = new Vuex.Store({ diff --git a/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js index 75f1cc41e23..85ecb4313c2 100644 --- a/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js +++ b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js @@ -1,12 +1,12 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; import CommitItem from '~/diffs/components/commit_item.vue'; -import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; describe('ReviewTabContainer', () => { let wrapper; - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; const createWrapper = (props = {}) => { wrapper = shallowMount(ReviewTabContainer, { diff --git a/spec/frontend/add_context_commits_modal/store/mutations_spec.js b/spec/frontend/add_context_commits_modal/store/mutations_spec.js index 2331a4af1bc..7517c1c391e 100644 --- a/spec/frontend/add_context_commits_modal/store/mutations_spec.js +++ b/spec/frontend/add_context_commits_modal/store/mutations_spec.js @@ -1,10 +1,10 @@ +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import { TEST_HOST } from 'helpers/test_constants'; import * as types from '~/add_context_commits_modal/store/mutation_types'; import mutations from '~/add_context_commits_modal/store/mutations'; -import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; describe('AddContextCommitsModalStoreMutations', () => { - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; describe('SET_BASE_CONFIG', () => { it('should set contextCommitsPath, mergeRequestIid and projectId', () => { const state = {}; diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js index 4bb22feb913..5b4f954b672 100644 --- a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js +++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js @@ -35,9 +35,6 @@ describe('Signup Form', () => { const findDenyListRawInputGroup = () => wrapper.findByTestId('domain-denylist-raw-input-group'); const findDenyListFileInputGroup = () => wrapper.findByTestId('domain-denylist-file-input-group'); - - const findRequireAdminApprovalCheckbox = () => - wrapper.findByTestId('require-admin-approval-checkbox'); const findUserCapInput = () => wrapper.findByTestId('user-cap-input'); const findModal = () => wrapper.find(GlModal); @@ -191,125 +188,6 @@ describe('Signup Form', () => { }); describe('form submit button confirmation modal for side-effect of adding possibly unwanted new users', () => { - it.each` - requireAdminApprovalAction | userCapAction | pendingUserCount | buttonEffect - ${'unchanged from true'} | ${'unchanged'} | ${0} | ${'submits form'} - ${'unchanged from false'} | ${'unchanged'} | ${0} | ${'submits form'} - ${'toggled off'} | ${'unchanged'} | ${1} | ${'shows confirmation modal'} - ${'toggled off'} | ${'unchanged'} | ${0} | ${'submits form'} - ${'toggled on'} | ${'unchanged'} | ${0} | ${'submits form'} - ${'unchanged from false'} | ${'increased'} | ${1} | ${'shows confirmation modal'} - ${'unchanged from true'} | ${'increased'} | ${0} | ${'submits form'} - ${'toggled off'} | ${'increased'} | ${1} | ${'shows confirmation modal'} - ${'toggled off'} | ${'increased'} | ${0} | ${'submits form'} - ${'toggled on'} | ${'increased'} | ${1} | ${'shows confirmation modal'} - ${'toggled on'} | ${'increased'} | ${0} | ${'submits form'} - ${'toggled on'} | ${'decreased'} | ${0} | ${'submits form'} - ${'toggled on'} | ${'decreased'} | ${1} | ${'submits form'} - ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${1} | ${'shows confirmation modal'} - ${'unchanged from false'} | ${'changed from limited to unlimited'} | ${0} | ${'submits form'} - ${'unchanged from false'} | ${'changed from unlimited to limited'} | ${0} | ${'submits form'} - ${'unchanged from false'} | ${'unchanged from unlimited'} | ${0} | ${'submits form'} - `( - '$buttonEffect if require admin approval for new sign-ups is $requireAdminApprovalAction and the user cap is $userCapAction and pending user count is $pendingUserCount', - async ({ requireAdminApprovalAction, userCapAction, pendingUserCount, buttonEffect }) => { - let isModalDisplayed; - - switch (buttonEffect) { - case 'shows confirmation modal': - isModalDisplayed = true; - break; - case 'submits form': - isModalDisplayed = false; - break; - default: - isModalDisplayed = false; - break; - } - - const isFormSubmittedWhenClickingFormSubmitButton = !isModalDisplayed; - - const injectedProps = { - pendingUserCount, - }; - - const USER_CAP_DEFAULT = 5; - - switch (userCapAction) { - case 'changed from unlimited to limited': - injectedProps.newUserSignupsCap = ''; - break; - case 'unchanged from unlimited': - injectedProps.newUserSignupsCap = ''; - break; - default: - injectedProps.newUserSignupsCap = USER_CAP_DEFAULT; - break; - } - - switch (requireAdminApprovalAction) { - case 'unchanged from true': - injectedProps.requireAdminApprovalAfterUserSignup = true; - break; - case 'unchanged from false': - injectedProps.requireAdminApprovalAfterUserSignup = false; - break; - case 'toggled off': - injectedProps.requireAdminApprovalAfterUserSignup = true; - break; - case 'toggled on': - injectedProps.requireAdminApprovalAfterUserSignup = false; - break; - default: - injectedProps.requireAdminApprovalAfterUserSignup = false; - break; - } - - formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(); - - await mountComponent({ - injectedProps, - stubs: { GlButton, GlModal: stubComponent(GlModal) }, - }); - - findModal().vm.show = jest.fn(); - - if ( - requireAdminApprovalAction === 'toggled off' || - requireAdminApprovalAction === 'toggled on' - ) { - await findRequireAdminApprovalCheckbox().vm.$emit('input', false); - } - - switch (userCapAction) { - case 'increased': - await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT + 1); - break; - case 'decreased': - await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT - 1); - break; - case 'changed from limited to unlimited': - await findUserCapInput().vm.$emit('input', ''); - break; - case 'changed from unlimited to limited': - await findUserCapInput().vm.$emit('input', USER_CAP_DEFAULT); - break; - default: - break; - } - - await findFormSubmitButton().trigger('click'); - - if (isFormSubmittedWhenClickingFormSubmitButton) { - expect(formSubmitSpy).toHaveBeenCalled(); - expect(findModal().vm.show).not.toHaveBeenCalled(); - } else { - expect(formSubmitSpy).not.toHaveBeenCalled(); - expect(findModal().vm.show).toHaveBeenCalled(); - } - }, - ); - describe('modal actions', () => { beforeEach(async () => { const INITIAL_USER_CAP = 5; diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index fd05b08a3fb..67dcf5c6149 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -5,6 +5,7 @@ import { nextTick } from 'vue'; import Actions from '~/admin/users/components/actions'; import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; import { paths } from '../../mock_data'; @@ -46,7 +47,10 @@ describe('Action components', () => { }); describe('DELETE_ACTION_COMPONENTS', () => { - const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }]; + const userDeletionObstacles = [ + { name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules }, + { name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies }, + ]; it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( 'renders a dropdown item for "%s"', @@ -56,7 +60,7 @@ describe('Action components', () => { props: { username: 'John Doe', paths, - oncallSchedules, + userDeletionObstacles, }, stubs: { SharedDeleteAction }, }); @@ -69,8 +73,8 @@ describe('Action components', () => { expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); expect(sharedAction.attributes('data-username')).toBe('John Doe'); - expect(sharedAction.attributes('data-oncall-schedules')).toBe( - JSON.stringify(oncallSchedules), + expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe( + JSON.stringify(userDeletionObstacles), ); expect(findDropdownItem().exists()).toBe(true); }, diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 5e367891337..472158a9b10 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -8,8 +8,8 @@ exports[`User Operation confirmation modal renders modal with form included 1`] /> </p> - <oncall-schedules-list-stub - schedules="schedule1,schedule2" + <user-deletion-obstacles-list-stub + obstacles="schedule1,policy1" username="username" /> diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index fee74764645..82307c9e3b3 100644 --- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import ModalStub from './stubs/modal_stub'; const TEST_DELETE_USER_URL = 'delete-url'; @@ -25,7 +25,7 @@ describe('User Operation confirmation modal', () => { const getUsername = () => findUsernameInput().attributes('value'); const getMethodParam = () => new FormData(findForm().element).get('_method'); const getFormAction = () => findForm().attributes('action'); - const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); const setUsername = (username) => { findUsernameInput().vm.$emit('input', username); @@ -33,7 +33,7 @@ describe('User Operation confirmation modal', () => { const username = 'username'; const badUsername = 'bad_username'; - const oncallSchedules = '["schedule1", "schedule2"]'; + const userDeletionObstacles = '["schedule1", "policy1"]'; const createComponent = (props = {}) => { wrapper = shallowMount(DeleteUserModal, { @@ -46,7 +46,7 @@ describe('User Operation confirmation modal', () => { deleteUserUrl: TEST_DELETE_USER_URL, blockUserUrl: TEST_BLOCK_USER_URL, csrfToken: TEST_CSRF, - oncallSchedules, + userDeletionObstacles, ...props, }, stubs: { @@ -150,18 +150,18 @@ describe('User Operation confirmation modal', () => { }); }); - describe('Related oncall-schedules list', () => { - it('does NOT render the list when user has no related schedules', () => { - createComponent({ oncallSchedules: '[]' }); - expect(findOnCallSchedulesList().exists()).toBe(false); + describe('Related user-deletion-obstacles list', () => { + it('does NOT render the list when user has no related obstacles', () => { + createComponent({ userDeletionObstacles: '[]' }); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); - it('renders the list when user has related schedules', () => { + it('renders the list when user has related obstalces', () => { createComponent(); - const schedules = findOnCallSchedulesList(); - expect(schedules.exists()).toBe(true); - expect(schedules.props('schedules')).toEqual(JSON.parse(oncallSchedules)); + const obstacles = findUserDeletionObstaclesList(); + expect(obstacles.exists()).toBe(true); + expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles)); }); }); }); diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index ddb188edb10..f4d3fd97fd8 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -52,13 +52,13 @@ exports[`Alert integration settings form default state should match the default block="true" category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" data-qa-selector="incident_templates_dropdown" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" id="alert-integration-settings-issue-template" - showhighlighteditemstitle="true" size="medium" text="selecte_tmpl" variant="default" diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js index 2537b8fb816..5d681c7da4f 100644 --- a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -1,6 +1,8 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { stubComponent } from 'helpers/stub_component'; import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; import getProjects from '~/analytics/shared/graphql/projects.query.graphql'; @@ -25,6 +27,17 @@ const projects = [ }, ]; +const MockGlDropdown = stubComponent(GlDropdown, { + template: ` + <div> + <div data-testid="vsa-highlighted-items"> + <slot name="highlighted-items"></slot> + </div> + <div data-testid="vsa-default-items"><slot></slot></div> + </div> + `, +}); + const defaultMocks = { $apollo: { query: jest.fn().mockResolvedValue({ @@ -38,22 +51,33 @@ let spyQuery; describe('ProjectsDropdownFilter component', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = (props = {}, stubs = {}) => { spyQuery = defaultMocks.$apollo.query; - wrapper = mount(ProjectsDropdownFilter, { + wrapper = mountExtended(ProjectsDropdownFilter, { mocks: { ...defaultMocks }, propsData: { groupId: 1, groupNamespace: 'gitlab-org', ...props, }, + stubs, }); }; + const createWithMockDropdown = (props) => { + createComponent(props, { GlDropdown: MockGlDropdown }); + return waitForPromises(); + }; + afterEach(() => { wrapper.destroy(); }); + const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items'); + const findUnhighlightedItems = () => wrapper.findByTestId('vsa-default-items'); + const findHighlightedItemsTitle = () => wrapper.findByText('Selected'); + const findClearAllButton = () => wrapper.findByText('Clear all'); + const findDropdown = () => wrapper.find(GlDropdown); const findDropdownItems = () => @@ -75,8 +99,19 @@ describe('ProjectsDropdownFilter component', () => { const findDropdownFullPathAtIndex = (index) => findDropdownAtIndex(index).find('[data-testid="project-full-path"]'); - const selectDropdownItemAtIndex = (index) => + const selectDropdownItemAtIndex = (index) => { findDropdownAtIndex(index).find('button').trigger('click'); + return wrapper.vm.$nextTick(); + }; + + // NOTE: Selected items are now visually separated from unselected items + const findSelectedDropdownItems = () => findHighlightedItems().findAll(GlDropdownItem); + + const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index); + const findSelectedButtonIdentIconAtIndex = (index) => + findSelectedDropdownAtIndex(index).find('div.gl-avatar-identicon'); + const findSelectedButtonAvatarItemAtIndex = (index) => + findSelectedDropdownAtIndex(index).find('img.gl-avatar'); const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id); @@ -109,7 +144,80 @@ describe('ProjectsDropdownFilter component', () => { }); }); - describe('when passed a an array of defaultProject as prop', () => { + describe('highlighted items', () => { + const blockDefaultProps = { multiSelect: true }; + beforeEach(() => { + createComponent(blockDefaultProps); + }); + + describe('with no project selected', () => { + it('does not render the highlighted items', async () => { + await createWithMockDropdown(blockDefaultProps); + expect(findSelectedDropdownItems().length).toBe(0); + }); + + it('does not render the highlighted items title', () => { + expect(findHighlightedItemsTitle().exists()).toBe(false); + }); + + it('does not render the clear all button', () => { + expect(findClearAllButton().exists()).toBe(false); + }); + }); + + describe('with a selected project', () => { + beforeEach(async () => { + await selectDropdownItemAtIndex(0); + }); + + it('renders the highlighted items', async () => { + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(0); + + expect(findSelectedDropdownItems().length).toBe(1); + }); + + it('renders the highlighted items title', () => { + expect(findHighlightedItemsTitle().exists()).toBe(true); + }); + + it('renders the clear all button', () => { + expect(findClearAllButton().exists()).toBe(true); + }); + + it('clears all selected items when the clear all button is clicked', async () => { + await selectDropdownItemAtIndex(1); + + expect(wrapper.text()).toContain('2 projects selected'); + + findClearAllButton().trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain('2 projects selected'); + expect(wrapper.text()).toContain('Select projects'); + }); + }); + }); + + describe('with a selected project and search term', () => { + beforeEach(async () => { + await createWithMockDropdown({ multiSelect: true }); + + selectDropdownItemAtIndex(0); + wrapper.setData({ searchTerm: 'this is a very long search string' }); + }); + + it('renders the highlighted items', async () => { + expect(findUnhighlightedItems().findAll('li').length).toBe(1); + }); + + it('hides the unhighlighted items that do not match the string', async () => { + expect(findUnhighlightedItems().findAll('li').length).toBe(1); + expect(findUnhighlightedItems().text()).toContain('No matching results'); + }); + }); + + describe('when passed an array of defaultProject as prop', () => { beforeEach(() => { createComponent({ defaultProjects: [projects[0]], @@ -130,8 +238,9 @@ describe('ProjectsDropdownFilter component', () => { }); describe('when multiSelect is false', () => { + const blockDefaultProps = { multiSelect: false }; beforeEach(() => { - createComponent({ multiSelect: false }); + createComponent(blockDefaultProps); }); describe('displays the correct information', () => { @@ -183,21 +292,19 @@ describe('ProjectsDropdownFilter component', () => { }); it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => { - selectDropdownItemAtIndex(0); + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(0); - await wrapper.vm.$nextTick().then(() => { - expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); - expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false); - }); + expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true); + expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false); }); it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => { - selectDropdownItemAtIndex(1); + await createWithMockDropdown(blockDefaultProps); + await selectDropdownItemAtIndex(1); - await wrapper.vm.$nextTick().then(() => { - expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); - expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true); - }); + expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false); + expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/analytics/shared/utils_spec.js b/spec/frontend/analytics/shared/utils_spec.js index e3293f2d8bd..0513ccb2890 100644 --- a/spec/frontend/analytics/shared/utils_spec.js +++ b/spec/frontend/analytics/shared/utils_spec.js @@ -1,4 +1,10 @@ -import { filterBySearchTerm } from '~/analytics/shared/utils'; +import { + filterBySearchTerm, + extractFilterQueryParameters, + extractPaginationQueryParameters, + getDataZoomOption, +} from '~/analytics/shared/utils'; +import { objectToQuery } from '~/lib/utils/url_utility'; describe('filterBySearchTerm', () => { const data = [ @@ -22,3 +28,151 @@ describe('filterBySearchTerm', () => { expect(filterBySearchTerm(data, 'ne', 'title')).toEqual([data[0]]); }); }); + +describe('extractFilterQueryParameters', () => { + const selectedAuthor = 'Author 1'; + const selectedMilestone = 'Milestone 1.0'; + const selectedSourceBranch = 'main'; + const selectedTargetBranch = 'feature-1'; + const selectedAssigneeList = ['Alice', 'Bob']; + const selectedLabelList = ['Label 1', 'Label 2']; + + const queryParamsString = objectToQuery({ + source_branch_name: selectedSourceBranch, + target_branch_name: selectedTargetBranch, + author_username: selectedAuthor, + milestone_title: selectedMilestone, + assignee_username: selectedAssigneeList, + label_name: selectedLabelList, + }); + + it('extracts the correct filter parameters from a url', () => { + const result = extractFilterQueryParameters(queryParamsString); + const operator = '='; + const expectedFilters = { + selectedAssigneeList: { operator, value: selectedAssigneeList.join(',') }, + selectedLabelList: { operator, value: selectedLabelList.join(',') }, + selectedAuthor: { operator, value: selectedAuthor }, + selectedMilestone: { operator, value: selectedMilestone }, + selectedSourceBranch: { operator, value: selectedSourceBranch }, + selectedTargetBranch: { operator, value: selectedTargetBranch }, + }; + expect(result).toMatchObject(expectedFilters); + }); + + it('returns null for missing parameters', () => { + const result = extractFilterQueryParameters(''); + const expectedFilters = { + selectedAuthor: null, + selectedMilestone: null, + selectedSourceBranch: null, + selectedTargetBranch: null, + }; + expect(result).toMatchObject(expectedFilters); + }); + + it('only returns the parameters we expect', () => { + const result = extractFilterQueryParameters('foo="one"&bar="two"'); + const resultKeys = Object.keys(result); + ['foo', 'bar'].forEach((key) => { + expect(resultKeys).not.toContain(key); + }); + + [ + 'selectedAuthor', + 'selectedMilestone', + 'selectedSourceBranch', + 'selectedTargetBranch', + 'selectedAssigneeList', + 'selectedLabelList', + ].forEach((key) => { + expect(resultKeys).toContain(key); + }); + }); + + it('returns an empty array for missing list parameters', () => { + const result = extractFilterQueryParameters(''); + const expectedFilters = { selectedAssigneeList: [], selectedLabelList: [] }; + expect(result).toMatchObject(expectedFilters); + }); +}); + +describe('extractPaginationQueryParameters', () => { + const sort = 'title'; + const direction = 'asc'; + const page = '1'; + const queryParamsString = objectToQuery({ sort, direction, page }); + + it('extracts the correct filter parameters from a url', () => { + const result = extractPaginationQueryParameters(queryParamsString); + const expectedFilters = { sort, page, direction }; + expect(result).toMatchObject(expectedFilters); + }); + + it('returns null for missing parameters', () => { + const result = extractPaginationQueryParameters(''); + const expectedFilters = { sort: null, direction: null, page: null }; + expect(result).toMatchObject(expectedFilters); + }); + + it('only returns the parameters we expect', () => { + const result = extractPaginationQueryParameters('foo="one"&bar="two"&qux="three"'); + const resultKeys = Object.keys(result); + ['foo', 'bar', 'qux'].forEach((key) => { + expect(resultKeys).not.toContain(key); + }); + + ['sort', 'page', 'direction'].forEach((key) => { + expect(resultKeys).toContain(key); + }); + }); +}); + +describe('getDataZoomOption', () => { + it('returns an empty object when totalItems <= maxItemsPerPage', () => { + const totalItems = 10; + const maxItemsPerPage = 20; + + expect(getDataZoomOption({ totalItems, maxItemsPerPage })).toEqual({}); + }); + + describe('when totalItems > maxItemsPerPage', () => { + const totalItems = 30; + const maxItemsPerPage = 20; + + it('properly computes the end interval for the default datazoom config', () => { + const expected = [ + { + type: 'slider', + bottom: 10, + start: 0, + end: 67, + }, + ]; + + expect(getDataZoomOption({ totalItems, maxItemsPerPage })).toEqual(expected); + }); + + it('properly computes the end interval for a custom datazoom config', () => { + const dataZoom = [ + { type: 'slider', bottom: 0, start: 0 }, + { type: 'inside', start: 0 }, + ]; + const expected = [ + { + type: 'slider', + bottom: 0, + start: 0, + end: 67, + }, + { + type: 'inside', + start: 0, + end: 67, + }, + ]; + + expect(getDataZoomOption({ totalItems, maxItemsPerPage, dataZoom })).toEqual(expected); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js index 61c6a1dd167..870375318e3 100644 --- a/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/manage_two_factor_form_spec.js @@ -1,5 +1,5 @@ -import { GlForm } from '@gitlab/ui'; import { within } from '@testing-library/dom'; +import { GlForm } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ManageTwoFactorForm, { diff --git a/spec/frontend/blob/file_template_mediator_spec.js b/spec/frontend/blob/file_template_mediator_spec.js new file mode 100644 index 00000000000..44e12deb564 --- /dev/null +++ b/spec/frontend/blob/file_template_mediator_spec.js @@ -0,0 +1,53 @@ +import TemplateSelectorMediator from '~/blob/file_template_mediator'; + +describe('Template Selector Mediator', () => { + let mediator; + + describe('setFilename', () => { + let input; + const newFileName = 'foo'; + const editor = jest.fn().mockImplementationOnce(() => ({ + getValue: jest.fn().mockImplementation(() => {}), + }))(); + + beforeEach(() => { + setFixtures('<div class="file-editor"><input class="js-file-path-name-input" /></div>'); + input = document.querySelector('.js-file-path-name-input'); + mediator = new TemplateSelectorMediator({ + editor, + currentAction: jest.fn(), + projectId: jest.fn(), + }); + }); + + it('fills out the input field', () => { + expect(input.value).toBe(''); + mediator.setFilename(newFileName); + expect(input.value).toBe(newFileName); + }); + + it.each` + name | newName | shouldDispatch + ${newFileName} | ${newFileName} | ${false} + ${newFileName} | ${''} | ${true} + ${newFileName} | ${undefined} | ${false} + ${''} | ${''} | ${false} + ${''} | ${newFileName} | ${true} + ${''} | ${undefined} | ${false} + `( + 'correctly reacts to the name change when current name is $name and newName is $newName', + ({ name, newName, shouldDispatch }) => { + input.value = name; + const eventHandler = jest.fn(); + input.addEventListener('change', eventHandler); + + mediator.setFilename(newName); + if (shouldDispatch) { + expect(eventHandler).toHaveBeenCalledTimes(1); + } else { + expect(eventHandler).not.toHaveBeenCalled(); + } + }, + ); + }); +}); diff --git a/spec/frontend/boards/components/board_add_new_column_trigger_spec.js b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js new file mode 100644 index 00000000000..c35f2463f69 --- /dev/null +++ b/spec/frontend/boards/components/board_add_new_column_trigger_spec.js @@ -0,0 +1,59 @@ +import { GlButton } from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; +import { createStore } from '~/boards/stores'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +Vue.use(Vuex); + +describe('BoardAddNewColumnTrigger', () => { + let wrapper; + + const findBoardsCreateList = () => wrapper.findByTestId('boards-create-list'); + const findTooltipText = () => getBinding(findBoardsCreateList().element, 'gl-tooltip'); + + const mountComponent = () => { + wrapper = mountExtended(BoardAddNewColumnTrigger, { + directives: { + GlTooltip: createMockDirective(), + }, + store: createStore(), + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when button is active', () => { + it('does not show the tooltip', () => { + const tooltip = findTooltipText(); + + expect(tooltip.value).toBe(''); + }); + + it('renders an enabled button', () => { + const button = wrapper.find(GlButton); + + expect(button.props('disabled')).toBe(false); + }); + }); + + describe('when button is disabled', () => { + it('shows the tooltip', async () => { + wrapper.find(GlButton).vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + const tooltip = findTooltipText(); + + expect(tooltip.value).toBe('The list creation wizard is already open'); + }); + }); +}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 62e0fa7a68a..0b90912a584 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -21,9 +21,10 @@ import { getMoveData, updateListPosition, } from '~/boards/boards_util'; +import { gqlClient } from '~/boards/graphql'; import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql'; import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'; -import actions, { gqlClient } from '~/boards/stores/actions'; +import actions from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import mutations from '~/boards/stores/mutations'; @@ -1331,20 +1332,54 @@ describe('addListItem', () => { list: mockLists[0], item: mockIssue, position: 0, + inProgress: true, }; - testAction(actions.addListItem, payload, {}, [ - { - type: types.ADD_BOARD_ITEM_TO_LIST, - payload: { - listId: mockLists[0].id, - itemId: mockIssue.id, - atIndex: 0, - inProgress: false, + testAction( + actions.addListItem, + payload, + {}, + [ + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { + listId: mockLists[0].id, + itemId: mockIssue.id, + atIndex: 0, + inProgress: true, + }, }, - }, - { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, - ]); + { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, + ], + [], + ); + }); + + it('should commit ADD_BOARD_ITEM_TO_LIST and UPDATE_BOARD_ITEM mutations, dispatch setActiveId action when inProgress is false', () => { + const payload = { + list: mockLists[0], + item: mockIssue, + position: 0, + }; + + testAction( + actions.addListItem, + payload, + {}, + [ + { + type: types.ADD_BOARD_ITEM_TO_LIST, + payload: { + listId: mockLists[0].id, + itemId: mockIssue.id, + atIndex: 0, + inProgress: false, + }, + }, + { type: types.UPDATE_BOARD_ITEM, payload: mockIssue }, + ], + [{ type: 'setActiveId', payload: { id: mockIssue.id, sidebarType: ISSUABLE } }], + ); }); }); @@ -1542,7 +1577,7 @@ describe('setActiveIssueLabels', () => { projectPath: 'h/b', }; - it('should assign labels on success', (done) => { + it('should assign labels on success, and sets loading state for labels', (done) => { jest .spyOn(gqlClient, 'mutate') .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); @@ -1559,6 +1594,14 @@ describe('setActiveIssueLabels', () => { { ...state, ...getters }, [ { + type: types.SET_LABELS_LOADING, + payload: true, + }, + { + type: types.SET_LABELS_LOADING, + payload: false, + }, + { type: types.UPDATE_BOARD_ITEM_BY_ID, payload, }, diff --git a/spec/frontend/clusters/agents/components/show_spec.js b/spec/frontend/clusters/agents/components/show_spec.js new file mode 100644 index 00000000000..fd04ff8b3e7 --- /dev/null +++ b/spec/frontend/clusters/agents/components/show_spec.js @@ -0,0 +1,195 @@ +import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlSprintf, GlTab } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import ClusterAgentShow from '~/clusters/agents/components/show.vue'; +import TokenTable from '~/clusters/agents/components/token_table.vue'; +import getAgentQuery from '~/clusters/agents/graphql/queries/get_cluster_agent.query.graphql'; +import { useFakeDate } from 'helpers/fake_date'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('ClusterAgentShow', () => { + let wrapper; + useFakeDate([2021, 2, 15]); + + const propsData = { + agentName: 'cluster-agent', + projectPath: 'path/to/project', + }; + + const defaultClusterAgent = { + id: '1', + createdAt: '2021-02-13T00:00:00Z', + createdByUser: { + name: 'user-1', + }, + name: 'token-1', + tokens: { + count: 1, + nodes: [], + pageInfo: null, + }, + }; + + const createWrapper = ({ clusterAgent, queryResponse = null }) => { + const agentQueryResponse = + queryResponse || jest.fn().mockResolvedValue({ data: { project: { clusterAgent } } }); + const apolloProvider = createMockApollo([[getAgentQuery, agentQueryResponse]]); + + wrapper = shallowMount(ClusterAgentShow, { + localVue, + apolloProvider, + propsData, + stubs: { GlSprintf, TimeAgoTooltip, GlTab }, + }); + }; + + const createWrapperWithoutApollo = ({ clusterAgent, loading = false }) => { + const $apollo = { queries: { clusterAgent: { loading } } }; + + wrapper = shallowMount(ClusterAgentShow, { + propsData, + mocks: { $apollo, clusterAgent }, + stubs: { GlTab }, + }); + }; + + const findCreatedText = () => wrapper.find('[data-testid="cluster-agent-create-info"]').text(); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findPaginationButtons = () => wrapper.find(GlKeysetPagination); + const findTokenCount = () => wrapper.find('[data-testid="cluster-agent-token-count"]').text(); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default behaviour', () => { + beforeEach(() => { + return createWrapper({ clusterAgent: defaultClusterAgent }); + }); + + it('displays the agent name', () => { + expect(wrapper.text()).toContain(propsData.agentName); + }); + + it('displays agent create information', () => { + expect(findCreatedText()).toMatchInterpolatedText('Created by user-1 2 days ago'); + }); + + it('displays token count', () => { + expect(findTokenCount()).toMatchInterpolatedText( + `${ClusterAgentShow.i18n.tokens} ${defaultClusterAgent.tokens.count}`, + ); + }); + + it('renders token table', () => { + expect(wrapper.find(TokenTable).exists()).toBe(true); + }); + + it('should not render pagination buttons when there are no additional pages', () => { + expect(findPaginationButtons().exists()).toBe(false); + }); + }); + + describe('when create user is unknown', () => { + const missingUser = { + ...defaultClusterAgent, + createdByUser: null, + }; + + beforeEach(() => { + return createWrapper({ clusterAgent: missingUser }); + }); + + it('displays agent create information with unknown user', () => { + expect(findCreatedText()).toMatchInterpolatedText('Created by Unknown user 2 days ago'); + }); + }); + + describe('when token count is missing', () => { + const missingTokens = { + ...defaultClusterAgent, + tokens: null, + }; + + beforeEach(() => { + return createWrapper({ clusterAgent: missingTokens }); + }); + + it('displays token header with no count', () => { + expect(findTokenCount()).toMatchInterpolatedText(`${ClusterAgentShow.i18n.tokens}`); + }); + }); + + describe('when the token list has additional pages', () => { + const pageInfo = { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'prev', + endCursor: 'next', + }; + + const tokenPagination = { + ...defaultClusterAgent, + tokens: { + ...defaultClusterAgent.tokens, + pageInfo, + }, + }; + + beforeEach(() => { + return createWrapper({ clusterAgent: tokenPagination }); + }); + + it('should render pagination buttons', () => { + expect(findPaginationButtons().exists()).toBe(true); + }); + + it('should pass pageInfo to the pagination component', () => { + expect(findPaginationButtons().props()).toMatchObject(pageInfo); + }); + }); + + describe('when the agent query is loading', () => { + describe('when the clusterAgent is missing', () => { + beforeEach(() => { + return createWrapper({ + clusterAgent: null, + queryResponse: jest.fn().mockReturnValue(new Promise(() => {})), + }); + }); + + it('displays a loading icon and hides the token tab', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(wrapper.text()).not.toContain(ClusterAgentShow.i18n.tokens); + }); + }); + + describe('when the clusterAgent is present', () => { + beforeEach(() => { + createWrapperWithoutApollo({ clusterAgent: defaultClusterAgent, loading: true }); + }); + + it('displays a loading icon and token tab', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(wrapper.text()).toContain(ClusterAgentShow.i18n.tokens); + }); + }); + }); + + describe('when the agent query has errored', () => { + beforeEach(() => { + createWrapper({ clusterAgent: null, queryResponse: jest.fn().mockRejectedValue() }); + return waitForPromises(); + }); + + it('displays an alert message', () => { + expect(wrapper.find(GlAlert).exists()).toBe(true); + expect(wrapper.text()).toContain(ClusterAgentShow.i18n.loadingError); + }); + }); +}); diff --git a/spec/frontend/clusters/agents/components/token_table_spec.js b/spec/frontend/clusters/agents/components/token_table_spec.js new file mode 100644 index 00000000000..47ff944dd84 --- /dev/null +++ b/spec/frontend/clusters/agents/components/token_table_spec.js @@ -0,0 +1,135 @@ +import { GlEmptyState, GlLink, GlTooltip, GlTruncate } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import TokenTable from '~/clusters/agents/components/token_table.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +describe('ClusterAgentTokenTable', () => { + let wrapper; + useFakeDate([2021, 2, 15]); + + const defaultTokens = [ + { + id: '1', + createdAt: '2021-02-13T00:00:00Z', + description: 'Description of token 1', + createdByUser: { + name: 'user-1', + }, + lastUsedAt: '2021-02-13T00:00:00Z', + name: 'token-1', + }, + { + id: '2', + createdAt: '2021-02-10T00:00:00Z', + description: null, + createdByUser: null, + lastUsedAt: null, + name: 'token-2', + }, + ]; + + const createComponent = (tokens) => { + wrapper = extendedWrapper(mount(TokenTable, { propsData: { tokens } })); + }; + + const findEmptyState = () => wrapper.find(GlEmptyState); + const findLink = () => wrapper.find(GlLink); + + beforeEach(() => { + return createComponent(defaultTokens); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a learn more link', () => { + const learnMoreLink = findLink(); + + expect(learnMoreLink.exists()).toBe(true); + expect(learnMoreLink.text()).toBe(TokenTable.i18n.learnMore); + }); + + it.each` + name | lineNumber + ${'token-1'} | ${0} + ${'token-2'} | ${1} + `('displays token name "$name" for line "$lineNumber"', ({ name, lineNumber }) => { + const tokens = wrapper.findAll('[data-testid="agent-token-name"]'); + const token = tokens.at(lineNumber); + + expect(token.text()).toBe(name); + }); + + it.each` + lastContactText | lineNumber + ${'2 days ago'} | ${0} + ${'Never'} | ${1} + `( + 'displays last contact information "$lastContactText" for line "$lineNumber"', + ({ lastContactText, lineNumber }) => { + const tokens = wrapper.findAllByTestId('agent-token-used'); + const token = tokens.at(lineNumber); + + expect(token.text()).toBe(lastContactText); + }, + ); + + it.each` + createdText | lineNumber + ${'2 days ago'} | ${0} + ${'5 days ago'} | ${1} + `( + 'displays created information "$createdText" for line "$lineNumber"', + ({ createdText, lineNumber }) => { + const tokens = wrapper.findAll('[data-testid="agent-token-created-time"]'); + const token = tokens.at(lineNumber); + + expect(token.text()).toBe(createdText); + }, + ); + + it.each` + createdBy | lineNumber + ${'user-1'} | ${0} + ${'Unknown user'} | ${1} + `( + 'displays creator information "$createdBy" for line "$lineNumber"', + ({ createdBy, lineNumber }) => { + const tokens = wrapper.findAll('[data-testid="agent-token-created-user"]'); + const token = tokens.at(lineNumber); + + expect(token.text()).toBe(createdBy); + }, + ); + + it.each` + description | truncatesText | hasTooltip | lineNumber + ${'Description of token 1'} | ${true} | ${true} | ${0} + ${''} | ${false} | ${false} | ${1} + `( + 'displays description information "$description" for line "$lineNumber"', + ({ description, truncatesText, hasTooltip, lineNumber }) => { + const tokens = wrapper.findAll('[data-testid="agent-token-description"]'); + const token = tokens.at(lineNumber); + + expect(token.text()).toContain(description); + expect(token.find(GlTruncate).exists()).toBe(truncatesText); + expect(token.find(GlTooltip).exists()).toBe(hasTooltip); + }, + ); + + describe('when there are no tokens', () => { + beforeEach(() => { + return createComponent([]); + }); + + it('displays an empty state', () => { + const emptyState = findEmptyState(); + + expect(emptyState.exists()).toBe(true); + expect(emptyState.text()).toContain(TokenTable.i18n.noTokens); + }); + }); +}); diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index b34265b7234..42d81900911 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -33,7 +33,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ <span class="sr-only" > - Toggle Dropdown + Toggle dropdown </span> </button> <ul @@ -46,21 +46,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ > <!----> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" - > - <div - class="gl-display-flex" - > - <!----> - </div> - - <div - class="gl-display-flex" - > - <!----> - </div> - </div> + <!----> <div class="gl-new-dropdown-contents" diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js new file mode 100644 index 00000000000..a548721588e --- /dev/null +++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js @@ -0,0 +1,77 @@ +import { GlAlert, GlEmptyState, GlSprintf } from '@gitlab/ui'; +import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +const emptyStateImage = '/path/to/image'; +const projectPath = 'path/to/project'; +const agentDocsUrl = 'path/to/agentDocs'; +const installDocsUrl = 'path/to/installDocs'; +const getStartedDocsUrl = 'path/to/getStartedDocs'; +const integrationDocsUrl = 'path/to/integrationDocs'; + +describe('AgentEmptyStateComponent', () => { + let wrapper; + + const propsData = { + hasConfigurations: false, + }; + const provideData = { + emptyStateImage, + projectPath, + agentDocsUrl, + installDocsUrl, + getStartedDocsUrl, + integrationDocsUrl, + }; + + const findConfigurationsAlert = () => wrapper.findComponent(GlAlert); + const findAgentDocsLink = () => wrapper.findByTestId('agent-docs-link'); + const findInstallDocsLink = () => wrapper.findByTestId('install-docs-link'); + const findIntegrationButton = () => wrapper.findByTestId('integration-primary-button'); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + beforeEach(() => { + wrapper = shallowMountExtended(AgentEmptyState, { + propsData, + provide: provideData, + stubs: { GlEmptyState, GlSprintf }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('renders correct href attributes for the links', () => { + expect(findAgentDocsLink().attributes('href')).toBe(agentDocsUrl); + expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl); + }); + + describe('when there are no agent configurations in repository', () => { + it('should render notification message box', () => { + expect(findConfigurationsAlert().exists()).toBe(true); + }); + + it('should disable integration button', () => { + expect(findIntegrationButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('when there is a list of agent configurations', () => { + beforeEach(() => { + propsData.hasConfigurations = true; + wrapper = shallowMountExtended(AgentEmptyState, { + propsData, + provide: provideData, + }); + }); + it('should render content without notification message box', () => { + expect(findEmptyState().exists()).toBe(true); + expect(findConfigurationsAlert().exists()).toBe(false); + expect(findIntegrationButton().attributes('disabled')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js new file mode 100644 index 00000000000..e3b90584f29 --- /dev/null +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -0,0 +1,117 @@ +import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; +import AgentTable from '~/clusters_list/components/agent_table.vue'; +import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +const connectedTimeNow = new Date(); +const connectedTimeInactive = new Date(connectedTimeNow.getTime() - ACTIVE_CONNECTION_TIME); + +const propsData = { + agents: [ + { + name: 'agent-1', + configFolder: { + webPath: '/agent/full/path', + }, + webPath: '/agent-1', + status: 'unused', + lastContact: null, + tokens: null, + }, + { + name: 'agent-2', + webPath: '/agent-2', + status: 'active', + lastContact: connectedTimeNow.getTime(), + tokens: { + nodes: [ + { + lastUsedAt: connectedTimeNow, + }, + ], + }, + }, + { + name: 'agent-3', + webPath: '/agent-3', + status: 'inactive', + lastContact: connectedTimeInactive.getTime(), + tokens: { + nodes: [ + { + lastUsedAt: connectedTimeInactive, + }, + ], + }, + }, + ], +}; +const provideData = { integrationDocsUrl: 'path/to/integrationDocs' }; + +describe('AgentTable', () => { + let wrapper; + + const findAgentLink = (at) => wrapper.findAllByTestId('cluster-agent-name-link').at(at); + const findStatusIcon = (at) => wrapper.findAllComponents(GlIcon).at(at); + const findStatusText = (at) => wrapper.findAllByTestId('cluster-agent-connection-status').at(at); + const findLastContactText = (at) => wrapper.findAllByTestId('cluster-agent-last-contact').at(at); + const findConfiguration = (at) => + wrapper.findAllByTestId('cluster-agent-configuration-link').at(at); + + beforeEach(() => { + wrapper = mountExtended(AgentTable, { propsData, provide: provideData }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('displays header button', () => { + expect(wrapper.find(GlButton).text()).toBe('Install a new GitLab Agent'); + }); + + describe('agent table', () => { + it.each` + agentName | link | lineNumber + ${'agent-1'} | ${'/agent-1'} | ${0} + ${'agent-2'} | ${'/agent-2'} | ${1} + `('displays agent link', ({ agentName, link, lineNumber }) => { + expect(findAgentLink(lineNumber).text()).toBe(agentName); + expect(findAgentLink(lineNumber).attributes('href')).toBe(link); + }); + + it.each` + status | iconName | lineNumber + ${'Never connected'} | ${'status-neutral'} | ${0} + ${'Connected'} | ${'status-success'} | ${1} + ${'Not connected'} | ${'severity-critical'} | ${2} + `('displays agent connection status', ({ status, iconName, lineNumber }) => { + expect(findStatusText(lineNumber).text()).toBe(status); + expect(findStatusIcon(lineNumber).props('name')).toBe(iconName); + }); + + it.each` + lastContact | lineNumber + ${'Never'} | ${0} + ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1} + ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2} + `('displays agent last contact time', ({ lastContact, lineNumber }) => { + expect(findLastContactText(lineNumber).text()).toBe(lastContact); + }); + + it.each` + agentPath | hasLink | lineNumber + ${'.gitlab/agents/agent-1'} | ${true} | ${0} + ${'.gitlab/agents/agent-2'} | ${false} | ${1} + `('displays config file path', ({ agentPath, hasLink, lineNumber }) => { + const findLink = findConfiguration(lineNumber).find(GlLink); + + expect(findLink.exists()).toBe(hasLink); + expect(findConfiguration(lineNumber).text()).toBe(agentPath); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js new file mode 100644 index 00000000000..54d5ae94172 --- /dev/null +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -0,0 +1,246 @@ +import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; +import AgentTable from '~/clusters_list/components/agent_table.vue'; +import Agents from '~/clusters_list/components/agents.vue'; +import { ACTIVE_CONNECTION_TIME } from '~/clusters_list/constants'; +import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('Agents', () => { + let wrapper; + + const propsData = { + defaultBranchName: 'default', + }; + const provideData = { + projectPath: 'path/to/project', + kasAddress: 'kas.example.com', + }; + + const createWrapper = ({ agents = [], pageInfo = null, trees = [] }) => { + const provide = provideData; + const apolloQueryResponse = { + data: { + project: { + clusterAgents: { nodes: agents, pageInfo, tokens: { nodes: [] } }, + repository: { tree: { trees: { nodes: trees, pageInfo } } }, + }, + }, + }; + + const apolloProvider = createMockApollo([ + [getAgentsQuery, jest.fn().mockResolvedValue(apolloQueryResponse, provide)], + ]); + + wrapper = shallowMount(Agents, { + localVue, + apolloProvider, + propsData, + provide: provideData, + }); + + return wrapper.vm.$nextTick(); + }; + + const findAgentTable = () => wrapper.find(AgentTable); + const findEmptyState = () => wrapper.find(AgentEmptyState); + const findPaginationButtons = () => wrapper.find(GlKeysetPagination); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('when there is a list of agents', () => { + let testDate = new Date(); + const agents = [ + { + id: '1', + name: 'agent-1', + webPath: '/agent-1', + tokens: null, + }, + { + id: '2', + name: 'agent-2', + webPath: '/agent-2', + tokens: { + nodes: [ + { + lastUsedAt: testDate, + }, + ], + }, + }, + ]; + + const trees = [ + { + name: 'agent-2', + path: '.gitlab/agents/agent-2', + webPath: '/project/path/.gitlab/agents/agent-2', + }, + ]; + + const expectedAgentsList = [ + { + id: '1', + name: 'agent-1', + webPath: '/agent-1', + configFolder: undefined, + status: 'unused', + lastContact: null, + tokens: null, + }, + { + id: '2', + name: 'agent-2', + configFolder: { + name: 'agent-2', + path: '.gitlab/agents/agent-2', + webPath: '/project/path/.gitlab/agents/agent-2', + }, + webPath: '/agent-2', + status: 'active', + lastContact: new Date(testDate).getTime(), + tokens: { + nodes: [ + { + lastUsedAt: testDate, + }, + ], + }, + }, + ]; + + beforeEach(() => { + return createWrapper({ agents, trees }); + }); + + it('should render agent table', () => { + expect(findAgentTable().exists()).toBe(true); + expect(findEmptyState().exists()).toBe(false); + }); + + it('should pass agent and folder info to table component', () => { + expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); + }); + + describe('when the agent has recently connected tokens', () => { + it('should set agent status to active', () => { + expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); + }); + }); + + describe('when the agent has tokens connected more then 8 minutes ago', () => { + const now = new Date(); + testDate = new Date(now.getTime() - ACTIVE_CONNECTION_TIME); + it('should set agent status to inactive', () => { + expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); + }); + }); + + describe('when the agent has no connected tokens', () => { + testDate = null; + it('should set agent status to unused', () => { + expect(findAgentTable().props('agents')).toMatchObject(expectedAgentsList); + }); + }); + + it('should not render pagination buttons when there are no additional pages', () => { + expect(findPaginationButtons().exists()).toBe(false); + }); + + describe('when the list has additional pages', () => { + const pageInfo = { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'prev', + endCursor: 'next', + }; + + beforeEach(() => { + return createWrapper({ + agents, + pageInfo, + }); + }); + + it('should render pagination buttons', () => { + expect(findPaginationButtons().exists()).toBe(true); + }); + + it('should pass pageInfo to the pagination component', () => { + expect(findPaginationButtons().props()).toMatchObject(pageInfo); + }); + }); + }); + + describe('when the agent list is empty', () => { + beforeEach(() => { + return createWrapper({ agents: [] }); + }); + + it('should render empty state', () => { + expect(findAgentTable().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('when the agent configurations are present', () => { + const trees = [ + { + name: 'agent-1', + path: '.gitlab/agents/agent-1', + webPath: '/project/path/.gitlab/agents/agent-1', + }, + ]; + + beforeEach(() => { + return createWrapper({ agents: [], trees }); + }); + + it('should pass the correct hasConfigurations boolean value to empty state component', () => { + expect(findEmptyState().props('hasConfigurations')).toEqual(true); + }); + }); + + describe('when agents query has errored', () => { + beforeEach(() => { + return createWrapper({ agents: null }); + }); + + it('displays an alert message', () => { + expect(wrapper.find(GlAlert).exists()).toBe(true); + }); + }); + + describe('when agents query is loading', () => { + const mocks = { + $apollo: { + queries: { + agents: { + loading: true, + }, + }, + }, + }; + + beforeEach(() => { + wrapper = shallowMount(Agents, { mocks, propsData, provide: provideData }); + + return wrapper.vm.$nextTick(); + }); + + it('displays a loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js new file mode 100644 index 00000000000..40c2c59e187 --- /dev/null +++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js @@ -0,0 +1,129 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { createLocalVue, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; +import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants'; +import agentConfigurationsQuery from '~/clusters_list/graphql/queries/agent_configurations.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { agentConfigurationsResponse } from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('AvailableAgentsDropdown', () => { + let wrapper; + + const i18n = I18N_AVAILABLE_AGENTS_DROPDOWN; + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findConfiguredAgentItem = () => findDropdownItems().at(0); + + const createWrapper = ({ propsData = {}, isLoading = false }) => { + const provide = { + projectPath: 'path/to/project', + }; + + wrapper = (() => { + if (isLoading) { + const mocks = { + $apollo: { + queries: { + agents: { + loading: true, + }, + }, + }, + }; + + return mount(AvailableAgentsDropdown, { mocks, provide, propsData }); + } + + const apolloProvider = createMockApollo([ + [agentConfigurationsQuery, jest.fn().mockResolvedValue(agentConfigurationsResponse)], + ]); + + return mount(AvailableAgentsDropdown, { + localVue, + apolloProvider, + provide, + propsData, + }); + })(); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('there are agents available', () => { + const propsData = { + isRegistering: false, + }; + + beforeEach(() => { + createWrapper({ propsData }); + }); + + it('prompts to select an agent', () => { + expect(findDropdown().props('text')).toBe(i18n.selectAgent); + }); + + it('shows only agents that are not yet installed', () => { + expect(findDropdownItems()).toHaveLength(1); + expect(findConfiguredAgentItem().text()).toBe('configured-agent'); + expect(findConfiguredAgentItem().props('isChecked')).toBe(false); + }); + + describe('click events', () => { + beforeEach(() => { + findConfiguredAgentItem().vm.$emit('click'); + }); + + it('emits agentSelected with the name of the clicked agent', () => { + expect(wrapper.emitted('agentSelected')).toEqual([['configured-agent']]); + }); + + it('marks the clicked item as selected', () => { + expect(findDropdown().props('text')).toBe('configured-agent'); + expect(findConfiguredAgentItem().props('isChecked')).toBe(true); + }); + }); + }); + + describe('registration in progress', () => { + const propsData = { + isRegistering: true, + }; + + beforeEach(() => { + createWrapper({ propsData }); + }); + + it('updates the text in the dropdown', () => { + expect(findDropdown().props('text')).toBe(i18n.registeringAgent); + }); + + it('displays a loading icon', () => { + expect(findDropdown().props('loading')).toBe(true); + }); + }); + + describe('agents query is loading', () => { + const propsData = { + isRegistering: false, + }; + + beforeEach(() => { + createWrapper({ propsData, isLoading: true }); + }); + + it('updates the text in the dropdown', () => { + expect(findDropdown().text()).toBe(i18n.selectAgent); + }); + + it('displays a loading icon', () => { + expect(findDropdown().props('loading')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/install_agent_modal_spec.js b/spec/frontend/clusters_list/components/install_agent_modal_spec.js new file mode 100644 index 00000000000..98ca5e05b3f --- /dev/null +++ b/spec/frontend/clusters_list/components/install_agent_modal_spec.js @@ -0,0 +1,190 @@ +import { GlAlert, GlButton, GlFormInputGroup } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; +import InstallAgentModal from '~/clusters_list/components/install_agent_modal.vue'; +import { I18N_INSTALL_AGENT_MODAL } from '~/clusters_list/constants'; +import createAgentMutation from '~/clusters_list/graphql/mutations/create_agent.mutation.graphql'; +import createAgentTokenMutation from '~/clusters_list/graphql/mutations/create_agent_token.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import CodeBlock from '~/vue_shared/components/code_block.vue'; +import { + createAgentResponse, + createAgentErrorResponse, + createAgentTokenResponse, + createAgentTokenErrorResponse, +} from '../mocks/apollo'; +import ModalStub from '../stubs'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('InstallAgentModal', () => { + let wrapper; + let apolloProvider; + + const i18n = I18N_INSTALL_AGENT_MODAL; + const findModal = () => wrapper.findComponent(ModalStub); + const findAgentDropdown = () => findModal().findComponent(AvailableAgentsDropdown); + const findAlert = () => findModal().findComponent(GlAlert); + const findButtonByVariant = (variant) => + findModal() + .findAll(GlButton) + .wrappers.find((button) => button.props('variant') === variant); + const findActionButton = () => findButtonByVariant('confirm'); + const findCancelButton = () => findButtonByVariant('default'); + + const expectDisabledAttribute = (element, disabled) => { + if (disabled) { + expect(element.attributes('disabled')).toBe('true'); + } else { + expect(element.attributes('disabled')).toBeUndefined(); + } + }; + + const createWrapper = () => { + const provide = { + projectPath: 'path/to/project', + kasAddress: 'kas.example.com', + }; + + wrapper = shallowMount(InstallAgentModal, { + attachTo: document.body, + stubs: { + GlModal: ModalStub, + }, + localVue, + apolloProvider, + provide, + }); + }; + + const mockSelectedAgentResponse = () => { + createWrapper(); + + wrapper.vm.setAgentName('agent-name'); + findActionButton().vm.$emit('click'); + + return waitForPromises(); + }; + + beforeEach(() => { + createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + apolloProvider = null; + }); + + describe('initial state', () => { + it('renders the dropdown for available agents', () => { + expect(findAgentDropdown().isVisible()).toBe(true); + expect(findModal().text()).not.toContain(i18n.basicInstallTitle); + expect(findModal().findComponent(GlFormInputGroup).exists()).toBe(false); + expect(findModal().findComponent(GlAlert).exists()).toBe(false); + expect(findModal().findComponent(CodeBlock).exists()).toBe(false); + }); + + it('renders a cancel button', () => { + expect(findCancelButton().isVisible()).toBe(true); + expectDisabledAttribute(findCancelButton(), false); + }); + + it('renders a disabled next button', () => { + expect(findActionButton().isVisible()).toBe(true); + expect(findActionButton().text()).toBe(i18n.next); + expectDisabledAttribute(findActionButton(), true); + }); + }); + + describe('an agent is selected', () => { + beforeEach(() => { + findAgentDropdown().vm.$emit('agentSelected'); + }); + + it('enables the next button', () => { + expect(findActionButton().isVisible()).toBe(true); + expectDisabledAttribute(findActionButton(), false); + }); + }); + + describe('registering an agent', () => { + const createAgentHandler = jest.fn().mockResolvedValue(createAgentResponse); + const createAgentTokenHandler = jest.fn().mockResolvedValue(createAgentTokenResponse); + + beforeEach(() => { + apolloProvider = createMockApollo([ + [createAgentMutation, createAgentHandler], + [createAgentTokenMutation, createAgentTokenHandler], + ]); + + return mockSelectedAgentResponse(apolloProvider); + }); + + it('creates an agent and token', () => { + expect(createAgentHandler).toHaveBeenCalledWith({ + input: { name: 'agent-name', projectPath: 'path/to/project' }, + }); + + expect(createAgentTokenHandler).toHaveBeenCalledWith({ + input: { clusterAgentId: 'agent-id', name: 'agent-name' }, + }); + }); + + it('renders a done button', () => { + expect(findActionButton().isVisible()).toBe(true); + expect(findActionButton().text()).toBe(i18n.done); + expectDisabledAttribute(findActionButton(), false); + }); + + it('shows agent instructions', () => { + const modalText = findModal().text(); + expect(modalText).toContain(i18n.basicInstallTitle); + expect(modalText).toContain(i18n.basicInstallBody); + + const token = findModal().findComponent(GlFormInputGroup); + expect(token.props('value')).toBe('mock-agent-token'); + + const alert = findModal().findComponent(GlAlert); + expect(alert.props('title')).toBe(i18n.tokenSingleUseWarningTitle); + + const code = findModal().findComponent(CodeBlock).props('code'); + expect(code).toContain('--agent-token=mock-agent-token'); + expect(code).toContain('--kas-address=kas.example.com'); + }); + + describe('error creating agent', () => { + beforeEach(() => { + apolloProvider = createMockApollo([ + [createAgentMutation, jest.fn().mockResolvedValue(createAgentErrorResponse)], + ]); + + return mockSelectedAgentResponse(); + }); + + it('displays the error message', () => { + expect(findAlert().text()).toBe(createAgentErrorResponse.data.createClusterAgent.errors[0]); + }); + }); + + describe('error creating token', () => { + beforeEach(() => { + apolloProvider = createMockApollo([ + [createAgentMutation, jest.fn().mockResolvedValue(createAgentResponse)], + [createAgentTokenMutation, jest.fn().mockResolvedValue(createAgentTokenErrorResponse)], + ]); + + return mockSelectedAgentResponse(); + }); + + it('displays the error message', () => { + expect(findAlert().text()).toBe( + createAgentTokenErrorResponse.data.clusterAgentTokenCreate.errors[0], + ); + }); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js new file mode 100644 index 00000000000..e388d791b89 --- /dev/null +++ b/spec/frontend/clusters_list/components/mock_data.js @@ -0,0 +1,12 @@ +export const agentConfigurationsResponse = { + data: { + project: { + agentConfigurations: { + nodes: [{ agentName: 'installed-agent' }, { agentName: 'configured-agent' }], + }, + clusterAgents: { + nodes: [{ name: 'installed-agent' }], + }, + }, + }, +}; diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js new file mode 100644 index 00000000000..27b71a0d4b5 --- /dev/null +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -0,0 +1,45 @@ +export const createAgentResponse = { + data: { + createClusterAgent: { + clusterAgent: { + id: 'agent-id', + }, + errors: [], + }, + }, +}; + +export const createAgentErrorResponse = { + data: { + createClusterAgent: { + clusterAgent: { + id: 'agent-id', + }, + errors: ['could not create agent'], + }, + }, +}; + +export const createAgentTokenResponse = { + data: { + clusterAgentTokenCreate: { + token: { + id: 'token-id', + }, + secret: 'mock-agent-token', + errors: [], + }, + }, +}; + +export const createAgentTokenErrorResponse = { + data: { + clusterAgentTokenCreate: { + token: { + id: 'token-id', + }, + secret: 'mock-agent-token', + errors: ['could not create agent token'], + }, + }, +}; diff --git a/spec/frontend/clusters_list/stubs.js b/spec/frontend/clusters_list/stubs.js new file mode 100644 index 00000000000..5769d6190f6 --- /dev/null +++ b/spec/frontend/clusters_list/stubs.js @@ -0,0 +1,14 @@ +const ModalStub = { + name: 'glmodal-stub', + template: ` + <div> + <slot></slot> + <slot name="modal-footer"></slot> + </div> + `, + methods: { + hide: jest.fn(), + }, +}; + +export default ModalStub; diff --git a/spec/frontend/comment_type_toggle_spec.js b/spec/frontend/comment_type_toggle_spec.js deleted file mode 100644 index 06dbfac1803..00000000000 --- a/spec/frontend/comment_type_toggle_spec.js +++ /dev/null @@ -1,169 +0,0 @@ -import CommentTypeToggle from '~/comment_type_toggle'; -import DropLab from '~/droplab/drop_lab'; -import InputSetter from '~/droplab/plugins/input_setter'; - -describe('CommentTypeToggle', () => { - const testContext = {}; - - describe('class constructor', () => { - beforeEach(() => { - testContext.dropdownTrigger = {}; - testContext.dropdownList = {}; - testContext.noteTypeInput = {}; - testContext.submitButton = {}; - testContext.closeButton = {}; - - testContext.commentTypeToggle = new CommentTypeToggle({ - dropdownTrigger: testContext.dropdownTrigger, - dropdownList: testContext.dropdownList, - noteTypeInput: testContext.noteTypeInput, - submitButton: testContext.submitButton, - closeButton: testContext.closeButton, - }); - }); - - it('should set .dropdownTrigger', () => { - expect(testContext.commentTypeToggle.dropdownTrigger).toBe(testContext.dropdownTrigger); - }); - - it('should set .dropdownList', () => { - expect(testContext.commentTypeToggle.dropdownList).toBe(testContext.dropdownList); - }); - - it('should set .noteTypeInput', () => { - expect(testContext.commentTypeToggle.noteTypeInput).toBe(testContext.noteTypeInput); - }); - - it('should set .submitButton', () => { - expect(testContext.commentTypeToggle.submitButton).toBe(testContext.submitButton); - }); - - it('should set .closeButton', () => { - expect(testContext.commentTypeToggle.closeButton).toBe(testContext.closeButton); - }); - - it('should set .reopenButton', () => { - expect(testContext.commentTypeToggle.reopenButton).toBe(testContext.reopenButton); - }); - }); - - describe('initDroplab', () => { - beforeEach(() => { - testContext.commentTypeToggle = { - dropdownTrigger: {}, - dropdownList: {}, - noteTypeInput: {}, - submitButton: {}, - closeButton: {}, - setConfig: () => {}, - }; - testContext.config = {}; - - jest.spyOn(DropLab.prototype, 'init').mockImplementation(); - jest.spyOn(DropLab.prototype, 'constructor').mockImplementation(); - - jest.spyOn(testContext.commentTypeToggle, 'setConfig').mockReturnValue(testContext.config); - - CommentTypeToggle.prototype.initDroplab.call(testContext.commentTypeToggle); - }); - - it('should instantiate a DropLab instance and set .droplab', () => { - expect(testContext.commentTypeToggle.droplab instanceof DropLab).toBe(true); - }); - - it('should call .setConfig', () => { - expect(testContext.commentTypeToggle.setConfig).toHaveBeenCalled(); - }); - - it('should call DropLab.prototype.init', () => { - expect(DropLab.prototype.init).toHaveBeenCalledWith( - testContext.commentTypeToggle.dropdownTrigger, - testContext.commentTypeToggle.dropdownList, - [InputSetter], - testContext.config, - ); - }); - }); - - describe('setConfig', () => { - describe('if no .closeButton is provided', () => { - beforeEach(() => { - testContext.commentTypeToggle = { - dropdownTrigger: {}, - dropdownList: {}, - noteTypeInput: {}, - submitButton: {}, - reopenButton: {}, - }; - - testContext.setConfig = CommentTypeToggle.prototype.setConfig.call( - testContext.commentTypeToggle, - ); - }); - - it('should not add .closeButton related InputSetter config', () => { - expect(testContext.setConfig).toEqual({ - InputSetter: [ - { - input: testContext.commentTypeToggle.noteTypeInput, - valueAttribute: 'data-value', - }, - { - input: testContext.commentTypeToggle.submitButton, - valueAttribute: 'data-submit-text', - }, - { - input: testContext.commentTypeToggle.reopenButton, - valueAttribute: 'data-reopen-text', - }, - { - input: testContext.commentTypeToggle.reopenButton, - valueAttribute: 'data-reopen-text', - inputAttribute: 'data-alternative-text', - }, - ], - }); - }); - }); - - describe('if no .reopenButton is provided', () => { - beforeEach(() => { - testContext.commentTypeToggle = { - dropdownTrigger: {}, - dropdownList: {}, - noteTypeInput: {}, - submitButton: {}, - closeButton: {}, - }; - - testContext.setConfig = CommentTypeToggle.prototype.setConfig.call( - testContext.commentTypeToggle, - ); - }); - - it('should not add .reopenButton related InputSetter config', () => { - expect(testContext.setConfig).toEqual({ - InputSetter: [ - { - input: testContext.commentTypeToggle.noteTypeInput, - valueAttribute: 'data-value', - }, - { - input: testContext.commentTypeToggle.submitButton, - valueAttribute: 'data-submit-text', - }, - { - input: testContext.commentTypeToggle.closeButton, - valueAttribute: 'data-close-text', - }, - { - input: testContext.commentTypeToggle.closeButton, - valueAttribute: 'data-close-text', - inputAttribute: 'data-alternative-text', - }, - ], - }); - }); - }); - }); -}); diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js index 8082b8524e7..3a549e66eb7 100644 --- a/spec/frontend/commit/commit_pipeline_status_component_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js @@ -1,7 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Visibility from 'visibilityjs'; -import { getJSONFixture } from 'helpers/fixtures'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; @@ -20,7 +20,7 @@ jest.mock('~/projects/tree/services/commit_pipeline_service', () => describe('Commit pipeline status component', () => { let wrapper; - const { pipelines } = getJSONFixture('pipelines/pipelines.json'); + const { pipelines } = fixture; const { status: mockCiStatus } = pipelines[0].details; const defaultProps = { diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index 1defb3d586c..17f7be9d1d7 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -1,6 +1,7 @@ import { GlEmptyState, GlLoadingIcon, GlModal, GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; @@ -8,7 +9,6 @@ import PipelinesTable from '~/commit/pipelines/pipelines_table.vue'; import axios from '~/lib/utils/axios_utils'; describe('Pipelines table in Commits and Merge requests', () => { - const jsonFixtureName = 'pipelines/pipelines.json'; let wrapper; let pipeline; let mock; @@ -37,7 +37,7 @@ describe('Pipelines table in Commits and Merge requests', () => { beforeEach(() => { mock = new MockAdapter(axios); - const { pipelines } = getJSONFixture(jsonFixtureName); + const { pipelines } = fixture; pipeline = pipelines.find((p) => p.user !== null && p.commit !== null); }); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap index 8f5516545eb..178c7d749c8 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -11,14 +11,7 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen <ul role=\\"menu\\" tabindex=\\"-1\\" class=\\"dropdown-menu\\"> <div class=\\"gl-new-dropdown-inner\\"> <!----> - <div class=\\"gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5\\"> - <div class=\\"gl-display-flex\\"> - <!----> - </div> - <div class=\\"gl-display-flex\\"> - <!----> - </div> - </div> + <!----> <div class=\\"gl-new-dropdown-contents\\"> <!----> <li role=\\"presentation\\" class=\\"gl-px-3!\\"> diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index a5df3d73289..ec58877470c 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -31,6 +31,7 @@ describe('content_editor/components/top_toolbar', () => { ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} + ${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }} ${'horizontal-rule'} | ${{ contentType: 'horizontalRule', iconName: 'dash', label: 'Add a horizontal rule', editorCommand: 'setHorizontalRule' }} ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} ${'text-styles'} | ${{}} diff --git a/spec/frontend/content_editor/components/wrappers/details_spec.js b/spec/frontend/content_editor/components/wrappers/details_spec.js new file mode 100644 index 00000000000..d746b9fa2f1 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/details_spec.js @@ -0,0 +1,40 @@ +import { NodeViewContent } from '@tiptap/vue-2'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import DetailsWrapper from '~/content_editor/components/wrappers/details.vue'; + +describe('content/components/wrappers/details', () => { + let wrapper; + + const createWrapper = async () => { + wrapper = shallowMountExtended(DetailsWrapper, { + propsData: { + node: {}, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-content as a ul element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewContent).props().as).toBe('ul'); + }); + + it('is "open" by default', () => { + createWrapper(); + + expect(wrapper.findByTestId('details-toggle-icon').classes()).toContain('is-open'); + expect(wrapper.findComponent(NodeViewContent).classes()).toContain('is-open'); + }); + + it('closes the details block on clicking the details toggle icon', async () => { + createWrapper(); + + await wrapper.findByTestId('details-toggle-icon').trigger('click'); + expect(wrapper.findByTestId('details-toggle-icon').classes()).not.toContain('is-open'); + expect(wrapper.findComponent(NodeViewContent).classes()).not.toContain('is-open'); + }); +}); diff --git a/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js new file mode 100644 index 00000000000..de8f8efd260 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/frontmatter_spec.js @@ -0,0 +1,43 @@ +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; +import { shallowMount } from '@vue/test-utils'; +import FrontmatterWrapper from '~/content_editor/components/wrappers/frontmatter.vue'; + +describe('content/components/wrappers/frontmatter', () => { + let wrapper; + + const createWrapper = async (nodeAttrs = { language: 'yaml' }) => { + wrapper = shallowMount(FrontmatterWrapper, { + propsData: { + node: { + attrs: nodeAttrs, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-wrapper as a pre element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewWrapper).props().as).toBe('pre'); + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-relative'); + }); + + it('renders a node-view-content as a code element', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewContent).props().as).toBe('code'); + }); + + it('renders label indicating that code block is frontmatter', () => { + createWrapper(); + + const label = wrapper.find('[data-testid="frontmatter-label"]'); + + expect(label.text()).toEqual('frontmatter:yaml'); + expect(label.classes()).toEqual(['gl-absolute', 'gl-top-0', 'gl-right-3']); + }); +}); diff --git a/spec/frontend/content_editor/extensions/color_chip_spec.js b/spec/frontend/content_editor/extensions/color_chip_spec.js new file mode 100644 index 00000000000..4bb6f344ab4 --- /dev/null +++ b/spec/frontend/content_editor/extensions/color_chip_spec.js @@ -0,0 +1,33 @@ +import ColorChip, { colorDecoratorPlugin } from '~/content_editor/extensions/color_chip'; +import Code from '~/content_editor/extensions/code'; +import { createTestEditor } from '../test_utils'; + +describe('content_editor/extensions/color_chip', () => { + let tiptapEditor; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [ColorChip, Code] }); + }); + + describe.each` + colorExpression | decorated + ${'#F00'} | ${true} + ${'rgba(0,0,0,0)'} | ${true} + ${'hsl(540,70%,50%)'} | ${true} + ${'F00'} | ${false} + ${'F00'} | ${false} + ${'gba(0,0,0,0)'} | ${false} + ${'hls(540,70%,50%)'} | ${false} + ${'red'} | ${false} + `( + 'when a code span with $colorExpression color expression is found', + ({ colorExpression, decorated }) => { + it(`${decorated ? 'adds' : 'does not add'} a color chip decorator`, () => { + tiptapEditor.commands.setContent(`<p><code>${colorExpression}</code></p>`); + const pluginState = colorDecoratorPlugin.getState(tiptapEditor.state); + + expect(pluginState.children).toHaveLength(decorated ? 3 : 0); + }); + }, + ); +}); diff --git a/spec/frontend/content_editor/extensions/details_content_spec.js b/spec/frontend/content_editor/extensions/details_content_spec.js new file mode 100644 index 00000000000..575f3bf65e4 --- /dev/null +++ b/spec/frontend/content_editor/extensions/details_content_spec.js @@ -0,0 +1,76 @@ +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/details_content', () => { + let tiptapEditor; + let doc; + let p; + let details; + let detailsContent; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Details, DetailsContent] }); + + ({ + builders: { doc, p, details, detailsContent }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, + }, + })); + }); + + describe('shortcut: Enter', () => { + it('splits a details content into two items', () => { + const initialDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + const expectedDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.commands.setTextSelection(10); + tiptapEditor.commands.keyboardShortcut('Enter'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('shortcut: Shift-Tab', () => { + it('lifts a details content and creates two separate details items', () => { + const initialDoc = doc( + details( + detailsContent(p('Summary')), + detailsContent(p('Text content')), + detailsContent(p('Text content')), + ), + ); + const expectedDoc = doc( + details(detailsContent(p('Summary'))), + p('Text content'), + details(detailsContent(p('Text content'))), + ); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + + tiptapEditor.commands.setTextSelection(20); + tiptapEditor.commands.keyboardShortcut('Shift-Tab'); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/details_spec.js b/spec/frontend/content_editor/extensions/details_spec.js new file mode 100644 index 00000000000..cd59943982f --- /dev/null +++ b/spec/frontend/content_editor/extensions/details_spec.js @@ -0,0 +1,92 @@ +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/details', () => { + let tiptapEditor; + let doc; + let p; + let details; + let detailsContent; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Details, DetailsContent] }); + + ({ + builders: { doc, p, details, detailsContent }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, + }, + })); + }); + + describe('setDetails command', () => { + describe('when current block is a paragraph', () => { + it('converts current paragraph into a details block', () => { + const initialDoc = doc(p('Text content')); + const expectedDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when current block is a details block', () => { + it('maintains the same document structure', () => { + const initialDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.setDetails(); + + expect(tiptapEditor.getJSON()).toEqual(initialDoc.toJSON()); + }); + }); + }); + + describe('toggleDetails command', () => { + describe('when current block is a paragraph', () => { + it('converts current paragraph into a details block', () => { + const initialDoc = doc(p('Text content')); + const expectedDoc = doc(details(detailsContent(p('Text content')))); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.toggleDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + + describe('when current block is a details block', () => { + it('convert details block into a paragraph', () => { + const initialDoc = doc(details(detailsContent(p('Text content')))); + const expectedDoc = doc(p('Text content')); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.toggleDetails(); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); + }); + + it.each` + input | insertedNode + ${'<details>'} | ${(...args) => details(detailsContent(p(...args)))} + ${'<details'} | ${(...args) => p(...args)} + ${'details>'} | ${(...args) => p(...args)} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { view } = tiptapEditor; + const { selection } = view.state; + const expectedDoc = doc(insertedNode()); + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/math_inline_spec.js b/spec/frontend/content_editor/extensions/math_inline_spec.js new file mode 100644 index 00000000000..82eb85477de --- /dev/null +++ b/spec/frontend/content_editor/extensions/math_inline_spec.js @@ -0,0 +1,42 @@ +import MathInline from '~/content_editor/extensions/math_inline'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/math_inline', () => { + let tiptapEditor; + let doc; + let p; + let mathInline; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [MathInline] }); + + ({ + builders: { doc, p, mathInline }, + } = createDocBuilder({ + tiptapEditor, + names: { + details: { markType: MathInline.name }, + }, + })); + }); + + it.each` + input | insertedNode + ${'$`a^2`$'} | ${() => p(mathInline('a^2'))} + ${'$`a^2`'} | ${() => p('$`a^2`')} + ${'`a^2`$'} | ${() => p('`a^2`$')} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { view } = tiptapEditor; + const expectedDoc = doc(insertedNode()); + + tiptapEditor.chain().setContent(input).setTextSelection(0).run(); + + const { state } = tiptapEditor; + const { selection } = state; + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, input.length + 1, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/extensions/table_of_contents_spec.js b/spec/frontend/content_editor/extensions/table_of_contents_spec.js new file mode 100644 index 00000000000..83818899c17 --- /dev/null +++ b/spec/frontend/content_editor/extensions/table_of_contents_spec.js @@ -0,0 +1,35 @@ +import TableOfContents from '~/content_editor/extensions/table_of_contents'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/emoji', () => { + let tiptapEditor; + let builders; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [TableOfContents] }); + ({ builders } = createDocBuilder({ + tiptapEditor, + names: { tableOfContents: { nodeType: TableOfContents.name } }, + })); + }); + + it.each` + input | insertedNode + ${'[[_TOC_]]'} | ${'tableOfContents'} + ${'[TOC]'} | ${'tableOfContents'} + ${'[toc]'} | ${'p'} + ${'TOC'} | ${'p'} + ${'[_TOC_]'} | ${'p'} + ${'[[TOC]]'} | ${'p'} + `('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => { + const { doc } = builders; + const { view } = tiptapEditor; + const { selection } = view.state; + const expectedDoc = doc(builders[insertedNode]()); + + // Triggers the event handler that input rules listen to + view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input)); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js index b3aabfeb145..da895970289 100644 --- a/spec/frontend/content_editor/markdown_processing_examples.js +++ b/spec/frontend/content_editor/markdown_processing_examples.js @@ -1,11 +1,13 @@ import fs from 'fs'; import path from 'path'; import jsYaml from 'js-yaml'; +// eslint-disable-next-line import/no-deprecated import { getJSONFixture } from 'helpers/fixtures'; export const loadMarkdownApiResult = (testName) => { const fixturePathPrefix = `api/markdown/${testName}.json`; + // eslint-disable-next-line import/no-deprecated const fixture = getJSONFixture(fixturePathPrefix); return fixture.body || fixture.html; }; diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 6f2c908c289..33056ab9e4a 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -5,6 +5,8 @@ import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; +import Details from '~/content_editor/extensions/details'; +import DetailsContent from '~/content_editor/extensions/details_content'; import Division from '~/content_editor/extensions/division'; import Emoji from '~/content_editor/extensions/emoji'; import Figure from '~/content_editor/extensions/figure'; @@ -45,6 +47,8 @@ const tiptapEditor = createTestEditor({ CodeBlockHighlight, DescriptionItem, DescriptionList, + Details, + DetailsContent, Division, Emoji, Figure, @@ -78,6 +82,8 @@ const { bulletList, code, codeBlock, + details, + detailsContent, division, descriptionItem, descriptionList, @@ -110,6 +116,8 @@ const { bulletList: { nodeType: BulletList.name }, code: { markType: Code.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, + details: { nodeType: Details.name }, + detailsContent: { nodeType: DetailsContent.name }, division: { nodeType: Division.name }, descriptionItem: { nodeType: DescriptionItem.name }, descriptionList: { nodeType: DescriptionList.name }, @@ -588,6 +596,105 @@ A giant _owl-like_ creature. ); }); + it('correctly renders a simple details/summary', () => { + expect( + serialize( + details( + detailsContent(paragraph('this is the summary')), + detailsContent(paragraph('this content will be hidden')), + ), + ), + ).toBe( + ` +<details> +<summary>this is the summary</summary> +this content will be hidden +</details> + `.trim(), + ); + }); + + it('correctly renders details/summary with styled content', () => { + expect( + serialize( + details( + detailsContent(paragraph('this is the ', bold('summary'))), + detailsContent( + codeBlock( + { language: 'javascript' }, + 'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);', + ), + ), + detailsContent(paragraph('this content will be ', italic('hidden'))), + ), + details(detailsContent(paragraph('summary 2')), detailsContent(paragraph('content 2'))), + ), + ).toBe( + ` +<details> +<summary> + +this is the **summary** + +</summary> + +\`\`\`javascript +var a = 2; +var b = 3; +var c = a + d; + +console.log(c); +\`\`\` + +this content will be _hidden_ + +</details> +<details> +<summary>summary 2</summary> +content 2 +</details> + `.trim(), + ); + }); + + it('correctly renders nested details', () => { + expect( + serialize( + details( + detailsContent(paragraph('dream level 1')), + detailsContent( + details( + detailsContent(paragraph('dream level 2')), + detailsContent( + details( + detailsContent(paragraph('dream level 3')), + detailsContent(paragraph(italic('inception'))), + ), + ), + ), + ), + ), + ), + ).toBe( + ` +<details> +<summary>dream level 1</summary> + +<details> +<summary>dream level 2</summary> + +<details> +<summary>dream level 3</summary> + +_inception_ + +</details> +</details> +</details> + `.trim(), + ); + }); + it('correctly renders div', () => { expect( serialize( diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js index 5d3361bfa35..9a9415cc12a 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/cycle_analytics/base_spec.js @@ -19,6 +19,7 @@ import { createdAfter, currentGroup, stageCounts, + initialPaginationState as pagination, } from './mock_data'; const selectedStageEvents = issueEvents.events; @@ -81,6 +82,7 @@ const findOverviewMetrics = () => wrapper.findComponent(ValueStreamMetrics); const findStageTable = () => wrapper.findComponent(StageTable); const findStageEvents = () => findStageTable().props('stageEvents'); const findEmptyStageTitle = () => wrapper.findComponent(GlEmptyState).props('title'); +const findPagination = () => wrapper.findByTestId('vsa-stage-pagination'); const hasMetricsRequests = (reqs) => { const foundReqs = findOverviewMetrics().props('requests'); @@ -90,7 +92,7 @@ const hasMetricsRequests = (reqs) => { describe('Value stream analytics component', () => { beforeEach(() => { - wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents } }); + wrapper = createComponent({ initialState: { selectedStage, selectedStageEvents, pagination } }); }); afterEach(() => { @@ -153,6 +155,10 @@ describe('Value stream analytics component', () => { expect(findLoadingIcon().exists()).toBe(false); }); + it('renders pagination', () => { + expect(findPagination().exists()).toBe(true); + }); + describe('with `cycleAnalyticsForGroups=true` license', () => { beforeEach(() => { wrapper = createComponent({ initialState: { features: { cycleAnalyticsForGroups: true } } }); diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index d9659d5d4c3..1882457960a 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -1,6 +1,14 @@ +/* eslint-disable import/no-deprecated */ + import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; -import { DEFAULT_VALUE_STREAM, DEFAULT_DAYS_IN_PAST } from '~/cycle_analytics/constants'; +import { + DEFAULT_VALUE_STREAM, + DEFAULT_DAYS_IN_PAST, + PAGINATION_TYPE, + PAGINATION_SORT_DIRECTION_DESC, + PAGINATION_SORT_FIELD_END_EVENT, +} from '~/cycle_analytics/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { getDateInPast } from '~/lib/utils/datetime_utility'; @@ -13,9 +21,10 @@ export const getStageByTitle = (stages, title) => stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {}; const fixtureEndpoints = { - customizableCycleAnalyticsStagesAndEvents: 'projects/analytics/value_stream_analytics/stages', - stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}`, - metricsData: 'projects/analytics/value_stream_analytics/summary', + customizableCycleAnalyticsStagesAndEvents: + 'projects/analytics/value_stream_analytics/stages.json', + stageEvents: (stage) => `projects/analytics/value_stream_analytics/events/${stage}.json`, + metricsData: 'projects/analytics/value_stream_analytics/summary.json', }; export const metricsData = getJSONFixture(fixtureEndpoints.metricsData); @@ -256,3 +265,22 @@ export const rawValueStreamStages = customizableStagesAndEvents.stages; export const valueStreamStages = rawValueStreamStages.map((s) => convertObjectPropsToCamelCase(s, { deep: true }), ); + +export const initialPaginationQuery = { + page: 15, + sort: PAGINATION_SORT_FIELD_END_EVENT, + direction: PAGINATION_SORT_DIRECTION_DESC, +}; + +export const initialPaginationState = { + ...initialPaginationQuery, + page: null, + hasNextPage: false, +}; + +export const basePaginationResult = { + pagination: PAGINATION_TYPE, + sort: PAGINATION_SORT_FIELD_END_EVENT, + direction: PAGINATION_SORT_DIRECTION_DESC, + page: null, +}; diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 97b5bd03e18..993e6b6b73a 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -11,6 +11,8 @@ import { currentGroup, createdAfter, createdBefore, + initialPaginationState, + reviewEvents, } from '../mock_data'; const { id: groupId, path: groupPath } = currentGroup; @@ -31,7 +33,13 @@ const mockSetDateActionCommit = { type: 'SET_DATE_RANGE', }; -const defaultState = { ...getters, selectedValueStream, createdAfter, createdBefore }; +const defaultState = { + ...getters, + selectedValueStream, + createdAfter, + createdBefore, + pagination: initialPaginationState, +}; describe('Project Value Stream Analytics actions', () => { let state; @@ -112,6 +120,21 @@ describe('Project Value Stream Analytics actions', () => { }); }); + describe('updateStageTablePagination', () => { + beforeEach(() => { + state = { ...state, selectedStage }; + }); + + it(`will dispatch the "fetchStageData" action and commit the 'SET_PAGINATION' mutation`, () => { + return testAction({ + action: actions.updateStageTablePagination, + state, + expectedMutations: [{ type: 'SET_PAGINATION' }], + expectedActions: [{ type: 'fetchStageData', payload: selectedStage.id }], + }); + }); + }); + describe('fetchCycleAnalyticsData', () => { beforeEach(() => { state = { ...defaultState, endpoints: mockEndpoints }; @@ -154,6 +177,10 @@ describe('Project Value Stream Analytics actions', () => { describe('fetchStageData', () => { const mockStagePath = /value_streams\/\w+\/stages\/\w+\/records/; + const headers = { + 'X-Next-Page': 2, + 'X-Page': 1, + }; beforeEach(() => { state = { @@ -162,7 +189,7 @@ describe('Project Value Stream Analytics actions', () => { selectedStage, }; mock = new MockAdapter(axios); - mock.onGet(mockStagePath).reply(httpStatusCodes.OK); + mock.onGet(mockStagePath).reply(httpStatusCodes.OK, reviewEvents, headers); }); it(`commits the 'RECEIVE_STAGE_DATA_SUCCESS' mutation`, () => @@ -170,7 +197,11 @@ describe('Project Value Stream Analytics actions', () => { action: actions.fetchStageData, state, payload: {}, - expectedMutations: [{ type: 'REQUEST_STAGE_DATA' }, { type: 'RECEIVE_STAGE_DATA_SUCCESS' }], + expectedMutations: [ + { type: 'REQUEST_STAGE_DATA' }, + { type: 'RECEIVE_STAGE_DATA_SUCCESS', payload: reviewEvents }, + { type: 'SET_PAGINATION', payload: { hasNextPage: true, page: 1 } }, + ], expectedActions: [], })); diff --git a/spec/frontend/cycle_analytics/store/getters_spec.js b/spec/frontend/cycle_analytics/store/getters_spec.js index c47a30a5f79..c9208045a68 100644 --- a/spec/frontend/cycle_analytics/store/getters_spec.js +++ b/spec/frontend/cycle_analytics/store/getters_spec.js @@ -1,17 +1,42 @@ import * as getters from '~/cycle_analytics/store/getters'; + import { allowedStages, stageMedians, transformedProjectStagePathData, selectedStage, stageCounts, + basePaginationResult, + initialPaginationState, } from '../mock_data'; describe('Value stream analytics getters', () => { + let state = {}; + describe('pathNavigationData', () => { it('returns the transformed data', () => { - const state = { stages: allowedStages, medians: stageMedians, selectedStage, stageCounts }; + state = { stages: allowedStages, medians: stageMedians, selectedStage, stageCounts }; expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData); }); }); + + describe('paginationParams', () => { + beforeEach(() => { + state = { pagination: initialPaginationState }; + }); + + it('returns the `pagination` type', () => { + expect(getters.paginationParams(state)).toEqual(basePaginationResult); + }); + + it('returns the `sort` type', () => { + expect(getters.paginationParams(state)).toEqual(basePaginationResult); + }); + + it('with page=10, sets the `page` property', () => { + const page = 10; + state = { pagination: { ...initialPaginationState, page } }; + expect(getters.paginationParams(state)).toEqual({ ...basePaginationResult, page }); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 628e2a4e7ae..4860225c995 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -2,6 +2,10 @@ import { useFakeDate } from 'helpers/fake_date'; import * as types from '~/cycle_analytics/store/mutation_types'; import mutations from '~/cycle_analytics/store/mutations'; import { + PAGINATION_SORT_FIELD_END_EVENT, + PAGINATION_SORT_DIRECTION_DESC, +} from '~/cycle_analytics/constants'; +import { selectedStage, rawIssueEvents, issueEvents, @@ -12,6 +16,7 @@ import { formattedStageMedians, rawStageCounts, stageCounts, + initialPaginationState as pagination, } from '../mock_data'; let state; @@ -25,7 +30,7 @@ describe('Project Value Stream Analytics mutations', () => { useFakeDate(2020, 6, 18); beforeEach(() => { - state = {}; + state = { pagination }; }); afterEach(() => { @@ -88,16 +93,18 @@ describe('Project Value Stream Analytics mutations', () => { }); it.each` - mutation | payload | stateKey | value - ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdAfter'} | ${mockCreatedAfter} - ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdBefore'} | ${mockCreatedBefore} - ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} - ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} - ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} - ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} - ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} - ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} - ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts} + mutation | payload | stateKey | value + ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.SET_DATE_RANGE} | ${mockSetDatePayload} | ${'createdBefore'} | ${mockCreatedBefore} + ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} + ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} + ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} + ${types.SET_PAGINATION} | ${pagination} | ${'pagination'} | ${{ ...pagination, sort: PAGINATION_SORT_FIELD_END_EVENT, direction: PAGINATION_SORT_DIRECTION_DESC }} + ${types.SET_PAGINATION} | ${{ ...pagination, sort: 'duration', direction: 'asc' }} | ${'pagination'} | ${{ ...pagination, sort: 'duration', direction: 'asc' }} + ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} + ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} + ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} + ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index 69fed879fd8..74d64cd8d71 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -1,7 +1,6 @@ import { useFakeDate } from 'helpers/fake_date'; import { transformStagesForPathNavigation, - timeSummaryForPathNavigation, medianTimeToParsedSeconds, formatMedianValues, filterStagesByHiddenStatus, @@ -47,21 +46,6 @@ describe('Value stream analytics utils', () => { }); }); - describe('timeSummaryForPathNavigation', () => { - it.each` - unit | value | result - ${'months'} | ${1.5} | ${'1.5M'} - ${'weeks'} | ${1.25} | ${'1.5w'} - ${'days'} | ${2} | ${'2d'} - ${'hours'} | ${10} | ${'10h'} - ${'minutes'} | ${20} | ${'20m'} - ${'seconds'} | ${10} | ${'<1m'} - ${'seconds'} | ${0} | ${'-'} - `('will format $value $unit to $result', ({ unit, value, result }) => { - expect(timeSummaryForPathNavigation({ [unit]: value })).toBe(result); - }); - }); - describe('medianTimeToParsedSeconds', () => { it.each` value | result diff --git a/spec/frontend/deploy_freeze/helpers.js b/spec/frontend/deploy_freeze/helpers.js index 598f14d45f6..43e66183ab5 100644 --- a/spec/frontend/deploy_freeze/helpers.js +++ b/spec/frontend/deploy_freeze/helpers.js @@ -1,7 +1,8 @@ +import freezePeriodsFixture from 'test_fixtures/api/freeze-periods/freeze_periods.json'; +import timezoneDataFixture from 'test_fixtures/timezones/short.json'; import { secondsToHours } from '~/lib/utils/datetime_utility'; -export const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); -export const timezoneDataFixture = getJSONFixture('/timezones/short.json'); +export { freezePeriodsFixture, timezoneDataFixture }; export const findTzByName = (identifier = '') => timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase()); diff --git a/spec/frontend/deploy_keys/components/action_btn_spec.js b/spec/frontend/deploy_keys/components/action_btn_spec.js index 307a0b6d8b0..6ac68061518 100644 --- a/spec/frontend/deploy_keys/components/action_btn_spec.js +++ b/spec/frontend/deploy_keys/components/action_btn_spec.js @@ -1,10 +1,10 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import data from 'test_fixtures/deploy_keys/keys.json'; import actionBtn from '~/deploy_keys/components/action_btn.vue'; import eventHub from '~/deploy_keys/eventhub'; describe('Deploy keys action btn', () => { - const data = getJSONFixture('deploy_keys/keys.json'); const deployKey = data.enabled_keys[0]; let wrapper; diff --git a/spec/frontend/deploy_keys/components/app_spec.js b/spec/frontend/deploy_keys/components/app_spec.js index a72b2b00776..598b7a0f173 100644 --- a/spec/frontend/deploy_keys/components/app_spec.js +++ b/spec/frontend/deploy_keys/components/app_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import data from 'test_fixtures/deploy_keys/keys.json'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import deployKeysApp from '~/deploy_keys/components/app.vue'; @@ -10,7 +11,6 @@ import axios from '~/lib/utils/axios_utils'; const TEST_ENDPOINT = `${TEST_HOST}/dummy/`; describe('Deploy keys app component', () => { - const data = getJSONFixture('deploy_keys/keys.json'); let wrapper; let mock; diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js index 5420f9a01f9..511b9d6ef55 100644 --- a/spec/frontend/deploy_keys/components/key_spec.js +++ b/spec/frontend/deploy_keys/components/key_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import data from 'test_fixtures/deploy_keys/keys.json'; import key from '~/deploy_keys/components/key.vue'; import DeployKeysStore from '~/deploy_keys/store'; import { getTimeago } from '~/lib/utils/datetime_utility'; @@ -7,8 +8,6 @@ describe('Deploy keys key', () => { let wrapper; let store; - const data = getJSONFixture('deploy_keys/keys.json'); - const findTextAndTrim = (selector) => wrapper.find(selector).text().trim(); const createComponent = (propsData) => { diff --git a/spec/frontend/deploy_keys/components/keys_panel_spec.js b/spec/frontend/deploy_keys/components/keys_panel_spec.js index d6419356166..f3b907e5450 100644 --- a/spec/frontend/deploy_keys/components/keys_panel_spec.js +++ b/spec/frontend/deploy_keys/components/keys_panel_spec.js @@ -1,9 +1,9 @@ import { mount } from '@vue/test-utils'; +import data from 'test_fixtures/deploy_keys/keys.json'; import deployKeysPanel from '~/deploy_keys/components/keys_panel.vue'; import DeployKeysStore from '~/deploy_keys/store'; describe('Deploy keys panel', () => { - const data = getJSONFixture('deploy_keys/keys.json'); let wrapper; const findTableRowHeader = () => wrapper.find('.table-row-header'); diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 4a6dee31cd5..7e4c6e131b4 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -3,6 +3,8 @@ import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import '~/lib/utils/common_utils'; +// eslint-disable-next-line import/no-deprecated +import { getJSONFixture } from 'helpers/fixtures'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -66,6 +68,7 @@ describe('deprecatedJQueryDropdown', () => { loadFixtures('static/deprecated_jquery_dropdown.html'); test.dropdownContainerElement = $('.dropdown.inline'); test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement); + // eslint-disable-next-line import/no-deprecated test.projectsData = getJSONFixture('static/projects.json'); }); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 67e4a82787c..2b706d21f51 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -4,13 +4,13 @@ exports[`Design management design version dropdown component renders design vers <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" issueiid="" projectpath="" - showhighlighteditemstitle="true" size="small" text="Showing latest version" variant="default" @@ -85,13 +85,13 @@ exports[`Design management design version dropdown component renders design vers <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" issueiid="" projectpath="" - showhighlighteditemstitle="true" size="small" text="Showing latest version" variant="default" diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js index 7327cf00abd..fa6a666bb37 100644 --- a/spec/frontend/design_management/utils/cache_update_spec.js +++ b/spec/frontend/design_management/utils/cache_update_spec.js @@ -26,11 +26,11 @@ describe('Design Management cache update', () => { describe('error handling', () => { it.each` - fnName | subject | errorMessage | extraArgs - ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} - ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} - ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} - ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterRepositionImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} + fnName | subject | errorMessage | extraArgs + ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError()} | ${[[design]]} + ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} + ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} + ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterRepositionImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => { expect(createFlash).not.toHaveBeenCalled(); expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow(); diff --git a/spec/frontend/design_management/utils/error_messages_spec.js b/spec/frontend/design_management/utils/error_messages_spec.js index b80dcd9abde..4994f4f6fd0 100644 --- a/spec/frontend/design_management/utils/error_messages_spec.js +++ b/spec/frontend/design_management/utils/error_messages_spec.js @@ -10,20 +10,21 @@ const mockFilenames = (n) => describe('Error message', () => { describe('designDeletionError', () => { - const singularMsg = 'Could not archive a design. Please try again.'; - const pluralMsg = 'Could not archive designs. Please try again.'; + const singularMsg = 'Failed to archive a design. Please try again.'; + const pluralMsg = 'Failed to archive designs. Please try again.'; - describe('when [singular=true]', () => { - it.each([[undefined], [true]])('uses singular grammar', (singularOption) => { - expect(designDeletionError({ singular: singularOption })).toEqual(singularMsg); - }); - }); - - describe('when [singular=false]', () => { - it('uses plural grammar', () => { - expect(designDeletionError({ singular: false })).toEqual(pluralMsg); - }); - }); + it.each` + designsLength | expectedText + ${undefined} | ${singularMsg} + ${0} | ${pluralMsg} + ${1} | ${singularMsg} + ${2} | ${pluralMsg} + `( + 'returns "$expectedText" when designsLength is $designsLength', + ({ designsLength, expectedText }) => { + expect(designDeletionError(designsLength)).toBe(expectedText); + }, + ); }); describe.each([ @@ -47,12 +48,12 @@ describe('Error message', () => { [ mockFilenames(7), mockFilenames(6), - 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 1 more.', + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg and 1 more.', ], [ mockFilenames(8), mockFilenames(7), - 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 2 more.', + 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg and 2 more.', ], ])('designUploadSkippedWarning', (uploadedFiles, skippedFiles, expected) => { it('returns expected warning message', () => { diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 9dc82bbdc93..0527c2153f4 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -13,11 +13,8 @@ import DiffFile from '~/diffs/components/diff_file.vue'; import NoChanges from '~/diffs/components/no_changes.vue'; import TreeList from '~/diffs/components/tree_list.vue'; -/* eslint-disable import/order */ -/* You know what: sometimes alphabetical isn't the best order */ import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; -/* eslint-enable import/order */ import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; @@ -705,4 +702,23 @@ describe('diffs/components/app', () => { ); }); }); + + describe('fluid layout', () => { + beforeEach(() => { + setFixtures( + '<div><div class="merge-request-container limit-container-width container-limited"></div></div>', + ); + }); + + it('removes limited container classes when on diffs tab', () => { + createComponent({ isFluidLayout: false, shouldShow: true }, () => {}, { + glFeatures: { mrChangesFluidLayout: true }, + }); + + const containerClassList = document.querySelector('.merge-request-container').classList; + + expect(containerClassList).not.toContain('container-limited'); + expect(containerClassList).not.toContain('limit-container-width'); + }); + }); }); diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 0191822d97a..d887029124f 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -1,10 +1,10 @@ import { mount } from '@vue/test-utils'; +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import Component from '~/diffs/components/commit_item.vue'; import { getTimeago } from '~/lib/utils/datetime_utility'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; -import getDiffWithCommit from '../mock_data/diff_with_commit'; jest.mock('~/user_popovers'); @@ -18,7 +18,7 @@ describe('diffs/components/commit_item', () => { let wrapper; const timeago = getTimeago(); - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; const getTitleElement = () => wrapper.find('.commit-row-message.item-title'); const getDescElement = () => wrapper.find('pre.commit-row-description'); diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index 1c0cb1193fa..c48935bc4f0 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -1,11 +1,11 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import getDiffWithCommit from 'test_fixtures/merge_request_diffs/with_commit.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { trimText } from 'helpers/text_helper'; import CompareVersionsComponent from '~/diffs/components/compare_versions.vue'; import { createStore } from '~/mr_notes/stores'; -import getDiffWithCommit from '../mock_data/diff_with_commit'; import diffsMockData from '../mock_data/merge_request_diffs'; const localVue = createLocalVue(); @@ -22,7 +22,7 @@ describe('CompareVersions', () => { let wrapper; let store; const targetBranchName = 'tmp-wine-dev'; - const { commit } = getDiffWithCommit(); + const { commit } = getDiffWithCommit; const createWrapper = (props = {}, commitArgs = {}, createCommit = true) => { if (createCommit) { @@ -150,7 +150,7 @@ describe('CompareVersions', () => { describe('commit', () => { beforeEach(() => { - store.state.diffs.commit = getDiffWithCommit().commit; + store.state.diffs.commit = getDiffWithCommit.commit; createWrapper(); }); diff --git a/spec/frontend/diffs/mock_data/diff_with_commit.js b/spec/frontend/diffs/mock_data/diff_with_commit.js deleted file mode 100644 index f3b39bd3577..00000000000 --- a/spec/frontend/diffs/mock_data/diff_with_commit.js +++ /dev/null @@ -1,5 +0,0 @@ -const FIXTURE = 'merge_request_diffs/with_commit.json'; - -export default function getDiffWithCommit() { - return getJSONFixture(FIXTURE); -} diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index b35abc9da02..85734e05aeb 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -51,7 +51,7 @@ import { } from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; -import * as workerUtils from '~/diffs/utils/workers'; +import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; @@ -253,7 +253,7 @@ describe('DiffsStoreActions', () => { // Workers are synchronous in Jest environment (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58805) { type: types.SET_TREE_DATA, - payload: workerUtils.generateTreeList(diffMetadata.diff_files), + payload: treeWorkerUtils.generateTreeList(diffMetadata.diff_files), }, ], [], diff --git a/spec/frontend/diffs/utils/workers_spec.js b/spec/frontend/diffs/utils/tree_worker_utils_spec.js index 25d8183b777..8113428f712 100644 --- a/spec/frontend/diffs/utils/workers_spec.js +++ b/spec/frontend/diffs/utils/tree_worker_utils_spec.js @@ -1,6 +1,10 @@ -import { generateTreeList, getLowestSingleFolder, flattenTree } from '~/diffs/utils/workers'; +import { + generateTreeList, + getLowestSingleFolder, + flattenTree, +} from '~/diffs/utils/tree_worker_utils'; -describe('~/diffs/utils/workers', () => { +describe('~/diffs/utils/tree_worker_utils', () => { describe('generateTreeList', () => { let files; diff --git a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js index 07ac080fe08..8a0d1ecf1af 100644 --- a/spec/frontend/editor/source_editor_ci_schema_ext_spec.js +++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js @@ -1,7 +1,7 @@ import { languages } from 'monaco-editor'; import { TEST_HOST } from 'helpers/test_constants'; -import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '~/editor/constants'; import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; +import ciSchemaPath from '~/editor/schema/ci.json'; import SourceEditor from '~/editor/source_editor'; const mockRef = 'AABBCCDD'; @@ -84,7 +84,7 @@ describe('~/editor/editor_ci_config_ext', () => { }); expect(getConfiguredYmlSchema()).toEqual({ - uri: `${TEST_HOST}/${mockProjectNamespace}/${mockProjectPath}/-/schema/${mockRef}/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`, + uri: `${TEST_HOST}${ciSchemaPath}`, fileMatch: [defaultBlobPath], }); }); @@ -99,7 +99,7 @@ describe('~/editor/editor_ci_config_ext', () => { }); expect(getConfiguredYmlSchema()).toEqual({ - uri: `${TEST_HOST}/${mockProjectNamespace}/${mockProjectPath}/-/schema/${mockRef}/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`, + uri: `${TEST_HOST}${ciSchemaPath}`, fileMatch: ['another-ci-filename.yml'], }); }); diff --git a/spec/frontend/environments/environment_delete_spec.js b/spec/frontend/environments/environment_delete_spec.js index a8c288a3bd8..2d8cff0c74a 100644 --- a/spec/frontend/environments/environment_delete_spec.js +++ b/spec/frontend/environments/environment_delete_spec.js @@ -1,4 +1,4 @@ -import { GlButton } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import DeleteComponent from '~/environments/components/environment_delete.vue'; @@ -15,7 +15,7 @@ describe('External URL Component', () => { }); }; - const findButton = () => wrapper.find(GlButton); + const findDropdownItem = () => wrapper.find(GlDropdownItem); beforeEach(() => { jest.spyOn(window, 'confirm'); @@ -23,14 +23,15 @@ describe('External URL Component', () => { createWrapper(); }); - it('should render a button to delete the environment', () => { - expect(findButton().exists()).toBe(true); - expect(wrapper.attributes('title')).toEqual('Delete environment'); + it('should render a dropdown item to delete the environment', () => { + expect(findDropdownItem().exists()).toBe(true); + expect(wrapper.text()).toEqual('Delete environment'); + expect(findDropdownItem().attributes('variant')).toBe('danger'); }); it('emits requestDeleteEnvironment in the event hub when button is clicked', () => { jest.spyOn(eventHub, '$emit'); - findButton().vm.$emit('click'); + findDropdownItem().vm.$emit('click'); expect(eventHub.$emit).toHaveBeenCalledWith('requestDeleteEnvironment', wrapper.vm.environment); }); }); diff --git a/spec/frontend/environments/environment_monitoring_spec.js b/spec/frontend/environments/environment_monitoring_spec.js index 3a53b57c3c6..98dd9edd812 100644 --- a/spec/frontend/environments/environment_monitoring_spec.js +++ b/spec/frontend/environments/environment_monitoring_spec.js @@ -1,6 +1,6 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import MonitoringComponent from '~/environments/components/environment_monitoring.vue'; +import { __ } from '~/locale'; describe('Monitoring Component', () => { let wrapper; @@ -8,31 +8,19 @@ describe('Monitoring Component', () => { const monitoringUrl = 'https://gitlab.com'; const createWrapper = () => { - wrapper = shallowMount(MonitoringComponent, { + wrapper = mountExtended(MonitoringComponent, { propsData: { monitoringUrl, }, }); }; - const findButtons = () => wrapper.findAll(GlButton); - const findButtonsByIcon = (icon) => - findButtons().filter((button) => button.props('icon') === icon); - beforeEach(() => { createWrapper(); }); - describe('computed', () => { - it('title', () => { - expect(wrapper.vm.title).toBe('Monitoring'); - }); - }); - it('should render a link to environment monitoring page', () => { - expect(wrapper.attributes('href')).toEqual(monitoringUrl); - expect(findButtonsByIcon('chart').length).toBe(1); - expect(wrapper.attributes('title')).toBe('Monitoring'); - expect(wrapper.attributes('aria-label')).toBe('Monitoring'); + const link = wrapper.findByRole('menuitem', { name: __('Monitoring') }); + expect(link.attributes('href')).toEqual(monitoringUrl); }); }); diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js index 5cdd52294b6..a9a58071e12 100644 --- a/spec/frontend/environments/environment_pin_spec.js +++ b/spec/frontend/environments/environment_pin_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import PinComponent from '~/environments/components/environment_pin.vue'; import eventHub from '~/environments/event_hub'; @@ -30,15 +30,15 @@ describe('Pin Component', () => { wrapper.destroy(); }); - it('should render the component with thumbtack icon', () => { - expect(wrapper.find(GlIcon).props('name')).toBe('thumbtack'); + it('should render the component with descriptive text', () => { + expect(wrapper.text()).toBe('Prevent auto-stopping'); }); it('should emit onPinClick when clicked', () => { const eventHubSpy = jest.spyOn(eventHub, '$emit'); - const button = wrapper.find(GlButton); + const item = wrapper.find(GlDropdownItem); - button.vm.$emit('click'); + item.vm.$emit('click'); expect(eventHubSpy).toHaveBeenCalledWith('cancelAutoStop', autoStopUrl); }); diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js index b6c3d436c18..cde675cd9e7 100644 --- a/spec/frontend/environments/environment_rollback_spec.js +++ b/spec/frontend/environments/environment_rollback_spec.js @@ -1,5 +1,5 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import RollbackComponent from '~/environments/components/environment_rollback.vue'; import eventHub from '~/environments/event_hub'; @@ -7,7 +7,7 @@ describe('Rollback Component', () => { const retryUrl = 'https://gitlab.com/retry'; it('Should render Re-deploy label when isLastDeployment is true', () => { - const wrapper = mount(RollbackComponent, { + const wrapper = shallowMount(RollbackComponent, { propsData: { retryUrl, isLastDeployment: true, @@ -15,11 +15,11 @@ describe('Rollback Component', () => { }, }); - expect(wrapper.element).toHaveSpriteIcon('repeat'); + expect(wrapper.text()).toBe('Re-deploy to environment'); }); it('Should render Rollback label when isLastDeployment is false', () => { - const wrapper = mount(RollbackComponent, { + const wrapper = shallowMount(RollbackComponent, { propsData: { retryUrl, isLastDeployment: false, @@ -27,7 +27,7 @@ describe('Rollback Component', () => { }, }); - expect(wrapper.element).toHaveSpriteIcon('redo'); + expect(wrapper.text()).toBe('Rollback environment'); }); it('should emit a "rollback" event on button click', () => { @@ -40,7 +40,7 @@ describe('Rollback Component', () => { }, }, }); - const button = wrapper.find(GlButton); + const button = wrapper.find(GlDropdownItem); button.vm.$emit('click'); diff --git a/spec/frontend/environments/environment_terminal_button_spec.js b/spec/frontend/environments/environment_terminal_button_spec.js index 2475785a927..ab9f370595f 100644 --- a/spec/frontend/environments/environment_terminal_button_spec.js +++ b/spec/frontend/environments/environment_terminal_button_spec.js @@ -1,12 +1,13 @@ -import { shallowMount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import TerminalComponent from '~/environments/components/environment_terminal_button.vue'; +import { __ } from '~/locale'; -describe('Stop Component', () => { +describe('Terminal Component', () => { let wrapper; const terminalPath = '/path'; const mountWithProps = (props) => { - wrapper = shallowMount(TerminalComponent, { + wrapper = mountExtended(TerminalComponent, { propsData: props, }); }; @@ -15,17 +16,9 @@ describe('Stop Component', () => { mountWithProps({ terminalPath }); }); - describe('computed', () => { - it('title', () => { - expect(wrapper.vm.title).toEqual('Terminal'); - }); - }); - it('should render a link to open a web terminal with the provided path', () => { - expect(wrapper.element.tagName).toBe('A'); - expect(wrapper.attributes('title')).toBe('Terminal'); - expect(wrapper.attributes('aria-label')).toBe('Terminal'); - expect(wrapper.attributes('href')).toBe(terminalPath); + const link = wrapper.findByRole('menuitem', { name: __('Terminal') }); + expect(link.attributes('href')).toBe(terminalPath); }); it('should render a non-disabled button', () => { diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index babbc0c8a4d..4e459d800e8 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -503,6 +503,53 @@ describe('ErrorDetails', () => { }); }); }); + + describe('Release links', () => { + const firstReleaseVersion = '7975be01'; + const firstCommitLink = '/gitlab/-/commit/7975be01'; + const firstReleaseLink = '/sentry/releases/7975be01'; + const findFirstCommitLink = () => wrapper.find(`[href$="${firstCommitLink}"]`); + const findFirstReleaseLink = () => wrapper.find(`[href$="${firstReleaseLink}"]`); + + const lastReleaseVersion = '6ca5a5c1'; + const lastCommitLink = '/gitlab/-/commit/6ca5a5c1'; + const lastReleaseLink = '/sentry/releases/6ca5a5c1'; + const findLastCommitLink = () => wrapper.find(`[href$="${lastCommitLink}"]`); + const findLastReleaseLink = () => wrapper.find(`[href$="${lastReleaseLink}"]`); + + it('should display links to Sentry', async () => { + mocks.$apollo.queries.error.loading = false; + await wrapper.setData({ + error: { + firstReleaseVersion, + lastReleaseVersion, + externalBaseUrl: '/sentry', + }, + }); + + expect(findFirstReleaseLink().exists()).toBe(true); + expect(findLastReleaseLink().exists()).toBe(true); + expect(findFirstCommitLink().exists()).toBe(false); + expect(findLastCommitLink().exists()).toBe(false); + }); + + it('should display links to GitLab when integrated', async () => { + mocks.$apollo.queries.error.loading = false; + await wrapper.setData({ + error: { + firstReleaseVersion, + lastReleaseVersion, + integrated: true, + externalBaseUrl: '/gitlab', + }, + }); + + expect(findFirstCommitLink().exists()).toBe(true); + expect(findLastCommitLink().exists()).toBe(true); + expect(findFirstReleaseLink().exists()).toBe(false); + expect(findLastReleaseLink().exists()).toBe(false); + }); + }); }); describe('Snowplow tracking', () => { diff --git a/spec/frontend/error_tracking_settings/components/app_spec.js b/spec/frontend/error_tracking_settings/components/app_spec.js index 30541ba68a5..844faff64a1 100644 --- a/spec/frontend/error_tracking_settings/components/app_spec.js +++ b/spec/frontend/error_tracking_settings/components/app_spec.js @@ -1,7 +1,8 @@ -import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +import { GlFormRadioGroup, GlFormRadio, GlFormInputGroup } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { TEST_HOST } from 'helpers/test_constants'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ErrorTrackingSettings from '~/error_tracking_settings/components/app.vue'; @@ -12,6 +13,8 @@ import createStore from '~/error_tracking_settings/store'; const localVue = createLocalVue(); localVue.use(Vuex); +const TEST_GITLAB_DSN = 'https://gitlab.example.com/123456'; + describe('error tracking settings app', () => { let store; let wrapper; @@ -29,6 +32,10 @@ describe('error tracking settings app', () => { initialProject: null, listProjectsEndpoint: TEST_HOST, operationsSettingsEndpoint: TEST_HOST, + gitlabDsn: TEST_GITLAB_DSN, + }, + stubs: { + GlFormInputGroup, // we need this non-shallow to query for a component within a slot }, }), ); @@ -41,6 +48,12 @@ describe('error tracking settings app', () => { findBackendSettingsRadioGroup().findAllComponents(GlFormRadio); const findElementWithText = (wrappers, text) => wrappers.filter((item) => item.text() === text); const findSentrySettings = () => wrapper.findByTestId('sentry-setting-form'); + const findDsnSettings = () => wrapper.findByTestId('gitlab-dsn-setting-form'); + + const enableGitLabErrorTracking = async () => { + findBackendSettingsRadioGroup().vm.$emit('change', true); + await nextTick(); + }; beforeEach(() => { store = createStore(); @@ -93,17 +106,35 @@ describe('error tracking settings app', () => { expect(findElementWithText(findBackendSettingsRadioButtons(), 'GitLab')).toHaveLength(1); }); - it('toggles the sentry-settings section when sentry is selected as a tracking-backend', async () => { + it('hides the Sentry settings when GitLab is selected as a tracking-backend', async () => { expect(findSentrySettings().exists()).toBe(true); - // set the "integrated" setting to "true" - findBackendSettingsRadioGroup().vm.$emit('change', true); - - await nextTick(); + await enableGitLabErrorTracking(); expect(findSentrySettings().exists()).toBe(false); }); + describe('GitLab DSN section', () => { + it('is visible when GitLab is selected as a tracking-backend and DSN is present', async () => { + expect(findDsnSettings().exists()).toBe(false); + + await enableGitLabErrorTracking(); + + expect(findDsnSettings().exists()).toBe(true); + }); + + it('contains copy-to-clipboard functionality for the GitLab DSN string', async () => { + await enableGitLabErrorTracking(); + + const clipBoardInput = findDsnSettings().findComponent(GlFormInputGroup); + const clipBoardButton = findDsnSettings().findComponent(ClipboardButton); + + expect(clipBoardInput.props('value')).toBe(TEST_GITLAB_DSN); + expect(clipBoardInput.attributes('readonly')).toBeTruthy(); + expect(clipBoardButton.props('text')).toBe(TEST_GITLAB_DSN); + }); + }); + it.each([true, false])( 'calls the `updateIntegrated` action when the setting changes to `%s`', (integrated) => { diff --git a/spec/frontend/experimentation/utils_spec.js b/spec/frontend/experimentation/utils_spec.js index 999bed1ffbd..de060f5eb8c 100644 --- a/spec/frontend/experimentation/utils_spec.js +++ b/spec/frontend/experimentation/utils_spec.js @@ -23,20 +23,6 @@ describe('experiment Utilities', () => { }); }); - describe('getExperimentContexts', () => { - describe.each` - gon | input | output - ${[TEST_KEY, '_data_']} | ${[TEST_KEY]} | ${[{ schema: TRACKING_CONTEXT_SCHEMA, data: { variant: '_data_' } }]} - ${[]} | ${[TEST_KEY]} | ${[]} - `('with input=$input and gon=$gon', ({ gon, input, output }) => { - assignGitlabExperiment(...gon); - - it(`returns ${output}`, () => { - expect(experimentUtils.getExperimentContexts(...input)).toEqual(output); - }); - }); - }); - describe('getAllExperimentContexts', () => { const schema = TRACKING_CONTEXT_SCHEMA; let origGon; diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js index 799b567a2c0..721b7249abc 100644 --- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -4,6 +4,7 @@ import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'spec/test_constants'; import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue'; import Form from '~/feature_flags/components/form.vue'; @@ -20,7 +21,7 @@ describe('Edit feature flag form', () => { endpoint: `${TEST_HOST}/feature_flags.json`, }); - const factory = (provide = {}) => { + const factory = (provide = { searchPath: '/search' }) => { if (wrapper) { wrapper.destroy(); wrapper = null; @@ -31,7 +32,7 @@ describe('Edit feature flag form', () => { }); }; - beforeEach((done) => { + beforeEach(() => { mock = new MockAdapter(axios); mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, { id: 21, @@ -45,7 +46,8 @@ describe('Edit feature flag form', () => { destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', }); factory(); - setImmediate(() => done()); + + return waitForPromises(); }); afterEach(() => { @@ -60,7 +62,7 @@ describe('Edit feature flag form', () => { }); it('should render the toggle', () => { - expect(wrapper.find(GlToggle).exists()).toBe(true); + expect(wrapper.findComponent(GlToggle).exists()).toBe(true); }); describe('with error', () => { @@ -80,11 +82,11 @@ describe('Edit feature flag form', () => { }); it('should render feature flag form', () => { - expect(wrapper.find(Form).exists()).toEqual(true); + expect(wrapper.findComponent(Form).exists()).toEqual(true); }); it('should track when the toggle is clicked', () => { - const toggle = wrapper.find(GlToggle); + const toggle = wrapper.findComponent(GlToggle); const spy = mockTracking('_category_', toggle.element, jest.spyOn); toggle.trigger('click'); @@ -95,7 +97,7 @@ describe('Edit feature flag form', () => { }); it('should render the toggle with a visually hidden label', () => { - expect(wrapper.find(GlToggle).props()).toMatchObject({ + expect(wrapper.findComponent(GlToggle).props()).toMatchObject({ label: 'Feature flag status', labelPosition: 'hidden', }); diff --git a/spec/frontend/filterable_list_spec.js b/spec/frontend/filterable_list_spec.js index 8c6a71abad7..556cf6f8137 100644 --- a/spec/frontend/filterable_list_spec.js +++ b/spec/frontend/filterable_list_spec.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-deprecated import { getJSONFixture, setHTMLFixture } from 'helpers/fixtures'; import FilterableList from '~/filterable_list'; @@ -14,6 +15,7 @@ describe('FilterableList', () => { </div> <div class="js-projects-list-holder"></div> `); + // eslint-disable-next-line import/no-deprecated getJSONFixture('static/projects.json'); form = document.querySelector('form#project-filter-form'); filter = document.querySelector('.js-projects-list-filter'); diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js index 961587f7146..9a20fb1bae6 100644 --- a/spec/frontend/filtered_search/dropdown_user_spec.js +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -1,8 +1,5 @@ -import DropdownUtils from '~/filtered_search/dropdown_utils'; -// TODO: Moving this line up throws an error about `FilteredSearchDropdown` -// being undefined in test. See gitlab-org/gitlab#321476 for more info. -// eslint-disable-next-line import/order import DropdownUser from '~/filtered_search/dropdown_user'; +import DropdownUtils from '~/filtered_search/dropdown_utils'; import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; import IssuableFilteredTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; diff --git a/spec/frontend/droplab/constants_spec.js b/spec/frontend/filtered_search/droplab/constants_spec.js index fd48228d6a2..9c1caf90ac0 100644 --- a/spec/frontend/droplab/constants_spec.js +++ b/spec/frontend/filtered_search/droplab/constants_spec.js @@ -1,4 +1,4 @@ -import * as constants from '~/droplab/constants'; +import * as constants from '~/filtered_search/droplab/constants'; describe('constants', () => { describe('DATA_TRIGGER', () => { diff --git a/spec/frontend/droplab/drop_down_spec.js b/spec/frontend/filtered_search/droplab/drop_down_spec.js index dcdbbcd4ccf..f49dbfcf79c 100644 --- a/spec/frontend/droplab/drop_down_spec.js +++ b/spec/frontend/filtered_search/droplab/drop_down_spec.js @@ -1,6 +1,6 @@ -import { SELECTED_CLASS } from '~/droplab/constants'; -import DropDown from '~/droplab/drop_down'; -import utils from '~/droplab/utils'; +import { SELECTED_CLASS } from '~/filtered_search/droplab/constants'; +import DropDown from '~/filtered_search/droplab/drop_down'; +import utils from '~/filtered_search/droplab/utils'; describe('DropLab DropDown', () => { let testContext; diff --git a/spec/frontend/droplab/hook_spec.js b/spec/frontend/filtered_search/droplab/hook_spec.js index 0b897a570f6..0d92170cfcf 100644 --- a/spec/frontend/droplab/hook_spec.js +++ b/spec/frontend/filtered_search/droplab/hook_spec.js @@ -1,7 +1,7 @@ -import DropDown from '~/droplab/drop_down'; -import Hook from '~/droplab/hook'; +import DropDown from '~/filtered_search/droplab/drop_down'; +import Hook from '~/filtered_search/droplab/hook'; -jest.mock('~/droplab/drop_down', () => jest.fn()); +jest.mock('~/filtered_search/droplab/drop_down', () => jest.fn()); describe('Hook', () => { let testContext; diff --git a/spec/frontend/droplab/plugins/ajax_filter_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js index d442d5cf416..88b3fc236e4 100644 --- a/spec/frontend/droplab/plugins/ajax_filter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js @@ -1,4 +1,4 @@ -import AjaxFilter from '~/droplab/plugins/ajax_filter'; +import AjaxFilter from '~/filtered_search/droplab/plugins/ajax_filter'; import AjaxCache from '~/lib/utils/ajax_cache'; describe('AjaxFilter', () => { diff --git a/spec/frontend/droplab/plugins/ajax_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_spec.js index 7c6452e8337..c968b982091 100644 --- a/spec/frontend/droplab/plugins/ajax_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_spec.js @@ -1,4 +1,4 @@ -import Ajax from '~/droplab/plugins/ajax'; +import Ajax from '~/filtered_search/droplab/plugins/ajax'; import AjaxCache from '~/lib/utils/ajax_cache'; describe('Ajax', () => { diff --git a/spec/frontend/droplab/plugins/input_setter_spec.js b/spec/frontend/filtered_search/droplab/plugins/input_setter_spec.js index eebde018fa1..811b5ca4573 100644 --- a/spec/frontend/droplab/plugins/input_setter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/input_setter_spec.js @@ -1,4 +1,4 @@ -import InputSetter from '~/droplab/plugins/input_setter'; +import InputSetter from '~/filtered_search/droplab/plugins/input_setter'; describe('InputSetter', () => { let testContext; diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index 7185f382fc1..8ac5b6fbea6 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -1,4 +1,5 @@ import { escape } from 'lodash'; +import labelData from 'test_fixtures/labels/project_labels.json'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import { TEST_HOST } from 'helpers/test_constants'; import DropdownUtils from '~/filtered_search/dropdown_utils'; @@ -132,15 +133,8 @@ describe('Filtered Search Visual Tokens', () => { }); describe('updateLabelTokenColor', () => { - const jsonFixtureName = 'labels/project_labels.json'; const dummyEndpoint = '/dummy/endpoint'; - let labelData; - - beforeAll(() => { - labelData = getJSONFixture(jsonFixtureName); - }); - const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken( 'label', '=', diff --git a/spec/frontend/fixtures/abuse_reports.rb b/spec/frontend/fixtures/abuse_reports.rb index f5524a10033..d8c8737b125 100644 --- a/spec/frontend/fixtures/abuse_reports.rb +++ b/spec/frontend/fixtures/abuse_reports.rb @@ -13,10 +13,6 @@ RSpec.describe Admin::AbuseReportsController, '(JavaScript fixtures)', type: :co render_views - before(:all) do - clean_frontend_fixtures('abuse_reports/') - end - before do sign_in(admin) enable_admin_mode!(admin) diff --git a/spec/frontend/fixtures/admin_users.rb b/spec/frontend/fixtures/admin_users.rb index e0fecbdb1aa..5579f50da74 100644 --- a/spec/frontend/fixtures/admin_users.rb +++ b/spec/frontend/fixtures/admin_users.rb @@ -17,10 +17,6 @@ RSpec.describe Admin::UsersController, '(JavaScript fixtures)', type: :controlle render_views - before(:all) do - clean_frontend_fixtures('admin/users') - end - it 'admin/users/new_with_internal_user_regex.html' do stub_application_setting(user_default_external: true) stub_application_setting(user_default_internal_regex: '^(?:(?!\.ext@).)*$\r?') diff --git a/spec/frontend/fixtures/analytics.rb b/spec/frontend/fixtures/analytics.rb index 6d106dce166..b6a5ea6616d 100644 --- a/spec/frontend/fixtures/analytics.rb +++ b/spec/frontend/fixtures/analytics.rb @@ -6,10 +6,6 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do let_it_be(:value_stream_id) { 'default' } - before(:all) do - clean_frontend_fixtures('projects/analytics/value_stream_analytics/') - end - before do update_metrics create_deployment @@ -26,7 +22,7 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do sign_in(user) end - it 'projects/analytics/value_stream_analytics/stages' do + it 'projects/analytics/value_stream_analytics/stages.json' do get(:index, params: params, format: :json) expect(response).to be_successful @@ -44,7 +40,7 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do end Gitlab::Analytics::CycleAnalytics::DefaultStages.all.each do |stage| - it "projects/analytics/value_stream_analytics/events/#{stage[:name]}" do + it "projects/analytics/value_stream_analytics/events/#{stage[:name]}.json" do get(stage[:name], params: params, format: :json) expect(response).to be_successful @@ -62,7 +58,7 @@ RSpec.describe 'Analytics (JavaScript fixtures)', :sidekiq_inline do sign_in(user) end - it "projects/analytics/value_stream_analytics/summary" do + it "projects/analytics/value_stream_analytics/summary.json" do get(:show, params: params, format: :json) expect(response).to be_successful diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb index cb9a116f293..89f012a5110 100644 --- a/spec/frontend/fixtures/api_markdown.rb +++ b/spec/frontend/fixtures/api_markdown.rb @@ -21,11 +21,7 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do let(:project_wiki_page) { create(:wiki_page, wiki: project_wiki) } - fixture_subdir = 'api/markdown' - before(:all) do - clean_frontend_fixtures(fixture_subdir) - group.add_owner(user) project.add_maintainer(user) end @@ -49,7 +45,7 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do name = "#{context}_#{name}" unless context.empty? - it "#{fixture_subdir}/#{name}.json" do + it "api/markdown/#{name}.json" do api_url = case context when 'project' "/#{project.full_path}/preview_markdown" diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index 1edb8cb3f41..45f73260887 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -77,6 +77,35 @@ </dd> </dl> +- name: details + markdown: |- + <details> + <summary>Apply this patch</summary> + + ```diff + diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml + index 8433efaf00c..69b12c59d46 100644 + --- a/spec/frontend/fixtures/api_markdown.yml + +++ b/spec/frontend/fixtures/api_markdown.yml + @@ -33,6 +33,13 @@ + * <ruby>漢<rt>ㄏㄢˋ</rt></ruby> + * C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O + * The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>.The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var> + +- name: details + + markdown: |- + + <details> + + <summary>Apply this patch</summary> + + + + 🐶 much meta, 🐶 many patch + + 🐶 such diff, 🐶 very meme + + 🐶 wow! + + </details> + - name: link + markdown: '[GitLab](https://gitlab.com)' + - name: attachment_link + ``` + + </details> - name: link markdown: '[GitLab](https://gitlab.com)' - name: attachment_link @@ -204,3 +233,57 @@ * [x] ![Sample Audio](https://gitlab.com/1.mp3) * [x] ![Sample Audio](https://gitlab.com/2.mp3) * [x] ![Sample Video](https://gitlab.com/3.mp4) +- name: table_of_contents + markdown: |- + [[_TOC_]] + + # Lorem + + Well, that's just like... your opinion.. man. + + ## Ipsum + + ### Dolar + + # Sit amit + + ### I don't know +- name: word_break + markdown: Fernstraßen<wbr>bau<wbr>privat<wbr>finanzierungs<wbr>gesetz +- name: frontmatter_yaml + markdown: |- + --- + title: Page title + --- +- name: frontmatter_toml + markdown: |- + +++ + title = "Page title" + +++ +- name: frontmatter_json + markdown: |- + ;;; + { + "title": "Page title" + } + ;;; +- name: color_chips + markdown: |- + - `#F00` + - `#F00A` + - `#FF0000` + - `#FF0000AA` + - `RGB(0,255,0)` + - `RGB(0%,100%,0%)` + - `RGBA(0,255,0,0.3)` + - `HSL(540,70%,50%)` + - `HSLA(540,70%,50%,0.3)` +- name: math + markdown: |- + This math is inline $`a^2+b^2=c^2`$. + + This is on a separate line: + + ```math + a^2+b^2=c^2 + ``` diff --git a/spec/frontend/fixtures/api_merge_requests.rb b/spec/frontend/fixtures/api_merge_requests.rb index 7117c9a1c7a..47321fbbeaa 100644 --- a/spec/frontend/fixtures/api_merge_requests.rb +++ b/spec/frontend/fixtures/api_merge_requests.rb @@ -11,10 +11,6 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } let_it_be(:mr) { create(:merge_request, source_project: project) } - before(:all) do - clean_frontend_fixtures('api/merge_requests') - end - it 'api/merge_requests/get.json' do 4.times { |i| create(:merge_request, source_project: project, source_branch: "branch-#{i}") } diff --git a/spec/frontend/fixtures/api_projects.rb b/spec/frontend/fixtures/api_projects.rb index fa77ca1c0cf..eada2f8e0f7 100644 --- a/spec/frontend/fixtures/api_projects.rb +++ b/spec/frontend/fixtures/api_projects.rb @@ -11,10 +11,6 @@ RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do let(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } let(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') } - before(:all) do - clean_frontend_fixtures('api/projects') - end - it 'api/projects/get.json' do get api("/projects/#{project.id}", admin) diff --git a/spec/frontend/fixtures/application_settings.rb b/spec/frontend/fixtures/application_settings.rb index b09bea56b94..9fa8d68e695 100644 --- a/spec/frontend/fixtures/application_settings.rb +++ b/spec/frontend/fixtures/application_settings.rb @@ -19,10 +19,6 @@ RSpec.describe Admin::ApplicationSettingsController, '(JavaScript fixtures)', ty render_views - before(:all) do - clean_frontend_fixtures('application_settings/') - end - after do remove_repository(project) end diff --git a/spec/frontend/fixtures/autocomplete.rb b/spec/frontend/fixtures/autocomplete.rb index 8983e241aa5..6215fa44e27 100644 --- a/spec/frontend/fixtures/autocomplete.rb +++ b/spec/frontend/fixtures/autocomplete.rb @@ -11,10 +11,6 @@ RSpec.describe ::AutocompleteController, '(JavaScript fixtures)', type: :control let(:project) { create(:project, namespace: group, path: 'autocomplete-project') } let(:merge_request) { create(:merge_request, source_project: project, author: user) } - before(:all) do - clean_frontend_fixtures('autocomplete/') - end - before do group.add_owner(user) sign_in(user) diff --git a/spec/frontend/fixtures/autocomplete_sources.rb b/spec/frontend/fixtures/autocomplete_sources.rb index 9ff0f959c11..74bf58cc106 100644 --- a/spec/frontend/fixtures/autocomplete_sources.rb +++ b/spec/frontend/fixtures/autocomplete_sources.rb @@ -10,10 +10,6 @@ RSpec.describe Projects::AutocompleteSourcesController, '(JavaScript fixtures)', let_it_be(:project) { create(:project, namespace: group, path: 'autocomplete-sources-project') } let_it_be(:issue) { create(:issue, project: project) } - before(:all) do - clean_frontend_fixtures('autocomplete_sources/') - end - before do group.add_owner(user) sign_in(user) diff --git a/spec/frontend/fixtures/blob.rb b/spec/frontend/fixtures/blob.rb index b112886b2ca..f90e3662e98 100644 --- a/spec/frontend/fixtures/blob.rb +++ b/spec/frontend/fixtures/blob.rb @@ -11,10 +11,6 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control render_views - before(:all) do - clean_frontend_fixtures('blob/') - end - before do sign_in(user) allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') diff --git a/spec/frontend/fixtures/branches.rb b/spec/frontend/fixtures/branches.rb index f3b3633347d..828564977e0 100644 --- a/spec/frontend/fixtures/branches.rb +++ b/spec/frontend/fixtures/branches.rb @@ -9,11 +9,6 @@ RSpec.describe 'Branches (JavaScript fixtures)' do let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } let_it_be(:user) { project.owner } - before(:all) do - clean_frontend_fixtures('branches/') - clean_frontend_fixtures('api/branches/') - end - after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/clusters.rb b/spec/frontend/fixtures/clusters.rb index b37aa137504..ea883555255 100644 --- a/spec/frontend/fixtures/clusters.rb +++ b/spec/frontend/fixtures/clusters.rb @@ -12,10 +12,6 @@ RSpec.describe Projects::ClustersController, '(JavaScript fixtures)', type: :con render_views - before(:all) do - clean_frontend_fixtures('clusters/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/commit.rb b/spec/frontend/fixtures/commit.rb index ff62a8286fc..f9e0f604b52 100644 --- a/spec/frontend/fixtures/commit.rb +++ b/spec/frontend/fixtures/commit.rb @@ -9,11 +9,6 @@ RSpec.describe 'Commit (JavaScript fixtures)' do let_it_be(:user) { project.owner } let_it_be(:commit) { project.commit("master") } - before(:all) do - clean_frontend_fixtures('commit/') - clean_frontend_fixtures('api/commits/') - end - before do allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon') end diff --git a/spec/frontend/fixtures/deploy_keys.rb b/spec/frontend/fixtures/deploy_keys.rb index 5c24c071792..bed6c798793 100644 --- a/spec/frontend/fixtures/deploy_keys.rb +++ b/spec/frontend/fixtures/deploy_keys.rb @@ -13,10 +13,6 @@ RSpec.describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :c let(:project3) { create(:project, :internal)} let(:project4) { create(:project, :internal)} - before(:all) do - clean_frontend_fixtures('deploy_keys/') - end - before do # Using an admin for these fixtures because they are used for verifying a frontend # component that would normally get its data from `Admin::DeployKeysController` diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb index 42762fa56f9..d9573c8000d 100644 --- a/spec/frontend/fixtures/freeze_period.rb +++ b/spec/frontend/fixtures/freeze_period.rb @@ -9,10 +9,6 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do let_it_be(:project) { create(:project, :repository, path: 'freeze-periods-project') } let_it_be(:user) { project.owner } - before(:all) do - clean_frontend_fixtures('api/freeze-periods/') - end - after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/groups.rb b/spec/frontend/fixtures/groups.rb index 42aad9f187e..ddd436b98c6 100644 --- a/spec/frontend/fixtures/groups.rb +++ b/spec/frontend/fixtures/groups.rb @@ -8,10 +8,6 @@ RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do let(:user) { create(:user) } let(:group) { create(:group, name: 'frontend-fixtures-group', runners_token: 'runnerstoken:intabulasreferre')} - before(:all) do - clean_frontend_fixtures('groups/') - end - before do group.add_owner(user) sign_in(user) diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index a027247bd0d..6519416cb9e 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -11,10 +11,6 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr render_views - before(:all) do - clean_frontend_fixtures('issues/') - end - before do project.add_maintainer(user) sign_in(user) diff --git a/spec/frontend/fixtures/jobs.rb b/spec/frontend/fixtures/jobs.rb index 22179c790bd..12584f38629 100644 --- a/spec/frontend/fixtures/jobs.rb +++ b/spec/frontend/fixtures/jobs.rb @@ -21,10 +21,6 @@ RSpec.describe Projects::JobsController, '(JavaScript fixtures)', type: :control render_views - before(:all) do - clean_frontend_fixtures('jobs/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/labels.rb b/spec/frontend/fixtures/labels.rb index d7ca2aff18c..6736baed199 100644 --- a/spec/frontend/fixtures/labels.rb +++ b/spec/frontend/fixtures/labels.rb @@ -17,10 +17,6 @@ RSpec.describe 'Labels (JavaScript fixtures)' do let!(:groub_label_space) { create(:group_label, group: group, title: 'some space', color: '#FFFFFF') } let!(:groub_label_violets) { create(:group_label, group: group, title: 'violets', color: '#0000FF') } - before(:all) do - clean_frontend_fixtures('labels/') - end - after do remove_repository(project) end diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index f10f96f2516..68ed2ca2359 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -49,10 +49,6 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: render_views - before(:all) do - clean_frontend_fixtures('merge_requests/') - end - before do sign_in(user) allow(Discussion).to receive(:build_discussion_id).and_return(['discussionid:ceterumcenseo']) diff --git a/spec/frontend/fixtures/merge_requests_diffs.rb b/spec/frontend/fixtures/merge_requests_diffs.rb index edf1fcf3c0a..e733764f248 100644 --- a/spec/frontend/fixtures/merge_requests_diffs.rb +++ b/spec/frontend/fixtures/merge_requests_diffs.rb @@ -20,10 +20,6 @@ RSpec.describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)' render_views - before(:all) do - clean_frontend_fixtures('merge_request_diffs/') - end - before do # Create a user that matches the project.commit author # This is so that the "author" information will be populated diff --git a/spec/frontend/fixtures/metrics_dashboard.rb b/spec/frontend/fixtures/metrics_dashboard.rb index eef79825ae7..d59b01b04af 100644 --- a/spec/frontend/fixtures/metrics_dashboard.rb +++ b/spec/frontend/fixtures/metrics_dashboard.rb @@ -12,10 +12,6 @@ RSpec.describe MetricsDashboard, '(JavaScript fixtures)', type: :controller do let_it_be(:environment) { create(:environment, id: 1, project: project) } let_it_be(:params) { { environment: environment } } - before(:all) do - clean_frontend_fixtures('metrics_dashboard/') - end - controller(::ApplicationController) do include MetricsDashboard end diff --git a/spec/frontend/fixtures/pipeline_schedules.rb b/spec/frontend/fixtures/pipeline_schedules.rb index a7d43fdbe62..6389f59aa0a 100644 --- a/spec/frontend/fixtures/pipeline_schedules.rb +++ b/spec/frontend/fixtures/pipeline_schedules.rb @@ -15,10 +15,6 @@ RSpec.describe Projects::PipelineSchedulesController, '(JavaScript fixtures)', t render_views - before(:all) do - clean_frontend_fixtures('pipeline_schedules/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index f695b74ec87..709e14183df 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -23,10 +23,6 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co let!(:build_test) { create(:ci_build, pipeline: pipeline, stage: 'test') } let!(:build_deploy_failed) { create(:ci_build, status: :failed, pipeline: pipeline, stage: 'deploy') } - before(:all) do - clean_frontend_fixtures('pipelines/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index 7873d59dbad..3c8964d398a 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -16,10 +16,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do render_views - before(:all) do - clean_frontend_fixtures('projects/') - end - before do project_with_repo.add_maintainer(user) sign_in(user) @@ -57,10 +53,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do project_variable_populated.add_maintainer(user) end - before(:all) do - clean_frontend_fixtures('graphql/projects/access_tokens') - end - base_input_path = 'access_tokens/graphql/queries/' base_output_path = 'graphql/projects/access_tokens/' query_name = 'get_projects.query.graphql' diff --git a/spec/frontend/fixtures/projects_json.rb b/spec/frontend/fixtures/projects_json.rb index c081d4f08dc..c4de56ccfab 100644 --- a/spec/frontend/fixtures/projects_json.rb +++ b/spec/frontend/fixtures/projects_json.rb @@ -8,10 +8,6 @@ RSpec.describe 'Projects JSON endpoints (JavaScript fixtures)', type: :controlle let(:admin) { create(:admin, name: 'root') } let(:project) { create(:project, :repository) } - before(:all) do - clean_frontend_fixtures('projects_json/') - end - before do project.add_maintainer(admin) sign_in(admin) diff --git a/spec/frontend/fixtures/prometheus_service.rb b/spec/frontend/fixtures/prometheus_service.rb index c349f2a24bc..bbd938c66f6 100644 --- a/spec/frontend/fixtures/prometheus_service.rb +++ b/spec/frontend/fixtures/prometheus_service.rb @@ -12,10 +12,6 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con render_views - before(:all) do - clean_frontend_fixtures('services/prometheus') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/raw.rb b/spec/frontend/fixtures/raw.rb index 44927bd29d8..211c4e7c048 100644 --- a/spec/frontend/fixtures/raw.rb +++ b/spec/frontend/fixtures/raw.rb @@ -9,14 +9,6 @@ RSpec.describe 'Raw files', '(JavaScript fixtures)' do let(:project) { create(:project, :repository, namespace: namespace, path: 'raw-project') } let(:response) { @blob.data.force_encoding('UTF-8') } - before(:all) do - clean_frontend_fixtures('blob/notebook/') - clean_frontend_fixtures('blob/pdf/') - clean_frontend_fixtures('blob/text/') - clean_frontend_fixtures('blob/binary/') - clean_frontend_fixtures('blob/images/') - end - after do remove_repository(project) end diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb index e8f259fba15..fc344472588 100644 --- a/spec/frontend/fixtures/releases.rb +++ b/spec/frontend/fixtures/releases.rb @@ -116,10 +116,6 @@ RSpec.describe 'Releases (JavaScript fixtures)' do end describe API::Releases, type: :request do - before(:all) do - clean_frontend_fixtures('api/releases/') - end - it 'api/releases/release.json' do get api("/projects/#{project.id}/releases/#{release.tag}", admin) @@ -134,10 +130,6 @@ RSpec.describe 'Releases (JavaScript fixtures)' do one_release_query_path = 'releases/graphql/queries/one_release.query.graphql' one_release_for_editing_query_path = 'releases/graphql/queries/one_release_for_editing.query.graphql' - before(:all) do - clean_frontend_fixtures('graphql/releases/') - end - it "graphql/#{all_releases_query_path}.json" do query = get_graphql_query_as_string(all_releases_query_path) diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index d5d6f534def..fa150fbf57c 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -20,10 +20,6 @@ RSpec.describe 'Runner (JavaScript fixtures)' do query_path = 'runner/graphql/' fixtures_path = 'graphql/runner/' - before(:all) do - clean_frontend_fixtures(fixtures_path) - end - after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb index 264ce7d010c..db1ef67998f 100644 --- a/spec/frontend/fixtures/search.rb +++ b/spec/frontend/fixtures/search.rb @@ -9,10 +9,6 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do let_it_be(:user) { create(:user) } - before(:all) do - clean_frontend_fixtures('search/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/services.rb b/spec/frontend/fixtures/services.rb index 91e6c2eb280..a8293a080a9 100644 --- a/spec/frontend/fixtures/services.rb +++ b/spec/frontend/fixtures/services.rb @@ -12,10 +12,6 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con render_views - before(:all) do - clean_frontend_fixtures('services/') - end - before do sign_in(user) end diff --git a/spec/frontend/fixtures/sessions.rb b/spec/frontend/fixtures/sessions.rb index 0ef14c1d4fa..bb73bf3215c 100644 --- a/spec/frontend/fixtures/sessions.rb +++ b/spec/frontend/fixtures/sessions.rb @@ -5,10 +5,6 @@ require 'spec_helper' RSpec.describe 'Sessions (JavaScript fixtures)' do include JavaScriptFixturesHelpers - before(:all) do - clean_frontend_fixtures('sessions/') - end - describe SessionsController, '(JavaScript fixtures)', type: :controller do include DeviseHelpers diff --git a/spec/frontend/fixtures/snippet.rb b/spec/frontend/fixtures/snippet.rb index 5211d52f374..397fb3e7124 100644 --- a/spec/frontend/fixtures/snippet.rb +++ b/spec/frontend/fixtures/snippet.rb @@ -12,10 +12,6 @@ RSpec.describe SnippetsController, '(JavaScript fixtures)', type: :controller do render_views - before(:all) do - clean_frontend_fixtures('snippets/') - end - before do sign_in(user) allow(Discussion).to receive(:build_discussion_id).and_return(['discussionid:ceterumcenseo']) diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb index 1bd99f5cd7f..e19a98c3bab 100644 --- a/spec/frontend/fixtures/startup_css.rb +++ b/spec/frontend/fixtures/startup_css.rb @@ -9,15 +9,15 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do render_views - before(:all) do - clean_frontend_fixtures('startup_css/') - end - shared_examples 'startup css project fixtures' do |type| let(:user) { create(:user, :admin) } let(:project) { create(:project, :public, :repository, description: 'Code and stuff', creator: user) } before do + # We want vNext badge to be included and com/canary don't remove/hide any other elements. + # This is why we're turning com and canary on by default for now. + allow(Gitlab).to receive(:com?).and_return(true) + allow(Gitlab).to receive(:canary?).and_return(true) sign_in(user) end diff --git a/spec/frontend/fixtures/static/oauth_remember_me.html b/spec/frontend/fixtures/static/oauth_remember_me.html index c6af8129b4d..0b4d482925d 100644 --- a/spec/frontend/fixtures/static/oauth_remember_me.html +++ b/spec/frontend/fixtures/static/oauth_remember_me.html @@ -1,22 +1,21 @@ <div id="oauth-container"> -<input id="remember_me" type="checkbox"> + <input id="remember_me" type="checkbox" /> -<form method="post" action="http://example.com/"> - <button class="oauth-login twitter" type="submit"> - <span>Twitter</span> - </button> -</form> + <form method="post" action="http://example.com/"> + <button class="js-oauth-login twitter" type="submit"> + <span>Twitter</span> + </button> + </form> -<form method="post" action="http://example.com/"> - <button class="oauth-login github" type="submit"> - <span>GitHub</span> - </button> -</form> - -<form method="post" action="http://example.com/?redirect_fragment=L1"> - <button class="oauth-login facebook" type="submit"> - <span>Facebook</span> - </button> -</form> + <form method="post" action="http://example.com/"> + <button class="js-oauth-login github" type="submit"> + <span>GitHub</span> + </button> + </form> + <form method="post" action="http://example.com/?redirect_fragment=L1"> + <button class="js-oauth-login facebook" type="submit"> + <span>Facebook</span> + </button> + </form> </div> diff --git a/spec/frontend/fixtures/tags.rb b/spec/frontend/fixtures/tags.rb index 9483f0a4492..6cfa5f82efe 100644 --- a/spec/frontend/fixtures/tags.rb +++ b/spec/frontend/fixtures/tags.rb @@ -8,10 +8,6 @@ RSpec.describe 'Tags (JavaScript fixtures)' do let_it_be(:project) { create(:project, :repository, path: 'tags-project') } let_it_be(:user) { project.owner } - before(:all) do - clean_frontend_fixtures('api/tags/') - end - after(:all) do remove_repository(project) end diff --git a/spec/frontend/fixtures/timezones.rb b/spec/frontend/fixtures/timezones.rb index 261dcf5e116..157f47855ea 100644 --- a/spec/frontend/fixtures/timezones.rb +++ b/spec/frontend/fixtures/timezones.rb @@ -8,10 +8,6 @@ RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json } - before(:all) do - clean_frontend_fixtures('timezones/') - end - it 'timezones/short.json' do @timezones = timezone_data(format: :short) end diff --git a/spec/frontend/fixtures/todos.rb b/spec/frontend/fixtures/todos.rb index 985afafe50e..a0573b0b658 100644 --- a/spec/frontend/fixtures/todos.rb +++ b/spec/frontend/fixtures/todos.rb @@ -13,10 +13,6 @@ RSpec.describe 'Todos (JavaScript fixtures)' do let(:issue_2) { create(:issue, title: 'issue_2', project: project) } let!(:todo_2) { create(:todo, :done, user: user, project: project, target: issue_2, created_at: 50.hours.ago) } - before(:all) do - clean_frontend_fixtures('todos/') - end - after do remove_repository(project) end diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb index a6a8ba7318b..96820c9ae80 100644 --- a/spec/frontend/fixtures/u2f.rb +++ b/spec/frontend/fixtures/u2f.rb @@ -7,10 +7,6 @@ RSpec.context 'U2F' do let(:user) { create(:user, :two_factor_via_u2f, otp_secret: 'otpsecret:coolkids') } - before(:all) do - clean_frontend_fixtures('u2f/') - end - before do stub_feature_flags(webauthn: false) end diff --git a/spec/frontend/fixtures/webauthn.rb b/spec/frontend/fixtures/webauthn.rb index b195fee76f0..c6e9b41b584 100644 --- a/spec/frontend/fixtures/webauthn.rb +++ b/spec/frontend/fixtures/webauthn.rb @@ -7,10 +7,6 @@ RSpec.context 'WebAuthn' do let(:user) { create(:user, :two_factor_via_webauthn, otp_secret: 'otpsecret:coolkids') } - before(:all) do - clean_frontend_fixtures('webauthn/') - end - describe SessionsController, '(JavaScript fixtures)', type: :controller do include DeviseHelpers diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 94ad7759110..eb11df2fe43 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -1,17 +1,15 @@ /* eslint no-param-reassign: "off" */ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import labelsFixture from 'test_fixtures/autocomplete_sources/labels.json'; import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete'; import { initEmojiMock } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; -import { getJSONFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import AjaxCache from '~/lib/utils/ajax_cache'; import axios from '~/lib/utils/axios_utils'; -const labelsFixture = getJSONFixture('autocomplete_sources/labels.json'); - describe('GfmAutoComplete', () => { const fetchDataMock = { fetchData: jest.fn() }; let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index 2cbcb73ce5b..2ea2693a978 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import HeaderSearchApp from '~/header_search/components/app.vue'; +import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys'; @@ -20,6 +21,7 @@ describe('HeaderSearchApp', () => { const actionSpies = { setSearch: jest.fn(), + fetchAutocompleteOptions: jest.fn(), }; const createComponent = (initialState) => { @@ -46,6 +48,8 @@ describe('HeaderSearchApp', () => { const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); + const findHeaderSearchAutocompleteItems = () => + wrapper.findComponent(HeaderSearchAutocompleteItems); describe('template', () => { it('always renders Header Search Input', () => { @@ -74,11 +78,11 @@ describe('HeaderSearchApp', () => { }); describe.each` - search | showDefault | showScoped - ${null} | ${true} | ${false} - ${''} | ${true} | ${false} - ${MOCK_SEARCH} | ${false} | ${true} - `('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => { + search | showDefault | showScoped | showAutocomplete + ${null} | ${true} | ${false} | ${false} + ${''} | ${true} | ${false} | ${false} + ${MOCK_SEARCH} | ${false} | ${true} | ${true} + `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { describe(`when search is ${search}`, () => { beforeEach(() => { createComponent({ search }); @@ -93,6 +97,10 @@ describe('HeaderSearchApp', () => { it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); }); + + it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => { + expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete); + }); }); }); }); @@ -139,12 +147,18 @@ describe('HeaderSearchApp', () => { }); }); - it('calls setSearch when search input event is fired', async () => { - findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + describe('onInput', () => { + beforeEach(() => { + findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + }); - await wrapper.vm.$nextTick(); + it('calls setSearch with search term', () => { + expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); + }); - expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); + it('calls fetchAutocompleteOptions', () => { + expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled(); + }); }); it('submits a search onKey-Enter', async () => { diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js new file mode 100644 index 00000000000..6b84e63989d --- /dev/null +++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js @@ -0,0 +1,108 @@ +import { GlDropdownItem, GlLoadingIcon, GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue'; +import { + GROUPS_CATEGORY, + LARGE_AVATAR_PX, + PROJECTS_CATEGORY, + SMALL_AVATAR_PX, +} from '~/header_search/constants'; +import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; + +Vue.use(Vuex); + +describe('HeaderSearchAutocompleteItems', () => { + let wrapper; + + const createComponent = (initialState, mockGetters) => { + const store = new Vuex.Store({ + state: { + loading: false, + ...initialState, + }, + getters: { + autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + ...mockGetters, + }, + }); + + wrapper = shallowMount(HeaderSearchAutocompleteItems, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text()); + const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findGlAvatar = () => wrapper.findComponent(GlAvatar); + + describe('template', () => { + describe('when loading is true', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('renders GlLoadingIcon', () => { + expect(findGlLoadingIcon().exists()).toBe(true); + }); + + it('does not render autocomplete options', () => { + expect(findDropdownItems()).toHaveLength(0); + }); + }); + + describe('when loading is false', () => { + beforeEach(() => { + createComponent({ loading: false }); + }); + + it('does not render GlLoadingIcon', () => { + expect(findGlLoadingIcon().exists()).toBe(false); + }); + + describe('Dropdown items', () => { + it('renders item for each option in autocomplete option', () => { + expect(findDropdownItems()).toHaveLength(MOCK_AUTOCOMPLETE_OPTIONS.length); + }); + + it('renders titles correctly', () => { + const expectedTitles = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.label); + expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + }); + + it('renders links correctly', () => { + const expectedLinks = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.url); + expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + }); + }); + describe.each` + item | showAvatar | avatarSize + ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} + ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} + ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} + ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} + `('GlAvatar', ({ item, showAvatar, avatarSize }) => { + describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => { + beforeEach(() => { + createComponent({}, { autocompleteGroupedSearchOptions: () => [item] }); + }); + + it(`should${showAvatar ? '' : ' not'} render`, () => { + expect(findGlAvatar().exists()).toBe(showAvatar); + }); + + it(`should set avatarSize to ${avatarSize}`, () => { + expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js index 5963ad9c279..915b3a4a678 100644 --- a/spec/frontend/header_search/mock_data.js +++ b/spec/frontend/header_search/mock_data.js @@ -19,6 +19,8 @@ export const MOCK_MR_PATH = '/dashboard/merge_requests'; export const MOCK_ALL_PATH = '/'; +export const MOCK_AUTOCOMPLETE_PATH = '/autocomplete'; + export const MOCK_PROJECT = { id: 123, name: 'MockProject', @@ -81,3 +83,70 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [ url: MOCK_ALL_PATH, }, ]; + +export const MOCK_AUTOCOMPLETE_OPTIONS = [ + { + category: 'Projects', + id: 1, + label: 'MockProject1', + url: 'project/1', + }, + { + category: 'Projects', + id: 2, + label: 'MockProject2', + url: 'project/2', + }, + { + category: 'Groups', + id: 1, + label: 'MockGroup1', + url: 'group/1', + }, + { + category: 'Help', + label: 'GitLab Help', + url: 'help/gitlab', + }, +]; + +export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ + { + category: 'Projects', + data: [ + { + category: 'Projects', + id: 1, + label: 'MockProject1', + url: 'project/1', + }, + { + category: 'Projects', + id: 2, + label: 'MockProject2', + url: 'project/2', + }, + ], + }, + { + category: 'Groups', + data: [ + { + category: 'Groups', + id: 1, + label: 'MockGroup1', + url: 'group/1', + }, + ], + }, + { + category: 'Help', + data: [ + { + category: 'Help', + label: 'GitLab Help', + url: 'help/gitlab', + }, + ], + }, +]; diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js index 4530df0d91c..ee2c72df77b 100644 --- a/spec/frontend/header_search/store/actions_spec.js +++ b/spec/frontend/header_search/store/actions_spec.js @@ -1,18 +1,50 @@ +import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; import * as actions from '~/header_search/store/actions'; import * as types from '~/header_search/store/mutation_types'; import createState from '~/header_search/store/state'; -import { MOCK_SEARCH } from '../mock_data'; +import axios from '~/lib/utils/axios_utils'; +import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; + +jest.mock('~/flash'); describe('Header Search Store Actions', () => { let state; + let mock; + + const flashCallback = (callCount) => { + expect(createFlash).toHaveBeenCalledTimes(callCount); + createFlash.mockClear(); + }; beforeEach(() => { state = createState({}); + mock = new MockAdapter(axios); }); afterEach(() => { state = null; + mock.restore(); + }); + + describe.each` + axiosMock | type | expectedMutations | flashCallCount + ${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS }]} | ${0} + ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} | ${1} + `('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations, flashCallCount }) => { + describe(`on ${type}`, () => { + beforeEach(() => { + mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res); + }); + it(`should dispatch the correct mutations`, () => { + return testAction({ + action: actions.fetchAutocompleteOptions, + state, + expectedMutations, + }).then(() => flashCallback(flashCallCount)); + }); + }); }); describe('setSearch', () => { diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js index 2ad0a082f6a..d55db07188e 100644 --- a/spec/frontend/header_search/store/getters_spec.js +++ b/spec/frontend/header_search/store/getters_spec.js @@ -5,6 +5,7 @@ import { MOCK_SEARCH_PATH, MOCK_ISSUE_PATH, MOCK_MR_PATH, + MOCK_AUTOCOMPLETE_PATH, MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, @@ -12,6 +13,8 @@ import { MOCK_GROUP, MOCK_ALL_PATH, MOCK_SEARCH, + MOCK_AUTOCOMPLETE_OPTIONS, + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, } from '../mock_data'; describe('Header Search Store Getters', () => { @@ -22,6 +25,7 @@ describe('Header Search Store Getters', () => { searchPath: MOCK_SEARCH_PATH, issuesPath: MOCK_ISSUE_PATH, mrPath: MOCK_MR_PATH, + autocompletePath: MOCK_AUTOCOMPLETE_PATH, searchContext: MOCK_SEARCH_CONTEXT, ...initialState, }); @@ -56,6 +60,29 @@ describe('Header Search Store Getters', () => { }); describe.each` + project | ref | expectedPath + ${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=undefined&project_ref=null`} + ${MOCK_PROJECT} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=null`} + ${MOCK_PROJECT} | ${MOCK_PROJECT.id} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=${MOCK_PROJECT.id}`} + `('autocompleteQuery', ({ project, ref, expectedPath }) => { + describe(`when project is ${project?.name} and project ref is ${ref}`, () => { + beforeEach(() => { + createState({ + searchContext: { + project, + ref, + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.autocompleteQuery(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` group | group_metadata | project | project_metadata | expectedPath ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH} ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'} @@ -208,4 +235,17 @@ describe('Header Search Store Getters', () => { ); }); }); + + describe('autocompleteGroupedSearchOptions', () => { + beforeEach(() => { + createState(); + state.autocompleteOptions = MOCK_AUTOCOMPLETE_OPTIONS; + }); + + it('returns the correct grouped array', () => { + expect(getters.autocompleteGroupedSearchOptions(state)).toStrictEqual( + MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + ); + }); + }); }); diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js index 8196c06099d..7f9b7631a7e 100644 --- a/spec/frontend/header_search/store/mutations_spec.js +++ b/spec/frontend/header_search/store/mutations_spec.js @@ -1,7 +1,7 @@ import * as types from '~/header_search/store/mutation_types'; import mutations from '~/header_search/store/mutations'; import createState from '~/header_search/store/state'; -import { MOCK_SEARCH } from '../mock_data'; +import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data'; describe('Header Search Store Mutations', () => { let state; @@ -10,6 +10,33 @@ describe('Header Search Store Mutations', () => { state = createState({}); }); + describe('REQUEST_AUTOCOMPLETE', () => { + it('sets loading to true and empties autocompleteOptions array', () => { + mutations[types.REQUEST_AUTOCOMPLETE](state); + + expect(state.loading).toBe(true); + expect(state.autocompleteOptions).toStrictEqual([]); + }); + }); + + describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => { + it('sets loading to false and sets autocompleteOptions array', () => { + mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS); + + expect(state.loading).toBe(false); + expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS); + }); + }); + + describe('RECEIVE_AUTOCOMPLETE_ERROR', () => { + it('sets loading to false and empties autocompleteOptions array', () => { + mutations[types.RECEIVE_AUTOCOMPLETE_ERROR](state); + + expect(state.loading).toBe(false); + expect(state.autocompleteOptions).toStrictEqual([]); + }); + }); + describe('SET_SEARCH', () => { it('sets search to value', () => { mutations[types.SET_SEARCH](state, MOCK_SEARCH); diff --git a/spec/frontend/ide/components/jobs/detail_spec.js b/spec/frontend/ide/components/jobs/detail_spec.js index 79ac0a8122a..3634599f328 100644 --- a/spec/frontend/ide/components/jobs/detail_spec.js +++ b/spec/frontend/ide/components/jobs/detail_spec.js @@ -41,7 +41,7 @@ describe('IDE jobs detail view', () => { }); it('scrolls to bottom', () => { - expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalled(); + expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalled(); }); it('renders job output', () => { @@ -125,15 +125,15 @@ describe('IDE jobs detail view', () => { beforeEach(() => { vm = vm.$mount(); - jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); }); it('scrolls build trace to bottom', () => { - jest.spyOn(vm.$refs.buildTrace, 'scrollHeight', 'get').mockReturnValue(1000); + jest.spyOn(vm.$refs.buildJobLog, 'scrollHeight', 'get').mockReturnValue(1000); vm.scrollDown(); - expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalledWith(0, 1000); + expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalledWith(0, 1000); }); }); @@ -141,26 +141,26 @@ describe('IDE jobs detail view', () => { beforeEach(() => { vm = vm.$mount(); - jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); }); it('scrolls build trace to top', () => { vm.scrollUp(); - expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalledWith(0, 0); + expect(vm.$refs.buildJobLog.scrollTo).toHaveBeenCalledWith(0, 0); }); }); describe('scrollBuildLog', () => { beforeEach(() => { vm = vm.$mount(); - jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); - jest.spyOn(vm.$refs.buildTrace, 'offsetHeight', 'get').mockReturnValue(100); - jest.spyOn(vm.$refs.buildTrace, 'scrollHeight', 'get').mockReturnValue(200); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTo').mockImplementation(); + jest.spyOn(vm.$refs.buildJobLog, 'offsetHeight', 'get').mockReturnValue(100); + jest.spyOn(vm.$refs.buildJobLog, 'scrollHeight', 'get').mockReturnValue(200); }); it('sets scrollPos to bottom when at the bottom', () => { - jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(100); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(100); vm.scrollBuildLog(); @@ -168,7 +168,7 @@ describe('IDE jobs detail view', () => { }); it('sets scrollPos to top when at the top', () => { - jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(0); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(0); vm.scrollPos = 1; vm.scrollBuildLog(); @@ -177,7 +177,7 @@ describe('IDE jobs detail view', () => { }); it('resets scrollPos when not at top or bottom', () => { - jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(10); + jest.spyOn(vm.$refs.buildJobLog, 'scrollTop', 'get').mockReturnValue(10); vm.scrollBuildLog(); diff --git a/spec/frontend/ide/stores/modules/commit/getters_spec.js b/spec/frontend/ide/stores/modules/commit/getters_spec.js index 7a07ed05201..1e34087b290 100644 --- a/spec/frontend/ide/stores/modules/commit/getters_spec.js +++ b/spec/frontend/ide/stores/modules/commit/getters_spec.js @@ -126,7 +126,7 @@ describe('IDE commit module getters', () => { ); expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe( - 'Update test-file, index.js files', + 'Update test-file, index.js', ); }); diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js index 79b6b66319e..a8875e0cd02 100644 --- a/spec/frontend/ide/stores/utils_spec.js +++ b/spec/frontend/ide/stores/utils_spec.js @@ -94,7 +94,7 @@ describe('Multi-file store utils', () => { { action: commitActionTypes.move, file_path: 'renamedFile', - content: null, + content: undefined, encoding: 'text', last_commit_id: undefined, previous_path: 'prevPath', diff --git a/spec/frontend/import_entities/components/pagination_bar_spec.js b/spec/frontend/import_entities/components/pagination_bar_spec.js new file mode 100644 index 00000000000..163ce11a8db --- /dev/null +++ b/spec/frontend/import_entities/components/pagination_bar_spec.js @@ -0,0 +1,92 @@ +import { GlPagination, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; + +describe('Pagination bar', () => { + const DEFAULT_PROPS = { + pageInfo: { + total: 50, + page: 1, + perPage: 20, + }, + itemsCount: 17, + }; + let wrapper; + + const createComponent = (propsData) => { + wrapper = mount(PaginationBar, { + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + }); + + it('emits set-page event when page is selected', () => { + const NEXT_PAGE = 3; + // PaginationLinks uses prop instead of event for handling page change + // So we go one level deep to test this + wrapper + .findComponent(PaginationLinks) + .findComponent(GlPagination) + .vm.$emit('input', NEXT_PAGE); + expect(wrapper.emitted('set-page')).toEqual([[NEXT_PAGE]]); + }); + + it('emits set-page-size event when page size is selected', () => { + const firstItemInPageSizeDropdown = wrapper.findComponent(GlDropdownItem); + firstItemInPageSizeDropdown.vm.$emit('click'); + + const [emittedPageSizeChange] = wrapper.emitted('set-page-size')[0]; + expect(firstItemInPageSizeDropdown.text()).toMatchInterpolatedText( + `${emittedPageSizeChange} items per page`, + ); + }); + }); + + it('renders current page size', () => { + const CURRENT_PAGE_SIZE = 40; + + createComponent({ + pageInfo: { + ...DEFAULT_PROPS.pageInfo, + perPage: CURRENT_PAGE_SIZE, + }, + }); + + expect(wrapper.find(GlDropdown).find('button').text()).toMatchInterpolatedText( + `${CURRENT_PAGE_SIZE} items per page`, + ); + }); + + it('renders current page information', () => { + createComponent(); + + expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText( + 'Showing 1 - 17 of 50', + ); + }); + + it('renders current page information when total count is over 1000', () => { + createComponent({ + pageInfo: { + ...DEFAULT_PROPS.pageInfo, + total: 1200, + }, + }); + + expect(wrapper.find('[data-testid="information"]').text()).toMatchInterpolatedText( + 'Showing 1 - 17 of 1000+', + ); + }); +}); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index ff602327592..0a9cbadb249 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -11,7 +11,7 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; -import { integrationLevels } from '~/integrations/edit/constants'; +import { integrationLevels } from '~/integrations/constants'; import { createStore } from '~/integrations/edit/store'; describe('IntegrationForm', () => { diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index 2860d3cc37a..119afbfecfe 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -1,6 +1,7 @@ import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { GET_JIRA_ISSUE_TYPES_EVENT } from '~/integrations/constants'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import eventHub from '~/integrations/edit/event_hub'; import { createStore } from '~/integrations/edit/store'; @@ -207,7 +208,7 @@ describe('JiraIssuesFields', () => { await setEnableCheckbox(true); await findJiraForVulnerabilities().vm.$emit('request-get-issue-types'); - expect(eventHubEmitSpy).toHaveBeenCalledWith('getJiraIssueTypes'); + expect(eventHubEmitSpy).toHaveBeenCalledWith(GET_JIRA_ISSUE_TYPES_EVENT); }); }); }); diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js index eb43d940f5e..90facaff1f9 100644 --- a/spec/frontend/integrations/edit/components/override_dropdown_spec.js +++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js @@ -2,7 +2,7 @@ import { GlDropdown, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; -import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/edit/constants'; +import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/constants'; import { createStore } from '~/integrations/edit/store'; describe('OverrideDropdown', () => { diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js index cbb2ef380ba..f8f3f0fd318 100644 --- a/spec/frontend/integrations/integration_settings_form_spec.js +++ b/spec/frontend/integrations/integration_settings_form_spec.js @@ -23,7 +23,7 @@ describe('IntegrationSettingsForm', () => { it('should initialize form element refs on class object', () => { // Form Reference expect(integrationSettingsForm.$form).toBeDefined(); - expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM'); + expect(integrationSettingsForm.$form.nodeName).toBe('FORM'); expect(integrationSettingsForm.formActive).toBeDefined(); }); @@ -43,14 +43,14 @@ describe('IntegrationSettingsForm', () => { integrationSettingsForm.formActive = true; integrationSettingsForm.toggleServiceState(); - expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined(); + expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBe(null); }); it('should set `novalidate` attribute to form when called with `false`', () => { integrationSettingsForm.formActive = false; integrationSettingsForm.toggleServiceState(); - expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined(); + expect(integrationSettingsForm.$form.getAttribute('novalidate')).toBeDefined(); }); }); @@ -67,8 +67,7 @@ describe('IntegrationSettingsForm', () => { integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); integrationSettingsForm.init(); - // eslint-disable-next-line no-jquery/no-serialize - formData = integrationSettingsForm.$form.serialize(); + formData = new FormData(integrationSettingsForm.$form); }); afterEach(() => { @@ -145,8 +144,7 @@ describe('IntegrationSettingsForm', () => { integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); integrationSettingsForm.init(); - // eslint-disable-next-line no-jquery/no-serialize - formData = integrationSettingsForm.$form.serialize(); + formData = new FormData(integrationSettingsForm.$form); }); afterEach(() => { diff --git a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js index dbed236d7df..ae89d05cead 100644 --- a/spec/frontend/integrations/overrides/components/integration_overrides_spec.js +++ b/spec/frontend/integrations/overrides/components/integration_overrides_spec.js @@ -1,16 +1,14 @@ -import { GlTable, GlLink, GlPagination } from '@gitlab/ui'; +import { GlTable, GlLink, GlPagination, GlAlert } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { shallowMount, mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { DEFAULT_PER_PAGE } from '~/api'; -import createFlash from '~/flash'; import IntegrationOverrides from '~/integrations/overrides/components/integration_overrides.vue'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; -jest.mock('~/flash'); - const mockOverrides = Array(DEFAULT_PER_PAGE * 3) .fill(1) .map((_, index) => ({ @@ -62,6 +60,7 @@ describe('IntegrationOverrides', () => { text: link.text(), }; }); + const findAlert = () => wrapper.findComponent(GlAlert); describe('while loading', () => { it('sets GlTable `busy` attribute to `true`', () => { @@ -104,18 +103,26 @@ describe('IntegrationOverrides', () => { describe('when request fails', () => { beforeEach(async () => { + jest.spyOn(Sentry, 'captureException'); mockAxios.onGet(defaultProps.overridesPath).reply(httpStatus.INTERNAL_SERVER_ERROR); + createComponent(); await waitForPromises(); }); - it('calls createFlash', () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: IntegrationOverrides.i18n.defaultErrorMessage, - captureError: true, - error: expect.any(Error), - }); + it('displays error alert', () => { + const alert = findAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe(IntegrationOverrides.i18n.defaultErrorMessage); + }); + + it('hides overrides table', () => { + const table = findGlTable(); + expect(table.exists()).toBe(false); + }); + + it('captures exception in Sentry', () => { + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); }); }); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 95b1c55b82d..8c3c549a5eb 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -242,7 +242,7 @@ describe('InviteMembersModal', () => { }; const expectedEmailRestrictedError = - "email 'email@example.com' does not match the allowed domains: example1.org"; + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups."; const expectedSyntaxError = 'email contains an invalid email address'; it('calls the API with the expected focus data when an areas_of_focus checkbox is clicked', () => { @@ -421,7 +421,7 @@ describe('InviteMembersModal', () => { await waitForPromises(); expect(membersFormGroupInvalidFeedback()).toBe( - "root: User email 'admin@example.com' does not match the allowed domain of example2.com", + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", ); expect(findMembersSelect().props('validationState')).toBe(false); }); diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js index 79b56a33708..dd84b4fd78f 100644 --- a/spec/frontend/invite_members/mock_data/api_responses.js +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -9,7 +9,7 @@ const INVITATIONS_API_ERROR_EMAIL_INVALID = { const INVITATIONS_API_EMAIL_RESTRICTED = { message: { 'email@example.com': - "Invite email 'email@example.com' does not match the allowed domains: example1.org", + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", }, status: 'error', }; @@ -17,9 +17,9 @@ const INVITATIONS_API_EMAIL_RESTRICTED = { const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = { message: { 'email@example.com': - "Invite email email 'email@example.com' does not match the allowed domains: example1.org", + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", 'email4@example.com': - "Invite email email 'email4@example.com' does not match the allowed domains: example1.org", + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.", }, status: 'error', }; @@ -36,7 +36,11 @@ const MEMBERS_API_MEMBER_ALREADY_EXISTS = { }; const MEMBERS_API_SINGLE_USER_RESTRICTED = { - message: { user: ["email 'email@example.com' does not match the allowed domains: example1.org"] }, + message: { + user: [ + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.", + ], + }, }; const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = { @@ -49,7 +53,7 @@ const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = { const MEMBERS_API_MULTIPLE_USERS_RESTRICTED = { message: - "root: User email 'admin@example.com' does not match the allowed domain of example2.com and user18: User email 'user18@example.org' does not match the allowed domain of example2.com", + "root: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups. and user18: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist. and john_doe31: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Email restrictions for sign-ups.", status: 'error', }; diff --git a/spec/frontend/invite_members/utils/response_message_parser_spec.js b/spec/frontend/invite_members/utils/response_message_parser_spec.js index 3c88b5a2418..e2cc87c8547 100644 --- a/spec/frontend/invite_members/utils/response_message_parser_spec.js +++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js @@ -2,18 +2,20 @@ import { responseMessageFromSuccess, responseMessageFromError, } from '~/invite_members/utils/response_message_parser'; +import { membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; describe('Response message parser', () => { - const expectedMessage = 'expected display message'; + const expectedMessage = 'expected display and message.'; describe('parse message from successful response', () => { const exampleKeyedMsg = { 'email@example.com': expectedMessage }; + const exampleFirstPartMultiple = 'username1: expected display and message.'; const exampleUserMsgMultiple = - ' and username1: id not found and username2: email is restricted'; + ' and username2: id not found and restricted email. and username3: email is restricted.'; it.each([ [[{ data: { message: expectedMessage } }]], - [[{ data: { message: expectedMessage + exampleUserMsgMultiple } }]], + [[{ data: { message: exampleFirstPartMultiple + exampleUserMsgMultiple } }]], [[{ data: { error: expectedMessage } }]], [[{ data: { message: [expectedMessage] } }]], [[{ data: { message: exampleKeyedMsg } }]], @@ -33,4 +35,24 @@ describe('Response message parser', () => { expect(responseMessageFromError(errorResponse)).toBe(expectedMessage); }); }); + + describe('displaying only the first error when a response has messages for multiple users', () => { + const expected = + "The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups."; + + it.each([ + [[{ data: membersApiResponse.MULTIPLE_USERS_RESTRICTED }]], + [[{ data: invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED }]], + [[{ data: invitationsApiResponse.EMAIL_RESTRICTED }]], + ])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => { + expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected); + }); + + it.each([[{ response: { data: membersApiResponse.SINGLE_USER_RESTRICTED } }]])( + `returns "${expectedMessage}" from error response: %j`, + (singleRestrictedResponse) => { + expect(responseMessageFromError(singleRestrictedResponse)).toBe(expected); + }, + ); + }); }); diff --git a/spec/frontend/issuable/components/csv_export_modal_spec.js b/spec/frontend/issuable/components/csv_export_modal_spec.js index 34094d22e68..ad4abda6912 100644 --- a/spec/frontend/issuable/components/csv_export_modal_spec.js +++ b/spec/frontend/issuable/components/csv_export_modal_spec.js @@ -61,11 +61,6 @@ describe('CsvExportModal', () => { expect(wrapper.text()).toContain('10 issues selected'); expect(findIcon().exists()).toBe(true); }); - - it("doesn't display the info text when issuableCount is -1", () => { - wrapper = createComponent({ props: { issuableCount: -1 } }); - expect(wrapper.text()).not.toContain('issues selected'); - }); }); describe('email info text', () => { diff --git a/spec/frontend/issuable/components/csv_import_modal_spec.js b/spec/frontend/issuable/components/csv_import_modal_spec.js index 0c88b6b1283..307323ef07a 100644 --- a/spec/frontend/issuable/components/csv_import_modal_spec.js +++ b/spec/frontend/issuable/components/csv_import_modal_spec.js @@ -17,7 +17,6 @@ describe('CsvImportModal', () => { ...props, }, provide: { - issuableType: 'issues', ...injectedProperties, }, stubs: { @@ -43,9 +42,9 @@ describe('CsvImportModal', () => { const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token'); describe('template', () => { - it('displays modal title', () => { + it('passes correct title props to modal', () => { wrapper = createComponent(); - expect(findModal().text()).toContain('Import issues'); + expect(findModal().props('title')).toContain('Import issues'); }); it('displays a note about the maximum allowed file size', () => { @@ -73,7 +72,7 @@ describe('CsvImportModal', () => { }); it('submits the form when the primary action is clicked', () => { - findPrimaryButton().trigger('click'); + findModal().vm.$emit('primary'); expect(formSubmitSpy).toHaveBeenCalled(); }); diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js index 173d12757e3..ff6922989cb 100644 --- a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js +++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js @@ -1,5 +1,6 @@ import { mount, shallowMount } from '@vue/test-utils'; import AddIssuableForm from '~/related_issues/components/add_issuable_form.vue'; +import IssueToken from '~/related_issues/components/issue_token.vue'; import { issuableTypesMap, linkedIssueTypesMap, PathIdSeparator } from '~/related_issues/constants'; const issuable1 = { @@ -22,7 +23,7 @@ const issuable2 = { const pathIdSeparator = PathIdSeparator.Issue; -const findFormInput = (wrapper) => wrapper.find('.js-add-issuable-form-input').element; +const findFormInput = (wrapper) => wrapper.find('input').element; const findRadioInput = (inputs, value) => inputs.filter((input) => input.element.value === value)[0]; @@ -105,11 +106,11 @@ describe('AddIssuableForm', () => { }); it('should put input value in place', () => { - expect(findFormInput(wrapper).value).toEqual(inputValue); + expect(findFormInput(wrapper).value).toBe(inputValue); }); it('should render pending issuables items', () => { - expect(wrapper.findAll('.js-add-issuable-form-token-list-item').length).toEqual(2); + expect(wrapper.findAllComponents(IssueToken)).toHaveLength(2); }); it('should not have disabled submit button', () => { diff --git a/spec/frontend/issuable_form_spec.js b/spec/frontend/issuable_form_spec.js index bc7a87eb65c..c77fde4261e 100644 --- a/spec/frontend/issuable_form_spec.js +++ b/spec/frontend/issuable_form_spec.js @@ -20,16 +20,13 @@ describe('IssuableForm', () => { describe('removeWip', () => { it.each` prefix - ${'drAft '} ${'draFT: '} ${' [DRaft] '} ${'drAft:'} ${'[draFT]'} - ${' dRaFt - '} - ${'dRaFt - '} ${'(draft) '} ${' (DrafT)'} - ${'draft draft - draft: [draft] (draft)'} + ${'draft: [draft] (draft)'} `('removes "$prefix" from the beginning of the title', ({ prefix }) => { instance.titleField.val(`${prefix}The Issuable's Title Value`); @@ -48,4 +45,18 @@ describe('IssuableForm', () => { expect(instance.titleField.val()).toBe("Draft: The Issuable's Title Value"); }); }); + + describe('workInProgress', () => { + it.each` + title | expected + ${'draFT: something is happening'} | ${true} + ${'draft something is happening'} | ${false} + ${'something is happening to drafts'} | ${false} + ${'something is happening'} | ${false} + `('returns $expected with "$title"', ({ title, expected }) => { + instance.titleField.val(title); + + expect(instance.workInProgress()).toBe(expected); + }); + }); }); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js index ea36d59ff83..ac3bf7f3269 100644 --- a/spec/frontend/issuable_list/components/issuable_item_spec.js +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlLabel, GlIcon, GlFormCheckbox } from '@gitlab/ui'; +import { GlLink, GlLabel, GlIcon, GlFormCheckbox, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; import IssuableItem from '~/issuable_list/components/issuable_item.vue'; @@ -16,6 +16,9 @@ const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots showCheckbox: false, }, slots, + stubs: { + GlSprintf, + }, }); const MOCK_GITLAB_URL = 'http://0.0.0.0:3000'; @@ -135,13 +138,6 @@ describe('IssuableItem', () => { }); }); - describe('createdAt', () => { - it('returns string containing timeago string based on `issuable.createdAt`', () => { - expect(wrapper.vm.createdAt).toContain('created'); - expect(wrapper.vm.createdAt).toContain('ago'); - }); - }); - describe('updatedAt', () => { it('returns string containing timeago string based on `issuable.updatedAt`', () => { expect(wrapper.vm.updatedAt).toContain('updated'); @@ -449,8 +445,7 @@ describe('IssuableItem', () => { it('renders issuable updatedAt info', () => { const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); - expect(updatedAtEl.exists()).toBe(true); - expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); + expect(updatedAtEl.attributes('title')).toBe('Sep 10, 2020 11:41am UTC'); expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt); }); diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issuable_suggestions/components/item_spec.js index 39083b3d8fb..45f96103e3e 100644 --- a/spec/frontend/issuable_suggestions/components/item_spec.js +++ b/spec/frontend/issuable_suggestions/components/item_spec.js @@ -6,10 +6,10 @@ import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_ima import mockData from '../mock_data'; describe('Issuable suggestions suggestion component', () => { - let vm; + let wrapper; function createComponent(suggestion = {}) { - vm = shallowMount(Suggestion, { + wrapper = shallowMount(Suggestion, { propsData: { suggestion: { ...mockData(), @@ -19,37 +19,40 @@ describe('Issuable suggestions suggestion component', () => { }); } + const findLink = () => wrapper.findComponent(GlLink); + const findAuthorLink = () => wrapper.findAll(GlLink).at(1); + const findIcon = () => wrapper.findComponent(GlIcon); + const findTooltip = () => wrapper.findComponent(GlTooltip); + const findUserAvatar = () => wrapper.findComponent(UserAvatarImage); + afterEach(() => { - vm.destroy(); + wrapper.destroy(); }); it('renders title', () => { createComponent(); - expect(vm.text()).toContain('Test issue'); + expect(wrapper.text()).toContain('Test issue'); }); it('renders issue link', () => { createComponent(); - const link = vm.find(GlLink); - - expect(link.attributes('href')).toBe(`${TEST_HOST}/test/issue/1`); + expect(findLink().attributes('href')).toBe(`${TEST_HOST}/test/issue/1`); }); it('renders IID', () => { createComponent(); - expect(vm.text()).toContain('#1'); + expect(wrapper.text()).toContain('#1'); }); describe('opened state', () => { it('renders icon', () => { createComponent(); - const icon = vm.find(GlIcon); - - expect(icon.props('name')).toBe('issue-open-m'); + expect(findIcon().props('name')).toBe('issue-open-m'); + expect(findIcon().attributes('class')).toMatch('gl-text-green-500'); }); it('renders created timeago', () => { @@ -57,10 +60,8 @@ describe('Issuable suggestions suggestion component', () => { closedAt: '', }); - const tooltip = vm.find(GlTooltip); - - expect(tooltip.find('.d-block').text()).toContain('Opened'); - expect(tooltip.text()).toContain('3 days ago'); + expect(findTooltip().text()).toContain('Opened'); + expect(findTooltip().text()).toContain('3 days ago'); }); }); @@ -70,18 +71,15 @@ describe('Issuable suggestions suggestion component', () => { state: 'closed', }); - const icon = vm.find(GlIcon); - - expect(icon.props('name')).toBe('issue-close'); + expect(findIcon().props('name')).toBe('issue-close'); + expect(findIcon().attributes('class')).toMatch('gl-text-blue-500'); }); it('renders closed timeago', () => { createComponent(); - const tooltip = vm.find(GlTooltip); - - expect(tooltip.find('.d-block').text()).toContain('Opened'); - expect(tooltip.text()).toContain('1 day ago'); + expect(findTooltip().text()).toContain('Opened'); + expect(findTooltip().text()).toContain('1 day ago'); }); }); @@ -89,18 +87,14 @@ describe('Issuable suggestions suggestion component', () => { it('renders author info', () => { createComponent(); - const link = vm.findAll(GlLink).at(1); - - expect(link.text()).toContain('Author Name'); - expect(link.text()).toContain('@author.username'); + expect(findAuthorLink().text()).toContain('Author Name'); + expect(findAuthorLink().text()).toContain('@author.username'); }); it('renders author image', () => { createComponent(); - const image = vm.find(UserAvatarImage); - - expect(image.props('imgSrc')).toBe(`${TEST_HOST}/avatar`); + expect(findUserAvatar().props('imgSrc')).toBe(`${TEST_HOST}/avatar`); }); }); @@ -108,7 +102,7 @@ describe('Issuable suggestions suggestion component', () => { it('renders upvotes count', () => { createComponent(); - const count = vm.findAll('.suggestion-counts span').at(0); + const count = wrapper.findAll('.suggestion-counts span').at(0); expect(count.text()).toContain('1'); expect(count.find(GlIcon).props('name')).toBe('thumb-up'); @@ -117,7 +111,7 @@ describe('Issuable suggestions suggestion component', () => { it('renders notes count', () => { createComponent(); - const count = vm.findAll('.suggestion-counts span').at(1); + const count = wrapper.findAll('.suggestion-counts span').at(1); expect(count.text()).toContain('2'); expect(count.find(GlIcon).props('name')).toBe('comment'); @@ -130,10 +124,9 @@ describe('Issuable suggestions suggestion component', () => { confidential: true, }); - const icon = vm.find(GlIcon); - - expect(icon.props('name')).toBe('eye-slash'); - expect(icon.attributes('title')).toBe('Confidential'); + expect(findIcon().props('name')).toBe('eye-slash'); + expect(findIcon().attributes('class')).toMatch('gl-text-orange-500'); + expect(findIcon().attributes('title')).toBe('Confidential'); }); }); }); diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js index 8d79a5eed35..6b443062f12 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -24,6 +24,7 @@ import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; +import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; import { CREATED_DESC, DUE_DATE_OVERDUE, @@ -65,6 +66,7 @@ describe('IssuesListApp component', () => { exportCsvPath: 'export/csv/path', fullPath: 'path/to/project', hasAnyIssues: true, + hasAnyProjects: true, hasBlockedIssuesFeature: true, hasIssueWeightsFeature: true, hasIterationsFeature: true, @@ -93,6 +95,7 @@ describe('IssuesListApp component', () => { const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); const findGlLink = () => wrapper.findComponent(GlLink); const findIssuableList = () => wrapper.findComponent(IssuableList); + const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown); const mountComponent = ({ provide = {}, @@ -190,10 +193,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { setWindowLocation(search); - wrapper = mountComponent({ - provide: { isSignedIn: true }, - mountFn: mount, - }); + wrapper = mountComponent({ provide: { isSignedIn: true }, mountFn: mount }); jest.runOnlyPendingTimers(); }); @@ -208,7 +208,7 @@ describe('IssuesListApp component', () => { describe('when user is not signed in', () => { it('does not render', () => { - wrapper = mountComponent({ provide: { isSignedIn: false } }); + wrapper = mountComponent({ provide: { isSignedIn: false }, mountFn: mount }); expect(findCsvImportExportButtons().exists()).toBe(false); }); @@ -216,7 +216,7 @@ describe('IssuesListApp component', () => { describe('when in a group context', () => { it('does not render', () => { - wrapper = mountComponent({ provide: { isProject: false } }); + wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount }); expect(findCsvImportExportButtons().exists()).toBe(false); }); @@ -231,7 +231,7 @@ describe('IssuesListApp component', () => { }); it('does not render when user does not have permissions', () => { - wrapper = mountComponent({ provide: { canBulkUpdate: false } }); + wrapper = mountComponent({ provide: { canBulkUpdate: false }, mountFn: mount }); expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0); }); @@ -258,11 +258,25 @@ describe('IssuesListApp component', () => { }); it('does not render when user does not have permissions', () => { - wrapper = mountComponent({ provide: { showNewIssueLink: false } }); + wrapper = mountComponent({ provide: { showNewIssueLink: false }, mountFn: mount }); expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0); }); }); + + describe('new issue split dropdown', () => { + it('does not render in a project context', () => { + wrapper = mountComponent({ provide: { isProject: true }, mountFn: mount }); + + expect(findNewIssueDropdown().exists()).toBe(false); + }); + + it('renders in a group context', () => { + wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount }); + + expect(findNewIssueDropdown().exists()).toBe(true); + }); + }); }); describe('initial url params', () => { @@ -506,7 +520,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { wrapper = mountComponent({ provide: { - groupEpicsPath: '', + groupPath: '', }, }); }); @@ -522,7 +536,7 @@ describe('IssuesListApp component', () => { beforeEach(() => { wrapper = mountComponent({ provide: { - groupEpicsPath: '', + groupPath: '', }, }); }); @@ -550,7 +564,7 @@ describe('IssuesListApp component', () => { provide: { isSignedIn: true, projectIterationsPath: 'project/iterations/path', - groupEpicsPath: 'group/epics/path', + groupPath: 'group/path', hasIssueWeightsFeature: true, }, }); diff --git a/spec/frontend/issues_list/components/new_issue_dropdown_spec.js b/spec/frontend/issues_list/components/new_issue_dropdown_spec.js new file mode 100644 index 00000000000..1fcaa99cf5a --- /dev/null +++ b/spec/frontend/issues_list/components/new_issue_dropdown_spec.js @@ -0,0 +1,131 @@ +import { GlDropdown, GlDropdownItem, 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 NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue'; +import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql'; +import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; +import { + emptySearchProjectsQueryResponse, + project1, + project2, + searchProjectsQueryResponse, +} from '../mock_data'; + +describe('NewIssueDropdown component', () => { + let wrapper; + + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const mountComponent = ({ + search = '', + queryResponse = searchProjectsQueryResponse, + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [[searchProjectsQuery, jest.fn().mockResolvedValue(queryResponse)]]; + const apolloProvider = createMockApollo(requestHandlers); + + return mountFn(NewIssueDropdown, { + localVue, + apolloProvider, + provide: { + fullPath: 'mushroom-kingdom', + }, + data() { + return { search }; + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findInput = () => wrapper.findComponent(GlSearchBoxByType); + const showDropdown = async () => { + findDropdown().vm.$emit('shown'); + await wrapper.vm.$apollo.queries.projects.refetch(); + jest.runOnlyPendingTimers(); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a split dropdown', () => { + wrapper = mountComponent(); + + expect(findDropdown().props('split')).toBe(true); + }); + + it('renders a label for the dropdown toggle button', () => { + wrapper = mountComponent(); + + expect(findDropdown().attributes('toggle-text')).toBe(NewIssueDropdown.i18n.toggleButtonLabel); + }); + + it('focuses on input when dropdown is shown', async () => { + wrapper = mountComponent({ mountFn: mount }); + + const inputSpy = jest.spyOn(findInput().vm, 'focusInput'); + + await showDropdown(); + + expect(inputSpy).toHaveBeenCalledTimes(1); + }); + + it('renders expected dropdown items', async () => { + wrapper = mountComponent({ mountFn: mount }); + + await showDropdown(); + + const listItems = wrapper.findAll('li'); + + expect(listItems.at(0).text()).toBe(project1.nameWithNamespace); + expect(listItems.at(1).text()).toBe(project2.nameWithNamespace); + }); + + it('renders `No matches found` when there are no matches', async () => { + wrapper = mountComponent({ + search: 'no matches', + queryResponse: emptySearchProjectsQueryResponse, + mountFn: mount, + }); + + await showDropdown(); + + expect(wrapper.find('li').text()).toBe(NewIssueDropdown.i18n.noMatchesFound); + }); + + describe('when no project is selected', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('dropdown button is not a link', () => { + expect(findDropdown().attributes('split-href')).toBeUndefined(); + }); + + it('displays default text on the dropdown button', () => { + expect(findDropdown().props('text')).toBe(NewIssueDropdown.i18n.defaultDropdownText); + }); + }); + + describe('when a project is selected', () => { + beforeEach(async () => { + wrapper = mountComponent({ mountFn: mount }); + + await showDropdown(); + + wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1); + }); + + it('dropdown button is a link', () => { + const href = joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new'); + + expect(findDropdown().attributes('split-href')).toBe(href); + }); + + it('displays project name on the dropdown button', () => { + expect(findDropdown().props('text')).toBe(`New issue in ${project1.name}`); + }); + }); +}); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index 720f9cac986..3be256d8094 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -221,3 +221,37 @@ export const urlParamsWithSpecialValues = { epic_id: 'None', weight: 'None', }; + +export const project1 = { + id: 'gid://gitlab/Group/26', + name: 'Super Mario Project', + nameWithNamespace: 'Mushroom Kingdom / Super Mario Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/super-mario-project', +}; + +export const project2 = { + id: 'gid://gitlab/Group/59', + name: 'Mario Kart Project', + nameWithNamespace: 'Mushroom Kingdom / Mario Kart Project', + webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-kart-project', +}; + +export const searchProjectsQueryResponse = { + data: { + group: { + projects: { + nodes: [project1, project2], + }, + }, + }, +}; + +export const emptySearchProjectsQueryResponse = { + data: { + group: { + projects: { + nodes: [], + }, + }, + }, +}; diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index 891ba9c223c..9f5b772a5c7 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -127,21 +127,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = > <!----> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" - > - <div - class="gl-display-flex" - > - <!----> - </div> - - <div - class="gl-display-flex" - > - <!----> - </div> - </div> + <!----> <div class="gl-new-dropdown-contents" @@ -272,21 +258,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = > <!----> - <div - class="gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-px-5" - > - <div - class="gl-display-flex" - > - <!----> - </div> - - <div - class="gl-display-flex" - > - <!----> - </div> - </div> + <!----> <div class="gl-new-dropdown-contents" diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index f8a0059bf21..07e6ee46c41 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; import { TEST_HOST } from 'helpers/test_constants'; import EmptyState from '~/jobs/components/empty_state.vue'; import EnvironmentsBlock from '~/jobs/components/environments_block.vue'; @@ -19,8 +19,6 @@ describe('Job App', () => { const localVue = createLocalVue(); localVue.use(Vuex); - const delayedJobFixture = getJSONFixture('jobs/delayed.json'); - let store; let wrapper; let mock; @@ -47,9 +45,9 @@ describe('Job App', () => { wrapper = mount(JobApp, { propsData: { ...props }, store }); }; - const setupAndMount = ({ jobData = {}, traceData = {} } = {}) => { + const setupAndMount = ({ jobData = {}, jobLogData = {} } = {}) => { mock.onGet(initSettings.endpoint).replyOnce(200, { ...job, ...jobData }); - mock.onGet(`${initSettings.pagePath}/trace.json`).reply(200, traceData); + mock.onGet(`${initSettings.pagePath}/trace.json`).reply(200, jobLogData); const asyncInit = store.dispatch('init', initSettings); @@ -77,11 +75,10 @@ describe('Job App', () => { const findEmptyState = () => wrapper.find(EmptyState); const findJobNewIssueLink = () => wrapper.find('[data-testid="job-new-issue"]'); const findJobEmptyStateTitle = () => wrapper.find('[data-testid="job-empty-state-title"]'); - const findJobTraceScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); - const findJobTraceScrollBottom = () => - wrapper.find('[data-testid="job-controller-scroll-bottom"]'); - const findJobTraceController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); - const findJobTraceEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); + const findJobLogScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); + const findJobLogScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); + const findJobLogController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); + const findJobLogEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); beforeEach(() => { mock = new MockAdapter(axios); @@ -315,7 +312,7 @@ describe('Job App', () => { }); describe('empty states block', () => { - it('renders empty state when job does not have trace and is not running', () => + it('renders empty state when job does not have log and is not running', () => setupAndMount({ jobData: { has_trace: false, @@ -342,7 +339,7 @@ describe('Job App', () => { expect(findEmptyState().exists()).toBe(true); })); - it('does not render empty state when job does not have trace but it is running', () => + it('does not render empty state when job does not have log but it is running', () => setupAndMount({ jobData: { has_trace: false, @@ -358,7 +355,7 @@ describe('Job App', () => { expect(findEmptyState().exists()).toBe(false); })); - it('does not render empty state when job has trace but it is not running', () => + it('does not render empty state when job has log but it is not running', () => setupAndMount({ jobData: { has_trace: true } }).then(() => { expect(findEmptyState().exists()).toBe(false); })); @@ -424,10 +421,10 @@ describe('Job App', () => { }); }); - describe('trace controls', () => { + describe('job log controls', () => { beforeEach(() => setupAndMount({ - traceData: { + jobLogData: { html: '<span>Update</span>', status: 'success', append: false, @@ -439,16 +436,16 @@ describe('Job App', () => { ); it('should render scroll buttons', () => { - expect(findJobTraceScrollTop().exists()).toBe(true); - expect(findJobTraceScrollBottom().exists()).toBe(true); + expect(findJobLogScrollTop().exists()).toBe(true); + expect(findJobLogScrollBottom().exists()).toBe(true); }); it('should render link to raw ouput', () => { - expect(findJobTraceController().exists()).toBe(true); + expect(findJobLogController().exists()).toBe(true); }); it('should render link to erase job', () => { - expect(findJobTraceEraseLink().exists()).toBe(true); + expect(findJobLogEraseLink().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/jobs/components/job_container_item_spec.js b/spec/frontend/jobs/components/job_container_item_spec.js index 36038b69e64..6b488821bc1 100644 --- a/spec/frontend/jobs/components/job_container_item_spec.js +++ b/spec/frontend/jobs/components/job_container_item_spec.js @@ -1,12 +1,12 @@ import { GlIcon, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; import JobContainerItem from '~/jobs/components/job_container_item.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import job from '../mock_data'; describe('JobContainerItem', () => { let wrapper; - const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const findCiIconComponent = () => wrapper.findComponent(CiIcon); const findGlIconComponent = () => wrapper.findComponent(GlIcon); diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js index 97b0333cb32..0ba07522243 100644 --- a/spec/frontend/jobs/components/job_log_controllers_spec.js +++ b/spec/frontend/jobs/components/job_log_controllers_spec.js @@ -18,7 +18,7 @@ describe('Job log controllers', () => { isScrollTopDisabled: false, isScrollBottomDisabled: false, isScrollingDown: true, - isTraceSizeVisible: true, + isJobLogSizeVisible: true, }; const createWrapper = (props) => { @@ -38,7 +38,7 @@ describe('Job log controllers', () => { const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); describe('Truncate information', () => { - describe('with isTraceSizeVisible', () => { + describe('with isJobLogSizeVisible', () => { beforeEach(() => { createWrapper(); }); @@ -47,31 +47,31 @@ describe('Job log controllers', () => { expect(findTruncatedInfo().text()).toMatch('499.95 KiB'); }); - it('renders link to raw trace', () => { + it('renders link to raw job log', () => { expect(findRawLink().attributes('href')).toBe(defaultProps.rawPath); }); }); }); describe('links section', () => { - describe('with raw trace path', () => { + describe('with raw job log path', () => { beforeEach(() => { createWrapper(); }); - it('renders raw trace link', () => { + it('renders raw job log link', () => { expect(findRawLinkController().attributes('href')).toBe(defaultProps.rawPath); }); }); - describe('without raw trace path', () => { + describe('without raw job log path', () => { beforeEach(() => { createWrapper({ rawPath: null, }); }); - it('does not render raw trace link', () => { + it('does not render raw job log link', () => { expect(findRawLinkController().exists()).toBe(false); }); }); diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js index 4e23a3ba7b8..96bdf03796b 100644 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js @@ -6,7 +6,7 @@ describe('Job Log Collapsible Section', () => { let wrapper; let origGon; - const traceEndpoint = 'jobs/335'; + const jobLogEndpoint = 'jobs/335'; const findCollapsibleLine = () => wrapper.find('.collapsible-line'); const findCollapsibleLineSvg = () => wrapper.find('.collapsible-line svg'); @@ -35,7 +35,7 @@ describe('Job Log Collapsible Section', () => { beforeEach(() => { createComponent({ section: collapsibleSectionClosed, - traceEndpoint, + jobLogEndpoint, }); }); @@ -52,7 +52,7 @@ describe('Job Log Collapsible Section', () => { beforeEach(() => { createComponent({ section: collapsibleSectionOpened, - traceEndpoint, + jobLogEndpoint, }); }); @@ -72,7 +72,7 @@ describe('Job Log Collapsible Section', () => { it('emits onClickCollapsibleLine on click', () => { createComponent({ section: collapsibleSectionOpened, - traceEndpoint, + jobLogEndpoint, }); findCollapsibleLine().trigger('click'); diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index 99fb6846ce5..9a5522ab4cd 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -31,8 +31,8 @@ describe('Job Log', () => { window.gon = { features: { infinitelyCollapsibleSections: false } }; state = { - trace: logLinesParserLegacy(jobLog), - traceEndpoint: 'jobs/id', + jobLog: logLinesParserLegacy(jobLog), + jobLogEndpoint: 'jobs/id', }; store = new Vuex.Store({ @@ -59,7 +59,7 @@ describe('Job Log', () => { }); it('links to the provided path and correct line number', () => { - expect(wrapper.find('#L1').attributes('href')).toBe(`${state.traceEndpoint}#L1`); + expect(wrapper.find('#L1').attributes('href')).toBe(`${state.jobLogEndpoint}#L1`); }); }); @@ -111,8 +111,8 @@ describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => { window.gon = { features: { infinitelyCollapsibleSections: true } }; state = { - trace: logLinesParser(jobLog).parsedLines, - traceEndpoint: 'jobs/id', + jobLog: logLinesParser(jobLog).parsedLines, + jobLogEndpoint: 'jobs/id', }; store = new Vuex.Store({ @@ -139,7 +139,7 @@ describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => { }); it('links to the provided path and correct line number', () => { - expect(wrapper.find('#L1').attributes('href')).toBe(`${state.traceEndpoint}#L1`); + expect(wrapper.find('#L1').attributes('href')).toBe(`${state.jobLogEndpoint}#L1`); }); }); diff --git a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js index 838323df755..63dcd72f967 100644 --- a/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js +++ b/spec/frontend/jobs/mixins/delayed_job_mixin_spec.js @@ -1,9 +1,9 @@ import { shallowMount } from '@vue/test-utils'; +import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; describe('DelayedJobMixin', () => { let wrapper; - const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const dummyComponent = { props: { job: { diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js index a29bd15099f..16448d6a3ca 100644 --- a/spec/frontend/jobs/store/actions_spec.js +++ b/spec/frontend/jobs/store/actions_spec.js @@ -3,7 +3,7 @@ import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import { setJobEndpoint, - setTraceOptions, + setJobLogOptions, clearEtagPoll, stopPolling, requestJob, @@ -12,12 +12,12 @@ import { receiveJobError, scrollTop, scrollBottom, - requestTrace, - fetchTrace, - startPollingTrace, - stopPollingTrace, - receiveTraceSuccess, - receiveTraceError, + requestJobLog, + fetchJobLog, + startPollingJobLog, + stopPollingJobLog, + receiveJobLogSuccess, + receiveJobLogError, toggleCollapsibleLine, requestJobsForStage, fetchJobsForStage, @@ -51,13 +51,13 @@ describe('Job State actions', () => { }); }); - describe('setTraceOptions', () => { - it('should commit SET_TRACE_OPTIONS mutation', (done) => { + describe('setJobLogOptions', () => { + it('should commit SET_JOB_LOG_OPTIONS mutation', (done) => { testAction( - setTraceOptions, + setJobLogOptions, { pagePath: 'job/872324/trace.json' }, mockedState, - [{ type: types.SET_TRACE_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], + [{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }], [], done, ); @@ -191,17 +191,17 @@ describe('Job State actions', () => { }); }); - describe('requestTrace', () => { - it('should commit REQUEST_TRACE mutation', (done) => { - testAction(requestTrace, null, mockedState, [{ type: types.REQUEST_TRACE }], [], done); + describe('requestJobLog', () => { + it('should commit REQUEST_JOB_LOG mutation', (done) => { + testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], [], done); }); }); - describe('fetchTrace', () => { + describe('fetchJobLog', () => { let mock; beforeEach(() => { - mockedState.traceEndpoint = `${TEST_HOST}/endpoint`; + mockedState.jobLogEndpoint = `${TEST_HOST}/endpoint`; mock = new MockAdapter(axios); }); @@ -212,14 +212,14 @@ describe('Job State actions', () => { }); describe('success', () => { - it('dispatches requestTrace, receiveTraceSuccess and stopPollingTrace when job is complete', (done) => { + it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', (done) => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: true, }); testAction( - fetchTrace, + fetchJobLog, null, mockedState, [], @@ -233,10 +233,10 @@ describe('Job State actions', () => { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: true, }, - type: 'receiveTraceSuccess', + type: 'receiveJobLogSuccess', }, { - type: 'stopPollingTrace', + type: 'stopPollingJobLog', }, ], done, @@ -244,43 +244,43 @@ describe('Job State actions', () => { }); describe('when job is incomplete', () => { - let tracePayload; + let jobLogPayload; beforeEach(() => { - tracePayload = { + jobLogPayload = { html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :', complete: false, }; - mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, tracePayload); + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, jobLogPayload); }); - it('dispatches startPollingTrace', (done) => { + it('dispatches startPollingJobLog', (done) => { testAction( - fetchTrace, + fetchJobLog, null, mockedState, [], [ { type: 'toggleScrollisInBottom', payload: true }, - { type: 'receiveTraceSuccess', payload: tracePayload }, - { type: 'startPollingTrace' }, + { type: 'receiveJobLogSuccess', payload: jobLogPayload }, + { type: 'startPollingJobLog' }, ], done, ); }); - it('does not dispatch startPollingTrace when timeout is non-empty', (done) => { - mockedState.traceTimeout = 1; + it('does not dispatch startPollingJobLog when timeout is non-empty', (done) => { + mockedState.jobLogTimeout = 1; testAction( - fetchTrace, + fetchJobLog, null, mockedState, [], [ { type: 'toggleScrollisInBottom', payload: true }, - { type: 'receiveTraceSuccess', payload: tracePayload }, + { type: 'receiveJobLogSuccess', payload: jobLogPayload }, ], done, ); @@ -293,15 +293,15 @@ describe('Job State actions', () => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500); }); - it('dispatches requestTrace and receiveTraceError ', (done) => { + it('dispatches requestJobLog and receiveJobLogError ', (done) => { testAction( - fetchTrace, + fetchJobLog, null, mockedState, [], [ { - type: 'receiveTraceError', + type: 'receiveJobLogError', }, ], done, @@ -310,7 +310,7 @@ describe('Job State actions', () => { }); }); - describe('startPollingTrace', () => { + describe('startPollingJobLog', () => { let dispatch; let commit; @@ -318,18 +318,18 @@ describe('Job State actions', () => { dispatch = jest.fn(); commit = jest.fn(); - startPollingTrace({ dispatch, commit }); + startPollingJobLog({ dispatch, commit }); }); afterEach(() => { jest.clearAllTimers(); }); - it('should save the timeout id but not call fetchTrace', () => { - expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, expect.any(Number)); + it('should save the timeout id but not call fetchJobLog', () => { + expect(commit).toHaveBeenCalledWith(types.SET_JOB_LOG_TIMEOUT, expect.any(Number)); expect(commit.mock.calls[0][1]).toBeGreaterThan(0); - expect(dispatch).not.toHaveBeenCalledWith('fetchTrace'); + expect(dispatch).not.toHaveBeenCalledWith('fetchJobLog'); }); describe('after timeout has passed', () => { @@ -337,14 +337,14 @@ describe('Job State actions', () => { jest.advanceTimersByTime(4000); }); - it('should clear the timeout id and fetchTrace', () => { - expect(commit).toHaveBeenCalledWith(types.SET_TRACE_TIMEOUT, 0); - expect(dispatch).toHaveBeenCalledWith('fetchTrace'); + it('should clear the timeout id and fetchJobLog', () => { + expect(commit).toHaveBeenCalledWith(types.SET_JOB_LOG_TIMEOUT, 0); + expect(dispatch).toHaveBeenCalledWith('fetchJobLog'); }); }); }); - describe('stopPollingTrace', () => { + describe('stopPollingJobLog', () => { let origTimeout; beforeEach(() => { @@ -358,40 +358,40 @@ describe('Job State actions', () => { window.clearTimeout = origTimeout; }); - it('should commit STOP_POLLING_TRACE mutation ', (done) => { - const traceTimeout = 7; + it('should commit STOP_POLLING_JOB_LOG mutation ', (done) => { + const jobLogTimeout = 7; testAction( - stopPollingTrace, + stopPollingJobLog, null, - { ...mockedState, traceTimeout }, - [{ type: types.SET_TRACE_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_TRACE }], + { ...mockedState, jobLogTimeout }, + [{ type: types.SET_JOB_LOG_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_JOB_LOG }], [], ) .then(() => { - expect(window.clearTimeout).toHaveBeenCalledWith(traceTimeout); + expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout); }) .then(done) .catch(done.fail); }); }); - describe('receiveTraceSuccess', () => { - it('should commit RECEIVE_TRACE_SUCCESS mutation ', (done) => { + describe('receiveJobLogSuccess', () => { + it('should commit RECEIVE_JOB_LOG_SUCCESS mutation ', (done) => { testAction( - receiveTraceSuccess, + receiveJobLogSuccess, 'hello world', mockedState, - [{ type: types.RECEIVE_TRACE_SUCCESS, payload: 'hello world' }], + [{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }], [], done, ); }); }); - describe('receiveTraceError', () => { - it('should commit stop polling trace', (done) => { - testAction(receiveTraceError, null, mockedState, [], [{ type: 'stopPollingTrace' }], done); + describe('receiveJobLogError', () => { + it('should commit stop polling job log', (done) => { + testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }], done); }); }); diff --git a/spec/frontend/jobs/store/getters_spec.js b/spec/frontend/jobs/store/getters_spec.js index 379114c3737..f26c0cf00fd 100644 --- a/spec/frontend/jobs/store/getters_spec.js +++ b/spec/frontend/jobs/store/getters_spec.js @@ -102,13 +102,13 @@ describe('Job Store Getters', () => { }); }); - describe('hasTrace', () => { + describe('hasJobLog', () => { describe('when has_trace is true', () => { it('returns true', () => { localState.job.has_trace = true; localState.job.status = {}; - expect(getters.hasTrace(localState)).toEqual(true); + expect(getters.hasJobLog(localState)).toEqual(true); }); }); @@ -117,7 +117,7 @@ describe('Job Store Getters', () => { localState.job.has_trace = false; localState.job.status = { group: 'running' }; - expect(getters.hasTrace(localState)).toEqual(true); + expect(getters.hasJobLog(localState)).toEqual(true); }); }); @@ -126,7 +126,7 @@ describe('Job Store Getters', () => { localState.job.has_trace = false; localState.job.status = { group: 'pending' }; - expect(getters.hasTrace(localState)).toEqual(false); + expect(getters.hasJobLog(localState)).toEqual(false); }); }); }); diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js index 159315330e4..b73aa8abf4e 100644 --- a/spec/frontend/jobs/store/mutations_spec.js +++ b/spec/frontend/jobs/store/mutations_spec.js @@ -45,39 +45,39 @@ describe('Jobs Store Mutations', () => { }); }); - describe('RECEIVE_TRACE_SUCCESS', () => { - describe('when trace has state', () => { - it('sets traceState', () => { + describe('RECEIVE_JOB_LOG_SUCCESS', () => { + describe('when job log has state', () => { + it('sets jobLogState', () => { const stateLog = 'eyJvZmZzZXQiOjczNDQ1MSwibl9vcGVuX3RhZ3MiOjAsImZnX2NvbG9yIjpudWxsLCJiZ19jb2xvciI6bnVsbCwic3R5bGVfbWFzayI6MH0='; - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { state: stateLog, }); - expect(stateCopy.traceState).toEqual(stateLog); + expect(stateCopy.jobLogState).toEqual(stateLog); }); }); - describe('when traceSize is smaller than the total size', () => { - it('sets isTraceSizeVisible to true', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { total: 51184600, size: 1231 }); + describe('when jobLogSize is smaller than the total size', () => { + it('sets isJobLogSizeVisible to true', () => { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { total: 51184600, size: 1231 }); - expect(stateCopy.isTraceSizeVisible).toEqual(true); + expect(stateCopy.isJobLogSizeVisible).toEqual(true); }); }); - describe('when traceSize is bigger than the total size', () => { - it('sets isTraceSizeVisible to false', () => { - const copy = { ...stateCopy, traceSize: 5118460, size: 2321312 }; + describe('when jobLogSize is bigger than the total size', () => { + it('sets isJobLogSizeVisible to false', () => { + const copy = { ...stateCopy, jobLogSize: 5118460, size: 2321312 }; - mutations[types.RECEIVE_TRACE_SUCCESS](copy, { total: 511846 }); + mutations[types.RECEIVE_JOB_LOG_SUCCESS](copy, { total: 511846 }); - expect(copy.isTraceSizeVisible).toEqual(false); + expect(copy.isJobLogSizeVisible).toEqual(false); }); }); - it('sets trace, trace size and isTraceComplete', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + it('sets job log size and isJobLogComplete', () => { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, html, size: 511846, @@ -85,15 +85,15 @@ describe('Jobs Store Mutations', () => { lines: [], }); - expect(stateCopy.traceSize).toEqual(511846); - expect(stateCopy.isTraceComplete).toEqual(true); + expect(stateCopy.jobLogSize).toEqual(511846); + expect(stateCopy.isJobLogComplete).toEqual(true); }); describe('with new job log', () => { describe('log.lines', () => { describe('when append is true', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, size: 511846, complete: true, @@ -105,7 +105,7 @@ describe('Jobs Store Mutations', () => { ], }); - expect(stateCopy.trace).toEqual([ + expect(stateCopy.jobLog).toEqual([ { offset: 1, content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], @@ -117,7 +117,7 @@ describe('Jobs Store Mutations', () => { describe('when it is defined', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: false, size: 511846, complete: true, @@ -126,7 +126,7 @@ describe('Jobs Store Mutations', () => { ], }); - expect(stateCopy.trace).toEqual([ + expect(stateCopy.jobLog).toEqual([ { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], @@ -138,7 +138,7 @@ describe('Jobs Store Mutations', () => { describe('when it is null', () => { it('sets the default value', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, html, size: 511846, @@ -146,30 +146,30 @@ describe('Jobs Store Mutations', () => { lines: null, }); - expect(stateCopy.trace).toEqual([]); + expect(stateCopy.jobLog).toEqual([]); }); }); }); }); }); - describe('SET_TRACE_TIMEOUT', () => { - it('sets the traceTimeout id', () => { + describe('SET_JOB_LOG_TIMEOUT', () => { + it('sets the jobLogTimeout id', () => { const id = 7; - expect(stateCopy.traceTimeout).not.toEqual(id); + expect(stateCopy.jobLogTimeout).not.toEqual(id); - mutations[types.SET_TRACE_TIMEOUT](stateCopy, id); + mutations[types.SET_JOB_LOG_TIMEOUT](stateCopy, id); - expect(stateCopy.traceTimeout).toEqual(id); + expect(stateCopy.jobLogTimeout).toEqual(id); }); }); - describe('STOP_POLLING_TRACE', () => { - it('sets isTraceComplete to true', () => { - mutations[types.STOP_POLLING_TRACE](stateCopy); + describe('STOP_POLLING_JOB_LOG', () => { + it('sets isJobLogComplete to true', () => { + mutations[types.STOP_POLLING_JOB_LOG](stateCopy); - expect(stateCopy.isTraceComplete).toEqual(true); + expect(stateCopy.isJobLogComplete).toEqual(true); }); }); @@ -296,12 +296,12 @@ describe('Job Store mutations, feature flag ON', () => { window.gon = origGon; }); - describe('RECEIVE_TRACE_SUCCESS', () => { + describe('RECEIVE_JOB_LOG_SUCCESS', () => { describe('with new job log', () => { describe('log.lines', () => { describe('when append is true', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, size: 511846, complete: true, @@ -313,7 +313,7 @@ describe('Job Store mutations, feature flag ON', () => { ], }); - expect(stateCopy.trace).toEqual([ + expect(stateCopy.jobLog).toEqual([ { offset: 1, content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], @@ -325,7 +325,7 @@ describe('Job Store mutations, feature flag ON', () => { describe('when lines are defined', () => { it('sets the parsed log ', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: false, size: 511846, complete: true, @@ -334,7 +334,7 @@ describe('Job Store mutations, feature flag ON', () => { ], }); - expect(stateCopy.trace).toEqual([ + expect(stateCopy.jobLog).toEqual([ { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], @@ -346,7 +346,7 @@ describe('Job Store mutations, feature flag ON', () => { describe('when lines are null', () => { it('sets the default value', () => { - mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { append: true, html, size: 511846, @@ -354,7 +354,7 @@ describe('Job Store mutations, feature flag ON', () => { lines: null, }); - expect(stateCopy.trace).toEqual([]); + expect(stateCopy.jobLog).toEqual([]); }); }); }); diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 0c5fa150002..92ac33c8792 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -1,7 +1,7 @@ import { logLinesParser, logLinesParserLegacy, - updateIncrementalTrace, + updateIncrementalJobLog, parseHeaderLine, parseLine, addDurationToHeader, @@ -487,11 +487,11 @@ describe('Jobs Store Utils', () => { }); }); - describe('updateIncrementalTrace', () => { + describe('updateIncrementalJobLog', () => { describe('without repeated section', () => { it('concats and parses both arrays', () => { const oldLog = logLinesParserLegacy(originalTrace); - const result = updateIncrementalTrace(regularIncremental, oldLog); + const result = updateIncrementalJobLog(regularIncremental, oldLog); expect(result).toEqual([ { @@ -519,7 +519,7 @@ describe('Jobs Store Utils', () => { describe('with regular line repeated offset', () => { it('updates the last line and formats with the incremental part', () => { const oldLog = logLinesParserLegacy(originalTrace); - const result = updateIncrementalTrace(regularIncrementalRepeated, oldLog); + const result = updateIncrementalJobLog(regularIncrementalRepeated, oldLog); expect(result).toEqual([ { @@ -538,7 +538,7 @@ describe('Jobs Store Utils', () => { describe('with header line repeated', () => { it('updates the header line and formats with the incremental part', () => { const oldLog = logLinesParserLegacy(headerTrace); - const result = updateIncrementalTrace(headerTraceIncremental, oldLog); + const result = updateIncrementalJobLog(headerTraceIncremental, oldLog); expect(result).toEqual([ { @@ -564,7 +564,7 @@ describe('Jobs Store Utils', () => { describe('with collapsible line repeated', () => { it('updates the collapsible line and formats with the incremental part', () => { const oldLog = logLinesParserLegacy(collapsibleTrace); - const result = updateIncrementalTrace(collapsibleTraceIncremental, oldLog); + const result = updateIncrementalJobLog(collapsibleTraceIncremental, oldLog); expect(result).toEqual([ { diff --git a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js new file mode 100644 index 00000000000..852106db44e --- /dev/null +++ b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js @@ -0,0 +1,155 @@ +import { ApolloLink, Observable } from 'apollo-link'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getSuppressNetworkErrorsDuringNavigationLink } from '~/lib/apollo/suppress_network_errors_during_navigation_link'; +import { isNavigatingAway } from '~/lib/utils/is_navigating_away'; + +jest.mock('~/lib/utils/is_navigating_away'); + +describe('getSuppressNetworkErrorsDuringNavigationLink', () => { + const originalGon = window.gon; + let subscription; + + beforeEach(() => { + window.gon = originalGon; + }); + + afterEach(() => { + if (subscription) { + subscription.unsubscribe(); + } + }); + + const makeMockGraphQLErrorLink = () => + new ApolloLink(() => + Observable.of({ + errors: [ + { + message: 'foo', + }, + ], + }), + ); + + const makeMockNetworkErrorLink = () => + new ApolloLink( + () => + new Observable(() => { + throw new Error('NetworkError'); + }), + ); + + const makeMockSuccessLink = () => + new ApolloLink(() => Observable.of({ data: { foo: { id: 1 } } })); + + const createSubscription = (otherLink, observer) => { + const mockOperation = { operationName: 'foo' }; + const link = getSuppressNetworkErrorsDuringNavigationLink().concat(otherLink); + subscription = link.request(mockOperation).subscribe(observer); + }; + + describe('when disabled', () => { + it('returns null', () => { + expect(getSuppressNetworkErrorsDuringNavigationLink()).toBe(null); + }); + }); + + describe('when enabled', () => { + beforeEach(() => { + window.gon = { features: { suppressApolloErrorsDuringNavigation: true } }; + }); + + it('returns an ApolloLink', () => { + expect(getSuppressNetworkErrorsDuringNavigationLink()).toEqual(expect.any(ApolloLink)); + }); + + describe('suppression case', () => { + describe('when navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(true); + }); + + describe('given a network error', () => { + it('does not forward the error', async () => { + const spy = jest.fn(); + + createSubscription(makeMockNetworkErrorLink(), { + next: spy, + error: spy, + complete: spy, + }); + + // It's hard to test for something _not_ happening. The best we can + // do is wait a bit to make sure nothing happens. + await waitForPromises(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('non-suppression cases', () => { + describe('when not navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(false); + }); + + it('forwards successful requests', (done) => { + createSubscription(makeMockSuccessLink(), { + next({ data }) { + expect(data).toEqual({ foo: { id: 1 } }); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), + }); + }); + + it('forwards GraphQL errors', (done) => { + createSubscription(makeMockGraphQLErrorLink(), { + next({ errors }) { + expect(errors).toEqual([{ message: 'foo' }]); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), + }); + }); + + it('forwards network errors', (done) => { + createSubscription(makeMockNetworkErrorLink(), { + next: () => done.fail('Should not happen'), + error: (error) => { + expect(error.message).toBe('NetworkError'); + done(); + }, + complete: () => done.fail('Should not happen'), + }); + }); + }); + + describe('when navigating away', () => { + beforeEach(() => { + isNavigatingAway.mockReturnValue(true); + }); + + it('forwards successful requests', (done) => { + createSubscription(makeMockSuccessLink(), { + next({ data }) { + expect(data).toEqual({ foo: { id: 1 } }); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), + }); + }); + + it('forwards GraphQL errors', (done) => { + createSubscription(makeMockGraphQLErrorLink(), { + next({ errors }) { + expect(errors).toEqual([{ message: 'foo' }]); + }, + error: () => done.fail('Should not happen'), + complete: () => done(), + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap index 791ec05befd..0b156049dab 100644 --- a/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap +++ b/spec/frontend/lib/logger/__snapshots__/hello_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`~/lib/logger/hello logHello console logs a friendly hello message 1`] = ` +exports[`~/lib/logger/hello logHello when on dot_com console logs a friendly hello message including the careers page 1`] = ` Array [ Array [ "%cWelcome to GitLab!%c @@ -8,7 +8,24 @@ Array [ Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute! 🤝 Contribute to GitLab: https://about.gitlab.com/community/contribute/ -🔎 Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new", +🔎 Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new +🚀 We like your curiosity! Help us improve GitLab by joining the team: https://about.gitlab.com/jobs/", + "padding-top: 0.5em; font-size: 2em;", + "padding-bottom: 0.5em;", + ], +] +`; + +exports[`~/lib/logger/hello logHello when on self managed console logs a friendly hello message without including the careers page 1`] = ` +Array [ + Array [ + "%cWelcome to GitLab!%c + +Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute! + +🤝 Contribute to GitLab: https://about.gitlab.com/community/contribute/ +🔎 Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new +", "padding-top: 0.5em; font-size: 2em;", "padding-bottom: 0.5em;", ], diff --git a/spec/frontend/lib/logger/hello_spec.js b/spec/frontend/lib/logger/hello_spec.js index 39abe0e0dd0..39c1b55313b 100644 --- a/spec/frontend/lib/logger/hello_spec.js +++ b/spec/frontend/lib/logger/hello_spec.js @@ -9,12 +9,32 @@ describe('~/lib/logger/hello', () => { }); describe('logHello', () => { - it('console logs a friendly hello message', () => { - expect(consoleLogSpy).not.toHaveBeenCalled(); + describe('when on dot_com', () => { + beforeEach(() => { + gon.dot_com = true; + }); - logHello(); + it('console logs a friendly hello message including the careers page', () => { + expect(consoleLogSpy).not.toHaveBeenCalled(); - expect(consoleLogSpy.mock.calls).toMatchSnapshot(); + logHello(); + + expect(consoleLogSpy.mock.calls).toMatchSnapshot(); + }); + }); + + describe('when on self managed', () => { + beforeEach(() => { + gon.dot_com = false; + }); + + it('console logs a friendly hello message without including the careers page', () => { + expect(consoleLogSpy).not.toHaveBeenCalled(); + + logHello(); + + expect(consoleLogSpy.mock.calls).toMatchSnapshot(); + }); }); }); }); diff --git a/spec/frontend/lib/utils/color_utils_spec.js b/spec/frontend/lib/utils/color_utils_spec.js index c6b88b2957c..87966cf9fba 100644 --- a/spec/frontend/lib/utils/color_utils_spec.js +++ b/spec/frontend/lib/utils/color_utils_spec.js @@ -1,4 +1,5 @@ import { + isValidColorExpression, textColorForBackground, hexToRgb, validateHexColor, @@ -72,4 +73,21 @@ describe('Color utils', () => { }, ); }); + + describe('isValidColorExpression', () => { + it.each` + colorExpression | valid | desc + ${'#F00'} | ${true} | ${'valid'} + ${'rgba(0,0,0,0)'} | ${true} | ${'valid'} + ${'hsl(540,70%,50%)'} | ${true} | ${'valid'} + ${'red'} | ${true} | ${'valid'} + ${'F00'} | ${false} | ${'invalid'} + ${'F00'} | ${false} | ${'invalid'} + ${'gba(0,0,0,0)'} | ${false} | ${'invalid'} + ${'hls(540,70%,50%)'} | ${false} | ${'invalid'} + ${'hello'} | ${false} | ${'invalid'} + `('color expression $colorExpression is $desc', ({ colorExpression, valid }) => { + expect(isValidColorExpression(colorExpression)).toBe(valid); + }); + }); }); diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js index 942ba56196e..1adc70450e8 100644 --- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js +++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js @@ -118,3 +118,18 @@ describe('date_format_utility.js', () => { }); }); }); + +describe('formatTimeAsSummary', () => { + it.each` + unit | value | result + ${'months'} | ${1.5} | ${'1.5M'} + ${'weeks'} | ${1.25} | ${'1.5w'} + ${'days'} | ${2} | ${'2d'} + ${'hours'} | ${10} | ${'10h'} + ${'minutes'} | ${20} | ${'20m'} + ${'seconds'} | ${10} | ${'<1m'} + ${'seconds'} | ${0} | ${'-'} + `('will format $value $unit to $result', ({ unit, value, result }) => { + expect(utils.formatTimeAsSummary({ [unit]: value })).toBe(result); + }); +}); diff --git a/spec/frontend/lib/utils/is_navigating_away_spec.js b/spec/frontend/lib/utils/is_navigating_away_spec.js new file mode 100644 index 00000000000..e1230fe96bf --- /dev/null +++ b/spec/frontend/lib/utils/is_navigating_away_spec.js @@ -0,0 +1,23 @@ +import { isNavigatingAway, setNavigatingForTestsOnly } from '~/lib/utils/is_navigating_away'; + +describe('isNavigatingAway', () => { + beforeEach(() => { + // Make sure each test starts with the same state + setNavigatingForTestsOnly(false); + }); + + it.each([false, true])('it returns the navigation flag with value %s', (flag) => { + setNavigatingForTestsOnly(flag); + expect(isNavigatingAway()).toEqual(flag); + }); + + describe('when the browser starts navigating away', () => { + it('returns true', () => { + expect(isNavigatingAway()).toEqual(false); + + window.dispatchEvent(new Event('beforeunload')); + + expect(isNavigatingAway()).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 1f3659b5c76..9570d2a831c 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -363,4 +363,25 @@ describe('text_utility', () => { expect(textUtils.insertFinalNewline(input, '\r\n')).toBe(output); }); }); + + describe('escapeShellString', () => { + it.each` + character | input | output + ${'"'} | ${'";echo "you_shouldnt_run_this'} | ${'\'";echo "you_shouldnt_run_this\''} + ${'$'} | ${'$IFS'} | ${"'$IFS'"} + ${'\\'} | ${'evil-branch-name\\'} | ${"'evil-branch-name\\'"} + ${'!'} | ${'!event'} | ${"'!event'"} + `( + 'should not escape the $character character but wrap in single-quotes', + ({ input, output }) => { + expect(textUtils.escapeShellString(input)).toBe(output); + }, + ); + + it("should escape the ' character and wrap in single-quotes", () => { + expect(textUtils.escapeShellString("fix-'bug-behavior'")).toBe( + "'fix-'\\''bug-behavior'\\'''", + ); + }); + }); }); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 6f186ba3227..18b68d91e01 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -1004,4 +1004,39 @@ describe('URL utility', () => { expect(urlUtils.isSameOriginUrl(url)).toBe(expected); }); }); + + describe('constructWebIDEPath', () => { + let originalGl; + const projectIDEPath = '/foo/bar'; + const sourceProj = 'my_-fancy-proj/boo'; + const targetProj = 'boo/another-fancy-proj'; + const mrIid = '7'; + + beforeEach(() => { + originalGl = window.gl; + window.gl = { webIDEPath: projectIDEPath }; + }); + + afterEach(() => { + window.gl = originalGl; + }); + + it.each` + sourceProjectFullPath | targetProjectFullPath | iid | expectedPath + ${undefined} | ${undefined} | ${undefined} | ${projectIDEPath} + ${undefined} | ${undefined} | ${mrIid} | ${projectIDEPath} + ${undefined} | ${targetProj} | ${undefined} | ${projectIDEPath} + ${undefined} | ${targetProj} | ${mrIid} | ${projectIDEPath} + ${sourceProj} | ${undefined} | ${undefined} | ${projectIDEPath} + ${sourceProj} | ${targetProj} | ${undefined} | ${projectIDEPath} + ${sourceProj} | ${undefined} | ${mrIid} | ${`/-/ide/project/${sourceProj}/merge_requests/${mrIid}?target_project=`} + ${sourceProj} | ${sourceProj} | ${mrIid} | ${`/-/ide/project/${sourceProj}/merge_requests/${mrIid}?target_project=`} + ${sourceProj} | ${targetProj} | ${mrIid} | ${`/-/ide/project/${sourceProj}/merge_requests/${mrIid}?target_project=${encodeURIComponent(targetProj)}`} + `( + 'returns $expectedPath for "$sourceProjectFullPath + $targetProjectFullPath + $iid"', + ({ expectedPath, ...args } = {}) => { + expect(urlUtils.constructWebIDEPath(args)).toBe(expectedPath); + }, + ); + }); }); diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js index d8453d453e7..7eb0ea37fe6 100644 --- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -45,7 +45,7 @@ describe('RemoveMemberButton', () => { title: 'Remove member', isAccessRequest: true, isInvite: true, - oncallSchedules: { name: 'user', schedules: [] }, + userDeletionObstacles: { name: 'user', obstacles: [] }, ...propsData, }, directives: { diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js index 0aa3780f030..10e451376c8 100644 --- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js @@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { member, orphanedMember } from '../../mock_data'; describe('UserActionButtons', () => { @@ -45,9 +46,9 @@ describe('UserActionButtons', () => { isAccessRequest: false, isInvite: false, icon: 'remove', - oncallSchedules: { + userDeletionObstacles: { name: member.user.name, - schedules: member.user.oncallSchedules, + obstacles: parseUserDeletionObstacles(member.user), }, }); }); diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index 1dc913e5c78..f755f08dbf2 100644 --- a/spec/frontend/members/components/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -6,7 +6,8 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import LeaveModal from '~/members/components/modals/leave_modal.vue'; import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { member } from '../../mock_data'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -51,7 +52,7 @@ describe('LeaveModal', () => { const findModal = () => wrapper.findComponent(GlModal); const findForm = () => findModal().findComponent(GlForm); - const findOncallSchedulesList = () => findModal().findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => findModal().findComponent(UserDeletionObstaclesList); const getByText = (text, options) => createWrapper(within(findModal().element).getByText(text, options)); @@ -89,25 +90,27 @@ describe('LeaveModal', () => { ); }); - describe('On-call schedules list', () => { - it("displays oncall schedules list when member's user is part of on-call schedules ", () => { - const schedulesList = findOncallSchedulesList(); - expect(schedulesList.exists()).toBe(true); - expect(schedulesList.props()).toMatchObject({ + describe('User deletion obstacles list', () => { + it("displays obstacles list when member's user is part of on-call management", () => { + const obstaclesList = findUserDeletionObstaclesList(); + expect(obstaclesList.exists()).toBe(true); + expect(obstaclesList.props()).toMatchObject({ isCurrentUser: true, - schedules: member.user.oncallSchedules, + obstacles: parseUserDeletionObstacles(member.user), }); }); - it("does NOT display oncall schedules list when member's user is NOT a part of on-call schedules ", async () => { + it("does NOT display obstacles list when member's user is NOT a part of on-call management", async () => { wrapper.destroy(); - const memberWithoutOncallSchedules = cloneDeep(member); - delete memberWithoutOncallSchedules.user.oncallSchedules; - createComponent({ member: memberWithoutOncallSchedules }); + const memberWithoutOncall = cloneDeep(member); + delete memberWithoutOncall.user.oncallSchedules; + delete memberWithoutOncall.user.escalationPolicies; + + createComponent({ member: memberWithoutOncall }); await nextTick(); - expect(findOncallSchedulesList().exists()).toBe(false); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); }); diff --git a/spec/frontend/members/components/modals/remove_member_modal_spec.js b/spec/frontend/members/components/modals/remove_member_modal_spec.js index 1dc41582c12..1d39c4b3175 100644 --- a/spec/frontend/members/components/modals/remove_member_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_member_modal_spec.js @@ -4,15 +4,19 @@ import Vue from 'vue'; import Vuex from 'vuex'; import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue'; import { MEMBER_TYPES } from '~/members/constants'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; Vue.use(Vuex); describe('RemoveMemberModal', () => { const memberPath = '/gitlab-org/gitlab-test/-/project_members/90'; - const mockSchedules = { + const mockObstacles = { name: 'User1', - schedules: [{ id: 1, name: 'Schedule 1' }], + obstacles: [ + { name: 'Schedule 1', type: OBSTACLE_TYPES.oncallSchedules }, + { name: 'Policy 1', type: OBSTACLE_TYPES.escalationPolicies }, + ], }; let wrapper; @@ -44,18 +48,18 @@ describe('RemoveMemberModal', () => { const findForm = () => wrapper.find({ ref: 'form' }); const findGlModal = () => wrapper.findComponent(GlModal); - const findOnCallSchedulesList = () => wrapper.findComponent(OncallSchedulesList); + const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); afterEach(() => { wrapper.destroy(); }); describe.each` - state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | onCallSchedules - ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} - ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} - ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} - ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockSchedules} + state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall + ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} | ${false} + ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${true} + ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} | ${false} + ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${false} `( 'when $state', ({ @@ -66,7 +70,8 @@ describe('RemoveMemberModal', () => { message, removeSubMembershipsCheckboxExpected, unassignIssuablesCheckboxExpected, - onCallSchedules, + userDeletionObstacles, + isPartOfOncall, }) => { beforeEach(() => { createComponent({ @@ -75,12 +80,10 @@ describe('RemoveMemberModal', () => { message, memberPath, memberType, - onCallSchedules, + userDeletionObstacles, }); }); - const isPartOfOncallSchedules = Boolean(isAccessRequest && onCallSchedules.schedules?.length); - it(`has the title ${actionText}`, () => { expect(findGlModal().attributes('title')).toBe(actionText); }); @@ -109,8 +112,8 @@ describe('RemoveMemberModal', () => { ); }); - it(`shows ${isPartOfOncallSchedules ? 'all' : 'no'} related on-call schedules`, () => { - expect(findOnCallSchedulesList().exists()).toBe(isPartOfOncallSchedules); + it(`shows ${isPartOfOncall ? 'all' : 'no'} related on-call schedules or policies`, () => { + expect(findUserDeletionObstaclesList().exists()).toBe(isPartOfOncall); }); it('submits the form when the modal is submitted', () => { diff --git a/spec/frontend/members/components/table/expires_at_spec.js b/spec/frontend/members/components/table/expires_at_spec.js deleted file mode 100644 index 2b8e6ab8f2a..00000000000 --- a/spec/frontend/members/components/table/expires_at_spec.js +++ /dev/null @@ -1,86 +0,0 @@ -import { within } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; -import { useFakeDate } from 'helpers/fake_date'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ExpiresAt from '~/members/components/table/expires_at.vue'; - -describe('ExpiresAt', () => { - // March 15th, 2020 - useFakeDate(2020, 2, 15); - - let wrapper; - - const createComponent = (propsData) => { - wrapper = mount(ExpiresAt, { - propsData, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - const getByText = (text, options) => - createWrapper(within(wrapper.element).getByText(text, options)); - - const getTooltipDirective = (elementWrapper) => getBinding(elementWrapper.element, 'gl-tooltip'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when no expiration date is set', () => { - it('displays "No expiration set"', () => { - createComponent({ date: null }); - - expect(getByText('No expiration set').exists()).toBe(true); - }); - }); - - describe('when expiration date is in the past', () => { - let expiredText; - - beforeEach(() => { - createComponent({ date: '2019-03-15T00:00:00.000' }); - - expiredText = getByText('Expired'); - }); - - it('displays "Expired"', () => { - expect(expiredText.exists()).toBe(true); - expect(expiredText.classes()).toContain('gl-text-red-500'); - }); - - it('displays tooltip with formatted date', () => { - const tooltipDirective = getTooltipDirective(expiredText); - - expect(tooltipDirective).not.toBeUndefined(); - expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am UTC'); - }); - }); - - describe('when expiration date is in the future', () => { - it.each` - date | expected | warningColor - ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false} - ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true} - ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true} - ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true} - ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true} - ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true} - ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true} - ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true} - `('displays "$expected"', ({ date, expected, warningColor }) => { - createComponent({ date }); - - const expiredText = getByText(expected); - - expect(expiredText.exists()).toBe(true); - - if (warningColor) { - expect(expiredText.classes()).toContain('gl-text-orange-500'); - } else { - expect(expiredText.classes()).not.toContain('gl-text-orange-500'); - } - }); - }); -}); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 6885da53b26..580e5edd652 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -1,22 +1,24 @@ import { GlBadge, GlPagination, GlTable } from '@gitlab/ui'; -import { - getByText as getByTextHelper, - getByTestId as getByTestIdHelper, - within, -} from '@testing-library/dom'; -import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import setWindowLocation from 'helpers/set_window_location_helper'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import CreatedAt from '~/members/components/table/created_at.vue'; import ExpirationDatepicker from '~/members/components/table/expiration_datepicker.vue'; -import ExpiresAt from '~/members/components/table/expires_at.vue'; import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; import MemberAvatar from '~/members/components/table/member_avatar.vue'; import MemberSource from '~/members/components/table/member_source.vue'; import MembersTable from '~/members/components/table/members_table.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; -import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES } from '~/members/constants'; +import { + MEMBER_TYPES, + MEMBER_STATE_CREATED, + MEMBER_STATE_AWAITING, + MEMBER_STATE_ACTIVE, + USER_STATE_BLOCKED_PENDING_APPROVAL, + BADGE_LABELS_PENDING_OWNER_APPROVAL, + TAB_QUERY_PARAM_VALUES, +} from '~/members/constants'; import * as initUserPopovers from '~/user_popovers'; import { member as memberMock, @@ -53,7 +55,7 @@ describe('MembersTable', () => { }; const createComponent = (state, provide = {}) => { - wrapper = mount(MembersTable, { + wrapper = mountExtended(MembersTable, { localVue, propsData: { tabQueryParamValue: TAB_QUERY_PARAM_VALUES.invite, @@ -68,7 +70,6 @@ describe('MembersTable', () => { stubs: [ 'member-avatar', 'member-source', - 'expires-at', 'created-at', 'member-action-buttons', 'role-dropdown', @@ -81,17 +82,11 @@ describe('MembersTable', () => { const url = 'https://localhost/foo-bar/-/project_members?tab=invited'; - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - - const getByTestId = (id, options) => - createWrapper(getByTestIdHelper(wrapper.element, id, options)); - const findTable = () => wrapper.find(GlTable); const findTableCellByMemberId = (tableCellLabel, memberId) => - getByTestId(`members-table-row-${memberId}`).find( - `[data-label="${tableCellLabel}"][role="cell"]`, - ); + wrapper + .findByTestId(`members-table-row-${memberId}`) + .find(`[data-label="${tableCellLabel}"][role="cell"]`); const findPagination = () => extendedWrapper(wrapper.find(GlPagination)); @@ -103,7 +98,6 @@ describe('MembersTable', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('fields', () => { @@ -119,7 +113,6 @@ describe('MembersTable', () => { ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} `('renders the $label field', ({ field, label, member, expectedComponent }) => { @@ -128,7 +121,7 @@ describe('MembersTable', () => { tableFields: [field], }); - expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true); + expect(wrapper.findByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true); if (expectedComponent) { expect( @@ -137,11 +130,50 @@ describe('MembersTable', () => { } }); + describe('Invited column', () => { + describe.each` + state | userState | expectedBadgeLabel + ${MEMBER_STATE_CREATED} | ${null} | ${''} + ${MEMBER_STATE_CREATED} | ${USER_STATE_BLOCKED_PENDING_APPROVAL} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} + ${MEMBER_STATE_AWAITING} | ${''} | ${''} + ${MEMBER_STATE_AWAITING} | ${USER_STATE_BLOCKED_PENDING_APPROVAL} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} + ${MEMBER_STATE_AWAITING} | ${'something_else'} | ${BADGE_LABELS_PENDING_OWNER_APPROVAL} + ${MEMBER_STATE_ACTIVE} | ${null} | ${''} + ${MEMBER_STATE_ACTIVE} | ${'something_else'} | ${''} + `('Invited Badge', ({ state, userState, expectedBadgeLabel }) => { + it(`${ + expectedBadgeLabel ? 'shows' : 'hides' + } invited badge if user status: '${userState}' and member state: '${state}'`, () => { + createComponent({ + members: [ + { + ...invite, + state, + invite: { + ...invite.invite, + userState, + }, + }, + ], + tableFields: ['invited'], + }); + + const invitedTab = wrapper.findByTestId('invited-badge'); + + if (expectedBadgeLabel) { + expect(invitedTab.text()).toBe(expectedBadgeLabel); + } else { + expect(invitedTab.exists()).toBe(false); + } + }); + }); + }); + describe('"Actions" field', () => { it('renders "Actions" field for screen readers', () => { createComponent({ members: [memberCanUpdate], tableFields: ['actions'] }); - const actionField = getByTestId('col-actions'); + const actionField = wrapper.findByTestId('col-actions'); expect(actionField.exists()).toBe(true); expect(actionField.classes('gl-sr-only')).toBe(true); @@ -154,7 +186,7 @@ describe('MembersTable', () => { it('does not render the "Actions" field', () => { createComponent({ tableFields: ['actions'] }, { currentUserId: null }); - expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + expect(wrapper.findByTestId('col-actions').exists()).toBe(false); }); }); @@ -177,7 +209,7 @@ describe('MembersTable', () => { it('renders the "Actions" field', () => { createComponent({ members, tableFields: ['actions'] }); - expect(getByTestId('col-actions').exists()).toBe(true); + expect(wrapper.findByTestId('col-actions').exists()).toBe(true); expect(findTableCellByMemberId('Actions', members[0].id).classes()).toStrictEqual([ 'col-actions', @@ -199,7 +231,7 @@ describe('MembersTable', () => { it('does not render the "Actions" field', () => { createComponent({ members, tableFields: ['actions'] }); - expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null); + expect(wrapper.findByTestId('col-actions').exists()).toBe(false); }); }); }); @@ -209,7 +241,7 @@ describe('MembersTable', () => { it('displays a "No members found" message', () => { createComponent(); - expect(getByText('No members found').exists()).toBe(true); + expect(wrapper.findByText('No members found').exists()).toBe(true); }); }); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index eb9f905fea2..f42ee295511 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -1,4 +1,4 @@ -import { MEMBER_TYPES } from '~/members/constants'; +import { MEMBER_TYPES, MEMBER_STATE_CREATED } from '~/members/constants'; export const member = { requestedAt: null, @@ -14,6 +14,7 @@ export const member = { webUrl: 'https://gitlab.com/groups/foo-bar', }, type: 'GroupMember', + state: MEMBER_STATE_CREATED, user: { id: 123, name: 'Administrator', @@ -23,6 +24,7 @@ export const member = { blocked: false, twoFactorEnabled: false, oncallSchedules: [{ name: 'schedule 1' }], + escalationPolicies: [{ name: 'policy 1' }], }, id: 238, createdAt: '2020-07-17T16:22:46.923Z', @@ -63,12 +65,13 @@ export const modalData = { memberPath: '/groups/foo-bar/-/group_members/1', memberType: 'GroupMember', message: 'Are you sure you want to remove John Smith?', - oncallSchedules: { name: 'user', schedules: [] }, + userDeletionObstacles: { name: 'user', obstacles: [] }, }; const { user, ...memberNoUser } = member; export const invite = { ...memberNoUser, + state: MEMBER_STATE_CREATED, invite: { email: 'jewel@hudsonwalter.biz', avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon', diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 05538dbaeee..47b6c463377 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -37,6 +37,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` category="primary" class="flex-grow-1" clearalltext="Clear all" + clearalltextclass="gl-px-5" data-qa-selector="environments_dropdown" headertext="" hideheaderborder="true" @@ -44,7 +45,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` highlighteditemstitleclass="gl-px-5" id="monitor-environments-dropdown" menu-class="monitor-environment-dropdown-menu" - showhighlighteditemstitle="true" size="medium" text="production" toggleclass="dropdown-menu-toggle" diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js index d20a111c701..6a19815883a 100644 --- a/spec/frontend/monitoring/fixture_data.js +++ b/spec/frontend/monitoring/fixture_data.js @@ -1,3 +1,4 @@ +import fixture from 'test_fixtures/metrics_dashboard/environment_metrics_dashboard.json'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { metricStates } from '~/monitoring/constants'; import { mapToDashboardViewModel } from '~/monitoring/stores/utils'; @@ -5,10 +6,7 @@ import { stateAndPropsFromDataset } from '~/monitoring/utils'; import { metricsResult } from './mock_data'; -// Use globally available `getJSONFixture` so this file can be imported by both karma and jest specs -export const metricsDashboardResponse = getJSONFixture( - 'metrics_dashboard/environment_metrics_dashboard.json', -); +export const metricsDashboardResponse = fixture; export const metricsDashboardPayload = metricsDashboardResponse.dashboard; diff --git a/spec/frontend/namespace_select_spec.js b/spec/frontend/namespace_select_spec.js deleted file mode 100644 index a38508dd601..00000000000 --- a/spec/frontend/namespace_select_spec.js +++ /dev/null @@ -1,65 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import NamespaceSelect from '~/namespace_select'; - -jest.mock('~/deprecated_jquery_dropdown'); - -describe('NamespaceSelect', () => { - it('initializes deprecatedJQueryDropdown', () => { - const dropdown = document.createElement('div'); - - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - - expect(initDeprecatedJQueryDropdown).toHaveBeenCalled(); - }); - - describe('as input', () => { - let deprecatedJQueryDropdownOptions; - - beforeEach(() => { - const dropdown = document.createElement('div'); - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls; - }); - - it('prevents click events', () => { - const dummyEvent = new Event('dummy'); - jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); - - // expect(foo).toContain('test'); - deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent }); - - expect(dummyEvent.preventDefault).toHaveBeenCalled(); - }); - }); - - describe('as filter', () => { - let deprecatedJQueryDropdownOptions; - - beforeEach(() => { - const dropdown = document.createElement('div'); - dropdown.dataset.isFilter = 'true'; - // eslint-disable-next-line no-new - new NamespaceSelect({ dropdown }); - [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls; - }); - - it('does not prevent click events', () => { - const dummyEvent = new Event('dummy'); - jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); - - deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent }); - - expect(dummyEvent.preventDefault).not.toHaveBeenCalled(); - }); - - it('sets URL of dropdown items', () => { - const dummyNamespace = { id: 'eal' }; - - const itemUrl = deprecatedJQueryDropdownOptions.url(dummyNamespace); - - expect(itemUrl).toContain(`namespace_id=${dummyNamespace.id}`); - }); - }); -}); diff --git a/spec/frontend/notebook/cells/code_spec.js b/spec/frontend/notebook/cells/code_spec.js index e14767f2594..669bdc2f89a 100644 --- a/spec/frontend/notebook/cells/code_spec.js +++ b/spec/frontend/notebook/cells/code_spec.js @@ -1,14 +1,17 @@ import Vue from 'vue'; +import fixture from 'test_fixtures/blob/notebook/basic.json'; import CodeComponent from '~/notebook/cells/code.vue'; const Component = Vue.extend(CodeComponent); describe('Code component', () => { let vm; + let json; beforeEach(() => { - json = getJSONFixture('blob/notebook/basic.json'); + // Clone fixture as it could be modified by tests + json = JSON.parse(JSON.stringify(fixture)); }); const setupComponent = (cell) => { diff --git a/spec/frontend/notebook/cells/markdown_spec.js b/spec/frontend/notebook/cells/markdown_spec.js index 707efa21528..36b1e91f15f 100644 --- a/spec/frontend/notebook/cells/markdown_spec.js +++ b/spec/frontend/notebook/cells/markdown_spec.js @@ -1,6 +1,9 @@ import { mount } from '@vue/test-utils'; import katex from 'katex'; import Vue from 'vue'; +import markdownTableJson from 'test_fixtures/blob/notebook/markdown-table.json'; +import basicJson from 'test_fixtures/blob/notebook/basic.json'; +import mathJson from 'test_fixtures/blob/notebook/math.json'; import MarkdownComponent from '~/notebook/cells/markdown.vue'; const Component = Vue.extend(MarkdownComponent); @@ -35,7 +38,7 @@ describe('Markdown component', () => { let json; beforeEach(() => { - json = getJSONFixture('blob/notebook/basic.json'); + json = basicJson; // eslint-disable-next-line prefer-destructuring cell = json.cells[1]; @@ -104,7 +107,7 @@ describe('Markdown component', () => { describe('tables', () => { beforeEach(() => { - json = getJSONFixture('blob/notebook/markdown-table.json'); + json = markdownTableJson; }); it('renders images and text', () => { @@ -135,7 +138,7 @@ describe('Markdown component', () => { describe('katex', () => { beforeEach(() => { - json = getJSONFixture('blob/notebook/math.json'); + json = mathJson; }); it('renders multi-line katex', async () => { diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js index 2985abf0f4f..7ece73d375c 100644 --- a/spec/frontend/notebook/cells/output/index_spec.js +++ b/spec/frontend/notebook/cells/output/index_spec.js @@ -1,11 +1,11 @@ import Vue from 'vue'; +import json from 'test_fixtures/blob/notebook/basic.json'; import CodeComponent from '~/notebook/cells/output/index.vue'; const Component = Vue.extend(CodeComponent); describe('Output component', () => { let vm; - let json; const createComponent = (output) => { vm = new Component({ @@ -17,11 +17,6 @@ describe('Output component', () => { vm.$mount(); }; - beforeEach(() => { - // This is the output after rendering a jupyter notebook - json = getJSONFixture('blob/notebook/basic.json'); - }); - describe('text output', () => { beforeEach((done) => { const textType = json.cells[2]; diff --git a/spec/frontend/notebook/index_spec.js b/spec/frontend/notebook/index_spec.js index 4d0dacaf37e..cd531d628b3 100644 --- a/spec/frontend/notebook/index_spec.js +++ b/spec/frontend/notebook/index_spec.js @@ -1,18 +1,13 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; +import json from 'test_fixtures/blob/notebook/basic.json'; +import jsonWithWorksheet from 'test_fixtures/blob/notebook/worksheets.json'; import Notebook from '~/notebook/index.vue'; const Component = Vue.extend(Notebook); describe('Notebook component', () => { let vm; - let json; - let jsonWithWorksheet; - - beforeEach(() => { - json = getJSONFixture('blob/notebook/basic.json'); - jsonWithWorksheet = getJSONFixture('blob/notebook/worksheets.json'); - }); function buildComponent(notebook) { return mount(Component, { diff --git a/spec/frontend/notes/components/comment_type_dropdown_spec.js b/spec/frontend/notes/components/comment_type_dropdown_spec.js index 5e1cb813369..8ac6144e5c8 100644 --- a/spec/frontend/notes/components/comment_type_dropdown_spec.js +++ b/spec/frontend/notes/components/comment_type_dropdown_spec.js @@ -47,8 +47,18 @@ describe('CommentTypeDropdown component', () => { it('Should emit `change` event when clicking on an alternate dropdown option', () => { mountComponent({ props: { noteType: constants.DISCUSSION } }); - findCommentDropdownOption().vm.$emit('click'); - findDiscussionDropdownOption().vm.$emit('click'); + const event = { + type: 'click', + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }; + + findCommentDropdownOption().vm.$emit('click', event); + findDiscussionDropdownOption().vm.$emit('click', event); + + // ensure the native events don't trigger anything + expect(event.stopPropagation).toHaveBeenCalledTimes(2); + expect(event.preventDefault).toHaveBeenCalledTimes(2); expect(wrapper.emitted('change')[0]).toEqual([constants.COMMENT]); expect(wrapper.emitted('change').length).toEqual(1); diff --git a/spec/frontend/notes/components/diff_with_note_spec.js b/spec/frontend/notes/components/diff_with_note_spec.js index e997fc4da50..c352265654b 100644 --- a/spec/frontend/notes/components/diff_with_note_spec.js +++ b/spec/frontend/notes/components/diff_with_note_spec.js @@ -1,10 +1,9 @@ import { shallowMount } from '@vue/test-utils'; +import discussionFixture from 'test_fixtures/merge_requests/diff_discussion.json'; +import imageDiscussionFixture from 'test_fixtures/merge_requests/image_diff_discussion.json'; import { createStore } from '~/mr_notes/stores'; import DiffWithNote from '~/notes/components/diff_with_note.vue'; -const discussionFixture = 'merge_requests/diff_discussion.json'; -const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; - describe('diff_with_note', () => { let store; let wrapper; @@ -35,7 +34,7 @@ describe('diff_with_note', () => { describe('text diff', () => { beforeEach(() => { - const diffDiscussion = getJSONFixture(discussionFixture)[0]; + const diffDiscussion = discussionFixture[0]; wrapper = shallowMount(DiffWithNote, { propsData: { @@ -75,7 +74,7 @@ describe('diff_with_note', () => { describe('image diff', () => { beforeEach(() => { - const imageDiscussion = getJSONFixture(imageDiscussionFixture)[0]; + const imageDiscussion = imageDiscussionFixture[0]; wrapper = shallowMount(DiffWithNote, { propsData: { discussion: imageDiscussion, diffFile: {} }, store, diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index 92137d3190f..abc888cd245 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -150,6 +150,16 @@ describe('issue_note_form component', () => { expect(handleFormUpdate.length).toBe(1); }); + + it('should disable textarea when ctrl+enter is pressed', async () => { + textarea.trigger('keydown.enter', { ctrlKey: true }); + + expect(textarea.attributes('disabled')).toBeUndefined(); + + await nextTick(); + + expect(textarea.attributes('disabled')).toBe('disabled'); + }); }); }); diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index a364a524e7b..727ef02dcbb 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; import { trimText } from 'helpers/text_helper'; import mockDiffFile from 'jest/diffs/mock_data/diff_file'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; @@ -17,8 +18,6 @@ import { userDataMock, } from '../mock_data'; -const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; - describe('noteable_discussion component', () => { let store; let wrapper; @@ -119,7 +118,7 @@ describe('noteable_discussion component', () => { describe('for resolved thread', () => { beforeEach(() => { - const discussion = getJSONFixture(discussionWithTwoUnresolvedNotes)[0]; + const discussion = discussionWithTwoUnresolvedNotes[0]; wrapper.setProps({ discussion }); }); @@ -133,7 +132,7 @@ describe('noteable_discussion component', () => { describe('for unresolved thread', () => { beforeEach(() => { const discussion = { - ...getJSONFixture(discussionWithTwoUnresolvedNotes)[0], + ...discussionWithTwoUnresolvedNotes[0], expanded: true, }; discussion.resolved = false; diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index 467a8bec21b..038aff3be04 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -189,6 +189,27 @@ describe('issue_note', () => { createWrapper(); }); + describe('avatar sizes in diffs', () => { + const line = { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }; + + it('should render 24px avatars', async () => { + wrapper.setProps({ + note: { ...note }, + discussionRoot: true, + line, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.findComponent(UserAvatarLink).props('imgSize')).toBe(24); + }); + }); + it('should render user information', () => { const { author } = note; const avatar = wrapper.findComponent(UserAvatarLink); diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js index 3adb5da020e..9a11fdba508 100644 --- a/spec/frontend/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -1,3 +1,4 @@ +import discussionWithTwoUnresolvedNotes from 'test_fixtures/merge_requests/resolved_diff_discussion.json'; import { DESC, ASC } from '~/notes/constants'; import * as getters from '~/notes/stores/getters'; import { @@ -17,8 +18,6 @@ import { draftDiffDiscussion, } from '../mock_data'; -const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; - // Helper function to ensure that we're using the same schema across tests. const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({ discussionId, @@ -123,7 +122,7 @@ describe('Getters Notes Store', () => { describe('resolvedDiscussionsById', () => { it('ignores unresolved system notes', () => { - const [discussion] = getJSONFixture(discussionWithTwoUnresolvedNotes); + const [discussion] = discussionWithTwoUnresolvedNotes; discussion.notes[0].resolved = true; discussion.notes[1].resolved = false; state.discussions.push(discussion); diff --git a/spec/frontend/oauth_remember_me_spec.js b/spec/frontend/oauth_remember_me_spec.js index 70bda1d9f9e..3187cbf6547 100644 --- a/spec/frontend/oauth_remember_me_spec.js +++ b/spec/frontend/oauth_remember_me_spec.js @@ -3,7 +3,7 @@ import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me'; describe('OAuthRememberMe', () => { const findFormAction = (selector) => { - return $(`#oauth-container .oauth-login${selector}`).parent('form').attr('action'); + return $(`#oauth-container .js-oauth-login${selector}`).parent('form').attr('action'); }; beforeEach(() => { diff --git a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap deleted file mode 100644 index a3423e3f4d7..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ConanInstallation renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object]" - packagetype="conan" - /> - - <code-instruction-stub - copytext="Copy Conan Command" - instruction="foo/command" - label="Conan Command" - trackingaction="copy_conan_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <code-instruction-stub - copytext="Copy Conan Setup Command" - instruction="foo/setup" - label="Add Conan Remote" - trackingaction="copy_conan_setup_command" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}." - /> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap deleted file mode 100644 index 39469bf4fd0..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap +++ /dev/null @@ -1,34 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DependencyRow renders full dependency 1`] = ` -<div - class="gl-responsive-table-row" -> - <div - class="table-section section-50" - > - <strong - class="gl-text-body" - > - Test.Dependency - </strong> - - <span - data-testid="target-framework" - > - (.NETStandard2.0) - </span> - </div> - - <div - class="table-section section-50 gl-display-flex gl-md-justify-content-end" - data-testid="version-pattern" - > - <span - class="gl-text-body" - > - 2.3.7 - </span> - </div> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap deleted file mode 100644 index 8a2793c0010..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap +++ /dev/null @@ -1,112 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MavenInstallation groovy renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object],[object Object],[object Object]" - packagetype="maven" - /> - - <code-instruction-stub - class="gl-mb-5" - copytext="Copy Gradle Groovy DSL install command" - instruction="foo/gradle/groovy/install" - label="Gradle Groovy DSL install command" - trackingaction="copy_gradle_install_command" - trackinglabel="code_instruction" - /> - - <code-instruction-stub - copytext="Copy add Gradle Groovy DSL repository command" - instruction="foo/gradle/groovy/add/source" - label="Add Gradle Groovy DSL repository command" - multiline="true" - trackingaction="copy_gradle_add_to_source_command" - trackinglabel="code_instruction" - /> -</div> -`; - -exports[`MavenInstallation kotlin renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object],[object Object],[object Object]" - packagetype="maven" - /> - - <code-instruction-stub - class="gl-mb-5" - copytext="Copy Gradle Kotlin DSL install command" - instruction="foo/gradle/kotlin/install" - label="Gradle Kotlin DSL install command" - trackingaction="copy_kotlin_install_command" - trackinglabel="code_instruction" - /> - - <code-instruction-stub - copytext="Copy add Gradle Kotlin DSL repository command" - instruction="foo/gradle/kotlin/add/source" - label="Add Gradle Kotlin DSL repository command" - multiline="true" - trackingaction="copy_kotlin_add_to_source_command" - trackinglabel="code_instruction" - /> -</div> -`; - -exports[`MavenInstallation maven renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object],[object Object],[object Object]" - packagetype="maven" - /> - - <p> - <gl-sprintf-stub - message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block." - /> - </p> - - <code-instruction-stub - copytext="Copy Maven XML" - instruction="foo/xml" - label="" - multiline="true" - trackingaction="copy_maven_xml" - trackinglabel="code_instruction" - /> - - <code-instruction-stub - copytext="Copy Maven command" - instruction="foo/command" - label="Maven Command" - trackingaction="copy_maven_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <p> - <gl-sprintf-stub - message="If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file." - /> - </p> - - <code-instruction-stub - copytext="Copy Maven registry XML" - instruction="foo/setup" - label="" - multiline="true" - trackingaction="copy_maven_setup_xml" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}." - /> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap deleted file mode 100644 index 015c7b94dde..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NpmInstallation renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object],[object Object]" - packagetype="npm" - /> - - <code-instruction-stub - copytext="Copy npm command" - instruction="npm i @Test/package" - label="" - trackingaction="copy_npm_install_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <code-instruction-stub - copytext="Copy npm setup command" - instruction="echo @Test:registry=undefined/ >> .npmrc" - label="" - trackingaction="copy_npm_setup_command" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more." - /> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap deleted file mode 100644 index 04532743952..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`NugetInstallation renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object]" - packagetype="nuget" - /> - - <code-instruction-stub - copytext="Copy NuGet Command" - instruction="foo/command" - label="NuGet Command" - trackingaction="copy_nuget_install_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <code-instruction-stub - copytext="Copy NuGet Setup Command" - instruction="foo/setup" - label="Add NuGet Source" - trackingaction="copy_nuget_setup_command" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}." - /> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap deleted file mode 100644 index 318cea98b92..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap +++ /dev/null @@ -1,168 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PackageTitle renders with tags 1`] = ` -<div - class="gl-display-flex gl-flex-direction-column" - data-qa-selector="package_title" -> - <div - class="gl-display-flex gl-justify-content-space-between gl-py-3" - > - <div - class="gl-flex-direction-column gl-flex-grow-1" - > - <div - class="gl-display-flex" - > - <!----> - - <div - class="gl-display-flex gl-flex-direction-column" - > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" - data-testid="title" - > - Test package - </h1> - - <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" - > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> - - <gl-sprintf-stub - message="v%{version} published %{timeAgo}" - /> - </div> - </div> - </div> - - <div - class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" - > - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-type" - icon="package" - link="" - size="s" - text="maven" - texttooltip="" - /> - </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-size" - icon="disk" - link="" - size="s" - text="300 bytes" - texttooltip="" - /> - </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <package-tags-stub - hidelabel="true" - tagdisplaylimit="2" - tags="[object Object],[object Object],[object Object],[object Object]" - /> - </div> - </div> - </div> - - <!----> - </div> - - <p /> -</div> -`; - -exports[`PackageTitle renders without tags 1`] = ` -<div - class="gl-display-flex gl-flex-direction-column" - data-qa-selector="package_title" -> - <div - class="gl-display-flex gl-justify-content-space-between gl-py-3" - > - <div - class="gl-flex-direction-column gl-flex-grow-1" - > - <div - class="gl-display-flex" - > - <!----> - - <div - class="gl-display-flex gl-flex-direction-column" - > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" - data-testid="title" - > - Test package - </h1> - - <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" - > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> - - <gl-sprintf-stub - message="v%{version} published %{timeAgo}" - /> - </div> - </div> - </div> - - <div - class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" - > - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-type" - icon="package" - link="" - size="s" - text="maven" - texttooltip="" - /> - </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-size" - icon="disk" - link="" - size="s" - text="300 bytes" - texttooltip="" - /> - </div> - </div> - </div> - - <!----> - </div> - - <p /> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap deleted file mode 100644 index d5bb825d8d1..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap +++ /dev/null @@ -1,45 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PypiInstallation renders all the messages 1`] = ` -<div> - <installation-title-stub - options="[object Object]" - packagetype="pypi" - /> - - <code-instruction-stub - copytext="Copy Pip command" - data-testid="pip-command" - instruction="pip install" - label="Pip Command" - trackingaction="copy_pip_install_command" - trackinglabel="code_instruction" - /> - - <h3 - class="gl-font-lg" - > - Registry setup - </h3> - - <p> - <gl-sprintf-stub - message="If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file." - /> - </p> - - <code-instruction-stub - copytext="Copy .pypirc content" - data-testid="pypi-setup-content" - instruction="python setup" - label="" - multiline="true" - trackingaction="copy_pypi_setup_command" - trackinglabel="code_instruction" - /> - - <gl-sprintf-stub - message="For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}." - /> -</div> -`; diff --git a/spec/frontend/packages/details/components/additional_metadata_spec.js b/spec/frontend/packages/details/components/additional_metadata_spec.js deleted file mode 100644 index b339aa84348..00000000000 --- a/spec/frontend/packages/details/components/additional_metadata_spec.js +++ /dev/null @@ -1,119 +0,0 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import component from '~/packages/details/components/additional_metadata.vue'; -import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; - -import { mavenPackage, conanPackage, nugetPackage, npmPackage } from '../../mock_data'; - -describe('Package Additional Metadata', () => { - let wrapper; - const defaultProps = { - packageEntity: { ...mavenPackage }, - }; - - const mountComponent = (props) => { - wrapper = shallowMount(component, { - propsData: { ...defaultProps, ...props }, - stubs: { - DetailsRow, - GlSprintf, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findTitle = () => wrapper.find('[data-testid="title"]'); - const findMainArea = () => wrapper.find('[data-testid="main"]'); - const findNugetSource = () => wrapper.find('[data-testid="nuget-source"]'); - const findNugetLicense = () => wrapper.find('[data-testid="nuget-license"]'); - const findConanRecipe = () => wrapper.find('[data-testid="conan-recipe"]'); - const findMavenApp = () => wrapper.find('[data-testid="maven-app"]'); - const findMavenGroup = () => wrapper.find('[data-testid="maven-group"]'); - const findElementLink = (container) => container.find(GlLink); - - it('has the correct title', () => { - mountComponent(); - - const title = findTitle(); - - expect(title.exists()).toBe(true); - expect(title.text()).toBe('Additional Metadata'); - }); - - describe.each` - packageEntity | visible | metadata - ${mavenPackage} | ${true} | ${'maven_metadatum'} - ${conanPackage} | ${true} | ${'conan_metadatum'} - ${nugetPackage} | ${true} | ${'nuget_metadatum'} - ${npmPackage} | ${false} | ${null} - `('Component visibility', ({ packageEntity, visible, metadata }) => { - it(`Is ${visible} that the component markup is visible when the package is ${packageEntity.package_type}`, () => { - mountComponent({ packageEntity }); - - expect(findTitle().exists()).toBe(visible); - expect(findMainArea().exists()).toBe(visible); - }); - - it(`The component is hidden if ${metadata} is missing`, () => { - mountComponent({ packageEntity: { ...packageEntity, [metadata]: null } }); - - expect(findTitle().exists()).toBe(false); - expect(findMainArea().exists()).toBe(false); - }); - }); - - describe('nuget metadata', () => { - beforeEach(() => { - mountComponent({ packageEntity: nugetPackage }); - }); - - it.each` - name | finderFunction | text | link | icon - ${'source'} | ${findNugetSource} | ${'Source project located at project-foo-url'} | ${'project_url'} | ${'project'} - ${'license'} | ${findNugetLicense} | ${'License information located at license-foo-url'} | ${'license_url'} | ${'license'} - `('$name element', ({ finderFunction, text, link, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - expect(findElementLink(element).attributes('href')).toBe(nugetPackage.nuget_metadatum[link]); - }); - }); - - describe('conan metadata', () => { - beforeEach(() => { - mountComponent({ packageEntity: conanPackage }); - }); - - it.each` - name | finderFunction | text | icon - ${'recipe'} | ${findConanRecipe} | ${'Recipe: conan-package/1.0.0@conan+conan-package/stable'} | ${'information-o'} - `('$name element', ({ finderFunction, text, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - }); - }); - - describe('maven metadata', () => { - beforeEach(() => { - mountComponent(); - }); - - it.each` - name | finderFunction | text | icon - ${'app'} | ${findMavenApp} | ${'App name: test-app'} | ${'information-o'} - ${'group'} | ${findMavenGroup} | ${'App group: com.test.app'} | ${'information-o'} - `('$name element', ({ finderFunction, text, icon }) => { - const element = finderFunction(); - expect(element.exists()).toBe(true); - expect(element.text()).toBe(text); - expect(element.props('icon')).toBe(icon); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js deleted file mode 100644 index 18d11c7dd57..00000000000 --- a/spec/frontend/packages/details/components/composer_installation_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import { GlSprintf, GlLink } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { registryUrl as composerHelpPath } from 'jest/packages/details/mock_data'; -import { composerPackage as packageEntity } from 'jest/packages/mock_data'; -import ComposerInstallation from '~/packages/details/components/composer_installation.vue'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; - -import { TrackingActions } from '~/packages/details/constants'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('ComposerInstallation', () => { - let wrapper; - let store; - - const composerRegistryIncludeStr = 'foo/registry'; - const composerPackageIncludeStr = 'foo/package'; - - const createStore = (groupExists = true) => { - store = new Vuex.Store({ - state: { packageEntity, composerHelpPath }, - getters: { - composerRegistryInclude: () => composerRegistryIncludeStr, - composerPackageInclude: () => composerPackageIncludeStr, - groupExists: () => groupExists, - }, - }); - }; - - const findRootNode = () => wrapper.find('[data-testid="root-node"]'); - const findRegistryInclude = () => wrapper.find('[data-testid="registry-include"]'); - const findPackageInclude = () => wrapper.find('[data-testid="package-include"]'); - const findHelpText = () => wrapper.find('[data-testid="help-text"]'); - const findHelpLink = () => wrapper.find(GlLink); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent() { - wrapper = shallowMount(ComposerInstallation, { - localVue, - store, - stubs: { - GlSprintf, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - describe('install command switch', () => { - it('has the installation title component', () => { - createStore(); - createComponent(); - - expect(findInstallationTitle().exists()).toBe(true); - expect(findInstallationTitle().props()).toMatchObject({ - packageType: 'composer', - options: [{ value: 'composer', label: 'Show Composer commands' }], - }); - }); - }); - - describe('registry include command', () => { - beforeEach(() => { - createStore(); - createComponent(); - }); - - it('uses code_instructions', () => { - const registryIncludeCommand = findRegistryInclude(); - expect(registryIncludeCommand.exists()).toBe(true); - expect(registryIncludeCommand.props()).toMatchObject({ - instruction: composerRegistryIncludeStr, - copyText: 'Copy registry include', - trackingAction: TrackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, - }); - }); - - it('has the correct title', () => { - expect(findRegistryInclude().props('label')).toBe('Add composer registry'); - }); - }); - - describe('package include command', () => { - beforeEach(() => { - createStore(); - createComponent(); - }); - - it('uses code_instructions', () => { - const registryIncludeCommand = findPackageInclude(); - expect(registryIncludeCommand.exists()).toBe(true); - expect(registryIncludeCommand.props()).toMatchObject({ - instruction: composerPackageIncludeStr, - copyText: 'Copy require package include', - trackingAction: TrackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, - }); - }); - - it('has the correct title', () => { - expect(findPackageInclude().props('label')).toBe('Install package version'); - }); - - it('has the correct help text', () => { - expect(findHelpText().text()).toBe( - 'For more information on Composer packages in GitLab, see the documentation.', - ); - expect(findHelpLink().attributes()).toMatchObject({ - href: composerHelpPath, - target: '_blank', - }); - }); - }); - - describe('root node', () => { - it('is normally rendered', () => { - createStore(); - createComponent(); - - expect(findRootNode().exists()).toBe(true); - }); - - it('is not rendered when the group does not exist', () => { - createStore(false); - createComponent(); - - expect(findRootNode().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js deleted file mode 100644 index 78a7d265a21..00000000000 --- a/spec/frontend/packages/details/components/conan_installation_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import ConanInstallation from '~/packages/details/components/conan_installation.vue'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; -import { conanPackage as packageEntity } from '../../mock_data'; -import { registryUrl as conanPath } from '../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('ConanInstallation', () => { - let wrapper; - - const conanInstallationCommandStr = 'foo/command'; - const conanSetupCommandStr = 'foo/setup'; - - const store = new Vuex.Store({ - state: { - packageEntity, - conanPath, - }, - getters: { - conanInstallationCommand: () => conanInstallationCommandStr, - conanSetupCommand: () => conanSetupCommandStr, - }, - }); - - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent() { - wrapper = shallowMount(ConanInstallation, { - localVue, - store, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('install command switch', () => { - it('has the installation title component', () => { - expect(findInstallationTitle().exists()).toBe(true); - expect(findInstallationTitle().props()).toMatchObject({ - packageType: 'conan', - options: [{ value: 'conan', label: 'Show Conan commands' }], - }); - }); - }); - - describe('installation commands', () => { - it('renders the correct command', () => { - expect(findCodeInstructions().at(0).props('instruction')).toBe(conanInstallationCommandStr); - }); - }); - - describe('setup commands', () => { - it('renders the correct command', () => { - expect(findCodeInstructions().at(1).props('instruction')).toBe(conanSetupCommandStr); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/dependency_row_spec.js b/spec/frontend/packages/details/components/dependency_row_spec.js deleted file mode 100644 index 7d3ee92908d..00000000000 --- a/spec/frontend/packages/details/components/dependency_row_spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DependencyRow from '~/packages/details/components/dependency_row.vue'; -import { dependencyLinks } from '../../mock_data'; - -describe('DependencyRow', () => { - let wrapper; - - const { withoutFramework, withoutVersion, fullLink } = dependencyLinks; - - function createComponent({ dependencyLink = fullLink } = {}) { - wrapper = shallowMount(DependencyRow, { - propsData: { - dependency: dependencyLink, - }, - }); - } - - const dependencyVersion = () => wrapper.find('[data-testid="version-pattern"]'); - const dependencyFramework = () => wrapper.find('[data-testid="target-framework"]'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('renders', () => { - it('full dependency', () => { - createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('version', () => { - it('does not render any version information when not supplied', () => { - createComponent({ dependencyLink: withoutVersion }); - - expect(dependencyVersion().exists()).toBe(false); - }); - - it('does render version info when it exists', () => { - createComponent(); - - expect(dependencyVersion().exists()).toBe(true); - expect(dependencyVersion().text()).toBe(fullLink.version_pattern); - }); - }); - - describe('target framework', () => { - it('does not render any framework information when not supplied', () => { - createComponent({ dependencyLink: withoutFramework }); - - expect(dependencyFramework().exists()).toBe(false); - }); - - it('does render framework info when it exists', () => { - createComponent(); - - expect(dependencyFramework().exists()).toBe(true); - expect(dependencyFramework().text()).toBe(`(${fullLink.target_framework})`); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/installation_title_spec.js b/spec/frontend/packages/details/components/installation_title_spec.js deleted file mode 100644 index 14e990d3011..00000000000 --- a/spec/frontend/packages/details/components/installation_title_spec.js +++ /dev/null @@ -1,58 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; - -describe('InstallationTitle', () => { - let wrapper; - - const defaultProps = { packageType: 'foo', options: [{ value: 'foo', label: 'bar' }] }; - - const findPersistedDropdownSelection = () => wrapper.findComponent(PersistedDropdownSelection); - const findTitle = () => wrapper.find('h3'); - - function createComponent({ props = {} } = {}) { - wrapper = shallowMount(InstallationTitle, { - propsData: { - ...defaultProps, - ...props, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('has a title', () => { - createComponent(); - - expect(findTitle().exists()).toBe(true); - expect(findTitle().text()).toBe('Installation'); - }); - - describe('persisted dropdown selection', () => { - it('exists', () => { - createComponent(); - - expect(findPersistedDropdownSelection().exists()).toBe(true); - }); - - it('has the correct props', () => { - createComponent(); - - expect(findPersistedDropdownSelection().props()).toMatchObject({ - storageKey: 'package_foo_installation_instructions', - options: defaultProps.options, - }); - }); - - it('on change event emits a change event', () => { - createComponent(); - - findPersistedDropdownSelection().vm.$emit('change', 'baz'); - - expect(wrapper.emitted('change')).toEqual([['baz']]); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/installations_commands_spec.js b/spec/frontend/packages/details/components/installations_commands_spec.js deleted file mode 100644 index 164f9f69741..00000000000 --- a/spec/frontend/packages/details/components/installations_commands_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import ComposerInstallation from '~/packages/details/components/composer_installation.vue'; -import ConanInstallation from '~/packages/details/components/conan_installation.vue'; -import InstallationCommands from '~/packages/details/components/installation_commands.vue'; - -import MavenInstallation from '~/packages/details/components/maven_installation.vue'; -import NpmInstallation from '~/packages/details/components/npm_installation.vue'; -import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; -import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; -import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; - -import { - conanPackage, - mavenPackage, - npmPackage, - nugetPackage, - pypiPackage, - composerPackage, - terraformModule, -} from '../../mock_data'; - -describe('InstallationCommands', () => { - let wrapper; - - function createComponent(propsData) { - wrapper = shallowMount(InstallationCommands, { - propsData, - }); - } - - const npmInstallation = () => wrapper.find(NpmInstallation); - const mavenInstallation = () => wrapper.find(MavenInstallation); - const conanInstallation = () => wrapper.find(ConanInstallation); - const nugetInstallation = () => wrapper.find(NugetInstallation); - const pypiInstallation = () => wrapper.find(PypiInstallation); - const composerInstallation = () => wrapper.find(ComposerInstallation); - const terraformInstallation = () => wrapper.findComponent(TerraformInstallation); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('installation instructions', () => { - describe.each` - packageEntity | selector - ${conanPackage} | ${conanInstallation} - ${mavenPackage} | ${mavenInstallation} - ${npmPackage} | ${npmInstallation} - ${nugetPackage} | ${nugetInstallation} - ${pypiPackage} | ${pypiInstallation} - ${composerPackage} | ${composerInstallation} - ${terraformModule} | ${terraformInstallation} - `('renders', ({ packageEntity, selector }) => { - it(`${packageEntity.package_type} instructions exist`, () => { - createComponent({ packageEntity }); - - expect(selector()).toExist(); - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js deleted file mode 100644 index 4972fe70a3d..00000000000 --- a/spec/frontend/packages/details/components/maven_installation_spec.js +++ /dev/null @@ -1,184 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import Vuex from 'vuex'; -import { registryUrl as mavenPath } from 'jest/packages/details/mock_data'; -import { mavenPackage as packageEntity } from 'jest/packages/mock_data'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import MavenInstallation from '~/packages/details/components/maven_installation.vue'; -import { TrackingActions } from '~/packages/details/constants'; -import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('MavenInstallation', () => { - let wrapper; - - const xmlCodeBlock = 'foo/xml'; - const mavenCommandStr = 'foo/command'; - const mavenSetupXml = 'foo/setup'; - const gradleGroovyInstallCommandText = 'foo/gradle/groovy/install'; - const gradleGroovyAddSourceCommandText = 'foo/gradle/groovy/add/source'; - const gradleKotlinInstallCommandText = 'foo/gradle/kotlin/install'; - const gradleKotlinAddSourceCommandText = 'foo/gradle/kotlin/add/source'; - - const store = new Vuex.Store({ - state: { - packageEntity, - mavenPath, - }, - getters: { - mavenInstallationXml: () => xmlCodeBlock, - mavenInstallationCommand: () => mavenCommandStr, - mavenSetupXml: () => mavenSetupXml, - gradleGroovyInstalCommand: () => gradleGroovyInstallCommandText, - gradleGroovyAddSourceCommand: () => gradleGroovyAddSourceCommandText, - gradleKotlinInstalCommand: () => gradleKotlinInstallCommandText, - gradleKotlinAddSourceCommand: () => gradleKotlinAddSourceCommandText, - }, - }); - - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent({ data = {} } = {}) { - wrapper = shallowMount(MavenInstallation, { - localVue, - store, - data() { - return data; - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - describe('install command switch', () => { - it('has the installation title component', () => { - createComponent(); - - expect(findInstallationTitle().exists()).toBe(true); - expect(findInstallationTitle().props()).toMatchObject({ - packageType: 'maven', - options: [ - { value: 'maven', label: 'Maven XML' }, - { value: 'groovy', label: 'Gradle Groovy DSL' }, - { value: 'kotlin', label: 'Gradle Kotlin DSL' }, - ], - }); - }); - - it('on change event updates the instructions to show', async () => { - createComponent(); - - expect(findCodeInstructions().at(0).props('instruction')).toBe(xmlCodeBlock); - findInstallationTitle().vm.$emit('change', 'groovy'); - - await nextTick(); - - expect(findCodeInstructions().at(0).props('instruction')).toBe( - gradleGroovyInstallCommandText, - ); - }); - }); - - describe('maven', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('installation commands', () => { - it('renders the correct xml block', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: xmlCodeBlock, - multiline: true, - trackingAction: TrackingActions.COPY_MAVEN_XML, - }); - }); - - it('renders the correct maven command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: mavenCommandStr, - multiline: false, - trackingAction: TrackingActions.COPY_MAVEN_COMMAND, - }); - }); - }); - - describe('setup commands', () => { - it('renders the correct xml block', () => { - expect(findCodeInstructions().at(2).props()).toMatchObject({ - instruction: mavenSetupXml, - multiline: true, - trackingAction: TrackingActions.COPY_MAVEN_SETUP, - }); - }); - }); - }); - - describe('groovy', () => { - beforeEach(() => { - createComponent({ data: { instructionType: 'groovy' } }); - }); - - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('installation commands', () => { - it('renders the gradle install command', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: gradleGroovyInstallCommandText, - multiline: false, - trackingAction: TrackingActions.COPY_GRADLE_INSTALL_COMMAND, - }); - }); - }); - - describe('setup commands', () => { - it('renders the correct gradle command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: gradleGroovyAddSourceCommandText, - multiline: true, - trackingAction: TrackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND, - }); - }); - }); - }); - - describe('kotlin', () => { - beforeEach(() => { - createComponent({ data: { instructionType: 'kotlin' } }); - }); - - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('installation commands', () => { - it('renders the gradle install command', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: gradleKotlinInstallCommandText, - multiline: false, - trackingAction: TrackingActions.COPY_KOTLIN_INSTALL_COMMAND, - }); - }); - }); - - describe('setup commands', () => { - it('renders the correct gradle command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: gradleKotlinAddSourceCommandText, - multiline: true, - trackingAction: TrackingActions.COPY_KOTLIN_ADD_TO_SOURCE_COMMAND, - }); - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js deleted file mode 100644 index 1c49110bdf8..00000000000 --- a/spec/frontend/packages/details/components/npm_installation_spec.js +++ /dev/null @@ -1,123 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import Vuex from 'vuex'; -import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; -import { npmPackage as packageEntity } from 'jest/packages/mock_data'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import NpmInstallation from '~/packages/details/components/npm_installation.vue'; -import { TrackingActions } from '~/packages/details/constants'; -import { npmInstallationCommand, npmSetupCommand } from '~/packages/details/store/getters'; -import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('NpmInstallation', () => { - let wrapper; - - const npmInstallationCommandLabel = 'npm i @Test/package'; - const yarnInstallationCommandLabel = 'yarn add @Test/package'; - - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent({ data = {} } = {}) { - const store = new Vuex.Store({ - state: { - packageEntity, - nugetPath, - }, - getters: { - npmInstallationCommand, - npmSetupCommand, - }, - }); - - wrapper = shallowMount(NpmInstallation, { - localVue, - store, - data() { - return data; - }, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('install command switch', () => { - it('has the installation title component', () => { - expect(findInstallationTitle().exists()).toBe(true); - expect(findInstallationTitle().props()).toMatchObject({ - packageType: 'npm', - options: [ - { value: 'npm', label: 'Show NPM commands' }, - { value: 'yarn', label: 'Show Yarn commands' }, - ], - }); - }); - - it('on change event updates the instructions to show', async () => { - createComponent(); - - expect(findCodeInstructions().at(0).props('instruction')).toBe(npmInstallationCommandLabel); - findInstallationTitle().vm.$emit('change', 'yarn'); - - await nextTick(); - - expect(findCodeInstructions().at(0).props('instruction')).toBe(yarnInstallationCommandLabel); - }); - }); - - describe('npm', () => { - beforeEach(() => { - createComponent(); - }); - it('renders the correct installation command', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: npmInstallationCommandLabel, - multiline: false, - trackingAction: TrackingActions.COPY_NPM_INSTALL_COMMAND, - }); - }); - - it('renders the correct setup command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo @Test:registry=undefined/ >> .npmrc', - multiline: false, - trackingAction: TrackingActions.COPY_NPM_SETUP_COMMAND, - }); - }); - }); - - describe('yarn', () => { - beforeEach(() => { - createComponent({ data: { instructionType: 'yarn' } }); - }); - - it('renders the correct setup command', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: yarnInstallationCommandLabel, - multiline: false, - trackingAction: TrackingActions.COPY_YARN_INSTALL_COMMAND, - }); - }); - - it('renders the correct registry command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: 'echo \\"@Test:registry\\" \\"undefined/\\" >> .yarnrc', - multiline: false, - trackingAction: TrackingActions.COPY_YARN_SETUP_COMMAND, - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js deleted file mode 100644 index 8839a8f1108..00000000000 --- a/spec/frontend/packages/details/components/nuget_installation_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; -import { nugetPackage as packageEntity } from 'jest/packages/mock_data'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; -import { TrackingActions } from '~/packages/details/constants'; -import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('NugetInstallation', () => { - let wrapper; - - const nugetInstallationCommandStr = 'foo/command'; - const nugetSetupCommandStr = 'foo/setup'; - - const store = new Vuex.Store({ - state: { - packageEntity, - nugetPath, - }, - getters: { - nugetInstallationCommand: () => nugetInstallationCommandStr, - nugetSetupCommand: () => nugetSetupCommandStr, - }, - }); - - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent() { - wrapper = shallowMount(NugetInstallation, { - localVue, - store, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('install command switch', () => { - it('has the installation title component', () => { - expect(findInstallationTitle().exists()).toBe(true); - expect(findInstallationTitle().props()).toMatchObject({ - packageType: 'nuget', - options: [{ value: 'nuget', label: 'Show Nuget commands' }], - }); - }); - }); - - describe('installation commands', () => { - it('renders the correct command', () => { - expect(findCodeInstructions().at(0).props()).toMatchObject({ - instruction: nugetInstallationCommandStr, - trackingAction: TrackingActions.COPY_NUGET_INSTALL_COMMAND, - }); - }); - }); - - describe('setup commands', () => { - it('renders the correct command', () => { - expect(findCodeInstructions().at(1).props()).toMatchObject({ - instruction: nugetSetupCommandStr, - trackingAction: TrackingActions.COPY_NUGET_SETUP_COMMAND, - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js deleted file mode 100644 index 512cec85b40..00000000000 --- a/spec/frontend/packages/details/components/package_title_spec.js +++ /dev/null @@ -1,189 +0,0 @@ -import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import PackageTitle from '~/packages/details/components/package_title.vue'; -import PackageTags from '~/packages/shared/components/package_tags.vue'; -import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import { - conanPackage, - mavenFiles, - mavenPackage, - mockTags, - npmFiles, - npmPackage, - nugetPackage, -} from '../../mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('PackageTitle', () => { - let wrapper; - let store; - - function createComponent({ - packageEntity = mavenPackage, - packageFiles = mavenFiles, - icon = null, - } = {}) { - store = new Vuex.Store({ - state: { - packageEntity, - packageFiles, - }, - getters: { - packageTypeDisplay: ({ packageEntity: { package_type: type } }) => type, - packagePipeline: ({ packageEntity: { pipeline = null } }) => pipeline, - packageIcon: () => icon, - }, - }); - - wrapper = shallowMount(PackageTitle, { - localVue, - store, - stubs: { - TitleArea, - }, - }); - return wrapper.vm.$nextTick(); - } - - const findTitleArea = () => wrapper.find(TitleArea); - const packageType = () => wrapper.find('[data-testid="package-type"]'); - const packageSize = () => wrapper.find('[data-testid="package-size"]'); - const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]'); - const packageRef = () => wrapper.find('[data-testid="package-ref"]'); - const packageTags = () => wrapper.find(PackageTags); - const packageBadges = () => wrapper.findAll('[data-testid="tag-badge"]'); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('renders', () => { - it('without tags', async () => { - await createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('with tags', async () => { - await createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('with tags on mobile', async () => { - jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false); - await createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } }); - await wrapper.vm.$nextTick(); - - expect(packageBadges()).toHaveLength(mockTags.length); - }); - }); - - describe('package title', () => { - it('is correctly bound', async () => { - await createComponent(); - - expect(findTitleArea().props('title')).toBe('Test package'); - }); - }); - - describe('package icon', () => { - const fakeSrc = 'a-fake-src'; - - it('binds an icon when provided one from vuex', async () => { - await createComponent({ icon: fakeSrc }); - - expect(findTitleArea().props('avatar')).toBe(fakeSrc); - }); - - it('do not binds an icon when not provided one', async () => { - await createComponent(); - - expect(findTitleArea().props('avatar')).toBe(null); - }); - }); - - describe.each` - packageEntity | text - ${conanPackage} | ${'conan'} - ${mavenPackage} | ${'maven'} - ${npmPackage} | ${'npm'} - ${nugetPackage} | ${'nuget'} - `(`package type`, ({ packageEntity, text }) => { - beforeEach(() => createComponent({ packageEntity })); - - it(`${packageEntity.package_type} should render from Vuex getters ${text}`, () => { - expect(packageType().props()).toEqual(expect.objectContaining({ text, icon: 'package' })); - }); - }); - - describe('calculates the package size', () => { - it('correctly calculates when there is only 1 file', async () => { - await createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); - - expect(packageSize().props()).toMatchObject({ text: '200 bytes', icon: 'disk' }); - }); - - it('correctly calulates when there are multiple files', async () => { - await createComponent(); - - expect(packageSize().props('text')).toBe('300 bytes'); - }); - }); - - describe('package tags', () => { - it('displays the package-tags component when the package has tags', async () => { - await createComponent({ - packageEntity: { - ...npmPackage, - tags: mockTags, - }, - }); - - expect(packageTags().exists()).toBe(true); - }); - - it('does not display the package-tags component when there are no tags', async () => { - await createComponent(); - - expect(packageTags().exists()).toBe(false); - }); - }); - - describe('package ref', () => { - it('does not display the ref if missing', async () => { - await createComponent(); - - expect(packageRef().exists()).toBe(false); - }); - - it('correctly shows the package ref if there is one', async () => { - await createComponent({ packageEntity: npmPackage }); - expect(packageRef().props()).toMatchObject({ - text: npmPackage.pipeline.ref, - icon: 'branch', - }); - }); - }); - - describe('pipeline project', () => { - it('does not display the project if missing', async () => { - await createComponent(); - - expect(pipelineProject().exists()).toBe(false); - }); - - it('correctly shows the pipeline project if there is one', async () => { - await createComponent({ packageEntity: npmPackage }); - - expect(pipelineProject().props()).toMatchObject({ - text: npmPackage.pipeline.project.name, - icon: 'review-list', - link: npmPackage.pipeline.project.web_url, - }); - }); - }); -}); diff --git a/spec/frontend/packages/details/components/pypi_installation_spec.js b/spec/frontend/packages/details/components/pypi_installation_spec.js deleted file mode 100644 index 2cec84282d9..00000000000 --- a/spec/frontend/packages/details/components/pypi_installation_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { pypiPackage as packageEntity } from 'jest/packages/mock_data'; -import InstallationTitle from '~/packages/details/components/installation_title.vue'; -import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('PypiInstallation', () => { - let wrapper; - - const pipCommandStr = 'pip install'; - const pypiSetupStr = 'python setup'; - - const store = new Vuex.Store({ - state: { - packageEntity, - pypiHelpPath: 'foo', - }, - getters: { - pypiPipCommand: () => pipCommandStr, - pypiSetupCommand: () => pypiSetupStr, - }, - }); - - const pipCommand = () => wrapper.find('[data-testid="pip-command"]'); - const setupInstruction = () => wrapper.find('[data-testid="pypi-setup-content"]'); - - const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); - - function createComponent() { - wrapper = shallowMount(PypiInstallation, { - localVue, - store, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('install command switch', () => { - it('has the installation title component', () => { - expect(findInstallationTitle().exists()).toBe(true); - expect(findInstallationTitle().props()).toMatchObject({ - packageType: 'pypi', - options: [{ value: 'pypi', label: 'Show PyPi commands' }], - }); - }); - }); - - it('renders all the messages', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('installation commands', () => { - it('renders the correct pip command', () => { - expect(pipCommand().props('instruction')).toBe(pipCommandStr); - }); - }); - - describe('setup commands', () => { - it('renders the correct setup block', () => { - expect(setupInstruction().props('instruction')).toBe(pypiSetupStr); - }); - }); -}); diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js deleted file mode 100644 index 8210511bf8f..00000000000 --- a/spec/frontend/packages/details/store/getters_spec.js +++ /dev/null @@ -1,295 +0,0 @@ -import { NpmManager } from '~/packages/details/constants'; -import { - conanInstallationCommand, - conanSetupCommand, - packagePipeline, - packageTypeDisplay, - packageIcon, - mavenInstallationXml, - mavenInstallationCommand, - mavenSetupXml, - npmInstallationCommand, - npmSetupCommand, - nugetInstallationCommand, - nugetSetupCommand, - pypiPipCommand, - pypiSetupCommand, - composerRegistryInclude, - composerPackageInclude, - groupExists, - gradleGroovyInstalCommand, - gradleGroovyAddSourceCommand, - gradleKotlinInstalCommand, - gradleKotlinAddSourceCommand, -} from '~/packages/details/store/getters'; -import { - conanPackage, - npmPackage, - nugetPackage, - mockPipelineInfo, - mavenPackage as packageWithoutBuildInfo, - pypiPackage, - rubygemsPackage, -} from '../../mock_data'; -import { - generateMavenCommand, - generateXmlCodeBlock, - generateMavenSetupXml, - registryUrl, - pypiSetupCommandStr, -} from '../mock_data'; - -describe('Getters PackageDetails Store', () => { - let state; - - const defaultState = { - packageEntity: packageWithoutBuildInfo, - conanPath: registryUrl, - mavenPath: registryUrl, - npmPath: registryUrl, - nugetPath: registryUrl, - pypiPath: registryUrl, - }; - - const setupState = (testState = {}) => { - state = { - ...defaultState, - ...testState, - }; - }; - - const conanInstallationCommandStr = `conan install ${conanPackage.name} --remote=gitlab`; - const conanSetupCommandStr = `conan remote add gitlab ${registryUrl}`; - - const mavenCommandStr = generateMavenCommand(packageWithoutBuildInfo.maven_metadatum); - const mavenInstallationXmlBlock = generateXmlCodeBlock(packageWithoutBuildInfo.maven_metadatum); - const mavenSetupXmlBlock = generateMavenSetupXml(); - - const npmInstallStr = `npm i ${npmPackage.name}`; - const npmSetupStr = `echo @Test:registry=${registryUrl}/ >> .npmrc`; - const yarnInstallStr = `yarn add ${npmPackage.name}`; - const yarnSetupStr = `echo \\"@Test:registry\\" \\"${registryUrl}/\\" >> .yarnrc`; - - const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`; - const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`; - - const pypiPipCommandStr = `pip install ${pypiPackage.name} --extra-index-url ${registryUrl}`; - const composerRegistryIncludeStr = - 'composer config repositories.gitlab.com/123 \'{"type": "composer", "url": "foo"}\''; - const composerPackageIncludeStr = `composer req ${[packageWithoutBuildInfo.name]}:${ - packageWithoutBuildInfo.version - }`; - - describe('packagePipeline', () => { - it('should return the pipeline info when pipeline exists', () => { - setupState({ - packageEntity: { - ...npmPackage, - pipeline: mockPipelineInfo, - }, - }); - - expect(packagePipeline(state)).toEqual(mockPipelineInfo); - }); - - it('should return null when build_info does not exist', () => { - setupState(); - - expect(packagePipeline(state)).toBe(null); - }); - }); - - describe('packageTypeDisplay', () => { - describe.each` - packageEntity | expectedResult - ${conanPackage} | ${'Conan'} - ${packageWithoutBuildInfo} | ${'Maven'} - ${npmPackage} | ${'npm'} - ${nugetPackage} | ${'NuGet'} - ${pypiPackage} | ${'PyPI'} - ${rubygemsPackage} | ${'RubyGems'} - `(`package type`, ({ packageEntity, expectedResult }) => { - beforeEach(() => setupState({ packageEntity })); - - it(`${packageEntity.package_type} should show as ${expectedResult}`, () => { - expect(packageTypeDisplay(state)).toBe(expectedResult); - }); - }); - }); - - describe('packageIcon', () => { - describe('nuget packages', () => { - it('should return nuget package icon', () => { - setupState({ packageEntity: nugetPackage }); - - expect(packageIcon(state)).toBe(nugetPackage.nuget_metadatum.icon_url); - }); - - it('should return null when nuget package does not have an icon', () => { - setupState({ packageEntity: { ...nugetPackage, nuget_metadatum: {} } }); - - expect(packageIcon(state)).toBe(null); - }); - }); - - it('should not find icons for other package types', () => { - setupState({ packageEntity: npmPackage }); - - expect(packageIcon(state)).toBe(null); - }); - }); - - describe('conan string getters', () => { - it('gets the correct conanInstallationCommand', () => { - setupState({ packageEntity: conanPackage }); - - expect(conanInstallationCommand(state)).toBe(conanInstallationCommandStr); - }); - - it('gets the correct conanSetupCommand', () => { - setupState({ packageEntity: conanPackage }); - - expect(conanSetupCommand(state)).toBe(conanSetupCommandStr); - }); - }); - - describe('maven string getters', () => { - it('gets the correct mavenInstallationXml', () => { - setupState(); - - expect(mavenInstallationXml(state)).toBe(mavenInstallationXmlBlock); - }); - - it('gets the correct mavenInstallationCommand', () => { - setupState(); - - expect(mavenInstallationCommand(state)).toBe(mavenCommandStr); - }); - - it('gets the correct mavenSetupXml', () => { - setupState(); - - expect(mavenSetupXml(state)).toBe(mavenSetupXmlBlock); - }); - }); - - describe('npm string getters', () => { - it('gets the correct npmInstallationCommand for npm', () => { - setupState({ packageEntity: npmPackage }); - - expect(npmInstallationCommand(state)(NpmManager.NPM)).toBe(npmInstallStr); - }); - - it('gets the correct npmSetupCommand for npm', () => { - setupState({ packageEntity: npmPackage }); - - expect(npmSetupCommand(state)(NpmManager.NPM)).toBe(npmSetupStr); - }); - - it('gets the correct npmInstallationCommand for Yarn', () => { - setupState({ packageEntity: npmPackage }); - - expect(npmInstallationCommand(state)(NpmManager.YARN)).toBe(yarnInstallStr); - }); - - it('gets the correct npmSetupCommand for Yarn', () => { - setupState({ packageEntity: npmPackage }); - - expect(npmSetupCommand(state)(NpmManager.YARN)).toBe(yarnSetupStr); - }); - }); - - describe('nuget string getters', () => { - it('gets the correct nugetInstallationCommand', () => { - setupState({ packageEntity: nugetPackage }); - - expect(nugetInstallationCommand(state)).toBe(nugetInstallationCommandStr); - }); - - it('gets the correct nugetSetupCommand', () => { - setupState({ packageEntity: nugetPackage }); - - expect(nugetSetupCommand(state)).toBe(nugetSetupCommandStr); - }); - }); - - describe('pypi string getters', () => { - it('gets the correct pypiPipCommand', () => { - setupState({ packageEntity: pypiPackage }); - - expect(pypiPipCommand(state)).toBe(pypiPipCommandStr); - }); - - it('gets the correct pypiSetupCommand', () => { - setupState({ pypiSetupPath: 'foo' }); - - expect(pypiSetupCommand(state)).toBe(pypiSetupCommandStr); - }); - }); - - describe('composer string getters', () => { - it('gets the correct composerRegistryInclude command', () => { - setupState({ composerPath: 'foo', composerConfigRepositoryName: 'gitlab.com/123' }); - - expect(composerRegistryInclude(state)).toBe(composerRegistryIncludeStr); - }); - - it('gets the correct composerPackageInclude command', () => { - setupState(); - - expect(composerPackageInclude(state)).toBe(composerPackageIncludeStr); - }); - }); - - describe('gradle groovy string getters', () => { - it('gets the correct gradleGroovyInstalCommand', () => { - setupState(); - - expect(gradleGroovyInstalCommand(state)).toMatchInlineSnapshot( - `"implementation 'com.test.app:test-app:1.0-SNAPSHOT'"`, - ); - }); - - it('gets the correct gradleGroovyAddSourceCommand', () => { - setupState(); - - expect(gradleGroovyAddSourceCommand(state)).toMatchInlineSnapshot(` - "maven { - url 'foo/registry' - }" - `); - }); - }); - - describe('gradle kotlin string getters', () => { - it('gets the correct gradleKotlinInstalCommand', () => { - setupState(); - - expect(gradleKotlinInstalCommand(state)).toMatchInlineSnapshot( - `"implementation(\\"com.test.app:test-app:1.0-SNAPSHOT\\")"`, - ); - }); - - it('gets the correct gradleKotlinAddSourceCommand', () => { - setupState(); - - expect(gradleKotlinAddSourceCommand(state)).toMatchInlineSnapshot( - `"maven(\\"foo/registry\\")"`, - ); - }); - }); - - describe('check if group', () => { - it('is set', () => { - setupState({ groupListUrl: '/groups/composer/-/packages' }); - - expect(groupExists(state)).toBe(true); - }); - - it('is not set', () => { - setupState({ groupListUrl: '' }); - - expect(groupExists(state)).toBe(false); - }); - }); -}); diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js index bd15d48c4eb..5f2fc8ddfbd 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -1,5 +1,5 @@ import { GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; @@ -19,14 +19,14 @@ describe('packages_list_row', () => { const InfrastructureIconAndName = { name: 'InfrastructureIconAndName', template: '<div></div>' }; const PackageIconAndName = { name: 'PackageIconAndName', template: '<div></div>' }; - const findPackageTags = () => wrapper.find(PackageTags); - const findPackagePath = () => wrapper.find(PackagePath); - const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]'); - const findPackageIconAndName = () => wrapper.find(PackageIconAndName); + const findPackageTags = () => wrapper.findComponent(PackageTags); + const findPackagePath = () => wrapper.findComponent(PackagePath); + const findDeleteButton = () => wrapper.findByTestId('action-delete'); + const findPackageIconAndName = () => wrapper.findComponent(PackageIconAndName); const findInfrastructureIconAndName = () => wrapper.findComponent(InfrastructureIconAndName); const findListItem = () => wrapper.findComponent(ListItem); const findPackageLink = () => wrapper.findComponent(GlLink); - const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]'); + const findWarningIcon = () => wrapper.findByTestId('warning-icon'); const mountComponent = ({ isGroup = false, @@ -35,7 +35,7 @@ describe('packages_list_row', () => { disableDelete = false, provide, } = {}) => { - wrapper = shallowMount(PackagesListRow, { + wrapper = shallowMountExtended(PackagesListRow, { store, provide, stubs: { diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js new file mode 100644 index 00000000000..1f0252965b0 --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -0,0 +1,173 @@ +import { GlFormInputGroup, GlFormGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import DependencyProxyApp from '~/packages_and_registries/dependency_proxy/app.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql'; + +import { proxyDetailsQuery, proxyData } from './mock_data'; + +const localVue = createLocalVue(); + +describe('DependencyProxyApp', () => { + let wrapper; + let apolloProvider; + + const provideDefaults = { + groupPath: 'gitlab-org', + dependencyProxyAvailable: true, + }; + + function createComponent({ + provide = provideDefaults, + resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()), + } = {}) { + localVue.use(VueApollo); + + const requestHandlers = [[getDependencyProxyDetailsQuery, resolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(DependencyProxyApp, { + localVue, + apolloProvider, + provide, + stubs: { + GlFormInputGroup, + GlFormGroup, + GlSprintf, + }, + }); + } + + const findProxyNotAvailableAlert = () => wrapper.findByTestId('proxy-not-available'); + const findProxyDisabledAlert = () => wrapper.findByTestId('proxy-disabled'); + const findClipBoardButton = () => wrapper.findComponent(ClipboardButton); + const findFormGroup = () => wrapper.findComponent(GlFormGroup); + const findFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const findMainArea = () => wrapper.findByTestId('main-area'); + const findProxyCountText = () => wrapper.findByTestId('proxy-count'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when the dependency proxy is not available', () => { + const createComponentArguments = { + provide: { ...provideDefaults, dependencyProxyAvailable: false }, + }; + + it('renders an info alert', () => { + createComponent(createComponentArguments); + + expect(findProxyNotAvailableAlert().text()).toBe( + DependencyProxyApp.i18n.proxyNotAvailableText, + ); + }); + + it('does not render the main area', () => { + createComponent(createComponentArguments); + + expect(findMainArea().exists()).toBe(false); + }); + + it('does not call the graphql endpoint', async () => { + const resolver = jest.fn().mockResolvedValue(proxyDetailsQuery()); + createComponent({ ...createComponentArguments, resolver }); + + await waitForPromises(); + + expect(resolver).not.toHaveBeenCalled(); + }); + }); + + describe('when the dependency proxy is available', () => { + describe('when is loading', () => { + it('renders the skeleton loader', () => { + createComponent(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('does not show the main section', () => { + createComponent(); + + expect(findMainArea().exists()).toBe(false); + }); + + it('does not render the info alert', () => { + createComponent(); + + expect(findProxyNotAvailableAlert().exists()).toBe(false); + }); + }); + + describe('when the app is loaded', () => { + describe('when the dependency proxy is enabled', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('does not render the info alert', () => { + expect(findProxyNotAvailableAlert().exists()).toBe(false); + }); + + it('renders the main area', () => { + expect(findMainArea().exists()).toBe(true); + }); + + it('renders a form group with a label', () => { + expect(findFormGroup().attributes('label')).toBe( + DependencyProxyApp.i18n.proxyImagePrefix, + ); + }); + + it('renders a form input group', () => { + expect(findFormInputGroup().exists()).toBe(true); + expect(findFormInputGroup().props('value')).toBe(proxyData().dependencyProxyImagePrefix); + }); + + it('form input group has a clipboard button', () => { + expect(findClipBoardButton().exists()).toBe(true); + expect(findClipBoardButton().props()).toMatchObject({ + text: proxyData().dependencyProxyImagePrefix, + title: DependencyProxyApp.i18n.copyImagePrefixText, + }); + }); + + it('from group has a description with proxy count', () => { + expect(findProxyCountText().text()).toBe('Contains 2 blobs of images (1024 Bytes)'); + }); + }); + describe('when the dependency proxy is disabled', () => { + beforeEach(() => { + createComponent({ + resolver: jest + .fn() + .mockResolvedValue(proxyDetailsQuery({ extendSettings: { enabled: false } })), + }); + return waitForPromises(); + }); + + it('does not show the main area', () => { + expect(findMainArea().exists()).toBe(false); + }); + + it('does not show the loader', () => { + expect(findSkeletonLoader().exists()).toBe(false); + }); + + it('shows a proxy disabled alert', () => { + expect(findProxyDisabledAlert().text()).toBe(DependencyProxyApp.i18n.proxyDisabledText); + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js new file mode 100644 index 00000000000..23d42e109f9 --- /dev/null +++ b/spec/frontend/packages_and_registries/dependency_proxy/mock_data.js @@ -0,0 +1,21 @@ +export const proxyData = () => ({ + dependencyProxyBlobCount: 2, + dependencyProxyTotalSize: '1024 Bytes', + dependencyProxyImagePrefix: 'gdk.test:3000/private-group/dependency_proxy/containers', + dependencyProxySetting: { enabled: true, __typename: 'DependencyProxySetting' }, +}); + +export const proxySettings = (extend = {}) => ({ enabled: true, ...extend }); + +export const proxyDetailsQuery = ({ extendSettings = {} } = {}) => ({ + data: { + group: { + ...proxyData(), + __typename: 'Group', + dependencyProxySetting: { + ...proxySettings(extendSettings), + __typename: 'DependencyProxySetting', + }, + }, + }, +}); diff --git a/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap index 881d441e116..881d441e116 100644 --- a/spec/frontend/packages/details/components/__snapshots__/file_sha_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/file_sha_spec.js.snap diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/__snapshots__/terraform_installation_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/terraform_installation_spec.js.snap index 03236737572..03236737572 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/__snapshots__/terraform_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/__snapshots__/terraform_installation_spec.js.snap diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js index 377e7e05f09..c7c10cef504 100644 --- a/spec/frontend/packages/details/components/app_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/app_spec.js @@ -5,28 +5,19 @@ import Vuex from 'vuex'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import stubChildren from 'helpers/stub_children'; -import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; -import PackagesApp from '~/packages/details/components/app.vue'; -import DependencyRow from '~/packages/details/components/dependency_row.vue'; -import InstallationCommands from '~/packages/details/components/installation_commands.vue'; -import PackageFiles from '~/packages/details/components/package_files.vue'; -import PackageHistory from '~/packages/details/components/package_history.vue'; -import PackageTitle from '~/packages/details/components/package_title.vue'; -import * as getters from '~/packages/details/store/getters'; +import PackagesApp from '~/packages_and_registries/infrastructure_registry/details/components/app.vue'; +import PackageFiles from '~/packages_and_registries/infrastructure_registry/details/components/package_files.vue'; +import PackageHistory from '~/packages_and_registries/infrastructure_registry/details/components/package_history.vue'; +import * as getters from '~/packages_and_registries/infrastructure_registry/details/store/getters'; import PackageListRow from '~/packages/shared/components/package_list_row.vue'; import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; import { TrackingActions } from '~/packages/shared/constants'; import * as SharedUtils from '~/packages/shared/utils'; +import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import Tracking from '~/tracking'; -import { - composerPackage, - conanPackage, - mavenPackage, - mavenFiles, - npmPackage, - nugetPackage, -} from '../../mock_data'; +import { mavenPackage, mavenFiles, npmPackage } from 'jest/packages/mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -73,7 +64,7 @@ describe('PackagesApp', () => { store, stubs: { ...stubChildren(PackagesApp), - PackageTitle: false, + TerraformTitle: false, TitleArea: false, GlButton: false, GlModal: false, @@ -84,23 +75,18 @@ describe('PackagesApp', () => { }); } - const packageTitle = () => wrapper.find(PackageTitle); - const emptyState = () => wrapper.find(GlEmptyState); + const packageTitle = () => wrapper.findComponent(TerraformTitle); + const emptyState = () => wrapper.findComponent(GlEmptyState); const deleteButton = () => wrapper.find('.js-delete-button'); const findDeleteModal = () => wrapper.find({ ref: 'deleteModal' }); const findDeleteFileModal = () => wrapper.find({ ref: 'deleteFileModal' }); const versionsTab = () => wrapper.find('.js-versions-tab > a'); - const packagesLoader = () => wrapper.find(PackagesListLoader); - const packagesVersionRows = () => wrapper.findAll(PackageListRow); + const packagesLoader = () => wrapper.findComponent(PackagesListLoader); + const packagesVersionRows = () => wrapper.findAllComponents(PackageListRow); const noVersionsMessage = () => wrapper.find('[data-testid="no-versions-message"]'); - const dependenciesTab = () => wrapper.find('.js-dependencies-tab > a'); - const dependenciesCountBadge = () => wrapper.find('[data-testid="dependencies-badge"]'); - const noDependenciesMessage = () => wrapper.find('[data-testid="no-dependencies-message"]'); - const dependencyRows = () => wrapper.findAll(DependencyRow); - const findPackageHistory = () => wrapper.find(PackageHistory); - const findAdditionalMetadata = () => wrapper.find(AdditionalMetadata); - const findInstallationCommands = () => wrapper.find(InstallationCommands); - const findPackageFiles = () => wrapper.find(PackageFiles); + const findPackageHistory = () => wrapper.findComponent(PackageHistory); + const findTerraformInstallation = () => wrapper.findComponent(TerraformInstallation); + const findPackageFiles = () => wrapper.findComponent(PackageFiles); afterEach(() => { wrapper.destroy(); @@ -129,21 +115,10 @@ describe('PackagesApp', () => { expect(findPackageHistory().props('projectName')).toEqual(wrapper.vm.projectName); }); - it('additional metadata has the right props', () => { + it('terraform installation exists', () => { createComponent(); - expect(findAdditionalMetadata().exists()).toBe(true); - expect(findAdditionalMetadata().props('packageEntity')).toEqual(wrapper.vm.packageEntity); - }); - - it('installation commands has the right props', () => { - createComponent(); - expect(findInstallationCommands().exists()).toBe(true); - expect(findInstallationCommands().props('packageEntity')).toEqual(wrapper.vm.packageEntity); - }); - it('hides the files table if package type is COMPOSER', () => { - createComponent({ packageEntity: composerPackage }); - expect(findPackageFiles().exists()).toBe(false); + expect(findTerraformInstallation().exists()).toBe(true); }); describe('deleting packages', () => { @@ -198,45 +173,6 @@ describe('PackagesApp', () => { }); }); - describe('dependency links', () => { - it('does not show the dependency links for a non nuget package', () => { - createComponent(); - - expect(dependenciesTab().exists()).toBe(false); - }); - - it('shows the dependencies tab with 0 count when a nuget package with no dependencies', () => { - createComponent({ - packageEntity: { - ...nugetPackage, - dependency_links: [], - }, - }); - - return wrapper.vm.$nextTick(() => { - const dependenciesBadge = dependenciesCountBadge(); - - expect(dependenciesTab().exists()).toBe(true); - expect(dependenciesBadge.exists()).toBe(true); - expect(dependenciesBadge.text()).toBe('0'); - expect(noDependenciesMessage().exists()).toBe(true); - }); - }); - - it('renders the correct number of dependency rows for a nuget package', () => { - createComponent({ packageEntity: nugetPackage }); - - return wrapper.vm.$nextTick(() => { - const dependenciesBadge = dependenciesCountBadge(); - - expect(dependenciesTab().exists()).toBe(true); - expect(dependenciesBadge.exists()).toBe(true); - expect(dependenciesBadge.text()).toBe(nugetPackage.dependency_links.length.toString()); - expect(dependencyRows()).toHaveLength(nugetPackage.dependency_links.length); - }); - }); - }); - describe('tracking and delete', () => { describe('delete package', () => { const originalReferrer = document.referrer; @@ -305,9 +241,9 @@ describe('PackagesApp', () => { }); it('tracking category calls packageTypeToTrackCategory', () => { - createComponent({ packageEntity: conanPackage }); + createComponent({ packageEntity: npmPackage }); expect(wrapper.vm.tracking.category).toBe(category); - expect(utilSpy).toHaveBeenCalledWith('conan'); + expect(utilSpy).toHaveBeenCalledWith('npm'); }); it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => { @@ -371,7 +307,7 @@ describe('PackagesApp', () => { }); it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { - createComponent({ packageEntity: conanPackage }); + createComponent({ packageEntity: npmPackage }); findPackageFiles().vm.$emit('download-file'); expect(eventSpy).toHaveBeenCalledWith( diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js index 87e0059344c..a012ec4ab05 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details_title_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/details_title_spec.js @@ -1,7 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { terraformModule, mavenFiles, npmPackage } from 'jest/packages/mock_data'; -import component from '~/packages_and_registries/infrastructure_registry/components/details_title.vue'; +import component from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; const localVue = createLocalVue(); diff --git a/spec/frontend/packages/details/components/file_sha_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js index 7bfcf78baab..9ce590bfb51 100644 --- a/spec/frontend/packages/details/components/file_sha_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/file_sha_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import FileSha from '~/packages/details/components/file_sha.vue'; +import FileSha from '~/packages_and_registries/infrastructure_registry/details/components/file_sha.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; diff --git a/spec/frontend/packages/details/components/package_files_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js index e8e5a24d3a3..0c5aa30223b 100644 --- a/spec/frontend/packages/details/components/package_files_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_files_spec.js @@ -2,11 +2,11 @@ import { GlDropdown, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue/'; import stubChildren from 'helpers/stub_children'; -import component from '~/packages/details/components/package_files.vue'; +import component from '~/packages_and_registries/infrastructure_registry/details/components/package_files.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { npmFiles, mavenFiles } from '../../mock_data'; +import { npmFiles, mavenFiles } from 'jest/packages/mock_data'; describe('Package Files', () => { let wrapper; diff --git a/spec/frontend/packages/details/components/package_history_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js index 244805a9c82..4987af9f5b0 100644 --- a/spec/frontend/packages/details/components/package_history_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/package_history_spec.js @@ -1,12 +1,12 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; -import component from '~/packages/details/components/package_history.vue'; -import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; +import component from '~/packages_and_registries/infrastructure_registry/details/components/package_history.vue'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { mavenPackage, mockPipelineInfo } from '../../mock_data'; +import { mavenPackage, mockPipelineInfo } from 'jest/packages/mock_data'; describe('Package History', () => { let wrapper; diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js index ee1548ed5eb..c26784a4b75 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/terraform_installation_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/components/terraform_installation_spec.js @@ -1,7 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { terraformModule as packageEntity } from 'jest/packages/mock_data'; -import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue'; +import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; const localVue = createLocalVue(); diff --git a/spec/frontend/packages/details/mock_data.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/mock_data.js index d43abcedb2e..d43abcedb2e 100644 --- a/spec/frontend/packages/details/mock_data.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/mock_data.js diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js index b16e50debc4..61fa69c2f7a 100644 --- a/spec/frontend/packages/details/store/actions_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js @@ -1,19 +1,19 @@ import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; import createFlash from '~/flash'; -import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants'; +import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages_and_registries/infrastructure_registry/details/constants'; import { fetchPackageVersions, deletePackage, deletePackageFile, -} from '~/packages/details/store/actions'; -import * as types from '~/packages/details/store/mutation_types'; +} from '~/packages_and_registries/infrastructure_registry/details/store/actions'; +import * as types from '~/packages_and_registries/infrastructure_registry/details/store/mutation_types'; import { DELETE_PACKAGE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_ERROR_MESSAGE, DELETE_PACKAGE_FILE_SUCCESS_MESSAGE, } from '~/packages/shared/constants'; -import { npmPackage as packageEntity } from '../../mock_data'; +import { npmPackage as packageEntity } from '../../../../../packages/mock_data'; jest.mock('~/flash.js'); jest.mock('~/api.js'); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js new file mode 100644 index 00000000000..8740691a8ee --- /dev/null +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/getters_spec.js @@ -0,0 +1,40 @@ +import { packagePipeline } from '~/packages_and_registries/infrastructure_registry/details/store/getters'; +import { + npmPackage, + mockPipelineInfo, + mavenPackage as packageWithoutBuildInfo, +} from 'jest/packages/mock_data'; + +describe('Getters PackageDetails Store', () => { + let state; + + const defaultState = { + packageEntity: packageWithoutBuildInfo, + }; + + const setupState = (testState = {}) => { + state = { + ...defaultState, + ...testState, + }; + }; + + describe('packagePipeline', () => { + it('should return the pipeline info when pipeline exists', () => { + setupState({ + packageEntity: { + ...npmPackage, + pipeline: mockPipelineInfo, + }, + }); + + expect(packagePipeline(state)).toEqual(mockPipelineInfo); + }); + + it('should return null when build_info does not exist', () => { + setupState({ pipeline: undefined }); + + expect(packagePipeline(state)).toBe(null); + }); + }); +}); diff --git a/spec/frontend/packages/details/store/mutations_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js index 296ed02d786..6efefea4a14 100644 --- a/spec/frontend/packages/details/store/mutations_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/mutations_spec.js @@ -1,6 +1,6 @@ -import * as types from '~/packages/details/store/mutation_types'; -import mutations from '~/packages/details/store/mutations'; -import { npmPackage as packageEntity } from '../../mock_data'; +import * as types from '~/packages_and_registries/infrastructure_registry/details/store/mutation_types'; +import mutations from '~/packages_and_registries/infrastructure_registry/details/store/mutations'; +import { npmPackage as packageEntity } from 'jest/packages/mock_data'; describe('Mutations package details Store', () => { let mockState; diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap index 6a7f14dc33f..d5649e39561 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/npm_installation_spec.js.snap @@ -21,6 +21,15 @@ exports[`NpmInstallation renders all the messages 1`] = ` Registry setup </h3> + <gl-form-radio-group-stub + checked="instance" + disabledfield="disabled" + htmlfield="html" + options="[object Object],[object Object]" + textfield="text" + valuefield="value" + /> + <code-instruction-stub copytext="Copy npm setup command" instruction="echo @gitlab-org:registry=npmPath/ >> .npmrc" diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js index 279900edff2..f759fe7a81c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/metadata/nuget_spec.js @@ -9,9 +9,8 @@ import { PACKAGE_TYPE_NUGET } from '~/packages_and_registries/package_registry/c import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; -const nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; - describe('Nuget Metadata', () => { + let nugetPackage = { packageType: PACKAGE_TYPE_NUGET, metadata: nugetMetadata() }; let wrapper; const mountComponent = () => { @@ -52,4 +51,30 @@ describe('Nuget Metadata', () => { expect(element.props('icon')).toBe(icon); expect(findElementLink(element).attributes('href')).toBe(nugetPackage.metadata[link]); }); + + describe('without source', () => { + beforeAll(() => { + nugetPackage = { + packageType: PACKAGE_TYPE_NUGET, + metadata: { iconUrl: 'iconUrl', licenseUrl: 'licenseUrl' }, + }; + }); + + it('does not show additional metadata', () => { + expect(findNugetSource().exists()).toBe(false); + }); + }); + + describe('without license', () => { + beforeAll(() => { + nugetPackage = { + packageType: PACKAGE_TYPE_NUGET, + metadata: { iconUrl: 'iconUrl', projectUrl: 'projectUrl' }, + }; + }); + + it('does not show additional metadata', () => { + expect(findNugetLicense().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js index 083c6858ad0..b89410ede13 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/npm_installation_spec.js @@ -1,3 +1,4 @@ +import { GlFormRadioGroup } from '@gitlab/ui'; import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -12,6 +13,8 @@ import { PACKAGE_TYPE_NPM, NPM_PACKAGE_MANAGER, YARN_PACKAGE_MANAGER, + PROJECT_PACKAGE_ENDPOINT_TYPE, + INSTANCE_PACKAGE_ENDPOINT_TYPE, } from '~/packages_and_registries/package_registry/constants'; import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; @@ -25,12 +28,14 @@ describe('NpmInstallation', () => { const findCodeInstructions = () => wrapper.findAllComponents(CodeInstructions); const findInstallationTitle = () => wrapper.findComponent(InstallationTitle); + const findEndPointTypeSector = () => wrapper.findComponent(GlFormRadioGroup); function createComponent({ data = {} } = {}) { wrapper = shallowMountExtended(NpmInstallation, { provide: { npmHelpPath: 'npmHelpPath', npmPath: 'npmPath', + npmProjectPath: 'npmProjectPath', }, propsData: { packageEntity, @@ -53,6 +58,19 @@ describe('NpmInstallation', () => { expect(wrapper.element).toMatchSnapshot(); }); + describe('endpoint type selector', () => { + it('has the endpoint type selector', () => { + expect(findEndPointTypeSector().exists()).toBe(true); + expect(findEndPointTypeSector().vm.$attrs.checked).toBe(INSTANCE_PACKAGE_ENDPOINT_TYPE); + expect(findEndPointTypeSector().props()).toMatchObject({ + options: [ + { value: INSTANCE_PACKAGE_ENDPOINT_TYPE, text: 'Instance-level' }, + { value: PROJECT_PACKAGE_ENDPOINT_TYPE, text: 'Project-level' }, + ], + }); + }); + }); + describe('install command switch', () => { it('has the installation title component', () => { expect(findInstallationTitle().exists()).toBe(true); @@ -96,6 +114,28 @@ describe('NpmInstallation', () => { trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, }); }); + + it('renders the correct setup command for different endpoint types', async () => { + findEndPointTypeSector().vm.$emit('change', PROJECT_PACKAGE_ENDPOINT_TYPE); + + await nextTick(); + + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: `echo @gitlab-org:registry=npmProjectPath/ >> .npmrc`, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, + }); + + findEndPointTypeSector().vm.$emit('change', INSTANCE_PACKAGE_ENDPOINT_TYPE); + + await nextTick(); + + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: `echo @gitlab-org:registry=npmPath/ >> .npmrc`, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND, + }); + }); }); describe('yarn', () => { @@ -118,5 +158,27 @@ describe('NpmInstallation', () => { trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, }); }); + + it('renders the correct setup command for different endpoint types', async () => { + findEndPointTypeSector().vm.$emit('change', PROJECT_PACKAGE_ENDPOINT_TYPE); + + await nextTick(); + + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: `echo \\"@gitlab-org:registry\\" \\"npmProjectPath/\\" >> .yarnrc`, + multiline: false, + trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, + }); + + findEndPointTypeSector().vm.$emit('change', INSTANCE_PACKAGE_ENDPOINT_TYPE); + + await nextTick(); + + expect(findCodeInstructions().at(1).props()).toMatchObject({ + instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc', + multiline: false, + trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND, + }); + }); }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js index b69008f04f0..57b8be40a7c 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_history_spec.js @@ -5,7 +5,7 @@ import { packageData, packagePipelines, } from 'jest/packages_and_registries/package_registry/mock_data'; -import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants'; +import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants'; import component from '~/packages_and_registries/package_registry/components/details/package_history.vue'; import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap new file mode 100644 index 00000000000..1b556be5873 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/app_spec.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PackagesListApp renders 1`] = ` +<div> + <package-title-stub + count="2" + helpurl="packageHelpUrl" + /> + + <package-search-stub /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap new file mode 100644 index 00000000000..2f2be797251 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/package_list_row_spec.js.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_list_row renders 1`] = ` +<div + class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1 gl-border-t-transparent gl-border-b-gray-100" + data-qa-selector="package_row" +> + <div + class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5" + > + <!----> + + <div + class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-grow-1" + > + <div + class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1" + > + <div + class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" + > + <div + class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0" + > + <gl-link-stub + class="gl-text-body gl-min-w-0" + data-qa-selector="package_link" + href="http://gdk.test:3000/gitlab-org/gitlab-test/-/packages/111" + > + <gl-truncate-stub + position="end" + text="@gitlab-org/package-15" + /> + </gl-link-stub> + + <!----> + + <!----> + </div> + + <!----> + </div> + + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-min-h-6 gl-min-w-0 gl-flex-grow-1" + > + <div + class="gl-display-flex" + data-testid="left-secondary-infos" + > + <span> + 1.0.0 + </span> + + <!----> + + <package-icon-and-name-stub> + + npm + + </package-icon-and-name-stub> + + <!----> + </div> + </div> + </div> + + <div + class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" + > + <div + class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" + > + <publish-method-stub /> + </div> + + <div + class="gl-display-flex gl-align-items-center gl-min-h-6" + > + <span> + Created + <timeago-tooltip-stub + cssclass="" + time="2020-08-17T14:23:32Z" + tooltipplacement="top" + /> + </span> + </div> + </div> + </div> + + <div + class="gl-w-9 gl-display-none gl-sm-display-flex gl-justify-content-end gl-pr-1" + > + <gl-button-stub + aria-label="Remove package" + buttontextclasses="" + category="secondary" + data-testid="action-delete" + icon="remove" + size="medium" + title="Remove package" + variant="danger" + /> + </div> + </div> + + <div + class="gl-display-flex" + > + <div + class="gl-w-7" + /> + + <!----> + + <div + class="gl-w-9" + /> + </div> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap deleted file mode 100644 index dbebdeeb452..00000000000 --- a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/packages_list_app_spec.js.snap +++ /dev/null @@ -1,68 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`packages_list_app renders 1`] = ` -<div> - <div - help-url="foo" - /> - - <div /> - - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" - > - <div - class="svg-250 svg-content" - > - <img - alt="" - class="gl-max-w-full" - role="img" - src="helpSvg" - /> - </div> - </div> - - <div - class="col-12" - > - <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" - > - <h1 - class="h4" - > - There are no packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" - > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div - class="gl-display-flex gl-flex-wrap gl-justify-content-center" - > - <!----> - - <!----> - </div> - </div> - </div> - </section> - </div> -</div> -`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap new file mode 100644 index 00000000000..919dbe25ffe --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/__snapshots__/publish_method_spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`publish_method renders 1`] = ` +<div + class="gl-display-flex gl-align-items-center" +> + <gl-icon-stub + class="gl-mr-2" + name="git-merge" + size="16" + /> + + <span + class="gl-mr-2" + data-testid="pipeline-ref" + > + master + </span> + + <gl-icon-stub + class="gl-mr-2" + name="commit" + size="16" + /> + + <gl-link-stub + class="gl-mr-2" + data-testid="pipeline-sha" + href="/namespace14/project14/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0" + > + b83d6e39 + </gl-link-stub> + + <clipboard-button-stub + category="tertiary" + size="small" + text="b83d6e391c22777fca1ed3012fce84f633d7fed0" + title="Copy commit SHA" + tooltipplacement="top" + /> +</div> +`; diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js new file mode 100644 index 00000000000..3958cdf21bb --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/app_spec.js @@ -0,0 +1,154 @@ +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; + +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import PackageListApp from '~/packages_and_registries/package_registry/components/list/app.vue'; +import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue'; +import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue'; + +import { + PROJECT_RESOURCE_TYPE, + GROUP_RESOURCE_TYPE, + LIST_QUERY_DEBOUNCE_TIME, +} from '~/packages_and_registries/package_registry/constants'; + +import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql'; + +import { packagesListQuery } from '../../mock_data'; + +jest.mock('~/lib/utils/common_utils'); +jest.mock('~/flash'); + +const localVue = createLocalVue(); + +describe('PackagesListApp', () => { + let wrapper; + let apolloProvider; + + const defaultProvide = { + packageHelpUrl: 'packageHelpUrl', + emptyListIllustration: 'emptyListIllustration', + emptyListHelpUrl: 'emptyListHelpUrl', + isGroupPage: true, + fullPath: 'gitlab-org', + }; + + const PackageList = { + name: 'package-list', + template: '<div><slot name="empty-state"></slot></div>', + }; + const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' }; + + const findPackageTitle = () => wrapper.findComponent(PackageTitle); + const findSearch = () => wrapper.findComponent(PackageSearch); + + const mountComponent = ({ + resolver = jest.fn().mockResolvedValue(packagesListQuery()), + provide = defaultProvide, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[getPackagesQuery, resolver]]; + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(PackageListApp, { + localVue, + apolloProvider, + provide, + stubs: { + GlEmptyState, + GlLoadingIcon, + PackageList, + GlSprintf, + GlLink, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const waitForDebouncedApollo = () => { + jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME); + return waitForPromises(); + }; + + it('renders', async () => { + mountComponent(); + + await waitForDebouncedApollo(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has a package title', async () => { + mountComponent(); + + await waitForDebouncedApollo(); + + expect(findPackageTitle().exists()).toBe(true); + expect(findPackageTitle().props('count')).toBe(2); + }); + + describe('search component', () => { + it('exists', () => { + mountComponent(); + + expect(findSearch().exists()).toBe(true); + }); + + it('on update triggers a new query with updated values', async () => { + const resolver = jest.fn().mockResolvedValue(packagesListQuery()); + mountComponent({ resolver }); + + const payload = { + sort: 'VERSION_DESC', + filters: { packageName: 'foo', packageType: 'CONAN' }, + }; + + findSearch().vm.$emit('update', payload); + + await waitForDebouncedApollo(); + jest.advanceTimersByTime(LIST_QUERY_DEBOUNCE_TIME); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + groupSort: payload.sort, + ...payload.filters, + }), + ); + }); + }); + + describe.each` + type | sortType + ${PROJECT_RESOURCE_TYPE} | ${'sort'} + ${GROUP_RESOURCE_TYPE} | ${'groupSort'} + `('$type query', ({ type, sortType }) => { + let provide; + let resolver; + + const isGroupPage = type === GROUP_RESOURCE_TYPE; + + beforeEach(() => { + provide = { ...defaultProvide, isGroupPage }; + resolver = jest.fn().mockResolvedValue(packagesListQuery(type)); + mountComponent({ provide, resolver }); + return waitForDebouncedApollo(); + }); + + it('succeeds', () => { + expect(findPackageTitle().props('count')).toBe(2); + }); + + it('calls the resolver with the right parameters', () => { + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ isGroupPage, [sortType]: '' }), + ); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js new file mode 100644 index 00000000000..a276db104d7 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -0,0 +1,156 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue'; +import PackagePath from '~/packages/shared/components/package_path.vue'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue'; +import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry/constants'; + +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { packageData, packagePipelines, packageProject, packageTags } from '../../mock_data'; + +describe('packages_list_row', () => { + let wrapper; + + const defaultProvide = { + isGroupPage: false, + }; + + const packageWithoutTags = { ...packageData(), project: packageProject() }; + const packageWithTags = { ...packageWithoutTags, tags: { nodes: packageTags() } }; + + const findPackageTags = () => wrapper.find(PackageTags); + const findPackagePath = () => wrapper.find(PackagePath); + const findDeleteButton = () => wrapper.findByTestId('action-delete'); + const findPackageIconAndName = () => wrapper.find(PackageIconAndName); + const findListItem = () => wrapper.findComponent(ListItem); + const findPackageLink = () => wrapper.findComponent(GlLink); + const findWarningIcon = () => wrapper.findByTestId('warning-icon'); + const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos'); + + const mountComponent = ({ + packageEntity = packageWithoutTags, + provide = defaultProvide, + } = {}) => { + wrapper = shallowMountExtended(PackagesListRow, { + provide, + stubs: { + ListItem, + GlSprintf, + }, + propsData: { + packageEntity, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('tags', () => { + it('renders package tags when a package has tags', () => { + mountComponent({ packageEntity: packageWithTags }); + + expect(findPackageTags().exists()).toBe(true); + }); + + it('does not render when there are no tags', () => { + mountComponent(); + + expect(findPackageTags().exists()).toBe(false); + }); + }); + + describe('when it is group', () => { + it('has a package path component', () => { + mountComponent({ provide: { isGroupPage: true } }); + + expect(findPackagePath().exists()).toBe(true); + expect(findPackagePath().props()).toMatchObject({ path: 'gitlab-org/gitlab-test' }); + }); + }); + + describe('delete button', () => { + it('exists and has the correct props', () => { + mountComponent({ packageEntity: packageWithoutTags }); + + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().attributes()).toMatchObject({ + icon: 'remove', + category: 'secondary', + variant: 'danger', + title: 'Remove package', + }); + }); + + it('emits the packageToDelete event when the delete button is clicked', async () => { + mountComponent({ packageEntity: packageWithoutTags }); + + findDeleteButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('packageToDelete')).toBeTruthy(); + expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); + }); + }); + + describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => { + beforeEach(() => { + mountComponent({ packageEntity: { ...packageWithoutTags, status: PACKAGE_ERROR_STATUS } }); + }); + + it('list item has a disabled prop', () => { + expect(findListItem().props('disabled')).toBe(true); + }); + + it('details link is disabled', () => { + expect(findPackageLink().attributes('disabled')).toBe('true'); + }); + + it('has a warning icon', () => { + const icon = findWarningIcon(); + const tooltip = getBinding(icon.element, 'gl-tooltip'); + expect(icon.props('icon')).toBe('warning'); + expect(tooltip.value).toMatchObject({ + title: 'Invalid Package: failed metadata extraction', + }); + }); + + it('delete button does not exist', () => { + expect(findDeleteButton().exists()).toBe(false); + }); + }); + + describe('secondary left info', () => { + it('has the package version', () => { + mountComponent(); + + expect(findLeftSecondaryInfos().text()).toContain(packageWithoutTags.version); + }); + + it('if the pipeline exists show the author message', () => { + mountComponent({ + packageEntity: { ...packageWithoutTags, pipelines: { nodes: packagePipelines() } }, + }); + + expect(findLeftSecondaryInfos().text()).toContain('published by Administrator'); + }); + + it('has icon and name component', () => { + mountComponent(); + + expect(findPackageIconAndName().text()).toBe(packageWithoutTags.packageType.toLowerCase()); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js deleted file mode 100644 index 6c871a34d50..00000000000 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_list_app_spec.js +++ /dev/null @@ -1,273 +0,0 @@ -import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import setWindowLocation from 'helpers/set_window_location_helper'; -import createFlash from '~/flash'; -import * as commonUtils from '~/lib/utils/common_utils'; -import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; -import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; -import PackageListApp from '~/packages_and_registries/package_registry/components/list/packages_list_app.vue'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; -import * as packageUtils from '~/packages_and_registries/shared/utils'; - -jest.mock('~/lib/utils/common_utils'); -jest.mock('~/flash'); - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('packages_list_app', () => { - let wrapper; - let store; - - const PackageList = { - name: 'package-list', - template: '<div><slot name="empty-state"></slot></div>', - }; - const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' }; - - // we need to manually stub dynamic imported components because shallowMount is not able to stub them automatically. See: https://github.com/vuejs/vue-test-utils/issues/1279 - const PackageSearch = { name: 'PackageSearch', template: '<div></div>' }; - const PackageTitle = { name: 'PackageTitle', template: '<div></div>' }; - const InfrastructureTitle = { name: 'InfrastructureTitle', template: '<div></div>' }; - const InfrastructureSearch = { name: 'InfrastructureSearch', template: '<div></div>' }; - - const emptyListHelpUrl = 'helpUrl'; - const findEmptyState = () => wrapper.find(GlEmptyState); - const findListComponent = () => wrapper.find(PackageList); - const findPackageSearch = () => wrapper.find(PackageSearch); - const findPackageTitle = () => wrapper.find(PackageTitle); - const findInfrastructureTitle = () => wrapper.find(InfrastructureTitle); - const findInfrastructureSearch = () => wrapper.find(InfrastructureSearch); - - const createStore = (filter = []) => { - store = new Vuex.Store({ - state: { - isLoading: false, - config: { - resourceId: 'project_id', - emptyListIllustration: 'helpSvg', - emptyListHelpUrl, - packageHelpUrl: 'foo', - }, - filter, - }, - }); - store.dispatch = jest.fn(); - }; - - const mountComponent = (provide) => { - wrapper = shallowMount(PackageListApp, { - localVue, - store, - stubs: { - GlEmptyState, - GlLoadingIcon, - PackageList, - GlSprintf, - GlLink, - PackageSearch, - PackageTitle, - InfrastructureTitle, - InfrastructureSearch, - }, - provide, - }); - }; - - beforeEach(() => { - createStore(); - jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue({}); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders', () => { - mountComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('call requestPackagesList on page:changed', () => { - mountComponent(); - store.dispatch.mockClear(); - - const list = findListComponent(); - list.vm.$emit('page:changed', 1); - expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 }); - }); - - it('call requestDeletePackage on package:delete', () => { - mountComponent(); - - const list = findListComponent(); - list.vm.$emit('package:delete', 'foo'); - expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); - }); - - it('does call requestPackagesList only one time on render', () => { - mountComponent(); - - expect(store.dispatch).toHaveBeenCalledTimes(3); - expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', expect.any(Object)); - expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', expect.any(Array)); - expect(store.dispatch).toHaveBeenNthCalledWith(3, 'requestPackagesList'); - }); - - describe('url query string handling', () => { - const defaultQueryParamsMock = { - search: [1, 2], - type: 'npm', - sort: 'asc', - orderBy: 'created', - }; - - it('calls setSorting with the query string based sorting', () => { - jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock); - - mountComponent(); - - expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { - orderBy: defaultQueryParamsMock.orderBy, - sort: defaultQueryParamsMock.sort, - }); - }); - - it('calls setFilter with the query string based filters', () => { - jest.spyOn(packageUtils, 'getQueryParams').mockReturnValue(defaultQueryParamsMock); - - mountComponent(); - - expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', [ - { type: 'type', value: { data: defaultQueryParamsMock.type } }, - { type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[0] } }, - { type: FILTERED_SEARCH_TERM, value: { data: defaultQueryParamsMock.search[1] } }, - ]); - }); - - it('calls setSorting and setFilters with the results of extractFilterAndSorting', () => { - jest - .spyOn(packageUtils, 'extractFilterAndSorting') - .mockReturnValue({ filters: ['foo'], sorting: { sort: 'desc' } }); - - mountComponent(); - - expect(store.dispatch).toHaveBeenNthCalledWith(1, 'setSorting', { sort: 'desc' }); - expect(store.dispatch).toHaveBeenNthCalledWith(2, 'setFilter', ['foo']); - }); - }); - - describe('empty state', () => { - it('generate the correct empty list link', () => { - mountComponent(); - - const link = findListComponent().find(GlLink); - - expect(link.attributes('href')).toBe(emptyListHelpUrl); - expect(link.text()).toBe('publish and share your packages'); - }); - - it('includes the right content on the default tab', () => { - mountComponent(); - - const heading = findEmptyState().find('h1'); - - expect(heading.text()).toBe('There are no packages yet'); - }); - }); - - describe('filter without results', () => { - beforeEach(() => { - createStore([{ type: 'something' }]); - mountComponent(); - }); - - it('should show specific empty message', () => { - expect(findEmptyState().text()).toContain('Sorry, your filter produced no results'); - expect(findEmptyState().text()).toContain( - 'To widen your search, change or remove the filters above', - ); - }); - }); - - describe('Package Search', () => { - it('exists', () => { - mountComponent(); - - expect(findPackageSearch().exists()).toBe(true); - }); - - it('on update fetches data from the store', () => { - mountComponent(); - store.dispatch.mockClear(); - - findPackageSearch().vm.$emit('update'); - - expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); - }); - }); - - describe('Infrastructure config', () => { - it('defaults to package registry components', () => { - mountComponent(); - - expect(findPackageSearch().exists()).toBe(true); - expect(findPackageTitle().exists()).toBe(true); - - expect(findInfrastructureTitle().exists()).toBe(false); - expect(findInfrastructureSearch().exists()).toBe(false); - }); - - it('mount different component based on the provided values', () => { - mountComponent({ - titleComponent: 'InfrastructureTitle', - searchComponent: 'InfrastructureSearch', - }); - - expect(findPackageSearch().exists()).toBe(false); - expect(findPackageTitle().exists()).toBe(false); - - expect(findInfrastructureTitle().exists()).toBe(true); - expect(findInfrastructureSearch().exists()).toBe(true); - }); - }); - - describe('delete alert handling', () => { - const originalLocation = window.location.href; - const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`; - - beforeEach(() => { - createStore(); - jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {}); - setWindowLocation(search); - }); - - afterEach(() => { - setWindowLocation(originalLocation); - }); - - it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => { - mountComponent(); - - expect(createFlash).toHaveBeenCalledWith({ - message: DELETE_PACKAGE_SUCCESS_MESSAGE, - type: 'notice', - }); - }); - - it('calls historyReplaceState with a clean url', () => { - mountComponent(); - - expect(commonUtils.historyReplaceState).toHaveBeenCalledWith(originalLocation); - }); - - it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => { - setWindowLocation('?'); - mountComponent(); - - expect(createFlash).not.toHaveBeenCalled(); - expect(commonUtils.historyReplaceState).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index 42bc9fa3a9e..e65b2a6f320 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -1,79 +1,79 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { sortableFields } from '~/packages/list/utils'; import component from '~/packages_and_registries/package_registry/components/list/package_search.vue'; import PackageTypeToken from '~/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; -const localVue = createLocalVue(); -localVue.use(Vuex); +jest.mock('~/packages_and_registries/shared/utils'); + +useMockLocationHelper(); describe('Package Search', () => { let wrapper; - let store; + + const defaultQueryParamsMock = { + filters: ['foo'], + sorting: { sort: 'desc' }, + }; const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); const findUrlSync = () => wrapper.findComponent(UrlSync); - const createStore = (isGroupPage) => { - const state = { - config: { - isGroupPage, - }, - sorting: { - orderBy: 'version', - sort: 'desc', - }, - filter: [], - }; - store = new Vuex.Store({ - state, - }); - store.dispatch = jest.fn(); - }; - const mountComponent = (isGroupPage = false) => { - createStore(isGroupPage); - - wrapper = shallowMount(component, { - localVue, - store, + wrapper = shallowMountExtended(component, { + provide() { + return { + isGroupPage, + }; + }, stubs: { UrlSync, }, }); }; + beforeEach(() => { + extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock); + }); + afterEach(() => { wrapper.destroy(); - wrapper = null; }); - it('has a registry search component', () => { + it('has a registry search component', async () => { mountComponent(); + await nextTick(); + expect(findRegistrySearch().exists()).toBe(true); - expect(findRegistrySearch().props()).toMatchObject({ - filter: store.state.filter, - sorting: store.state.sorting, - tokens: expect.arrayContaining([ - expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), - ]), - sortableFields: sortableFields(), - }); + }); + + it('registry search is mounted after mount', async () => { + mountComponent(); + + expect(findRegistrySearch().exists()).toBe(false); + }); + + it('has a UrlSync component', () => { + mountComponent(); + + expect(findUrlSync().exists()).toBe(true); }); it.each` isGroupPage | page ${false} | ${'project'} ${true} | ${'group'} - `('in a $page page binds the right props', ({ isGroupPage }) => { + `('in a $page page binds the right props', async ({ isGroupPage }) => { mountComponent(isGroupPage); + await nextTick(); + expect(findRegistrySearch().props()).toMatchObject({ - filter: store.state.filter, - sorting: store.state.sorting, tokens: expect.arrayContaining([ expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), ]), @@ -81,48 +81,85 @@ describe('Package Search', () => { }); }); - it('on sorting:changed emits update event and calls vuex setSorting', () => { + it('on sorting:changed emits update event and update internal sort', async () => { const payload = { sort: 'foo' }; mountComponent(); + await nextTick(); + findRegistrySearch().vm.$emit('sorting:changed', payload); - expect(store.dispatch).toHaveBeenCalledWith('setSorting', payload); - expect(wrapper.emitted('update')).toEqual([[]]); + await nextTick(); + + expect(findRegistrySearch().props('sorting')).toEqual({ sort: 'foo', orderBy: 'name' }); + + // there is always a first call on mounted that emits up default values + expect(wrapper.emitted('update')[1]).toEqual([ + { + filters: { + packageName: '', + packageType: undefined, + }, + sort: 'NAME_FOO', + }, + ]); }); - it('on filter:changed calls vuex setFilter', () => { + it('on filter:changed updates the filters', async () => { const payload = ['foo']; mountComponent(); + await nextTick(); + findRegistrySearch().vm.$emit('filter:changed', payload); - expect(store.dispatch).toHaveBeenCalledWith('setFilter', payload); + await nextTick(); + + expect(findRegistrySearch().props('filter')).toEqual(['foo']); }); - it('on filter:submit emits update event', () => { + it('on filter:submit emits update event', async () => { mountComponent(); - findRegistrySearch().vm.$emit('filter:submit'); - - expect(wrapper.emitted('update')).toEqual([[]]); - }); + await nextTick(); - it('has a UrlSync component', () => { - mountComponent(); + findRegistrySearch().vm.$emit('filter:submit'); - expect(findUrlSync().exists()).toBe(true); + expect(wrapper.emitted('update')[1]).toEqual([ + { + filters: { + packageName: '', + packageType: undefined, + }, + sort: 'NAME_DESC', + }, + ]); }); - it('on query:changed calls updateQuery from UrlSync', () => { + it('on query:changed calls updateQuery from UrlSync', async () => { jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {}); mountComponent(); + await nextTick(); + findRegistrySearch().vm.$emit('query:changed'); expect(UrlSync.methods.updateQuery).toHaveBeenCalled(); }); + + it('sets the component sorting and filtering based on the querystring', async () => { + mountComponent(); + + await nextTick(); + + expect(getQueryParams).toHaveBeenCalled(); + + expect(findRegistrySearch().props()).toMatchObject({ + filter: defaultQueryParamsMock.filters, + sorting: defaultQueryParamsMock.sorting, + }); + }); }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js new file mode 100644 index 00000000000..fcbd7cc6a50 --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/list/publish_method_spec.js @@ -0,0 +1,47 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue'; +import { packagePipelines } from '../../mock_data'; + +const [pipelineData] = packagePipelines(); + +describe('publish_method', () => { + let wrapper; + + const findPipelineRef = () => wrapper.findByTestId('pipeline-ref'); + const findPipelineSha = () => wrapper.findByTestId('pipeline-sha'); + const findManualPublish = () => wrapper.findByTestId('manually-published'); + + const mountComponent = (pipeline = pipelineData) => { + wrapper = shallowMountExtended(PublishMethod, { + propsData: { + pipeline, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('pipeline information', () => { + it('displays branch and commit when pipeline info exists', () => { + mountComponent(); + + expect(findPipelineRef().exists()).toBe(true); + expect(findPipelineSha().exists()).toBe(true); + }); + + it('does not show any pipeline details when no information exists', () => { + mountComponent(null); + + expect(findPipelineRef().exists()).toBe(false); + expect(findPipelineSha().exists()).toBe(false); + expect(findManualPublish().text()).toBe(PublishMethod.i18n.MANUALLY_PUBLISHED); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index 9438a2d2d72..70fc096fa44 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -86,6 +86,12 @@ export const dependencyLinks = () => [ }, ]; +export const packageProject = () => ({ + fullPath: 'gitlab-org/gitlab-test', + webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-test', + __typename: 'Project', +}); + export const packageVersions = () => [ { createdAt: '2021-08-10T09:33:54Z', @@ -249,3 +255,31 @@ export const packageDestroyFileMutationError = () => ({ }, ], }); + +export const packagesListQuery = (type = 'group') => ({ + data: { + [type]: { + packages: { + count: 2, + nodes: [ + { + ...packageData(), + project: packageProject(), + tags: { nodes: packageTags() }, + pipelines: { + nodes: packagePipelines(), + }, + }, + { + ...packageData(), + project: packageProject(), + tags: { nodes: [] }, + pipelines: { nodes: [] }, + }, + ], + __typename: 'PackageConnection', + }, + __typename: 'Group', + }, + }, +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js new file mode 100644 index 00000000000..d3a970e86eb --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/dependency_proxy_settings_spec.js @@ -0,0 +1,189 @@ +import { GlSprintf, GlLink, GlToggle } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import component from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; +import { + DEPENDENCY_PROXY_HEADER, + DEPENDENCY_PROXY_SETTINGS_DESCRIPTION, + DEPENDENCY_PROXY_DOCS_PATH, +} from '~/packages_and_registries/settings/group/constants'; + +import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql'; +import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + dependencyProxySettings, + dependencyProxySettingMutationMock, + groupPackageSettingsMock, + dependencyProxySettingMutationErrorMock, +} from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'); + +const localVue = createLocalVue(); + +describe('DependencyProxySettings', () => { + let wrapper; + let apolloProvider; + + const defaultProvide = { + defaultExpanded: false, + groupPath: 'foo_group_path', + }; + + localVue.use(VueApollo); + + const mountComponent = ({ + provide = defaultProvide, + mutationResolver = jest.fn().mockResolvedValue(dependencyProxySettingMutationMock()), + isLoading = false, + } = {}) => { + const requestHandlers = [[updateDependencyProxySettings, mutationResolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + provide, + propsData: { + dependencyProxySettings: dependencyProxySettings(), + isLoading, + }, + stubs: { + GlSprintf, + SettingsBlock, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findDescription = () => wrapper.find('[data-testid="description"'); + const findLink = () => wrapper.findComponent(GlLink); + const findToggle = () => wrapper.findComponent(GlToggle); + + const fillApolloCache = () => { + apolloProvider.defaultClient.cache.writeQuery({ + query: getGroupPackagesSettingsQuery, + variables: { + fullPath: defaultProvide.groupPath, + }, + ...groupPackageSettingsMock, + }); + }; + + const emitSettingsUpdate = (value = false) => { + findToggle().vm.$emit('change', value); + }; + + it('renders a settings block', () => { + mountComponent(); + + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('passes the correct props to settings block', () => { + mountComponent(); + + expect(findSettingsBlock().props('defaultExpanded')).toBe(false); + }); + + it('has the correct header text', () => { + mountComponent(); + + expect(wrapper.text()).toContain(DEPENDENCY_PROXY_HEADER); + }); + + it('has the correct description text', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText(DEPENDENCY_PROXY_SETTINGS_DESCRIPTION); + }); + + it('has the correct link', () => { + mountComponent(); + + expect(findLink().attributes()).toMatchObject({ + href: DEPENDENCY_PROXY_DOCS_PATH, + }); + expect(findLink().text()).toBe('Learn more'); + }); + + describe('settings update', () => { + describe('success state', () => { + it('emits a success event', async () => { + mountComponent(); + + fillApolloCache(); + emitSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('success')).toEqual([[]]); + }); + + it('has an optimistic response', () => { + mountComponent(); + + fillApolloCache(); + + expect(findToggle().props('value')).toBe(true); + + emitSettingsUpdate(); + + expect(updateGroupDependencyProxySettingsOptimisticResponse).toHaveBeenCalledWith({ + enabled: false, + }); + }); + }); + + describe('errors', () => { + it('mutation payload with root level errors', async () => { + const mutationResolver = jest + .fn() + .mockResolvedValue(dependencyProxySettingMutationErrorMock); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + emitSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[]]); + }); + + it.each` + type | mutationResolver + ${'local'} | ${jest.fn().mockResolvedValue(dependencyProxySettingMutationMock({ errors: ['foo'] }))} + ${'network'} | ${jest.fn().mockRejectedValue()} + `('mutation payload with $type error', async ({ mutationResolver }) => { + mountComponent({ mutationResolver }); + + fillApolloCache(); + emitSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[]]); + }); + }); + }); + + describe('when isLoading is true', () => { + it('disables enable toggle', () => { + mountComponent({ isLoading: true }); + + expect(findToggle().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js index 0bbb1ce3436..79c2f811c08 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/duplicates_settings_spec.js @@ -143,4 +143,18 @@ describe('Duplicates Settings', () => { expect(findInputGroup().exists()).toBe(false); }); }); + + describe('loading', () => { + beforeEach(() => { + mountComponent({ ...defaultProps, loading: true }); + }); + + it('disables the enable toggle', () => { + expect(findToggle().props('disabled')).toBe(true); + }); + + it('disables the form input', () => { + expect(findInput().attributes('disabled')).toBe('true'); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js index f2877a1f2a5..e4d62bc6a6e 100644 --- a/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/components/group_settings_app_spec.js @@ -1,28 +1,16 @@ -import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; -import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; +import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue'; +import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue'; + import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue'; -import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; -import { - PACKAGE_SETTINGS_HEADER, - PACKAGE_SETTINGS_DESCRIPTION, - PACKAGES_DOCS_PATH, - ERROR_UPDATING_SETTINGS, - SUCCESS_UPDATING_SETTINGS, -} from '~/packages_and_registries/settings/group/constants'; -import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; -import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; -import { - groupPackageSettingsMock, - groupPackageSettingsMutationMock, - groupPackageSettingsMutationErrorMock, -} from '../mock_data'; +import { groupPackageSettingsMock, packageSettings, dependencyProxySettings } from '../mock_data'; jest.mock('~/flash'); @@ -36,20 +24,16 @@ describe('Group Settings App', () => { const defaultProvide = { defaultExpanded: false, groupPath: 'foo_group_path', + dependencyProxyAvailable: true, }; const mountComponent = ({ - provide = defaultProvide, resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock), - mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()), - data = {}, + provide = defaultProvide, } = {}) => { localVue.use(VueApollo); - const requestHandlers = [ - [getGroupPackagesSettingsQuery, resolver], - [updateNamespacePackageSettings, mutationResolver], - ]; + const requestHandlers = [[getGroupPackagesSettingsQuery, resolver]]; apolloProvider = createMockApollo(requestHandlers); @@ -57,17 +41,6 @@ describe('Group Settings App', () => { localVue, apolloProvider, provide, - data() { - return { - ...data, - }; - }, - stubs: { - GlSprintf, - SettingsBlock, - MavenSettings, - GenericSettings, - }, mocks: { $toast: { show, @@ -84,274 +57,89 @@ describe('Group Settings App', () => { wrapper.destroy(); }); - const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); - const findDescription = () => wrapper.find('[data-testid="description"'); - const findLink = () => wrapper.findComponent(GlLink); const findAlert = () => wrapper.findComponent(GlAlert); - const findMavenSettings = () => wrapper.findComponent(MavenSettings); - const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings); - const findGenericSettings = () => wrapper.findComponent(GenericSettings); - const findGenericDuplicatedSettings = () => - findGenericSettings().findComponent(DuplicatesSettings); + const findPackageSettings = () => wrapper.findComponent(PackagesSettings); + const findDependencyProxySettings = () => wrapper.findComponent(DependencyProxySettings); const waitForApolloQueryAndRender = async () => { await waitForPromises(); - await wrapper.vm.$nextTick(); - }; - - const emitSettingsUpdate = (override) => { - findMavenDuplicatedSettings().vm.$emit('update', { - mavenDuplicateExceptionRegex: ')', - ...override, - }); + await nextTick(); }; - it('renders a settings block', () => { - mountComponent(); - - expect(findSettingsBlock().exists()).toBe(true); - }); - - it('passes the correct props to settings block', () => { - mountComponent(); - - expect(findSettingsBlock().props('defaultExpanded')).toBe(false); - }); - - it('has the correct header text', () => { - mountComponent(); - - expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER); - }); - - it('has the correct description text', () => { - mountComponent(); - - expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION); - }); - - it('has the correct link', () => { - mountComponent(); - - expect(findLink().attributes()).toMatchObject({ - href: PACKAGES_DOCS_PATH, - target: '_blank', - }); - expect(findLink().text()).toBe('Learn more.'); - }); - - it('calls the graphql API with the proper variables', () => { - const resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock); - mountComponent({ resolver }); - - expect(resolver).toHaveBeenCalledWith({ - fullPath: defaultProvide.groupPath, - }); - }); - - describe('maven settings', () => { - it('exists', () => { + describe.each` + finder | entityProp | entityValue | successMessage | errorMessage + ${findPackageSettings} | ${'packageSettings'} | ${packageSettings()} | ${'Settings saved successfully'} | ${'An error occurred while saving the settings'} + ${findDependencyProxySettings} | ${'dependencyProxySettings'} | ${dependencyProxySettings()} | ${'Setting saved successfully'} | ${'An error occurred while saving the setting'} + `('settings blocks', ({ finder, entityProp, entityValue, successMessage, errorMessage }) => { + beforeEach(() => { mountComponent(); - - expect(findMavenSettings().exists()).toBe(true); + return waitForApolloQueryAndRender(); }); - it('assigns duplication allowness and exception props', async () => { - mountComponent(); - - expect(findMavenDuplicatedSettings().props('loading')).toBe(true); - - await waitForApolloQueryAndRender(); - - const { - mavenDuplicatesAllowed, - mavenDuplicateExceptionRegex, - } = groupPackageSettingsMock.data.group.packageSettings; - - expect(findMavenDuplicatedSettings().props()).toMatchObject({ - duplicatesAllowed: mavenDuplicatesAllowed, - duplicateExceptionRegex: mavenDuplicateExceptionRegex, - duplicateExceptionRegexError: '', - loading: false, - }); + it('renders the settings block', () => { + expect(finder().exists()).toBe(true); }); - it('on update event calls the mutation', async () => { - const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - expect(mutationResolver).toHaveBeenCalledWith({ - input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + it('binds the correctProps', () => { + expect(finder().props()).toMatchObject({ + isLoading: false, + [entityProp]: entityValue, }); }); - }); - - describe('generic settings', () => { - it('exists', () => { - mountComponent(); - - expect(findGenericSettings().exists()).toBe(true); - }); - - it('assigns duplication allowness and exception props', async () => { - mountComponent(); - - expect(findGenericDuplicatedSettings().props('loading')).toBe(true); - - await waitForApolloQueryAndRender(); - - const { - genericDuplicatesAllowed, - genericDuplicateExceptionRegex, - } = groupPackageSettingsMock.data.group.packageSettings; - expect(findGenericDuplicatedSettings().props()).toMatchObject({ - duplicatesAllowed: genericDuplicatesAllowed, - duplicateExceptionRegex: genericDuplicateExceptionRegex, - duplicateExceptionRegexError: '', - loading: false, + describe('success event', () => { + it('shows a success toast', () => { + finder().vm.$emit('success'); + expect(show).toHaveBeenCalledWith(successMessage); }); - }); - it('on update event calls the mutation', async () => { - const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); - mountComponent({ mutationResolver }); + it('hides the error alert', async () => { + finder().vm.$emit('error'); + await nextTick(); - await waitForApolloQueryAndRender(); + expect(findAlert().exists()).toBe(true); - findMavenDuplicatedSettings().vm.$emit('update', { - genericDuplicateExceptionRegex: ')', - }); + finder().vm.$emit('success'); + await nextTick(); - expect(mutationResolver).toHaveBeenCalledWith({ - input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + expect(findAlert().exists()).toBe(false); }); }); - }); - - describe('settings update', () => { - describe('success state', () => { - it('shows a success alert', async () => { - mountComponent(); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForPromises(); - - expect(show).toHaveBeenCalledWith(SUCCESS_UPDATING_SETTINGS); - }); - - it('has an optimistic response', async () => { - const mavenDuplicateExceptionRegex = 'latest[main]something'; - mountComponent(); - await waitForApolloQueryAndRender(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(''); - - emitSettingsUpdate({ mavenDuplicateExceptionRegex }); - - // wait for apollo to update the model with the optimistic response - await wrapper.vm.$nextTick(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( - mavenDuplicateExceptionRegex, - ); - - // wait for the call to resolve - await waitForPromises(); - - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe( - mavenDuplicateExceptionRegex, - ); + describe('error event', () => { + beforeEach(() => { + finder().vm.$emit('error'); + return nextTick(); }); - }); - describe('errors', () => { - const verifyAlert = () => { + it('shows an alert', () => { expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(ERROR_UPDATING_SETTINGS); - expect(findAlert().props('variant')).toBe('warning'); - }; - - it('mutation payload with root level errors', async () => { - // note this is a complex test that covers all the path around errors that are shown in the form - // it's one single it case, due to the expensive preparation and execution - const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock); - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForApolloQueryAndRender(); - - // errors are bound to the component - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe( - groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message, - ); - - // general error message is shown - - verifyAlert(); - - emitSettingsUpdate(); - - await wrapper.vm.$nextTick(); - - // errors are reset on mutation call - expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(''); }); - it.each` - type | mutationResolver - ${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))} - ${'network'} | ${jest.fn().mockRejectedValue()} - `('mutation payload with $type error', async ({ mutationResolver }) => { - mountComponent({ mutationResolver }); - - await waitForApolloQueryAndRender(); - - emitSettingsUpdate(); - - await waitForPromises(); - - verifyAlert(); + it('alert has the right text', () => { + expect(findAlert().text()).toBe(errorMessage); }); - it('a successful request dismisses the alert', async () => { - mountComponent({ data: { alertMessage: 'foo' } }); - - await waitForApolloQueryAndRender(); - + it('dismissing the alert removes it', async () => { expect(findAlert().exists()).toBe(true); - emitSettingsUpdate(); + findAlert().vm.$emit('dismiss'); - await waitForPromises(); + await nextTick(); expect(findAlert().exists()).toBe(false); }); + }); + }); - it('dismiss event from alert dismiss it from the page', async () => { - mountComponent({ data: { alertMessage: 'foo' } }); - - await waitForApolloQueryAndRender(); - - expect(findAlert().exists()).toBe(true); - - findAlert().vm.$emit('dismiss'); - - await wrapper.vm.$nextTick(); + describe('when the dependency proxy is not available', () => { + beforeEach(() => { + mountComponent({ provide: { ...defaultProvide, dependencyProxyAvailable: false } }); + return waitForApolloQueryAndRender(); + }); - expect(findAlert().exists()).toBe(false); - }); + it('the setting block is hidden', () => { + expect(findDependencyProxySettings().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js new file mode 100644 index 00000000000..693af21e24a --- /dev/null +++ b/spec/frontend/packages_and_registries/settings/group/components/package_settings_spec.js @@ -0,0 +1,277 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; +import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; +import component from '~/packages_and_registries/settings/group/components/packages_settings.vue'; +import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import { + PACKAGE_SETTINGS_HEADER, + PACKAGE_SETTINGS_DESCRIPTION, + PACKAGES_DOCS_PATH, +} from '~/packages_and_registries/settings/group/constants'; + +import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; +import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + packageSettings, + groupPackageSettingsMock, + groupPackageSettingsMutationMock, + groupPackageSettingsMutationErrorMock, +} from '../mock_data'; + +jest.mock('~/flash'); +jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'); + +const localVue = createLocalVue(); + +describe('Packages Settings', () => { + let wrapper; + let apolloProvider; + + const defaultProvide = { + defaultExpanded: false, + groupPath: 'foo_group_path', + }; + + const mountComponent = ({ + mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()), + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[updateNamespacePackageSettings, mutationResolver]]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMountExtended(component, { + localVue, + apolloProvider, + provide: defaultProvide, + propsData: { + packageSettings: packageSettings(), + }, + stubs: { + GlSprintf, + SettingsBlock, + MavenSettings, + GenericSettings, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findSettingsBlock = () => wrapper.findComponent(SettingsBlock); + const findDescription = () => wrapper.findByTestId('description'); + const findLink = () => wrapper.findComponent(GlLink); + const findMavenSettings = () => wrapper.findComponent(MavenSettings); + const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings); + const findGenericSettings = () => wrapper.findComponent(GenericSettings); + const findGenericDuplicatedSettings = () => + findGenericSettings().findComponent(DuplicatesSettings); + + const fillApolloCache = () => { + apolloProvider.defaultClient.cache.writeQuery({ + query: getGroupPackagesSettingsQuery, + variables: { + fullPath: defaultProvide.groupPath, + }, + ...groupPackageSettingsMock, + }); + }; + + const emitMavenSettingsUpdate = (override) => { + findMavenDuplicatedSettings().vm.$emit('update', { + mavenDuplicateExceptionRegex: ')', + ...override, + }); + }; + + it('renders a settings block', () => { + mountComponent(); + + expect(findSettingsBlock().exists()).toBe(true); + }); + + it('passes the correct props to settings block', () => { + mountComponent(); + + expect(findSettingsBlock().props('defaultExpanded')).toBe(false); + }); + + it('has the correct header text', () => { + mountComponent(); + + expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER); + }); + + it('has the correct description text', () => { + mountComponent(); + + expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION); + }); + + it('has the correct link', () => { + mountComponent(); + + expect(findLink().attributes()).toMatchObject({ + href: PACKAGES_DOCS_PATH, + target: '_blank', + }); + expect(findLink().text()).toBe('Learn more.'); + }); + + describe('maven settings', () => { + it('exists', () => { + mountComponent(); + + expect(findMavenSettings().exists()).toBe(true); + }); + + it('assigns duplication allowness and exception props', async () => { + mountComponent(); + + const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings(); + + expect(findMavenDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: mavenDuplicatesAllowed, + duplicateExceptionRegex: mavenDuplicateExceptionRegex, + duplicateExceptionRegexError: '', + loading: false, + }); + }); + + it('on update event calls the mutation', () => { + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + emitMavenSettingsUpdate(); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + }); + }); + }); + + describe('generic settings', () => { + it('exists', () => { + mountComponent(); + + expect(findGenericSettings().exists()).toBe(true); + }); + + it('assigns duplication allowness and exception props', async () => { + mountComponent(); + + const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings(); + + expect(findGenericDuplicatedSettings().props()).toMatchObject({ + duplicatesAllowed: genericDuplicatesAllowed, + duplicateExceptionRegex: genericDuplicateExceptionRegex, + duplicateExceptionRegexError: '', + loading: false, + }); + }); + + it('on update event calls the mutation', async () => { + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + findMavenDuplicatedSettings().vm.$emit('update', { + genericDuplicateExceptionRegex: ')', + }); + + expect(mutationResolver).toHaveBeenCalledWith({ + input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' }, + }); + }); + }); + + describe('settings update', () => { + describe('success state', () => { + it('emits a success event', async () => { + mountComponent(); + + fillApolloCache(); + emitMavenSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('success')).toEqual([[]]); + }); + + it('has an optimistic response', () => { + const mavenDuplicateExceptionRegex = 'latest[main]something'; + mountComponent(); + + fillApolloCache(); + + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(''); + + emitMavenSettingsUpdate({ mavenDuplicateExceptionRegex }); + + expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({ + ...packageSettings(), + mavenDuplicateExceptionRegex, + }); + }); + }); + + describe('errors', () => { + it('mutation payload with root level errors', async () => { + // note this is a complex test that covers all the path around errors that are shown in the form + // it's one single it case, due to the expensive preparation and execution + const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock); + mountComponent({ mutationResolver }); + + fillApolloCache(); + + emitMavenSettingsUpdate(); + + await waitForPromises(); + + // errors are bound to the component + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe( + groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message, + ); + + // general error message is shown + + expect(wrapper.emitted('error')).toEqual([[]]); + + emitMavenSettingsUpdate(); + + await wrapper.vm.$nextTick(); + + // errors are reset on mutation call + expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(''); + }); + + it.each` + type | mutationResolver + ${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))} + ${'network'} | ${jest.fn().mockRejectedValue()} + `('mutation payload with $type error', async ({ mutationResolver }) => { + mountComponent({ mutationResolver }); + + fillApolloCache(); + emitMavenSettingsUpdate(); + + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js index 03133bf1158..9d8504a1124 100644 --- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/cache_update_spec.js @@ -4,14 +4,16 @@ import { updateGroupPackageSettings } from '~/packages_and_registries/settings/g describe('Package and Registries settings group cache updates', () => { let client; - const payload = { - data: { - updateNamespacePackageSettings: { - packageSettings: { - mavenDuplicatesAllowed: false, - mavenDuplicateExceptionRegex: 'latest[main]something', - }, - }, + const updateNamespacePackageSettingsPayload = { + packageSettings: { + mavenDuplicatesAllowed: false, + mavenDuplicateExceptionRegex: 'latest[main]something', + }, + }; + + const updateDependencyProxySettingsPayload = { + dependencyProxySetting: { + enabled: false, }, }; @@ -21,6 +23,9 @@ describe('Package and Registries settings group cache updates', () => { mavenDuplicatesAllowed: true, mavenDuplicateExceptionRegex: '', }, + dependencyProxySetting: { + enabled: true, + }, }, }; @@ -35,22 +40,35 @@ describe('Package and Registries settings group cache updates', () => { writeQuery: jest.fn(), }; }); - describe('updateGroupPackageSettings', () => { - it('calls readQuery', () => { - updateGroupPackageSettings('foo')(client, payload); - expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables); - }); - - it('writes the correct result in the cache', () => { - updateGroupPackageSettings('foo')(client, payload); - expect(client.writeQuery).toHaveBeenCalledWith({ - ...queryAndVariables, - data: { - group: { - ...payload.data.updateNamespacePackageSettings, + + describe.each` + updateNamespacePackageSettings | updateDependencyProxySettings + ${updateNamespacePackageSettingsPayload} | ${updateDependencyProxySettingsPayload} + ${undefined} | ${updateDependencyProxySettingsPayload} + ${updateNamespacePackageSettingsPayload} | ${undefined} + ${undefined} | ${undefined} + `( + 'updateGroupPackageSettings', + ({ updateNamespacePackageSettings, updateDependencyProxySettings }) => { + const payload = { data: { updateNamespacePackageSettings, updateDependencyProxySettings } }; + it('calls readQuery', () => { + updateGroupPackageSettings('foo')(client, payload); + expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables); + }); + + it('writes the correct result in the cache', () => { + updateGroupPackageSettings('foo')(client, payload); + expect(client.writeQuery).toHaveBeenCalledWith({ + ...queryAndVariables, + data: { + group: { + ...cacheMock.group, + ...payload.data.updateNamespacePackageSettings, + ...payload.data.updateDependencyProxySettings, + }, }, - }, + }); }); - }); - }); + }, + ); }); diff --git a/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js b/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js index a3c53d5768a..debeb9aa89c 100644 --- a/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js +++ b/spec/frontend/packages_and_registries/settings/group/graphl/utils/optimistic_responses_spec.js @@ -1,4 +1,7 @@ -import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; +import { + updateGroupPackagesSettingsOptimisticResponse, + updateGroupDependencyProxySettingsOptimisticResponse, +} from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; describe('Optimistic responses', () => { describe('updateGroupPackagesSettingsOptimisticResponse', () => { @@ -17,4 +20,22 @@ describe('Optimistic responses', () => { `); }); }); + + describe('updateGroupDependencyProxySettingsOptimisticResponse', () => { + it('returns the correct structure', () => { + expect(updateGroupDependencyProxySettingsOptimisticResponse({ foo: 'bar' })) + .toMatchInlineSnapshot(` + Object { + "__typename": "Mutation", + "updateDependencyProxySettings": Object { + "__typename": "UpdateDependencyProxySettingsPayload", + "dependencyProxySetting": Object { + "foo": "bar", + }, + "errors": Array [], + }, + } + `); + }); + }); }); diff --git a/spec/frontend/packages_and_registries/settings/group/mock_data.js b/spec/frontend/packages_and_registries/settings/group/mock_data.js index 65119e288a1..81ba0795b7d 100644 --- a/spec/frontend/packages_and_registries/settings/group/mock_data.js +++ b/spec/frontend/packages_and_registries/settings/group/mock_data.js @@ -1,12 +1,20 @@ +export const packageSettings = () => ({ + mavenDuplicatesAllowed: true, + mavenDuplicateExceptionRegex: '', + genericDuplicatesAllowed: true, + genericDuplicateExceptionRegex: '', +}); + +export const dependencyProxySettings = () => ({ + enabled: true, +}); + export const groupPackageSettingsMock = { data: { group: { - packageSettings: { - mavenDuplicatesAllowed: true, - mavenDuplicateExceptionRegex: '', - genericDuplicatesAllowed: true, - genericDuplicateExceptionRegex: '', - }, + fullPath: 'foo_group_path', + packageSettings: packageSettings(), + dependencyProxySetting: dependencyProxySettings(), }, }, }; @@ -26,6 +34,16 @@ export const groupPackageSettingsMutationMock = (override) => ({ }, }); +export const dependencyProxySettingMutationMock = (override) => ({ + data: { + updateDependencyProxySettings: { + dependencyProxySetting: dependencyProxySettings(), + errors: [], + ...override, + }, + }, +}); + export const groupPackageSettingsMutationErrorMock = { errors: [ { @@ -50,3 +68,23 @@ export const groupPackageSettingsMutationErrorMock = { }, ], }; +export const dependencyProxySettingMutationErrorMock = { + errors: [ + { + message: 'Some error', + locations: [{ line: 1, column: 41 }], + extensions: { + value: { + enabled: 'gitlab-org', + }, + problems: [ + { + path: ['enabled'], + explanation: 'explaination', + message: 'message', + }, + ], + }, + }, + ], +}; diff --git a/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap index cf554717127..2719e917a9b 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap @@ -100,6 +100,12 @@ Array [ "variable": 30, }, Object { + "default": false, + "key": "SIXTY_DAYS", + "label": "60 days", + "variable": 60, + }, + Object { "default": true, "key": "NINETY_DAYS", "label": "90 days", diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap index 1009db46401..9938357ed24 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap @@ -44,7 +44,7 @@ exports[`Settings Form Keep Regex matches snapshot 1`] = ` exports[`Settings Form OlderThan matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="older-than-dropdown" - formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]" + formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" label="Remove tags older than:" name="older-than" value="FOURTEEN_DAYS" diff --git a/spec/frontend/pages/admin/projects/components/namespace_select_spec.js b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js new file mode 100644 index 00000000000..c579aa2f2da --- /dev/null +++ b/spec/frontend/pages/admin/projects/components/namespace_select_spec.js @@ -0,0 +1,93 @@ +import { mount } from '@vue/test-utils'; +import Api from '~/api'; +import NamespaceSelect from '~/pages/admin/projects/components/namespace_select.vue'; + +describe('Dropdown select component', () => { + let wrapper; + + const mountDropdown = (propsData) => { + wrapper = mount(NamespaceSelect, { propsData }); + }; + + const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); + const findNamespaceInput = () => wrapper.find('[data-testid="hidden-input"]'); + const findFilterInput = () => wrapper.find('.namespace-search-box input'); + const findDropdownOption = (match) => { + const buttons = wrapper + .findAll('button.dropdown-item') + .filter((node) => node.text().match(match)); + return buttons.length ? buttons.at(0) : buttons; + }; + + const setFieldValue = async (field, value) => { + await field.setValue(value); + field.trigger('blur'); + }; + + beforeEach(() => { + setFixtures('<div class="test-container"></div>'); + + jest.spyOn(Api, 'namespaces').mockImplementation((_, callback) => + callback([ + { id: 10, kind: 'user', full_path: 'Administrator' }, + { id: 20, kind: 'group', full_path: 'GitLab Org' }, + ]), + ); + }); + + it('creates a hidden input if fieldName is provided', () => { + mountDropdown({ fieldName: 'namespace-input' }); + + expect(findNamespaceInput()).toExist(); + expect(findNamespaceInput().attributes('name')).toBe('namespace-input'); + }); + + describe('clicking dropdown options', () => { + it('retrieves namespaces based on filter query', async () => { + mountDropdown(); + + await setFieldValue(findFilterInput(), 'test'); + + expect(Api.namespaces).toHaveBeenCalledWith('test', expect.anything()); + }); + + it('updates the dropdown value based upon selection', async () => { + mountDropdown({ fieldName: 'namespace-input' }); + + // wait for dropdown options to populate + await wrapper.vm.$nextTick(); + + expect(findDropdownOption('user: Administrator')).toExist(); + expect(findDropdownOption('group: GitLab Org')).toExist(); + expect(findDropdownOption('group: Foobar')).not.toExist(); + + findDropdownOption('user: Administrator').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(findNamespaceInput().attributes('value')).toBe('10'); + expect(findDropdownToggle().text()).toBe('user: Administrator'); + }); + + it('triggers a setNamespace event upon selection', async () => { + mountDropdown(); + + // wait for dropdown options to populate + await wrapper.vm.$nextTick(); + + findDropdownOption('group: GitLab Org').trigger('click'); + + expect(wrapper.emitted('setNamespace')).toHaveLength(1); + expect(wrapper.emitted('setNamespace')[0][0]).toBe(20); + }); + + it('displays "Any Namespace" option when showAny prop provided', () => { + mountDropdown({ showAny: true }); + expect(wrapper.text()).toContain('Any namespace'); + }); + + it('does not display "Any Namespace" option when showAny prop not provided', () => { + mountDropdown(); + expect(wrapper.text()).not.toContain('Any namespace'); + }); + }); +}); diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js new file mode 100644 index 00000000000..d6b394a42c6 --- /dev/null +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -0,0 +1,175 @@ +import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import PaginationBar from '~/import_entities/components/pagination_bar.vue'; +import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +describe('BulkImportsHistoryApp', () => { + const API_URL = '/api/v4/bulk_imports/entities'; + + const DEFAULT_HEADERS = { + 'x-page': 1, + 'x-per-page': 20, + 'x-next-page': 2, + 'x-total': 22, + 'x-total-pages': 2, + 'x-prev-page': null, + }; + const DUMMY_RESPONSE = [ + { + id: 1, + bulk_import_id: 1, + status: 'finished', + source_full_path: 'top-level-group-12', + destination_name: 'top-level-group-12', + destination_namespace: 'h5bp', + created_at: '2021-07-08T10:03:44.743Z', + failures: [], + }, + { + id: 2, + bulk_import_id: 2, + status: 'failed', + source_full_path: 'autodevops-demo', + destination_name: 'autodevops-demo', + destination_namespace: 'flightjs', + parent_id: null, + namespace_id: null, + project_id: null, + created_at: '2021-07-13T12:52:26.664Z', + updated_at: '2021-07-13T13:34:49.403Z', + failures: [ + { + pipeline_class: 'BulkImports::Groups::Pipelines::GroupPipeline', + pipeline_step: 'loader', + exception_class: 'ActiveRecord::RecordNotUnique', + correlation_id_value: '01FAFYSYZ7XPF3P9NSMTS693SZ', + created_at: '2021-07-13T13:34:49.344Z', + }, + ], + }, + ]; + + let wrapper; + let mock; + + function createComponent({ shallow = true } = {}) { + const mountFn = shallow ? shallowMount : mount; + wrapper = mountFn(BulkImportsHistoryApp); + } + + const originalApiVersion = gon.api_version; + beforeAll(() => { + gon.api_version = 'v4'; + }); + + afterAll(() => { + gon.api_version = originalApiVersion; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('general behavior', () => { + it('renders loading state when loading', () => { + createComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders empty state when no data is available', async () => { + mock.onGet(API_URL).reply(200, [], DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(GlEmptyState).exists()).toBe(true); + }); + + it('renders table with data when history is available', async () => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + + const table = wrapper.find(GlTable); + expect(table.exists()).toBe(true); + // can't use .props() or .attributes() here + expect(table.vm.$attrs.items).toHaveLength(DUMMY_RESPONSE.length); + }); + + it('changes page when requested by pagination bar', async () => { + const NEW_PAGE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE })); + }); + }); + + it('changes page size when requested by pagination bar', async () => { + const NEW_PAGE_SIZE = 4; + + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent(); + await axios.waitForAll(); + mock.resetHistory(); + + wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].params).toStrictEqual( + expect.objectContaining({ per_page: NEW_PAGE_SIZE }), + ); + }); + + describe('details button', () => { + beforeEach(() => { + mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); + createComponent({ shallow: false }); + return axios.waitForAll(); + }); + + it('renders details button if relevant item has failures', async () => { + expect( + extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(), + ).toBe(true); + }); + + it('does not render details button if relevant item has no failures', () => { + expect( + extendedWrapper(wrapper.find('tbody').findAll('tr').at(0)).findByText('Details').exists(), + ).toBe(false); + }); + + it('expands details when details button is clicked', async () => { + const ORIGINAL_ROW_INDEX = 1; + await extendedWrapper(wrapper.find('tbody').findAll('tr').at(ORIGINAL_ROW_INDEX)) + .findByText('Details') + .trigger('click'); + + const detailsRowContent = wrapper + .find('tbody') + .findAll('tr') + .at(ORIGINAL_ROW_INDEX + 1) + .find('pre'); + + expect(detailsRowContent.exists()).toBe(true); + expect(JSON.parse(detailsRowContent.text())).toStrictEqual(DUMMY_RESPONSE[1].failures); + }); + }); +}); diff --git a/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js b/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js new file mode 100644 index 00000000000..b722ac1e97b --- /dev/null +++ b/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js @@ -0,0 +1,92 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + I18N_PASSWORD_PROMPT_CANCEL_BUTTON, + I18N_PASSWORD_PROMPT_CONFIRM_BUTTON, +} from '~/pages/profiles/password_prompt/constants'; +import PasswordPromptModal from '~/pages/profiles/password_prompt/password_prompt_modal.vue'; + +const createComponent = ({ props }) => { + return shallowMountExtended(PasswordPromptModal, { + propsData: { + ...props, + }, + }); +}; + +describe('Password prompt modal', () => { + let wrapper; + + const mockPassword = 'not+fake+shady+password'; + const mockEvent = { preventDefault: jest.fn() }; + const handleConfirmPasswordSpy = jest.fn(); + + const findField = () => wrapper.findByTestId('password-prompt-field'); + const findModal = () => wrapper.findComponent(GlModal); + const findConfirmBtn = () => findModal().props('actionPrimary'); + const findConfirmBtnDisabledState = () => + findModal().props('actionPrimary').attributes[2].disabled; + + const findCancelBtn = () => findModal().props('actionCancel'); + + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const setPassword = (newPw) => findField().vm.$emit('input', newPw); + + beforeEach(() => { + wrapper = createComponent({ + props: { + handleConfirmPassword: handleConfirmPasswordSpy, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the password field', () => { + expect(findField().exists()).toBe(true); + }); + + it('renders the confirm button', () => { + expect(findConfirmBtn().text).toEqual(I18N_PASSWORD_PROMPT_CONFIRM_BUTTON); + }); + + it('renders the cancel button', () => { + expect(findCancelBtn().text).toEqual(I18N_PASSWORD_PROMPT_CANCEL_BUTTON); + }); + + describe('confirm button', () => { + describe('with a valid password', () => { + it('calls the `handleConfirmPassword` method when clicked', async () => { + setPassword(mockPassword); + submitModal(); + + await wrapper.vm.$nextTick(); + + expect(handleConfirmPasswordSpy).toHaveBeenCalledTimes(1); + expect(handleConfirmPasswordSpy).toHaveBeenCalledWith(mockPassword); + }); + + it('enables the confirm button', async () => { + setPassword(mockPassword); + + expect(findConfirmBtnDisabledState()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(findConfirmBtnDisabledState()).toBe(false); + }); + }); + + it('without a valid password is disabled', async () => { + setPassword(''); + + expect(findConfirmBtnDisabledState()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(findConfirmBtnDisabledState()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index 417567c9f4c..43361bb6f24 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -12,11 +12,11 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" - showhighlighteditemstitle="true" size="medium" text="rspec" variant="default" diff --git a/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js b/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js deleted file mode 100644 index 8a7f9229503..00000000000 --- a/spec/frontend/pages/projects/new/components/new_project_url_select_spec.js +++ /dev/null @@ -1,122 +0,0 @@ -import { GlButton, GlDropdown, GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; -import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import NewProjectUrlSelect from '~/pages/projects/new/components/new_project_url_select.vue'; -import searchQuery from '~/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; - -describe('NewProjectUrlSelect component', () => { - let wrapper; - - const data = { - currentUser: { - groups: { - nodes: [ - { - id: 'gid://gitlab/Group/26', - fullPath: 'flightjs', - }, - { - id: 'gid://gitlab/Group/28', - fullPath: 'h5bp', - }, - ], - }, - namespace: { - id: 'gid://gitlab/Namespace/1', - fullPath: 'root', - }, - }, - }; - - const localVue = createLocalVue(); - localVue.use(VueApollo); - - const requestHandlers = [[searchQuery, jest.fn().mockResolvedValue({ data })]]; - const apolloProvider = createMockApollo(requestHandlers); - - const provide = { - namespaceFullPath: 'h5bp', - namespaceId: '28', - rootUrl: 'https://gitlab.com/', - trackLabel: 'blank_project', - }; - - const mountComponent = ({ mountFn = shallowMount } = {}) => - mountFn(NewProjectUrlSelect, { localVue, apolloProvider, provide }); - - const findButtonLabel = () => wrapper.findComponent(GlButton); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findHiddenInput = () => wrapper.find('input'); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders the root url as a label', () => { - wrapper = mountComponent(); - - expect(findButtonLabel().text()).toBe(provide.rootUrl); - expect(findButtonLabel().props('label')).toBe(true); - }); - - it('renders a dropdown with the initial namespace full path as the text', () => { - wrapper = mountComponent(); - - expect(findDropdown().props('text')).toBe(provide.namespaceFullPath); - }); - - it('renders a dropdown with the initial namespace id in the hidden input', () => { - wrapper = mountComponent(); - - expect(findHiddenInput().attributes('value')).toBe(provide.namespaceId); - }); - - it('renders expected dropdown items', async () => { - wrapper = mountComponent({ mountFn: mount }); - - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - const listItems = wrapper.findAll('li'); - - expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups'); - expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[0].fullPath); - expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[1].fullPath); - expect(listItems.at(3).findComponent(GlDropdownSectionHeader).text()).toBe('Users'); - expect(listItems.at(4).text()).toBe(data.currentUser.namespace.fullPath); - }); - - it('updates hidden input with selected namespace', async () => { - wrapper = mountComponent(); - - jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - wrapper.findComponent(GlDropdownItem).vm.$emit('click'); - - await wrapper.vm.$nextTick(); - - expect(findHiddenInput().attributes()).toMatchObject({ - name: 'project[namespace_id]', - value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), - }); - }); - - it('tracks clicking on the dropdown', () => { - wrapper = mountComponent(); - - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - findDropdown().vm.$emit('show'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'activate_form_input', { - label: provide.trackLabel, - property: 'project_path', - }); - - unmockTracking(); - }); -}); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js index 2a3b07f95f2..53c1733eab9 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -10,7 +10,17 @@ describe('Timezone Dropdown', () => { let $dropdownEl = null; let $wrapper = null; const tzListSel = '.dropdown-content ul li a.is-active'; - const tzDropdownToggleText = '.dropdown-toggle-text'; + + const initTimezoneDropdown = (options = {}) => { + // eslint-disable-next-line no-new + new TimezoneDropdown({ + $inputEl, + $dropdownEl, + ...options, + }); + }; + + const findDropdownToggleText = () => $wrapper.find('.dropdown-toggle-text'); describe('Initialize', () => { describe('with dropdown already loaded', () => { @@ -18,16 +28,13 @@ describe('Timezone Dropdown', () => { loadFixtures('pipeline_schedules/edit.html'); $wrapper = $('.dropdown'); $inputEl = $('#schedule_cron_timezone'); + $inputEl.val(''); $dropdownEl = $('.js-timezone-dropdown'); - - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - }); }); it('can take an $inputEl in the constructor', () => { + initTimezoneDropdown(); + const tzStr = '[UTC + 5.5] Sri Jayawardenepura'; const tzValue = 'Asia/Colombo'; @@ -42,6 +49,8 @@ describe('Timezone Dropdown', () => { }); it('will format data array of timezones into a list of offsets', () => { + initTimezoneDropdown(); + const data = $dropdownEl.data('data'); const formatted = $wrapper.find(tzListSel).text(); @@ -50,10 +59,28 @@ describe('Timezone Dropdown', () => { }); }); - it('will default the timezone to UTC', () => { - const tz = $inputEl.val(); + describe('when `allowEmpty` property is `false`', () => { + beforeEach(() => { + initTimezoneDropdown(); + }); + + it('will default the timezone to UTC', () => { + const tz = $inputEl.val(); - expect(tz).toBe('UTC'); + expect(tz).toBe('UTC'); + }); + }); + + describe('when `allowEmpty` property is `true`', () => { + beforeEach(() => { + initTimezoneDropdown({ + allowEmpty: true, + }); + }); + + it('will default the value of the input to an empty string', () => { + expect($inputEl.val()).toBe(''); + }); }); }); @@ -68,23 +95,15 @@ describe('Timezone Dropdown', () => { it('will populate the list of UTC offsets after the dropdown is loaded', () => { expect($wrapper.find(tzListSel).length).toEqual(0); - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - }); + initTimezoneDropdown(); expect($wrapper.find(tzListSel).length).toEqual($($dropdownEl).data('data').length); }); it('will call a provided handler when a new timezone is selected', () => { const onSelectTimezone = jest.fn(); - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - onSelectTimezone, - }); + + initTimezoneDropdown({ onSelectTimezone }); $wrapper.find(tzListSel).first().trigger('click'); @@ -94,24 +113,15 @@ describe('Timezone Dropdown', () => { it('will correctly set the dropdown label if a timezone identifier is set on the inputEl', () => { $inputEl.val('America/St_Johns'); - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - displayFormat: (selectedItem) => formatTimezone(selectedItem), - }); + initTimezoneDropdown({ displayFormat: (selectedItem) => formatTimezone(selectedItem) }); - expect($wrapper.find(tzDropdownToggleText).html()).toEqual('[UTC - 2.5] Newfoundland'); + expect(findDropdownToggleText().html()).toEqual('[UTC - 2.5] Newfoundland'); }); it('will call a provided `displayFormat` handler to format the dropdown value', () => { const displayFormat = jest.fn(); - // eslint-disable-next-line no-new - new TimezoneDropdown({ - $inputEl, - $dropdownEl, - displayFormat, - }); + + initTimezoneDropdown({ displayFormat }); $wrapper.find(tzListSel).first().trigger('click'); diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js index e39a3904613..a29db961452 100644 --- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js +++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js @@ -44,7 +44,7 @@ describe('preserve_url_fragment', () => { }); it('when "remember-me" is present', () => { - $('.omniauth-btn') + $('.js-oauth-login') .parent('form') .attr('action', (i, href) => `${href}?remember_me=1`); diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js index 85222f2ecbb..a43da4b0f19 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -112,11 +112,6 @@ describe('Pipeline Editor | Text editor component', () => { it('configures editor with syntax highlight', () => { expect(mockUse).toHaveBeenCalledTimes(1); expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1); - expect(mockRegisterCiSchema).toHaveBeenCalledWith({ - projectNamespace: mockProjectNamespace, - projectPath: mockProjectPath, - ref: mockCommitSha, - }); }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js index 753682d438b..44656b2b67d 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js @@ -5,22 +5,18 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue'; import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data'; const localVue = createLocalVue(); localVue.use(VueApollo); -const mockProvide = { - projectFullPath: mockProjectFullPath, -}; - describe('Pipeline Status', () => { let wrapper; let mockApollo; let mockPipelineQuery; - const createComponentWithApollo = () => { + const createComponentWithApollo = (glFeatures = {}) => { const handlers = [[getPipelineQuery, mockPipelineQuery]]; mockApollo = createMockApollo(handlers); @@ -30,19 +26,23 @@ describe('Pipeline Status', () => { propsData: { commitSha: mockCommitSha, }, - provide: mockProvide, + provide: { + glFeatures, + projectFullPath: mockProjectFullPath, + }, stubs: { GlLink, GlSprintf }, }); }; const findIcon = () => wrapper.findComponent(GlIcon); - const findCiIcon = () => wrapper.findComponent(CiIcon); 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(); @@ -50,9 +50,7 @@ describe('Pipeline Status', () => { afterEach(() => { mockPipelineQuery.mockReset(); - wrapper.destroy(); - wrapper = null; }); describe('loading icon', () => { @@ -73,13 +71,13 @@ describe('Pipeline Status', () => { describe('when querying data', () => { describe('when data is set', () => { - beforeEach(async () => { + beforeEach(() => { mockPipelineQuery.mockResolvedValue({ - data: { project: mockProjectPipeline }, + data: { project: mockProjectPipeline() }, }); createComponentWithApollo(); - await waitForPromises(); + waitForPromises(); }); it('query is called with correct variables', async () => { @@ -91,20 +89,24 @@ describe('Pipeline Status', () => { }); it('does not render error', () => { - expect(findIcon().exists()).toBe(false); + expect(findPipelineErrorMsg().exists()).toBe(false); }); it('renders pipeline data', () => { const { id, detailedStatus: { detailsPath }, - } = mockProjectPipeline.pipeline; + } = mockProjectPipeline().pipeline; - expect(findCiIcon().exists()).toBe(true); + expect(findStatusIcon().exists()).toBe(true); expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`); expect(findPipelineCommit().text()).toBe(mockCommitSha); expect(findPipelineViewBtn().attributes('href')).toBe(detailsPath); }); + + it('does not render the pipeline mini graph', () => { + expect(findPipelineEditorMiniGraph().exists()).toBe(false); + }); }); describe('when data cannot be fetched', () => { @@ -121,11 +123,26 @@ describe('Pipeline Status', () => { }); it('does not render pipeline data', () => { - expect(findCiIcon().exists()).toBe(false); + expect(findStatusIcon().exists()).toBe(false); expect(findPipelineId().exists()).toBe(false); expect(findPipelineCommit().exists()).toBe(false); expect(findPipelineViewBtn().exists()).toBe(false); }); }); }); + + describe('when feature flag for pipeline mini graph is enabled', () => { + beforeEach(() => { + mockPipelineQuery.mockResolvedValue({ + data: { project: mockProjectPipeline() }, + }); + + createComponentWithApollo({ pipelineEditorMiniGraph: true }); + waitForPromises(); + }); + + it('renders the pipeline mini graph', () => { + expect(findPipelineEditorMiniGraph().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js b/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js new file mode 100644 index 00000000000..3d7c3c839da --- /dev/null +++ b/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; +import { mockProjectPipeline } from '../../mock_data'; + +describe('Pipeline Status', () => { + let wrapper; + + const createComponent = ({ hasStages = true } = {}) => { + wrapper = shallowMount(PipelineEditorMiniGraph, { + propsData: { + pipeline: mockProjectPipeline({ hasStages }).pipeline, + }, + }); + }; + + const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + + afterEach(() => { + 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); + }); + }); +}); diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js index b019bae886c..8e0a73b6e7c 100644 --- a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js @@ -6,9 +6,6 @@ import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_e describe('Pipeline editor empty state', () => { let wrapper; const defaultProvide = { - glFeatures: { - pipelineEditorEmptyStateAction: false, - }, emptyStateIllustrationPath: 'my/svg/path', }; @@ -51,24 +48,6 @@ describe('Pipeline editor empty state', () => { expect(findFileNav().exists()).toBe(true); }); - describe('with feature flag off', () => { - it('does not renders a CTA button', () => { - expect(findConfirmButton().exists()).toBe(false); - }); - }); - }); - - describe('with feature flag on', () => { - beforeEach(() => { - createComponent({ - provide: { - glFeatures: { - pipelineEditorEmptyStateAction: true, - }, - }, - }); - }); - it('renders a CTA button', () => { expect(findConfirmButton().exists()).toBe(true); expect(findConfirmButton().text()).toBe(wrapper.vm.$options.i18n.btnText); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index f2104f25324..0b0ff14486e 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -247,20 +247,47 @@ export const mockEmptySearchBranches = { export const mockBranchPaginationLimit = 10; export const mockTotalBranches = 20; // must be greater than mockBranchPaginationLimit to test pagination -export const mockProjectPipeline = { - pipeline: { - commitPath: '/-/commit/aabbccdd', - id: 'gid://gitlab/Ci::Pipeline/118', - iid: '28', - shortSha: mockCommitSha, - status: 'SUCCESS', - detailedStatus: { - detailsPath: '/root/sample-ci-project/-/pipelines/118"', - group: 'success', - icon: 'status_success', - text: 'passed', +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 { + pipeline: { + commitPath: '/-/commit/aabbccdd', + id: 'gid://gitlab/Ci::Pipeline/118', + iid: '28', + shortSha: mockCommitSha, + status: 'SUCCESS', + detailedStatus: { + detailsPath: '/root/sample-ci-project/-/pipelines/118', + group: 'success', + icon: 'status_success', + text: 'passed', + }, + stages, }, - }, + }; }; export const mockLintResponse = { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 393cad0546b..b6713319e69 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -22,7 +22,6 @@ import { mockCiConfigPath, mockCiConfigQueryResponse, mockBlobContentQueryResponse, - mockBlobContentQueryResponseEmptyCiFile, mockBlobContentQueryResponseNoCiFile, mockCiYml, mockCommitSha, @@ -43,9 +42,6 @@ const MockSourceEditor = { const mockProvide = { ciConfigPath: mockCiConfigPath, defaultBranch: mockDefaultBranch, - glFeatures: { - pipelineEditorEmptyStateAction: false, - }, projectFullPath: mockProjectFullPath, }; @@ -221,37 +217,12 @@ describe('Pipeline editor app component', () => { }); }); - describe('with an empty CI config file', () => { - describe('with empty state feature flag on', () => { - it('does not show the empty screen state', async () => { - mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseEmptyCiFile); - - await createComponentWithApollo({ - provide: { - glFeatures: { - pipelineEditorEmptyStateAction: true, - }, - }, - }); - - expect(findEmptyState().exists()).toBe(false); - expect(findTextEditor().exists()).toBe(true); - }); - }); - }); - - describe('when landing on the empty state with feature flag on', () => { - it('user can click on CTA button and see an empty editor', async () => { + 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({ - provide: { - glFeatures: { - pipelineEditorEmptyStateAction: true, - }, - }, - }); + await createComponentWithApollo(); expect(findEmptyState().exists()).toBe(true); expect(findTextEditor().exists()).toBe(false); diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js index 7aba336b8e8..335049892ec 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js @@ -25,7 +25,6 @@ describe('Pipeline editor home wrapper', () => { }, provide: { glFeatures: { - pipelineEditorDrawer: true, ...glFeatures, }, }, @@ -94,12 +93,4 @@ describe('Pipeline editor home wrapper', () => { expect(findCommitSection().exists()).toBe(true); }); }); - - describe('Pipeline drawer', () => { - it('hides the drawer when the feature flag is off', () => { - createComponent({ glFeatures: { pipelineEditorDrawer: false } }); - - expect(findPipelineEditorDrawer().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js index 154828aff4b..1cb43c199aa 100644 --- a/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/pipeline_mini_graph_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; -const { pipelines } = getJSONFixture('pipelines/pipelines.json'); const mockStages = pipelines[0].details.stages; describe('Pipeline Mini Graph', () => { diff --git a/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js b/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js new file mode 100644 index 00000000000..249126390f1 --- /dev/null +++ b/spec/frontend/pipelines/components/pipelines_list/pipieline_stop_modal_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; +import PipelineStopModal from '~/pipelines/components/pipelines_list/pipeline_stop_modal.vue'; +import { mockPipelineHeader } from '../../mock_data'; + +describe('PipelineStopModal', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(PipelineStopModal, { + propsData: { + pipeline: mockPipelineHeader, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should render "stop pipeline" warning', () => { + expect(wrapper.text()).toMatch(`You’re about to stop pipeline #${mockPipelineHeader.id}.`); + }); +}); diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index cbc5d11403e..06f1fa4c827 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -205,4 +205,64 @@ describe('pipeline graph job item', () => { }, ); }); + + describe('job classes', () => { + it('job class is shown', () => { + createWrapper({ + job: mockJob, + cssClassJobName: 'my-class', + }); + + expect(wrapper.find('a').classes()).toContain('my-class'); + + expect(wrapper.find('a').classes()).not.toContain(triggerActiveClass); + }); + + it('job class is shown, along with hover', () => { + createWrapper({ + job: mockJob, + cssClassJobName: 'my-class', + sourceJobHovered: mockJob.name, + }); + + expect(wrapper.find('a').classes()).toContain('my-class'); + expect(wrapper.find('a').classes()).toContain(triggerActiveClass); + }); + + it('multiple job classes are shown', () => { + createWrapper({ + job: mockJob, + cssClassJobName: ['my-class-1', 'my-class-2'], + }); + + expect(wrapper.find('a').classes()).toContain('my-class-1'); + expect(wrapper.find('a').classes()).toContain('my-class-2'); + + expect(wrapper.find('a').classes()).not.toContain(triggerActiveClass); + }); + + it('multiple job classes are shown conditionally', () => { + createWrapper({ + job: mockJob, + cssClassJobName: { 'my-class-1': true, 'my-class-2': true }, + }); + + expect(wrapper.find('a').classes()).toContain('my-class-1'); + expect(wrapper.find('a').classes()).toContain('my-class-2'); + + expect(wrapper.find('a').classes()).not.toContain(triggerActiveClass); + }); + + it('multiple job classes are shown, along with a hover', () => { + createWrapper({ + job: mockJob, + cssClassJobName: ['my-class-1', 'my-class-2'], + sourceJobHovered: mockJob.name, + }); + + expect(wrapper.find('a').classes()).toContain('my-class-1'); + expect(wrapper.find('a').classes()).toContain('my-class-2'); + expect(wrapper.find('a').classes()).toContain(triggerActiveClass); + }); + }); }); diff --git a/spec/frontend/pipelines/pipeline_multi_actions_spec.js b/spec/frontend/pipelines/pipeline_multi_actions_spec.js index a606595b37d..e24d2e51f08 100644 --- a/spec/frontend/pipelines/pipeline_multi_actions_spec.js +++ b/spec/frontend/pipelines/pipeline_multi_actions_spec.js @@ -95,7 +95,7 @@ describe('Pipeline Multi Actions Dropdown', () => { createComponent({ mockData: { artifacts } }); expect(findFirstArtifactItem().attributes('href')).toBe(artifacts[0].path); - expect(findFirstArtifactItem().text()).toBe(`Download ${artifacts[0].name} artifact`); + expect(findFirstArtifactItem().text()).toBe(artifacts[0].name); }); it('should render empty message when no artifacts are found', () => { diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index 336255768d7..f33c66dedf3 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -87,8 +87,7 @@ describe('Pipelines Artifacts dropdown', () => { createComponent({ mockData: { artifacts } }); expect(findFirstGlDropdownItem().attributes('href')).toBe(artifacts[0].path); - - expect(findFirstGlDropdownItem().text()).toBe(`Download ${artifacts[0].name} artifact`); + expect(findFirstGlDropdownItem().text()).toBe(artifacts[0].name); }); describe('with a failing request', () => { diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index aa30062c987..2875498bb52 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -4,6 +4,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { chunk } from 'lodash'; import { nextTick } from 'vue'; +import mockPipelinesResponse from 'test_fixtures/pipelines/pipelines.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -33,7 +34,6 @@ jest.mock('~/experimentation/utils', () => ({ const mockProjectPath = 'twitter/flight'; const mockProjectId = '21'; const mockPipelinesEndpoint = `/${mockProjectPath}/pipelines.json`; -const mockPipelinesResponse = getJSONFixture('pipelines/pipelines.json'); const mockPipelinesIds = mockPipelinesResponse.pipelines.map(({ id }) => id); const mockPipelineWithStages = mockPipelinesResponse.pipelines.find( (p) => p.details.stages && p.details.stages.length, diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 4472a5ae70d..fb019b463b1 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -1,6 +1,7 @@ import '~/commons'; import { GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; +import fixture from 'test_fixtures/pipelines/pipelines.json'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue'; @@ -20,8 +21,6 @@ describe('Pipelines Table', () => { let pipeline; let wrapper; - const jsonFixtureName = 'pipelines/pipelines.json'; - const defaultProps = { pipelines: [], viewType: 'root', @@ -29,7 +28,8 @@ describe('Pipelines Table', () => { }; const createMockPipeline = () => { - const { pipelines } = getJSONFixture(jsonFixtureName); + // Clone fixture as it could be modified by tests + const { pipelines } = JSON.parse(JSON.stringify(fixture)); return pipelines.find((p) => p.user !== null && p.commit !== null); }; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index e931ddb8496..84a9f4776b9 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -1,5 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import createFlash from '~/flash'; @@ -13,7 +13,6 @@ describe('Actions TestReports Store', () => { let mock; let state; - const testReports = getJSONFixture('pipelines/test_report.json'); const summary = { total_count: 1 }; const suiteEndpoint = `${TEST_HOST}/tests/suite.json`; diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js index f8298fdaba5..70e3a01dbf1 100644 --- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -1,4 +1,4 @@ -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { iconForTestStatus, @@ -9,8 +9,6 @@ import { describe('Getters TestReports Store', () => { let state; - const testReports = getJSONFixture('pipelines/test_report.json'); - const defaultState = { blobPath: '/test/blob/path', testReports, diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index 191e9e7391c..f2dbeec6a06 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -1,12 +1,10 @@ -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; import mutations from '~/pipelines/stores/test_reports/mutations'; describe('Mutations TestReports Store', () => { let mockState; - const testReports = getJSONFixture('pipelines/test_report.json'); - const defaultState = { endpoint: '', testReports: {}, diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js index e44d59ba888..384b7cf6930 100644 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -1,7 +1,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import EmptyState from '~/pipelines/components/test_reports/empty_state.vue'; import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; @@ -16,8 +16,6 @@ describe('Test reports app', () => { let wrapper; let store; - const testReports = getJSONFixture('pipelines/test_report.json'); - const loadingSpinner = () => wrapper.findComponent(GlLoadingIcon); const testsDetail = () => wrapper.findByTestId('tests-detail'); const emptyState = () => wrapper.findComponent(EmptyState); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index a87145cc557..793bad6b82a 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlFriendlyWrap, GlLink, GlPagination } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import { TestStatus } from '~/pipelines/constants'; import * as getters from '~/pipelines/stores/test_reports/getters'; @@ -17,7 +17,7 @@ describe('Test reports suite table', () => { const { test_suites: [testSuite], - } = getJSONFixture('pipelines/test_report.json'); + } = testReports; testSuite.test_cases = [...testSuite.test_cases, ...skippedTestCases]; const testCases = testSuite.test_cases; diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js index df404d87c99..7eed6671fb9 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import Summary from '~/pipelines/components/test_reports/test_summary.vue'; import { formattedTime } from '~/pipelines/stores/test_reports/utils'; @@ -8,7 +8,7 @@ describe('Test reports summary', () => { const { test_suites: [testSuite], - } = getJSONFixture('pipelines/test_report.json'); + } = testReports; const backButton = () => wrapper.find('.js-back-button'); const totalTests = () => wrapper.find('.js-total-tests'); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js index 892a3742fea..0813739d72f 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js @@ -1,6 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import testReports from 'test_fixtures/pipelines/test_report.json'; import SummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; import * as getters from '~/pipelines/stores/test_reports/getters'; @@ -11,8 +11,6 @@ describe('Test reports summary table', () => { let wrapper; let store; - const testReports = getJSONFixture('pipelines/test_report.json'); - const allSuitesRows = () => wrapper.findAll('.js-suite-row'); const noSuitesToShow = () => wrapper.find('.js-no-tests-suites'); diff --git a/spec/frontend/pages/projects/new/components/app_spec.js b/spec/frontend/projects/new/components/app_spec.js index ab8c6d529a8..f6edbab3cca 100644 --- a/spec/frontend/pages/projects/new/components/app_spec.js +++ b/spec/frontend/projects/new/components/app_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import App from '~/pages/projects/new/components/app.vue'; +import App from '~/projects/new/components/app.vue'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; describe('Experimental new project creation app', () => { diff --git a/spec/frontend/pages/projects/new/components/new_project_push_tip_popover_spec.js b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js index d4cf8c78600..31ddbc80ae4 100644 --- a/spec/frontend/pages/projects/new/components/new_project_push_tip_popover_spec.js +++ b/spec/frontend/projects/new/components/new_project_push_tip_popover_spec.js @@ -1,6 +1,6 @@ import { GlPopover, GlFormInputGroup } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import NewProjectPushTipPopover from '~/pages/projects/new/components/new_project_push_tip_popover.vue'; +import NewProjectPushTipPopover from '~/projects/new/components/new_project_push_tip_popover.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('New project push tip popover', () => { diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js new file mode 100644 index 00000000000..aa16b71172b --- /dev/null +++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js @@ -0,0 +1,235 @@ +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + 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 { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import eventHub from '~/projects/new/event_hub'; +import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue'; +import searchQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; + +describe('NewProjectUrlSelect component', () => { + let wrapper; + + const data = { + currentUser: { + groups: { + nodes: [ + { + id: 'gid://gitlab/Group/26', + fullPath: 'flightjs', + }, + { + id: 'gid://gitlab/Group/28', + fullPath: 'h5bp', + }, + { + id: 'gid://gitlab/Group/30', + fullPath: 'h5bp/subgroup', + }, + ], + }, + namespace: { + id: 'gid://gitlab/Namespace/1', + fullPath: 'root', + }, + }, + }; + + const localVue = createLocalVue(); + localVue.use(VueApollo); + + const defaultProvide = { + namespaceFullPath: 'h5bp', + namespaceId: '28', + rootUrl: 'https://gitlab.com/', + trackLabel: 'blank_project', + userNamespaceFullPath: 'root', + userNamespaceId: '1', + }; + + const mountComponent = ({ + search = '', + queryResponse = data, + provide = defaultProvide, + mountFn = shallowMount, + } = {}) => { + const requestHandlers = [[searchQuery, jest.fn().mockResolvedValue({ data: queryResponse })]]; + const apolloProvider = createMockApollo(requestHandlers); + + return mountFn(NewProjectUrlSelect, { + localVue, + apolloProvider, + provide, + data() { + return { + search, + }; + }, + }); + }; + + const findButtonLabel = () => wrapper.findComponent(GlButton); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findInput = () => wrapper.findComponent(GlSearchBoxByType); + const findHiddenInput = () => wrapper.find('input'); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the root url as a label', () => { + wrapper = mountComponent(); + + expect(findButtonLabel().text()).toBe(defaultProvide.rootUrl); + expect(findButtonLabel().props('label')).toBe(true); + }); + + describe('when namespaceId is provided', () => { + beforeEach(() => { + wrapper = mountComponent(); + }); + + it('renders a dropdown with the given namespace full path as the text', () => { + expect(findDropdown().props('text')).toBe(defaultProvide.namespaceFullPath); + }); + + it('renders a dropdown with the given namespace id in the hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe(defaultProvide.namespaceId); + }); + }); + + describe('when namespaceId is not provided', () => { + const provide = { + ...defaultProvide, + namespaceFullPath: undefined, + namespaceId: undefined, + }; + + beforeEach(() => { + wrapper = mountComponent({ provide }); + }); + + it("renders a dropdown with the user's namespace full path as the text", () => { + expect(findDropdown().props('text')).toBe(defaultProvide.userNamespaceFullPath); + }); + + it("renders a dropdown with the user's namespace id in the hidden input", () => { + expect(findHiddenInput().attributes('value')).toBe(defaultProvide.userNamespaceId); + }); + }); + + it('focuses on the input when the dropdown is opened', async () => { + wrapper = mountComponent({ mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + const spy = jest.spyOn(findInput().vm, 'focusInput'); + + findDropdown().vm.$emit('shown'); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('renders expected dropdown items', async () => { + wrapper = mountComponent({ mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + const listItems = wrapper.findAll('li'); + + expect(listItems).toHaveLength(6); + expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups'); + expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[0].fullPath); + expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[1].fullPath); + expect(listItems.at(3).text()).toBe(data.currentUser.groups.nodes[2].fullPath); + expect(listItems.at(4).findComponent(GlDropdownSectionHeader).text()).toBe('Users'); + expect(listItems.at(5).text()).toBe(data.currentUser.namespace.fullPath); + }); + + describe('when selecting from a group template', () => { + const groupId = getIdFromGraphQLId(data.currentUser.groups.nodes[1].id); + + beforeEach(async () => { + wrapper = mountComponent({ mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + eventHub.$emit('select-template', groupId); + }); + + it('filters the dropdown items to the selected group and children', async () => { + const listItems = wrapper.findAll('li'); + + expect(listItems).toHaveLength(3); + expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Groups'); + expect(listItems.at(1).text()).toBe(data.currentUser.groups.nodes[1].fullPath); + expect(listItems.at(2).text()).toBe(data.currentUser.groups.nodes[2].fullPath); + }); + + it('sets the selection to the group', async () => { + expect(findDropdown().props('text')).toBe(data.currentUser.groups.nodes[1].fullPath); + }); + }); + + it('renders `No matches found` when there are no matching dropdown items', async () => { + const queryResponse = { + currentUser: { + groups: { + nodes: [], + }, + namespace: { + id: 'gid://gitlab/Namespace/1', + fullPath: 'root', + }, + }, + }; + + wrapper = mountComponent({ search: 'no matches', queryResponse, mountFn: mount }); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('li').text()).toBe('No matches found'); + }); + + it('updates hidden input with selected namespace', async () => { + wrapper = mountComponent(); + + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(findHiddenInput().attributes()).toMatchObject({ + name: 'project[namespace_id]', + value: getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(), + }); + }); + + it('tracks clicking on the dropdown', () => { + wrapper = mountComponent(); + + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('show'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'activate_form_input', { + label: defaultProvide.trackLabel, + property: 'project_path', + }); + + unmockTracking(); + }); +}); diff --git a/spec/frontend/projects/projects_filterable_list_spec.js b/spec/frontend/projects/projects_filterable_list_spec.js index 377d347623a..d4dbf85b5ca 100644 --- a/spec/frontend/projects/projects_filterable_list_spec.js +++ b/spec/frontend/projects/projects_filterable_list_spec.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-deprecated import { getJSONFixture, setHTMLFixture } from 'helpers/fixtures'; import ProjectsFilterableList from '~/projects/projects_filterable_list'; @@ -14,6 +15,7 @@ describe('ProjectsFilterableList', () => { </div> <div class="js-projects-list-holder"></div> `); + // eslint-disable-next-line import/no-deprecated getJSONFixture('static/projects.json'); form = document.querySelector('form#project-filter-form'); filter = document.querySelector('.js-projects-list-filter'); diff --git a/spec/frontend/projects/settings/components/new_access_dropdown_spec.js b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js new file mode 100644 index 00000000000..a42891423cd --- /dev/null +++ b/spec/frontend/projects/settings/components/new_access_dropdown_spec.js @@ -0,0 +1,345 @@ +import { + GlSprintf, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { getUsers, getGroups, getDeployKeys } from '~/projects/settings/api/access_dropdown_api'; +import AccessDropdown, { i18n } from '~/projects/settings/components/access_dropdown.vue'; +import { ACCESS_LEVELS, LEVEL_TYPES } from '~/projects/settings/constants'; + +jest.mock('~/projects/settings/api/access_dropdown_api', () => ({ + getGroups: jest.fn().mockResolvedValue({ + data: [ + { id: 4, name: 'group4' }, + { id: 5, name: 'group5' }, + { id: 6, name: 'group6' }, + ], + }), + getUsers: jest.fn().mockResolvedValue({ + data: [ + { id: 7, name: 'user7' }, + { id: 8, name: 'user8' }, + { id: 9, name: 'user9' }, + ], + }), + getDeployKeys: jest.fn().mockResolvedValue({ + data: [ + { id: 10, title: 'key10', fingerprint: 'abcdefghijklmnop', owner: { name: 'user1' } }, + { id: 11, title: 'key11', fingerprint: 'abcdefghijklmnop', owner: { name: 'user2' } }, + { id: 12, title: 'key12', fingerprint: 'abcdefghijklmnop', owner: { name: 'user3' } }, + ], + }), +})); + +describe('Access Level Dropdown', () => { + let wrapper; + const mockAccessLevelsData = [ + { + id: 1, + text: 'role1', + }, + { + id: 2, + text: 'role2', + }, + { + id: 3, + text: 'role3', + }, + ]; + + const createComponent = ({ + accessLevelsData = mockAccessLevelsData, + accessLevel = ACCESS_LEVELS.PUSH, + hasLicense, + label, + disabled, + preselectedItems, + } = {}) => { + wrapper = shallowMountExtended(AccessDropdown, { + propsData: { + accessLevelsData, + accessLevel, + hasLicense, + label, + disabled, + preselectedItems, + }, + stubs: { + GlSprintf, + GlDropdown, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggleLabel = () => findDropdown().props('text'); + const findAllDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findAllDropdownHeaders = () => findDropdown().findAllComponents(GlDropdownSectionHeader); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + + const findDropdownItemWithText = (items, text) => + items.filter((item) => item.text().includes(text)).at(0); + + describe('data request', () => { + it('should make an api call for users, groups && deployKeys when user has a license', () => { + createComponent(); + expect(getUsers).toHaveBeenCalled(); + expect(getGroups).toHaveBeenCalled(); + expect(getDeployKeys).toHaveBeenCalled(); + }); + + it('should make an api call for deployKeys but not for users or groups when user does not have a license', () => { + createComponent({ hasLicense: false }); + expect(getUsers).not.toHaveBeenCalled(); + expect(getGroups).not.toHaveBeenCalled(); + expect(getDeployKeys).toHaveBeenCalled(); + }); + + it('should make api calls when search query is updated', async () => { + createComponent(); + const query = 'root'; + + findSearchBox().vm.$emit('input', query); + await nextTick(); + expect(getUsers).toHaveBeenCalledWith(query); + expect(getGroups).toHaveBeenCalled(); + expect(getDeployKeys).toHaveBeenCalledWith(query); + }); + }); + + describe('layout', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('renders headers for each section ', () => { + expect(findAllDropdownHeaders()).toHaveLength(4); + }); + + it('renders dropdown item for each access level type', () => { + expect(findAllDropdownItems()).toHaveLength(12); + }); + }); + + describe('toggleLabel', () => { + let dropdownItems = []; + beforeEach(async () => { + createComponent(); + await waitForPromises(); + dropdownItems = findAllDropdownItems(); + }); + + const findItemByNameAndClick = async (name) => { + findDropdownItemWithText(dropdownItems, name).trigger('click'); + await nextTick(); + }; + + it('when no items selected and custom label provided, displays it and has default CSS class', () => { + wrapper.destroy(); + const customLabel = 'Set the access level'; + createComponent({ label: customLabel }); + expect(findDropdownToggleLabel()).toBe(customLabel); + expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); + }); + + it('when no items selected, displays a default fallback label and has default CSS class ', () => { + expect(findDropdownToggleLabel()).toBe(i18n.selectUsers); + expect(findDropdown().props('toggleClass')).toBe('gl-text-gray-500!'); + }); + + it('displays a number of selected items for each group level', async () => { + dropdownItems.wrappers.forEach((item) => { + item.trigger('click'); + }); + await nextTick(); + expect(findDropdownToggleLabel()).toBe('3 roles, 3 users, 3 deploy keys, 3 groups'); + }); + + it('with only role selected displays the role name and has no class applied', async () => { + await findItemByNameAndClick('role1'); + expect(findDropdownToggleLabel()).toBe('role1'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + + it('with only groups selected displays the number of selected groups', async () => { + await findItemByNameAndClick('group4'); + await findItemByNameAndClick('group5'); + await findItemByNameAndClick('group6'); + expect(findDropdownToggleLabel()).toBe('3 groups'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + + it('with only users selected displays the number of selected users', async () => { + await findItemByNameAndClick('user7'); + await findItemByNameAndClick('user8'); + expect(findDropdownToggleLabel()).toBe('2 users'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + + it('with users and groups selected displays the number of selected users & groups', async () => { + await findItemByNameAndClick('group4'); + await findItemByNameAndClick('group6'); + await findItemByNameAndClick('user7'); + await findItemByNameAndClick('user9'); + expect(findDropdownToggleLabel()).toBe('2 users, 2 groups'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + + it('with users and deploy keys selected displays the number of selected users & keys', async () => { + await findItemByNameAndClick('user8'); + await findItemByNameAndClick('key10'); + await findItemByNameAndClick('key11'); + expect(findDropdownToggleLabel()).toBe('1 user, 2 deploy keys'); + expect(findDropdown().props('toggleClass')).toBe(''); + }); + }); + + describe('selecting an item', () => { + it('selects the item on click and deselects on the next click ', async () => { + createComponent(); + await waitForPromises(); + + const item = findAllDropdownItems().at(1); + item.trigger('click'); + await nextTick(); + expect(item.props('isChecked')).toBe(true); + item.trigger('click'); + await nextTick(); + expect(item.props('isChecked')).toBe(false); + }); + + it('emits a formatted update on selection ', async () => { + // ids: the items appear in that order in the dropdown + // 1 2 3 - roles + // 4 5 6 - groups + // 7 8 9 - users + // 10 11 12 - deploy_keys + // we set 2 from each group as preselected. Then for the sake of the test deselect one, leave one as-is + // and select a new one from the group. + // Preselected items should have `id` along with `user_id/group_id/access_level/deplo_key_id`. + // Items to be removed from previous selection will have `_deploy` flag set to true + // Newly selected items will have only `user_id/group_id/access_level/deploy_key_id` (depending on their type); + const preselectedItems = [ + { id: 112, type: 'role', access_level: 2 }, + { id: 113, type: 'role', access_level: 3 }, + { id: 115, type: 'group', group_id: 5 }, + { id: 116, type: 'group', group_id: 6 }, + { id: 118, type: 'user', user_id: 8, name: 'user8' }, + { id: 119, type: 'user', user_id: 9, name: 'user9' }, + { id: 121, type: 'deploy_key', deploy_key_id: 11 }, + { id: 122, type: 'deploy_key', deploy_key_id: 12 }, + ]; + + createComponent({ preselectedItems }); + await waitForPromises(); + const spy = jest.spyOn(wrapper.vm, '$emit'); + const dropdownItems = findAllDropdownItems(); + // select new item from each group + findDropdownItemWithText(dropdownItems, 'role1').trigger('click'); + findDropdownItemWithText(dropdownItems, 'group4').trigger('click'); + findDropdownItemWithText(dropdownItems, 'user7').trigger('click'); + findDropdownItemWithText(dropdownItems, 'key10').trigger('click'); + // deselect one item from each group + findDropdownItemWithText(dropdownItems, 'role2').trigger('click'); + findDropdownItemWithText(dropdownItems, 'group5').trigger('click'); + findDropdownItemWithText(dropdownItems, 'user8').trigger('click'); + findDropdownItemWithText(dropdownItems, 'key11').trigger('click'); + + expect(spy).toHaveBeenLastCalledWith('select', [ + { access_level: 1 }, + { id: 112, access_level: 2, _destroy: true }, + { id: 113, access_level: 3 }, + { group_id: 4 }, + { id: 115, group_id: 5, _destroy: true }, + { id: 116, group_id: 6 }, + { user_id: 7 }, + { id: 118, user_id: 8, _destroy: true }, + { id: 119, user_id: 9 }, + { deploy_key_id: 10 }, + { id: 121, deploy_key_id: 11, _destroy: true }, + { id: 122, deploy_key_id: 12 }, + ]); + }); + }); + + describe('Handling preselected items', () => { + const preselectedItems = [ + { id: 112, type: 'role', access_level: 2 }, + { id: 115, type: 'group', group_id: 5 }, + { id: 118, type: 'user', user_id: 8, name: 'user2' }, + { id: 121, type: 'deploy_key', deploy_key_id: 11 }, + ]; + + const findSelected = (type) => + wrapper.findAllByTestId(`${type}-dropdown-item`).filter((w) => w.props('isChecked')); + + beforeEach(async () => { + createComponent({ preselectedItems }); + await waitForPromises(); + }); + + it('should set selected roles as intersection between the server response and preselected', () => { + const selectedRoles = findSelected(LEVEL_TYPES.ROLE); + expect(selectedRoles).toHaveLength(1); + expect(selectedRoles.at(0).text()).toBe('role2'); + }); + + it('should set selected groups as intersection between the server response and preselected', () => { + const selectedGroups = findSelected(LEVEL_TYPES.GROUP); + expect(selectedGroups).toHaveLength(1); + expect(selectedGroups.at(0).text()).toBe('group5'); + }); + + it('should set selected users to all preselected mapping `user_id` to `id`', () => { + const selectedUsers = findSelected(LEVEL_TYPES.USER); + expect(selectedUsers).toHaveLength(1); + expect(selectedUsers.at(0).text()).toBe('user2'); + }); + + it('should set selected deploy keys as intersection between the server response and preselected mapping some keys', () => { + const selectedDeployKeys = findSelected(LEVEL_TYPES.DEPLOY_KEY); + expect(selectedDeployKeys).toHaveLength(1); + expect(selectedDeployKeys.at(0).text()).toContain('key11 (abcdefghijklmn...)'); + }); + }); + + describe('on dropdown open', () => { + beforeEach(() => { + createComponent(); + }); + + it('should set the search input focus', () => { + wrapper.vm.$refs.search.focusInput = jest.fn(); + findDropdown().vm.$emit('shown'); + + expect(wrapper.vm.$refs.search.focusInput).toHaveBeenCalled(); + }); + }); + + describe('on dropdown close', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('should emit `hidden` event with dropdown selection', () => { + jest.spyOn(wrapper.vm, '$emit'); + + findAllDropdownItems().at(1).trigger('click'); + + findDropdown().vm.$emit('hidden'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('hidden', [{ access_level: 2 }]); + }); + }); +}); diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index a642a8cf8c2..b486992ac4b 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -4,6 +4,9 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { merge, last } from 'lodash'; import Vuex from 'vuex'; +import commit from 'test_fixtures/api/commits/commit.json'; +import branches from 'test_fixtures/api/branches/branches.json'; +import tags from 'test_fixtures/api/tags/tags.json'; import { trimText } from 'helpers/text_helper'; import { ENTER_KEY } from '~/lib/utils/keys'; import { sprintf } from '~/locale'; @@ -21,11 +24,7 @@ const localVue = createLocalVue(); localVue.use(Vuex); describe('Ref selector component', () => { - const fixtures = { - branches: getJSONFixture('api/branches/branches.json'), - tags: getJSONFixture('api/tags/tags.json'), - commit: getJSONFixture('api/commits/commit.json'), - }; + const fixtures = { branches, tags, commit }; const projectId = '8'; @@ -480,8 +479,6 @@ describe('Ref selector component', () => { it('renders each commit as a selectable item with the short SHA and commit title', () => { const dropdownItems = findCommitDropdownItems(); - const { commit } = fixtures; - expect(dropdownItems.at(0).text()).toBe(`${commit.short_id} ${commit.title}`); }); }); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js index c8fcb3116cd..a5da37a2786 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -1,13 +1,12 @@ -import { GlFormCheckbox, GlSprintf, GlIcon } from '@gitlab/ui'; +import { GlFormCheckbox, GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import DeleteButton from '~/registry/explorer/components/delete_button.vue'; + import component from '~/registry/explorer/components/details_page/tags_list_row.vue'; import { REMOVE_TAG_BUTTON_TITLE, - REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, MISSING_MANIFEST_WARNING_TOOLTIP, NOT_AVAILABLE_TEXT, NOT_AVAILABLE_SIZE, @@ -25,19 +24,20 @@ describe('tags list row', () => { const defaultProps = { tag, isMobile: false, index: 0 }; - const findCheckbox = () => wrapper.find(GlFormCheckbox); + const findCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findName = () => wrapper.find('[data-testid="name"]'); const findSize = () => wrapper.find('[data-testid="size"]'); const findTime = () => wrapper.find('[data-testid="time"]'); const findShortRevision = () => wrapper.find('[data-testid="digest"]'); - const findClipboardButton = () => wrapper.find(ClipboardButton); - const findDeleteButton = () => wrapper.find(DeleteButton); - const findTimeAgoTooltip = () => wrapper.find(TimeAgoTooltip); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); const findDetailsRows = () => wrapper.findAll(DetailsRow); const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]'); const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); - const findWarningIcon = () => wrapper.find(GlIcon); + const findWarningIcon = () => wrapper.findComponent(GlIcon); + const findAdditionalActionsMenu = () => wrapper.findComponent(GlDropdown); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); const mountComponent = (propsData = defaultProps) => { wrapper = shallowMount(component, { @@ -45,6 +45,7 @@ describe('tags list row', () => { GlSprintf, ListItem, DetailsRow, + GlDropdown, }, propsData, directives: { @@ -262,44 +263,61 @@ describe('tags list row', () => { }); }); - describe('delete button', () => { + describe('additional actions menu', () => { it('exists', () => { mountComponent(); - expect(findDeleteButton().exists()).toBe(true); + expect(findAdditionalActionsMenu().exists()).toBe(true); }); - it('has the correct props/attributes', () => { + it('has the correct props', () => { mountComponent(); - expect(findDeleteButton().attributes()).toMatchObject({ - title: REMOVE_TAG_BUTTON_TITLE, - tooltiptitle: REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, - tooltipdisabled: 'true', + expect(findAdditionalActionsMenu().props()).toMatchObject({ + icon: 'ellipsis_v', + text: 'More actions', + textSrOnly: true, + category: 'tertiary', + right: true, }); }); it.each` - canDelete | digest | disabled - ${true} | ${null} | ${true} - ${false} | ${'foo'} | ${true} - ${false} | ${null} | ${true} - ${true} | ${'foo'} | ${true} + canDelete | digest | disabled | buttonDisabled + ${true} | ${null} | ${true} | ${true} + ${false} | ${'foo'} | ${true} | ${true} + ${false} | ${null} | ${true} | ${true} + ${true} | ${'foo'} | ${true} | ${true} + ${true} | ${'foo'} | ${false} | ${false} `( - 'is disabled when canDelete is $canDelete and digest is $digest and disabled is $disabled', - ({ canDelete, digest, disabled }) => { + 'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled', + ({ canDelete, digest, disabled, buttonDisabled }) => { mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled }); - expect(findDeleteButton().attributes('disabled')).toBe('true'); + expect(findAdditionalActionsMenu().props('disabled')).toBe(buttonDisabled); + expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(buttonDisabled); + expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(buttonDisabled); }, ); - it('delete event emits delete', () => { - mountComponent(); + describe('delete button', () => { + it('exists and has the correct attrs', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + expect(findDeleteButton().attributes()).toMatchObject({ + variant: 'danger', + }); + expect(findDeleteButton().text()).toBe(REMOVE_TAG_BUTTON_TITLE); + }); - findDeleteButton().vm.$emit('delete'); + it('delete event emits delete', () => { + mountComponent(); - expect(wrapper.emitted('delete')).toEqual([[]]); + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); }); }); diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index b58a53f0af2..e1f24a2b65b 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -129,13 +129,16 @@ describe('List Page', () => { }); }); - describe('connection error', () => { + describe.each([ + { error: 'connectionError', errorName: 'connection error' }, + { error: 'invalidPathError', errorName: 'invalid path error' }, + ])('handling $errorName', ({ error }) => { const config = { - characterError: true, containersErrorImage: 'foo', helpPagePath: 'bar', isGroupPage: false, }; + config[error] = true; it('should show an empty state', () => { mountComponent({ config }); diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js index f306fdef624..67f62815720 100644 --- a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js +++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js @@ -1,23 +1,19 @@ import { mount, createLocalVue } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import mockData from 'test_fixtures/issues/related_merge_requests.json'; import axios from '~/lib/utils/axios_utils'; import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue'; import createStore from '~/related_merge_requests/store/index'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -const FIXTURE_PATH = 'issues/related_merge_requests.json'; const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests'; const localVue = createLocalVue(); describe('RelatedMergeRequests', () => { let wrapper; let mock; - let mockData; beforeEach((done) => { - loadFixtures(FIXTURE_PATH); - mockData = getJSONFixture(FIXTURE_PATH); - // put the fixture in DOM as the component expects document.body.innerHTML = `<div id="js-issuable-app"></div>`; document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(mockData); diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 1db6fa21d6b..029d720f7b9 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -3,7 +3,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { merge } from 'lodash'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import * as commonUtils from '~/lib/utils/common_utils'; @@ -11,7 +11,6 @@ import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; -const originalRelease = getJSONFixture('api/releases/release.json'); const originalMilestones = originalRelease.milestones; const releasesPagePath = 'path/to/releases/page'; diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js index 096d319c82f..32bbfd386f5 100644 --- a/spec/frontend/releases/components/app_index_apollo_client_spec.js +++ b/spec/frontend/releases/components/app_index_apollo_client_spec.js @@ -1,6 +1,7 @@ import { cloneDeep } from 'lodash'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql'; @@ -32,9 +33,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ })); describe('app_index_apollo_client.vue', () => { - const originalAllReleasesQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/all_releases.query.graphql.json', - ); const projectPath = 'project/path'; const newReleasePath = 'path/to/new/release/page'; const before = 'beforeCursor'; diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 7ea7a6ffe94..72ebaaaf76c 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { getJSONFixture } from 'helpers/fixtures'; +import oneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; import createMockApollo from 'helpers/mock_apollo_helper'; import createFlash from '~/flash'; import ReleaseShowApp from '~/releases/components/app_show.vue'; @@ -11,10 +11,6 @@ import oneReleaseQuery from '~/releases/graphql/queries/one_release.query.graphq jest.mock('~/flash'); -const oneReleaseQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/one_release.query.graphql.json', -); - Vue.use(VueApollo); const EXPECTED_ERROR_MESSAGE = 'Something went wrong while getting the release details.'; diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js index 460007e48ef..839d127e00f 100644 --- a/spec/frontend/releases/components/asset_links_form_spec.js +++ b/spec/frontend/releases/components/asset_links_form_spec.js @@ -1,6 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import * as commonUtils from '~/lib/utils/common_utils'; import { ENTER_KEY } from '~/lib/utils/keys'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; @@ -9,8 +9,6 @@ import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; const localVue = createLocalVue(); localVue.use(Vuex); -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Release edit component', () => { let wrapper; let release; diff --git a/spec/frontend/releases/components/evidence_block_spec.js b/spec/frontend/releases/components/evidence_block_spec.js index 50b6d1c4707..973428257b7 100644 --- a/spec/frontend/releases/components/evidence_block_spec.js +++ b/spec/frontend/releases/components/evidence_block_spec.js @@ -1,13 +1,11 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Evidence Block', () => { let wrapper; let release; diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js index 3b9b16fa890..c63689e11ac 100644 --- a/spec/frontend/releases/components/release_block_assets_spec.js +++ b/spec/frontend/releases/components/release_block_assets_spec.js @@ -1,13 +1,11 @@ import { GlCollapse } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { getJSONFixture } from 'helpers/fixtures'; +import { assets } from 'test_fixtures/api/releases/release.json'; import { trimText } from 'helpers/text_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue'; import { ASSET_LINK_TYPE } from '~/releases/constants'; -const { assets } = getJSONFixture('api/releases/release.json'); - describe('Release block assets', () => { let wrapper; let defaultProps; diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index e9fa22b4ec7..f645dc309d7 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -1,13 +1,11 @@ import { GlLink, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import { trimText } from 'helpers/text_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; -const originalRelease = getJSONFixture('api/releases/release.json'); - // TODO: Encapsulate date helpers https://gitlab.com/gitlab-org/gitlab/-/issues/320883 const MONTHS_IN_MS = 1000 * 60 * 60 * 24 * 31; const mockFutureDate = new Date(new Date().getTime() + MONTHS_IN_MS).toISOString(); diff --git a/spec/frontend/releases/components/release_block_header_spec.js b/spec/frontend/releases/components/release_block_header_spec.js index 47fd6377fcf..167ae4f32a2 100644 --- a/spec/frontend/releases/components/release_block_header_spec.js +++ b/spec/frontend/releases/components/release_block_header_spec.js @@ -1,14 +1,12 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { merge } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import setWindowLocation from 'helpers/set_window_location_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockHeader from '~/releases/components/release_block_header.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Release block header', () => { let wrapper; let release; diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js index a2bf45c7861..146b2cc7490 100644 --- a/spec/frontend/releases/components/release_block_milestone_info_spec.js +++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js @@ -1,12 +1,12 @@ import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import { trimText } from 'helpers/text_helper'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue'; import { MAX_MILESTONES_TO_DISPLAY } from '~/releases/constants'; -const { milestones: originalMilestones } = getJSONFixture('api/releases/release.json'); +const { milestones: originalMilestones } = originalRelease; describe('Release block milestone info', () => { let wrapper; diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js index 1ca441f7a5a..a847c32b8f1 100644 --- a/spec/frontend/releases/components/release_block_spec.js +++ b/spec/frontend/releases/components/release_block_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import $ from 'jquery'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import * as commonUtils from '~/lib/utils/common_utils'; import * as urlUtility from '~/lib/utils/url_utility'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; @@ -9,8 +9,6 @@ import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Release block', () => { let wrapper; let release; diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 6504a09df2f..d8329fb82b1 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json'; import testAction from 'helpers/vuex_action_helper'; import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -27,10 +27,6 @@ jest.mock('~/releases/util', () => ({ }, })); -const originalOneReleaseForEditingQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json', -); - describe('Release edit/new actions', () => { let state; let releaseResponse; diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index 20ae332e500..24dcedb3580 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -1,12 +1,10 @@ -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; import * as types from '~/releases/stores/modules/edit_new/mutation_types'; import mutations from '~/releases/stores/modules/edit_new/mutations'; import createState from '~/releases/stores/modules/edit_new/state'; -const originalRelease = getJSONFixture('api/releases/release.json'); - describe('Release edit/new mutations', () => { let state; let release; diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js index af520c2eb20..91406f7e2f4 100644 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ b/spec/frontend/releases/stores/modules/list/actions_spec.js @@ -1,5 +1,5 @@ import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalGraphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; import testAction from 'helpers/vuex_action_helper'; import { PAGE_SIZE } from '~/releases/constants'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; @@ -12,10 +12,6 @@ import * as types from '~/releases/stores/modules/index/mutation_types'; import createState from '~/releases/stores/modules/index/state'; import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; -const originalGraphqlReleasesResponse = getJSONFixture( - 'graphql/releases/graphql/queries/all_releases.query.graphql.json', -); - describe('Releases State actions', () => { let mockedState; let graphqlReleasesResponse; diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js index 08d803b3c2c..49e324c28a5 100644 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js @@ -1,17 +1,13 @@ -import { getJSONFixture } from 'helpers/fixtures'; +import originalRelease from 'test_fixtures/api/releases/release.json'; +import graphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import * as types from '~/releases/stores/modules/index/mutation_types'; import mutations from '~/releases/stores/modules/index/mutations'; import createState from '~/releases/stores/modules/index/state'; import { convertAllReleasesGraphQLResponse } from '~/releases/util'; -const originalRelease = getJSONFixture('api/releases/release.json'); const originalReleases = [originalRelease]; -const graphqlReleasesResponse = getJSONFixture( - 'graphql/releases/graphql/queries/all_releases.query.graphql.json', -); - describe('Releases Store Mutations', () => { let stateCopy; let pageInfo; diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index 36e7be369d3..3c1060cb0e8 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -1,21 +1,13 @@ import { cloneDeep } from 'lodash'; -import { getJSONFixture } from 'helpers/fixtures'; +import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; +import originalOneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; +import originalOneReleaseForEditingQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json'; import { convertGraphQLRelease, convertAllReleasesGraphQLResponse, convertOneReleaseGraphQLResponse, } from '~/releases/util'; -const originalAllReleasesQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/all_releases.query.graphql.json', -); -const originalOneReleaseQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/one_release.query.graphql.json', -); -const originalOneReleaseForEditingQueryResponse = getJSONFixture( - 'graphql/releases/graphql/queries/one_release_for_editing.query.graphql.json', -); - describe('releases/util.js', () => { describe('convertGraphQLRelease', () => { let releaseFromResponse; diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js index f99dcbffdff..c548007a8a6 100644 --- a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js +++ b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js @@ -38,6 +38,12 @@ describe('code quality issue body issue body', () => { 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'} diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js index 84863eac3d3..685a1c50a46 100644 --- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js +++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js @@ -60,7 +60,7 @@ describe('Grouped code quality reports app', () => { }); it('should render loading text', () => { - expect(findWidget().text()).toEqual('Loading codeclimate report'); + expect(findWidget().text()).toEqual('Loading Code quality report'); }); }); @@ -84,7 +84,7 @@ describe('Grouped code quality reports app', () => { }); it('renders summary text', () => { - expect(findWidget().text()).toContain('Code quality degraded on 1 point'); + expect(findWidget().text()).toContain('Code quality degraded'); }); it('renders custom codequality issue body', () => { @@ -99,7 +99,7 @@ describe('Grouped code quality reports app', () => { }); it('renders summary text', () => { - expect(findWidget().text()).toContain('Code quality improved on 1 point'); + expect(findWidget().text()).toContain('Code quality improved'); }); it('renders custom codequality issue body', () => { @@ -115,7 +115,7 @@ describe('Grouped code quality reports app', () => { it('renders summary text', () => { expect(findWidget().text()).toContain( - 'Code quality improved on 1 point and degraded on 1 point', + 'Code quality scanning detected 2 changes in merged results', ); }); @@ -132,7 +132,7 @@ describe('Grouped code quality reports app', () => { }); it('renders error text', () => { - expect(findWidget().text()).toContain('Failed to load codeclimate report'); + expect(findWidget().text()).toContain('Failed to load Code quality report'); }); it('does not render a help icon', () => { diff --git a/spec/frontend/reports/codequality_report/store/getters_spec.js b/spec/frontend/reports/codequality_report/store/getters_spec.js index 0378171084d..b5f6edf85eb 100644 --- a/spec/frontend/reports/codequality_report/store/getters_spec.js +++ b/spec/frontend/reports/codequality_report/store/getters_spec.js @@ -61,9 +61,9 @@ describe('Codequality reports store getters', () => { it.each` resolvedIssues | newIssues | expectedText ${0} | ${0} | ${'No changes to code quality'} - ${0} | ${1} | ${'Code quality degraded on 1 point'} - ${2} | ${0} | ${'Code quality improved on 2 points'} - ${1} | ${2} | ${'Code quality improved on 1 point and degraded on 2 points'} + ${0} | ${1} | ${'Code quality degraded'} + ${2} | ${0} | ${'Code quality improved'} + ${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 }) => { diff --git a/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js b/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js index ba95294ab0a..5b77a2c74be 100644 --- a/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js +++ b/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js @@ -25,6 +25,18 @@ describe('Codequality report store utils', () => { }); }); + 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' } }; diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js index e1b36aa1e21..39932b62dbb 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/reports/components/report_section_spec.js @@ -23,7 +23,7 @@ describe('Report section', () => { const defaultProps = { component: '', status: 'SUCCESS', - loadingText: 'Loading codeclimate report', + loadingText: 'Loading Code Quality report', errorText: 'foo', successText: 'Code quality improved on 1 point and degraded on 1 point', resolvedIssues, @@ -117,13 +117,13 @@ describe('Report section', () => { vm = mountComponent(ReportSection, { component: '', status: 'LOADING', - loadingText: 'Loading codeclimate report', + loadingText: 'Loading Code Quality report', errorText: 'foo', successText: 'Code quality improved on 1 point and degraded on 1 point', hasIssues: false, }); - expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report'); + expect(vm.$el.textContent.trim()).toEqual('Loading Code Quality report'); }); }); @@ -229,13 +229,13 @@ describe('Report section', () => { vm = mountComponent(ReportSection, { component: '', status: 'ERROR', - loadingText: 'Loading codeclimate report', - errorText: 'Failed to load codeclimate report', + 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(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report'); + expect(vm.$el.textContent.trim()).toEqual('Failed to load Code Quality report'); }); }); diff --git a/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js index 0f7c2559e8b..c60c1f7b63c 100644 --- a/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/grouped_test_report/grouped_test_reports_app_spec.js @@ -24,7 +24,7 @@ describe('Grouped test reports app', () => { let wrapper; let mockStore; - const mountComponent = ({ props = { pipelinePath }, glFeatures = {} } = {}) => { + const mountComponent = ({ props = { pipelinePath } } = {}) => { wrapper = mount(GroupedTestReportsApp, { store: mockStore, localVue, @@ -34,9 +34,6 @@ describe('Grouped test reports app', () => { pipelinePath, ...props, }, - provide: { - glFeatures, - }, }); }; @@ -114,8 +111,8 @@ describe('Grouped test reports app', () => { setReports(newFailedTestReports); }); - it('tracks service ping metric when enabled', () => { - mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: true } }); + it('tracks service ping metric', () => { + mountComponent(); findExpandButton().trigger('click'); expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); @@ -123,7 +120,7 @@ describe('Grouped test reports app', () => { }); it('only tracks the first expansion', () => { - mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: true } }); + mountComponent(); const expandButton = findExpandButton(); expandButton.trigger('click'); expandButton.trigger('click'); @@ -131,13 +128,6 @@ describe('Grouped test reports app', () => { expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); }); - - it('does not track service ping metric when disabled', () => { - mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: false } }); - findExpandButton().trigger('click'); - - expect(Api.trackRedisHllUserEvent).not.toHaveBeenCalled(); - }); }); describe('with new failed result', () => { diff --git a/spec/frontend/repository/commits_service_spec.js b/spec/frontend/repository/commits_service_spec.js new file mode 100644 index 00000000000..d924974aede --- /dev/null +++ b/spec/frontend/repository/commits_service_spec.js @@ -0,0 +1,84 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service'; +import httpStatus from '~/lib/utils/http_status'; +import createFlash from '~/flash'; +import { I18N_COMMIT_DATA_FETCH_ERROR } from '~/repository/constants'; + +jest.mock('~/flash'); + +describe('commits service', () => { + let mock; + const url = `${gon.relative_url_root || ''}/my-project/-/refs/main/logs_tree/`; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onGet(url).reply(httpStatus.OK, [], {}); + + jest.spyOn(axios, 'get'); + }); + + afterEach(() => { + mock.restore(); + resetRequestedCommits(); + }); + + const requestCommits = (offset, project = 'my-project', path = '', ref = 'main') => + loadCommits(project, path, ref, offset); + + it('calls axios get', async () => { + const offset = 10; + const project = 'my-project'; + const path = 'my-path'; + const ref = 'my-ref'; + const testUrl = `${gon.relative_url_root || ''}/${project}/-/refs/${ref}/logs_tree/${path}`; + + await requestCommits(offset, project, path, ref); + + expect(axios.get).toHaveBeenCalledWith(testUrl, { params: { format: 'json', offset } }); + }); + + it('encodes the path correctly', async () => { + await requestCommits(1, 'some-project', 'with $peci@l ch@rs/'); + + const encodedUrl = '/some-project/-/refs/main/logs_tree/with%20%24peci%40l%20ch%40rs%2F'; + expect(axios.get).toHaveBeenCalledWith(encodedUrl, expect.anything()); + }); + + it('calls axios get once per batch', async () => { + await Promise.all([requestCommits(0), requestCommits(1), requestCommits(23)]); + + expect(axios.get.mock.calls.length).toEqual(1); + }); + + it('calls axios get twice if an offset is larger than 25', async () => { + await requestCommits(100); + + expect(axios.get.mock.calls[0][1]).toEqual({ params: { format: 'json', offset: 75 } }); + expect(axios.get.mock.calls[1][1]).toEqual({ params: { format: 'json', offset: 100 } }); + }); + + it('updates the list of requested offsets', async () => { + await requestCommits(200); + + expect(isRequested(200)).toBe(true); + }); + + it('resets the list of requested offsets', async () => { + await requestCommits(300); + + resetRequestedCommits(); + expect(isRequested(300)).toBe(false); + }); + + it('calls `createFlash` when the request fails', async () => { + const invalidPath = '/#@ some/path'; + const invalidUrl = `${url}${invalidPath}`; + mock.onGet(invalidUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, [], {}); + + await requestCommits(1, 'my-project', invalidPath); + + expect(createFlash).toHaveBeenCalledWith({ message: I18N_COMMIT_DATA_FETCH_ERROR }); + }); +}); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 8331adcdfc2..59db537282b 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -11,13 +11,18 @@ import BlobHeader from '~/blob/components/blob_header.vue'; import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import BlobEdit from '~/repository/components/blob_edit.vue'; +import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; import { loadViewer, viewerProps } from '~/repository/components/blob_viewers'; import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue'; import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue'; import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue'; import blobInfoQuery from '~/repository/queries/blob_info.query.graphql'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { isLoggedIn } from '~/lib/utils/common_utils'; jest.mock('~/repository/components/blob_viewers'); +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/lib/utils/common_utils'); let wrapper; let mockResolver; @@ -34,12 +39,14 @@ const simpleMockData = { webPath: 'some_file.js', editBlobPath: 'some_file.js/edit', ideEditPath: 'some_file.js/ide/edit', + forkAndEditPath: 'some_file.js/fork/edit', + ideForkAndEditPath: 'some_file.js/fork/ide', + canModifyBlob: true, storedExternally: false, rawPath: 'some_file.js', externalStorageUrl: 'some_file.js', replacePath: 'some_file.js/replace', deletePath: 'some_file.js/delete', - forkPath: 'some_file.js/fork', simpleViewer: { fileType: 'text', tooLarge: false, @@ -62,6 +69,8 @@ const projectMockData = { userPermissions: { pushCode: true, downloadCode: true, + createMergeRequestIn: true, + forkProject: true, }, repository: { empty: false, @@ -82,6 +91,8 @@ const createComponentWithApollo = (mockData = {}, inject = {}) => { emptyRepo = defaultEmptyRepo, canPushCode = defaultPushCode, canDownloadCode = defaultDownloadCode, + createMergeRequestIn = projectMockData.userPermissions.createMergeRequestIn, + forkProject = projectMockData.userPermissions.forkProject, pathLocks = [], } = mockData; @@ -89,7 +100,12 @@ const createComponentWithApollo = (mockData = {}, inject = {}) => { data: { project: { id: '1234', - userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode }, + userPermissions: { + pushCode: canPushCode, + downloadCode: canDownloadCode, + createMergeRequestIn, + forkProject, + }, pathLocks: { nodes: pathLocks, }, @@ -158,9 +174,16 @@ describe('Blob content viewer component', () => { const findBlobEdit = () => wrapper.findComponent(BlobEdit); const findBlobContent = () => wrapper.findComponent(BlobContent); const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); + const findForkSuggestion = () => wrapper.findComponent(ForkSuggestion); + + beforeEach(() => { + gon.features = { refactorTextViewer: true }; + isLoggedIn.mockReturnValue(true); + }); afterEach(() => { wrapper.destroy(); + mockAxios.reset(); }); it('renders a GlLoadingIcon component', () => { @@ -183,7 +206,6 @@ describe('Blob content viewer component', () => { it('renders a BlobContent component', () => { expect(findBlobContent().props('loading')).toEqual(false); - expect(findBlobContent().props('content')).toEqual('raw content'); expect(findBlobContent().props('isRawContent')).toBe(true); expect(findBlobContent().props('activeViewer')).toEqual({ fileType: 'text', @@ -192,6 +214,16 @@ describe('Blob content viewer component', () => { renderError: null, }); }); + + describe('legacy viewers', () => { + it('loads a legacy viewer when a viewer component is not available', async () => { + createComponentWithApollo({ blobs: { ...simpleMockData, fileType: 'unknown' } }); + await waitForPromises(); + + expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple'); + }); + }); }); describe('rich viewer', () => { @@ -210,7 +242,6 @@ describe('Blob content viewer component', () => { it('renders a BlobContent component', () => { expect(findBlobContent().props('loading')).toEqual(false); - expect(findBlobContent().props('content')).toEqual('raw content'); expect(findBlobContent().props('isRawContent')).toBe(true); expect(findBlobContent().props('activeViewer')).toEqual({ fileType: 'markup', @@ -241,18 +272,12 @@ describe('Blob content viewer component', () => { }); describe('legacy viewers', () => { - it('does not load a legacy viewer when a rich viewer is not available', async () => { - createComponentWithApollo({ blobs: simpleMockData }); - await waitForPromises(); - - expect(mockAxios.history.get).toHaveLength(0); - }); - - it('loads a legacy viewer when a rich viewer is available', async () => { - createComponentWithApollo({ blobs: richMockData }); + it('loads a legacy viewer when a viewer component is not available', async () => { + createComponentWithApollo({ blobs: { ...richMockData, fileType: 'unknown' } }); await waitForPromises(); expect(mockAxios.history.get).toHaveLength(1); + expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=rich'); }); }); @@ -462,7 +487,7 @@ describe('Blob content viewer component', () => { }); it('does not render if not logged in', async () => { - window.gon.current_user_id = null; + isLoggedIn.mockReturnValueOnce(false); fullFactory({ mockData: { blobInfo: simpleMockData }, @@ -506,4 +531,60 @@ describe('Blob content viewer component', () => { ); }); }); + + describe('edit blob', () => { + beforeEach(() => { + fullFactory({ + mockData: { blobInfo: simpleMockData }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + }); + + it('simple edit redirects to the simple editor', () => { + findBlobEdit().vm.$emit('edit', 'simple'); + expect(redirectTo).toHaveBeenCalledWith(simpleMockData.editBlobPath); + }); + + it('IDE edit redirects to the IDE editor', () => { + findBlobEdit().vm.$emit('edit', 'ide'); + expect(redirectTo).toHaveBeenCalledWith(simpleMockData.ideEditPath); + }); + + it.each` + loggedIn | canModifyBlob | createMergeRequestIn | forkProject | showForkSuggestion + ${true} | ${false} | ${true} | ${true} | ${true} + ${false} | ${false} | ${true} | ${true} | ${false} + ${true} | ${true} | ${false} | ${true} | ${false} + ${true} | ${true} | ${true} | ${false} | ${false} + `( + 'shows/hides a fork suggestion according to a set of conditions', + async ({ + loggedIn, + canModifyBlob, + createMergeRequestIn, + forkProject, + showForkSuggestion, + }) => { + isLoggedIn.mockReturnValueOnce(loggedIn); + fullFactory({ + mockData: { + blobInfo: { ...simpleMockData, canModifyBlob }, + project: { userPermissions: { createMergeRequestIn, forkProject } }, + }, + stubs: { + BlobContent: true, + BlobButtonGroup: true, + }, + }); + + findBlobEdit().vm.$emit('edit', 'simple'); + await nextTick(); + + expect(findForkSuggestion().exists()).toBe(showForkSuggestion); + }, + ); + }); }); diff --git a/spec/frontend/repository/components/blob_edit_spec.js b/spec/frontend/repository/components/blob_edit_spec.js index 11739674bc9..e2de7bc2957 100644 --- a/spec/frontend/repository/components/blob_edit_spec.js +++ b/spec/frontend/repository/components/blob_edit_spec.js @@ -7,6 +7,7 @@ const DEFAULT_PROPS = { editPath: 'some_file.js/edit', webIdePath: 'some_file.js/ide/edit', showEditButton: true, + needsToFork: false, }; describe('BlobEdit component', () => { @@ -56,7 +57,6 @@ describe('BlobEdit component', () => { it('renders the Edit button', () => { createComponent(); - expect(findEditButton().attributes('href')).toBe(DEFAULT_PROPS.editPath); expect(findEditButton().text()).toBe('Edit'); expect(findEditButton()).not.toBeDisabled(); }); @@ -64,7 +64,6 @@ describe('BlobEdit component', () => { it('renders the Web IDE button', () => { createComponent(); - expect(findWebIdeButton().attributes('href')).toBe(DEFAULT_PROPS.webIdePath); expect(findWebIdeButton().text()).toBe('Web IDE'); expect(findWebIdeButton()).not.toBeDisabled(); }); @@ -72,13 +71,14 @@ describe('BlobEdit component', () => { it('renders WebIdeLink component', () => { createComponent(true); - const { editPath: editUrl, webIdePath: webIdeUrl } = DEFAULT_PROPS; + const { editPath: editUrl, webIdePath: webIdeUrl, needsToFork } = DEFAULT_PROPS; expect(findWebIdeLink().props()).toMatchObject({ editUrl, webIdeUrl, isBlob: true, showEditButton: true, + needsToFork, }); }); diff --git a/spec/frontend/repository/components/blob_viewers/video_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/video_viewer_spec.js new file mode 100644 index 00000000000..34448c03b31 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/video_viewer_spec.js @@ -0,0 +1,22 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import VideoViewer from '~/repository/components/blob_viewers/video_viewer.vue'; + +describe('Video Viewer', () => { + let wrapper; + + const propsData = { url: 'some/video.mp4' }; + + const createComponent = () => { + wrapper = shallowMountExtended(VideoViewer, { propsData }); + }; + + const findVideo = () => wrapper.findByTestId('video'); + + it('renders a Video element', () => { + createComponent(); + + expect(findVideo().exists()).toBe(true); + expect(findVideo().attributes('src')).toBe(propsData.url); + expect(findVideo().attributes('controls')).not.toBeUndefined(); + }); +}); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 0733cffe4f4..eb957c635ac 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -2,6 +2,7 @@ import { GlDropdown } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; const defaultMockRoute = { name: 'blobPath', @@ -10,7 +11,7 @@ const defaultMockRoute = { describe('Repository breadcrumbs component', () => { let wrapper; - const factory = (currentPath, extraProps = {}, mockRoute = {}) => { + const factory = (currentPath, extraProps = {}, mockRoute = {}, newDirModal = true) => { const $apollo = { queries: { userPermissions: { @@ -34,10 +35,12 @@ describe('Repository breadcrumbs component', () => { }, $apollo, }, + provide: { glFeatures: { newDirModal } }, }); }; const findUploadBlobModal = () => wrapper.find(UploadBlobModal); + const findNewDirectoryModal = () => wrapper.find(NewDirectoryModal); afterEach(() => { wrapper.destroy(); @@ -121,4 +124,37 @@ describe('Repository breadcrumbs component', () => { expect(findUploadBlobModal().exists()).toBe(true); }); }); + + describe('renders the new directory modal', () => { + describe('with the feature flag enabled', () => { + beforeEach(() => { + window.gon.features = { + newDirModal: true, + }; + factory('/', { canEditTree: true }); + }); + + it('does not render the modal while loading', () => { + expect(findNewDirectoryModal().exists()).toBe(false); + }); + + it('renders the modal once loaded', async () => { + wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } }); + + await wrapper.vm.$nextTick(); + + expect(findNewDirectoryModal().exists()).toBe(true); + }); + }); + + describe('with the feature flag disabled', () => { + it('does not render the modal', () => { + window.gon.features = { + newDirModal: false, + }; + factory('/', { canEditTree: true }, {}, {}, false); + expect(findNewDirectoryModal().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/repository/components/fork_suggestion_spec.js b/spec/frontend/repository/components/fork_suggestion_spec.js new file mode 100644 index 00000000000..36a48a3fdb8 --- /dev/null +++ b/spec/frontend/repository/components/fork_suggestion_spec.js @@ -0,0 +1,44 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ForkSuggestion from '~/repository/components/fork_suggestion.vue'; + +const DEFAULT_PROPS = { forkPath: 'some_file.js/fork' }; + +describe('ForkSuggestion component', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(ForkSuggestion, { + propsData: { ...DEFAULT_PROPS }, + }); + }; + + beforeEach(() => createComponent()); + + afterEach(() => wrapper.destroy()); + + const { i18n } = ForkSuggestion; + const findMessage = () => wrapper.findByTestId('message'); + const findForkButton = () => wrapper.findByTestId('fork'); + const findCancelButton = () => wrapper.findByTestId('cancel'); + + it('renders a message', () => { + expect(findMessage().text()).toBe(i18n.message); + }); + + it('renders a Fork button', () => { + const forkButton = findForkButton(); + + expect(forkButton.text()).toBe(i18n.fork); + expect(forkButton.attributes('href')).toBe(DEFAULT_PROPS.forkPath); + }); + + it('renders a Cancel button', () => { + expect(findCancelButton().text()).toBe(i18n.cancel); + }); + + it('emits a cancel event when Cancel button is clicked', () => { + findCancelButton().vm.$emit('click'); + + expect(wrapper.emitted('cancel')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js new file mode 100644 index 00000000000..fe7f024e3ea --- /dev/null +++ b/spec/frontend/repository/components/new_directory_modal_spec.js @@ -0,0 +1,203 @@ +import { GlModal, GlFormTextarea, GlToggle } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { visitUrl } from '~/lib/utils/url_utility'; +import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), +})); + +const initialProps = { + modalTitle: 'Create New Directory', + modalId: 'modal-new-directory', + commitMessage: 'Add new directory', + targetBranch: 'some-target-branch', + originalBranch: 'master', + canPushCode: true, + path: 'create_dir', +}; + +const defaultFormValue = { + dirName: 'foo', + originalBranch: initialProps.originalBranch, + branchName: initialProps.targetBranch, + commitMessage: initialProps.commitMessage, + createNewMr: true, +}; + +describe('NewDirectoryModal', () => { + let wrapper; + let mock; + + const createComponent = (props = {}) => { + wrapper = shallowMount(NewDirectoryModal, { + propsData: { + ...initialProps, + ...props, + }, + attrs: { + static: true, + visible: true, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + const findDirName = () => wrapper.find('[name="dir_name"]'); + const findBranchName = () => wrapper.find('[name="branch_name"]'); + const findCommitMessage = () => wrapper.findComponent(GlFormTextarea); + const findMrToggle = () => wrapper.findComponent(GlToggle); + + const fillForm = async (inputValue = {}) => { + const { + dirName = defaultFormValue.dirName, + branchName = defaultFormValue.branchName, + commitMessage = defaultFormValue.commitMessage, + createNewMr = true, + } = inputValue; + + await findDirName().vm.$emit('input', dirName); + await findBranchName().vm.$emit('input', branchName); + await findCommitMessage().vm.$emit('input', commitMessage); + await findMrToggle().vm.$emit('change', createNewMr); + await nextTick; + }; + + const submitForm = async () => { + const mockEvent = { preventDefault: jest.fn() }; + findModal().vm.$emit('primary', mockEvent); + await waitForPromises(); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders modal component', () => { + createComponent(); + + const { modalTitle: title } = initialProps; + + expect(findModal().props()).toMatchObject({ + title, + size: 'md', + actionPrimary: { + text: NewDirectoryModal.i18n.PRIMARY_OPTIONS_TEXT, + }, + actionCancel: { + text: 'Cancel', + }, + }); + }); + + describe('form', () => { + it.each` + component | defaultValue | canPushCode | targetBranch | originalBranch | exist + ${findDirName} | ${undefined} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${findBranchName} | ${initialProps.targetBranch} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${findBranchName} | ${undefined} | ${false} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${false} + ${findCommitMessage} | ${initialProps.commitMessage} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${findMrToggle} | ${'true'} | ${true} | ${'new-target-branch'} | ${'master'} | ${true} + ${findMrToggle} | ${'true'} | ${true} | ${'master'} | ${'master'} | ${true} + `( + 'has the correct form fields ', + ({ component, defaultValue, canPushCode, targetBranch, originalBranch, exist }) => { + createComponent({ + canPushCode, + targetBranch, + originalBranch, + }); + const formField = component(); + + if (!exist) { + expect(formField.exists()).toBe(false); + return; + } + + expect(formField.exists()).toBe(true); + expect(formField.attributes('value')).toBe(defaultValue); + }, + ); + }); + + describe('form submission', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('valid form', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes the formData', async () => { + const { + dirName, + branchName, + commitMessage, + originalBranch, + createNewMr, + } = defaultFormValue; + mock.onPost(initialProps.path).reply(httpStatusCodes.OK, {}); + await fillForm(); + await submitForm(); + + expect(mock.history.post[0].data.get('dir_name')).toEqual(dirName); + expect(mock.history.post[0].data.get('branch_name')).toEqual(branchName); + expect(mock.history.post[0].data.get('commit_message')).toEqual(commitMessage); + expect(mock.history.post[0].data.get('original_branch')).toEqual(originalBranch); + expect(mock.history.post[0].data.get('create_merge_request')).toEqual(String(createNewMr)); + }); + + it('does not submit "create_merge_request" formData if createNewMr is not checked', async () => { + mock.onPost(initialProps.path).reply(httpStatusCodes.OK, {}); + await fillForm({ createNewMr: false }); + await submitForm(); + expect(mock.history.post[0].data.get('create_merge_request')).toBeNull(); + }); + + it('redirects to the new directory', async () => { + const response = { filePath: 'new-dir-path' }; + mock.onPost(initialProps.path).reply(httpStatusCodes.OK, response); + + await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' }); + await submitForm(); + + expect(visitUrl).toHaveBeenCalledWith(response.filePath); + }); + }); + + describe('invalid form', () => { + beforeEach(() => { + createComponent(); + }); + + it('disables submit button', async () => { + await fillForm({ dirName: '', branchName: '', commitMessage: '' }); + expect(findModal().props('actionPrimary').attributes[0].disabled).toBe(true); + }); + + it('creates a flash error', async () => { + mock.onPost(initialProps.path).timeout(); + + await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' }); + await submitForm(); + + expect(createFlash).toHaveBeenCalledWith({ + message: NewDirectoryModal.i18n.ERROR_MESSAGE, + }); + }); + }); + }); +}); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index 6f461f4c69b..26064e9b248 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -31,25 +31,36 @@ exports[`Repository table row component renders a symlink table row 1`] = ` <!----> - <!----> + <gl-icon-stub + class="ml-1" + name="lock" + size="12" + title="Locked by Root" + /> </td> <td class="d-none d-sm-table-cell tree-commit cursor-default" > - <gl-skeleton-loading-stub - class="h-auto" - lines="1" + <gl-link-stub + class="str-truncated-100 tree-commit-link" /> + + <gl-intersection-observer-stub> + <!----> + </gl-intersection-observer-stub> </td> <td class="tree-time-ago text-right cursor-default" > - <gl-skeleton-loading-stub - class="ml-auto h-auto w-50" - lines="1" + <timeago-tooltip-stub + cssclass="" + time="2019-01-01" + tooltipplacement="top" /> + + <!----> </td> </tr> `; @@ -85,25 +96,36 @@ exports[`Repository table row component renders table row 1`] = ` <!----> - <!----> + <gl-icon-stub + class="ml-1" + name="lock" + size="12" + title="Locked by Root" + /> </td> <td class="d-none d-sm-table-cell tree-commit cursor-default" > - <gl-skeleton-loading-stub - class="h-auto" - lines="1" + <gl-link-stub + class="str-truncated-100 tree-commit-link" /> + + <gl-intersection-observer-stub> + <!----> + </gl-intersection-observer-stub> </td> <td class="tree-time-ago text-right cursor-default" > - <gl-skeleton-loading-stub - class="ml-auto h-auto w-50" - lines="1" + <timeago-tooltip-stub + cssclass="" + time="2019-01-01" + tooltipplacement="top" /> + + <!----> </td> </tr> `; @@ -139,25 +161,36 @@ exports[`Repository table row component renders table row for path with special <!----> - <!----> + <gl-icon-stub + class="ml-1" + name="lock" + size="12" + title="Locked by Root" + /> </td> <td class="d-none d-sm-table-cell tree-commit cursor-default" > - <gl-skeleton-loading-stub - class="h-auto" - lines="1" + <gl-link-stub + class="str-truncated-100 tree-commit-link" /> + + <gl-intersection-observer-stub> + <!----> + </gl-intersection-observer-stub> </td> <td class="tree-time-ago text-right cursor-default" > - <gl-skeleton-loading-stub - class="ml-auto h-auto w-50" - lines="1" + <timeago-tooltip-stub + cssclass="" + time="2019-01-01" + tooltipplacement="top" /> + + <!----> </td> </tr> `; diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index e9e51abaf0f..c8dddefc4f2 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -34,17 +34,45 @@ const MOCK_BLOBS = [ }, ]; -function factory({ path, isLoading = false, hasMore = true, entries = {} }) { +const MOCK_COMMITS = [ + { + fileName: 'blob.md', + type: 'blob', + commit: { + message: 'Updated blob.md', + }, + }, + { + fileName: 'blob2.md', + type: 'blob', + commit: { + message: 'Updated blob2.md', + }, + }, + { + fileName: 'blob3.md', + type: 'blob', + commit: { + message: 'Updated blob3.md', + }, + }, +]; + +function factory({ path, isLoading = false, hasMore = true, entries = {}, commits = [] }) { vm = shallowMount(Table, { propsData: { path, isLoading, entries, hasMore, + commits, }, mocks: { $apollo, }, + provide: { + glFeatures: { lazyLoadCommits: true }, + }, }); } @@ -82,12 +110,15 @@ describe('Repository table component', () => { entries: { blobs: MOCK_BLOBS, }, + commits: MOCK_COMMITS, }); const rows = vm.findAll(TableRow); expect(rows.length).toEqual(3); expect(rows.at(2).attributes().mode).toEqual('120000'); + expect(rows.at(2).props().rowNumber).toBe(2); + expect(rows.at(2).props().commitInfo).toEqual(MOCK_COMMITS[2]); }); describe('Show more button', () => { diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index da28c9873d9..76e9f7da011 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -1,10 +1,12 @@ -import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; +import { GlBadge, GlLink, GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import TableRow from '~/repository/components/table/row.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { FILE_SYMLINK_MODE } from '~/vue_shared/constants'; +const COMMIT_MOCK = { lockLabel: 'Locked by Root', committedDate: '2019-01-01' }; + let vm; let $router; @@ -20,12 +22,14 @@ function factory(propsData = {}) { projectPath: 'gitlab-org/gitlab-ce', url: `https://test.com`, totalEntries: 10, + commitInfo: COMMIT_MOCK, + rowNumber: 123, }, directives: { GlHoverLoad: createMockDirective(), }, provide: { - glFeatures: { refactorBlobViewer: true }, + glFeatures: { refactorBlobViewer: true, lazyLoadCommits: true }, }, mocks: { $router, @@ -40,6 +44,7 @@ function factory(propsData = {}) { describe('Repository table row component', () => { const findRouterLink = () => vm.find(RouterLinkStub); + const findIntersectionObserver = () => vm.findComponent(GlIntersectionObserver); afterEach(() => { vm.destroy(); @@ -226,8 +231,6 @@ describe('Repository table row component', () => { currentPath: '/', }); - vm.setData({ commit: { lockLabel: 'Locked by Root', committedDate: '2019-01-01' } }); - return vm.vm.$nextTick().then(() => { expect(vm.find(GlIcon).exists()).toBe(true); expect(vm.find(GlIcon).props('name')).toBe('lock'); @@ -246,4 +249,27 @@ describe('Repository table row component', () => { expect(vm.find(FileIcon).props('loading')).toBe(true); }); + + describe('row visibility', () => { + beforeEach(() => { + factory({ + id: '1', + sha: '1', + path: 'test', + type: 'tree', + currentPath: '/', + }); + }); + it('emits a `row-appear` event', () => { + findIntersectionObserver().vm.$emit('appear'); + expect(vm.emitted('row-appear')).toEqual([ + [ + { + hasCommit: true, + rowNumber: 123, + }, + ], + ]); + }); + }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index e36287eff29..49397c77215 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -3,6 +3,13 @@ import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.g import FilePreview from '~/repository/components/preview/index.vue'; import FileTable from '~/repository/components/table/index.vue'; import TreeContent from '~/repository/components/tree_content.vue'; +import { loadCommits, isRequested, resetRequestedCommits } from '~/repository/commits_service'; + +jest.mock('~/repository/commits_service', () => ({ + loadCommits: jest.fn(() => Promise.resolve()), + isRequested: jest.fn(), + resetRequestedCommits: jest.fn(), +})); let vm; let $apollo; @@ -23,6 +30,7 @@ function factory(path, data = () => ({})) { glFeatures: { increasePageSizeExponentially: true, paginatedTreeGraphqlQuery: true, + lazyLoadCommits: true, }, }, }); @@ -45,7 +53,7 @@ describe('Repository table component', () => { expect(vm.find(FilePreview).exists()).toBe(true); }); - it('trigger fetchFiles when mounted', async () => { + it('trigger fetchFiles and resetRequestedCommits when mounted', async () => { factory('/'); jest.spyOn(vm.vm, 'fetchFiles').mockImplementation(() => {}); @@ -53,6 +61,7 @@ describe('Repository table component', () => { await vm.vm.$nextTick(); expect(vm.vm.fetchFiles).toHaveBeenCalled(); + expect(resetRequestedCommits).toHaveBeenCalled(); }); describe('normalizeData', () => { @@ -180,4 +189,15 @@ describe('Repository table component', () => { }); }); }); + + it('loads commit data when row-appear event is emitted', () => { + const path = 'some/path'; + const rowNumber = 1; + + factory(path); + findFileTable().vm.$emit('row-appear', { hasCommit: false, rowNumber }); + + expect(isRequested).toHaveBeenCalledWith(rowNumber); + expect(loadCommits).toHaveBeenCalledWith('', path, '', rowNumber); + }); }); diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js index bb82fa706fd..3f822db601f 100644 --- a/spec/frontend/repository/router_spec.js +++ b/spec/frontend/repository/router_spec.js @@ -24,4 +24,32 @@ describe('Repository router spec', () => { expect(componentsForRoute).toContain(component); } }); + + describe('Storing Web IDE path globally', () => { + const proj = 'foo-bar-group/foo-bar-proj'; + let originalGl; + + beforeEach(() => { + originalGl = window.gl; + }); + + afterEach(() => { + window.gl = originalGl; + }); + + it.each` + path | branch | expectedPath + ${'/'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/`} + ${'/tree/main'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/`} + ${'/tree/feat(test)'} | ${'feat(test)'} | ${`/-/ide/project/${proj}/edit/feat(test)/-/`} + ${'/-/tree/main'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/`} + ${'/-/tree/main/app/assets'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/app/assets/`} + ${'/-/blob/main/file.md'} | ${'main'} | ${`/-/ide/project/${proj}/edit/main/-/file.md`} + `('generates the correct Web IDE url for $path', ({ path, branch, expectedPath } = {}) => { + const router = createRouter(proj, branch); + + router.push(path); + expect(window.gl.webIDEPath).toBe(expectedPath); + }); + }); }); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 3292f635f6b..33e9c122080 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -1,3 +1,4 @@ +import { GlLink } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -5,6 +6,7 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; @@ -12,7 +14,6 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ import RunnerList from '~/runner/components/runner_list.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; -import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import { ADMIN_FILTERED_SEARCH_NAMESPACE, @@ -49,7 +50,6 @@ describe('AdminRunnersApp', () => { let wrapper; let mockRunnersQuery; - const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -86,10 +86,6 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); - it('shows the runner type help', () => { - expect(findRunnerTypeHelp().exists()).toBe(true); - }); - it('shows the runner setup instructions', () => { expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); }); @@ -98,6 +94,20 @@ describe('AdminRunnersApp', () => { expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); }); + it('runner item links to the runner admin page', async () => { + createComponent({ mountFn: mount }); + + await waitForPromises(); + + const { id, shortSha } = runnersData.data.runners.nodes[0]; + const numericId = getIdFromGraphQLId(id); + + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); + + expect(runnerLink.text()).toBe(`#${numericId} (${shortSha})`); + expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`); + }); + it('requests the runners with no filters', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ status: undefined, diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js index 95f7c38cafc..5aa3879ac3e 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -5,15 +5,18 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; +import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; import { captureException } from '~/runner/sentry_utils'; -import { runnerData } from '../../mock_data'; +import { runnersData, runnerData } from '../../mock_data'; -const mockRunner = runnerData.data.runner; +const mockRunner = runnersData.data.runners.nodes[0]; +const mockRunnerDetails = runnerData.data.runner; const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; +const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value; const localVue = createLocalVue(); localVue.use(VueApollo); @@ -36,6 +39,7 @@ describe('RunnerTypeCell', () => { propsData: { runner: { id: mockRunner.id, + adminUrl: mockRunner.adminUrl, active, }, }, @@ -61,7 +65,7 @@ describe('RunnerTypeCell', () => { runnerUpdateMutationHandler.mockResolvedValue({ data: { runnerUpdate: { - runner: runnerData.data.runner, + runner: mockRunnerDetails, errors: [], }, }, @@ -78,7 +82,7 @@ describe('RunnerTypeCell', () => { it('Displays the runner edit link with the correct href', () => { createComponent(); - expect(findEditBtn().attributes('href')).toBe('/admin/runners/1'); + expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl); }); describe.each` @@ -231,7 +235,7 @@ describe('RunnerTypeCell', () => { }, }, awaitRefetchQueries: true, - refetchQueries: [getRunnersQueryName], + refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName], }); }); diff --git a/spec/frontend/runner/components/cells/runner_name_cell_spec.js b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js index 26055fc0faf..1c9282e0acd 100644 --- a/spec/frontend/runner/components/cells/runner_name_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js @@ -1,6 +1,5 @@ -import { GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import RunnerNameCell from '~/runner/components/cells/runner_name_cell.vue'; +import RunnerSummaryCell from '~/runner/components/cells/runner_summary_cell.vue'; const mockId = '1'; const mockShortSha = '2P6oDVDm'; @@ -9,10 +8,8 @@ const mockDescription = 'runner-1'; describe('RunnerTypeCell', () => { let wrapper; - const findLink = () => wrapper.findComponent(GlLink); - - const createComponent = () => { - wrapper = mount(RunnerNameCell, { + const createComponent = (options) => { + wrapper = mount(RunnerSummaryCell, { propsData: { runner: { id: `gid://gitlab/Ci::Runner/${mockId}`, @@ -20,6 +17,7 @@ describe('RunnerTypeCell', () => { description: mockDescription, }, }, + ...options, }); }; @@ -31,12 +29,23 @@ describe('RunnerTypeCell', () => { wrapper.destroy(); }); - it('Displays the runner link with id and short token', () => { - expect(findLink().text()).toBe(`#${mockId} (${mockShortSha})`); - expect(findLink().attributes('href')).toBe(`/admin/runners/${mockId}`); + it('Displays the runner name as id and short token', () => { + expect(wrapper.text()).toContain(`#${mockId} (${mockShortSha})`); }); it('Displays the runner description', () => { expect(wrapper.text()).toContain(mockDescription); }); + + it('Displays a custom slot', () => { + const slotContent = 'My custom runner summary'; + + createComponent({ + slots: { + 'runner-name': slotContent, + }, + }); + + expect(wrapper.text()).toContain(slotContent); + }); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 344d1e5c150..e24dffea1eb 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -1,4 +1,4 @@ -import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui'; +import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -67,11 +67,11 @@ describe('RunnerList', () => { // Badges expect(findCell({ fieldKey: 'type' }).text()).toMatchInterpolatedText('specific paused'); - // Runner identifier - expect(findCell({ fieldKey: 'name' }).text()).toContain( + // Runner summary + expect(findCell({ fieldKey: 'summary' }).text()).toContain( `#${getIdFromGraphQLId(id)} (${shortSha})`, ); - expect(findCell({ fieldKey: 'name' }).text()).toContain(description); + expect(findCell({ fieldKey: 'summary' }).text()).toContain(description); // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); @@ -136,12 +136,11 @@ describe('RunnerList', () => { }); }); - it('Links to the runner page', () => { - const { id } = mockRunners[0]; + it('Shows runner identifier', () => { + const { id, shortSha } = mockRunners[0]; + const numericId = getIdFromGraphQLId(id); - expect(findCell({ fieldKey: 'name' }).find(GlLink).attributes('href')).toBe( - `/admin/runners/${getIdFromGraphQLId(id)}`, - ); + expect(findCell({ fieldKey: 'summary' }).text()).toContain(`#${numericId} (${shortSha})`); }); describe('When data is loading', () => { diff --git a/spec/frontend/runner/components/runner_state_locked_badge_spec.js b/spec/frontend/runner/components/runner_state_locked_badge_spec.js new file mode 100644 index 00000000000..e92b671f5a1 --- /dev/null +++ b/spec/frontend/runner/components/runner_state_locked_badge_spec.js @@ -0,0 +1,45 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerStateLockedBadge from '~/runner/components/runner_state_locked_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerStateLockedBadge, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders locked state', () => { + expect(wrapper.text()).toBe('locked'); + expect(findBadge().props('variant')).toBe('warning'); + }); + + it('renders tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); + + it('passes arbitrary attributes to the badge', () => { + createComponent({ props: { size: 'sm' } }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/runner/components/runner_state_paused_badge_spec.js b/spec/frontend/runner/components/runner_state_paused_badge_spec.js new file mode 100644 index 00000000000..8df56d6e3f3 --- /dev/null +++ b/spec/frontend/runner/components/runner_state_paused_badge_spec.js @@ -0,0 +1,45 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerStatePausedBadge from '~/runner/components/runner_state_paused_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerStatePausedBadge, { + propsData: { + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders paused state', () => { + expect(wrapper.text()).toBe('paused'); + expect(findBadge().props('variant')).toBe('danger'); + }); + + it('renders tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); + + it('passes arbitrary attributes to the badge', () => { + createComponent({ props: { size: 'sm' } }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/runner/components/runner_type_badge_spec.js b/spec/frontend/runner/components/runner_type_badge_spec.js index ab5ccf6390f..fb344e65389 100644 --- a/spec/frontend/runner/components/runner_type_badge_spec.js +++ b/spec/frontend/runner/components/runner_type_badge_spec.js @@ -1,18 +1,23 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; describe('RunnerTypeBadge', () => { let wrapper; const findBadge = () => wrapper.findComponent(GlBadge); + const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(RunnerTypeBadge, { propsData: { ...props, }, + directives: { + GlTooltip: createMockDirective(), + }, }); }; @@ -20,16 +25,24 @@ describe('RunnerTypeBadge', () => { wrapper.destroy(); }); - it.each` + describe.each` type | text | variant ${INSTANCE_TYPE} | ${'shared'} | ${'success'} ${GROUP_TYPE} | ${'group'} | ${'success'} ${PROJECT_TYPE} | ${'specific'} | ${'info'} - `('displays $type runner with as "$text" with a $variant variant ', ({ type, text, variant }) => { - createComponent({ props: { type } }); + `('displays $type runner', ({ type, text, variant }) => { + beforeEach(() => { + createComponent({ props: { type } }); + }); - expect(findBadge().text()).toBe(text); - expect(findBadge().props('variant')).toBe(variant); + it(`as "${text}" with a ${variant} variant`, () => { + expect(findBadge().text()).toBe(text); + expect(findBadge().props('variant')).toBe(variant); + }); + + it('with a tooltip', () => { + expect(getTooltip().value).toBeDefined(); + }); }); it('validation fails for an incorrect type', () => { diff --git a/spec/frontend/runner/components/runner_type_help_spec.js b/spec/frontend/runner/components/runner_type_help_spec.js deleted file mode 100644 index f0d03282f8e..00000000000 --- a/spec/frontend/runner/components/runner_type_help_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; - -describe('RunnerTypeHelp', () => { - let wrapper; - - const findBadges = () => wrapper.findAllComponents(GlBadge); - - const createComponent = () => { - wrapper = mount(RunnerTypeHelp); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('Displays each of the runner types', () => { - expect(findBadges().at(0).text()).toBe('shared'); - expect(findBadges().at(1).text()).toBe('group'); - expect(findBadges().at(2).text()).toBe('specific'); - }); - - it('Displays runner states', () => { - expect(findBadges().at(3).text()).toBe('locked'); - expect(findBadges().at(4).text()).toBe('paused'); - }); -}); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index e80da40e3bd..5f3aabd4bc3 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -1,3 +1,4 @@ +import { GlLink } from '@gitlab/ui'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -5,13 +6,13 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory } from '~/lib/utils/url_utility'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue'; -import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import { CREATED_ASC, @@ -34,8 +35,7 @@ localVue.use(VueApollo); const mockGroupFullPath = 'group1'; const mockRegistrationToken = 'AABBCC'; -const mockRunners = groupRunnersData.data.group.runners.nodes; -const mockGroupRunnersLimitedCount = mockRunners.length; +const mockGroupRunnersLimitedCount = groupRunnersData.data.group.runners.edges.length; jest.mock('~/flash'); jest.mock('~/runner/sentry_utils'); @@ -48,7 +48,6 @@ describe('GroupRunnersApp', () => { let wrapper; let mockGroupRunnersQuery; - const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -82,16 +81,27 @@ describe('GroupRunnersApp', () => { await waitForPromises(); }); - it('shows the runner type help', () => { - expect(findRunnerTypeHelp().exists()).toBe(true); - }); - it('shows the runner setup instructions', () => { expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); }); it('shows the runners list', () => { - expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes); + expect(findRunnerList().props('runners')).toEqual( + groupRunnersData.data.group.runners.edges.map(({ node }) => node), + ); + }); + + it('runner item links to the runner group page', async () => { + const { webUrl, node } = groupRunnersData.data.group.runners.edges[0]; + const { id, shortSha } = node; + + createComponent({ mountFn: mount }); + + await waitForPromises(); + + const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink); + expect(runnerLink.text()).toBe(`#${getIdFromGraphQLId(id)} (${shortSha})`); + expect(runnerLink.attributes('href')).toBe(webUrl); }); it('requests the runners with group path and no other filters', () => { diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index c90b9a4c426..b8d0f1273c7 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -1,14 +1,18 @@ -const runnerFixture = (filename) => getJSONFixture(`graphql/runner/${filename}`); - // Fixtures generated by: spec/frontend/fixtures/runner.rb // Admin queries -export const runnersData = runnerFixture('get_runners.query.graphql.json'); -export const runnersDataPaginated = runnerFixture('get_runners.query.graphql.paginated.json'); -export const runnerData = runnerFixture('get_runner.query.graphql.json'); +import runnersData from 'test_fixtures/graphql/runner/get_runners.query.graphql.json'; +import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json'; +import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json'; // Group queries -export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json'); -export const groupRunnersDataPaginated = runnerFixture( - 'get_group_runners.query.graphql.paginated.json', -); +import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json'; +import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.paginated.json'; + +export { + runnerData, + runnersDataPaginated, + runnersData, + groupRunnersData, + groupRunnersDataPaginated, +}; diff --git a/spec/frontend/search_settings/components/search_settings_spec.js b/spec/frontend/search_settings/components/search_settings_spec.js index 173936e1ce3..6beaea8dba5 100644 --- a/spec/frontend/search_settings/components/search_settings_spec.js +++ b/spec/frontend/search_settings/components/search_settings_spec.js @@ -11,6 +11,7 @@ describe('search_settings/components/search_settings.vue', () => { const GENERAL_SETTINGS_ID = 'js-general-settings'; const ADVANCED_SETTINGS_ID = 'js-advanced-settings'; const EXTRA_SETTINGS_ID = 'js-extra-settings'; + const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM} and <script>alert("111")</script> others.`; let wrapper; @@ -33,6 +34,21 @@ describe('search_settings/components/search_settings.vue', () => { const visibleSectionsCount = () => document.querySelectorAll(`${SECTION_SELECTOR}:not(.${HIDE_CLASS})`).length; const highlightedElementsCount = () => document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).length; + + const highlightedTextNodes = () => { + const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)); + return highlightedList.every((element) => { + return element.textContent.toLowerCase() === SEARCH_TERM.toLowerCase(); + }); + }; + + const matchParentElement = () => { + const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)); + return highlightedList.map((element) => { + return element.parentNode; + }); + }; + const findSearchBox = () => wrapper.find(GlSearchBoxByType); const search = (term) => { findSearchBox().vm.$emit('input', term); @@ -52,6 +68,7 @@ describe('search_settings/components/search_settings.vue', () => { </section> <section id="${EXTRA_SETTINGS_ID}" class="settings"> <span>${SEARCH_TERM}</span> + <span>${TEXT_CONTAIN_SEARCH_TERM}</span> </section> </div> </div> @@ -82,7 +99,23 @@ describe('search_settings/components/search_settings.vue', () => { it('highlight elements that match the search term', () => { search(SEARCH_TERM); - expect(highlightedElementsCount()).toBe(1); + expect(highlightedElementsCount()).toBe(2); + }); + + it('highlight only search term and not the whole line', () => { + search(SEARCH_TERM); + + expect(highlightedTextNodes()).toBe(true); + }); + + it('prevents search xss', () => { + search(SEARCH_TERM); + + const parentNodeList = matchParentElement(); + parentNodeList.forEach((element) => { + const scriptElement = element.getElementsByTagName('script'); + expect(scriptElement.length).toBe(0); + }); }); describe('default', () => { diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js index be27a800418..b3a67f18f82 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/assignees_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; import UsersMockHelper from 'helpers/user_mock_data_helper'; import Assignee from '~/sidebar/components/assignees/assignees.vue'; +import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue'; import UsersMock from './mock_data'; describe('Assignee component', () => { @@ -19,6 +20,7 @@ describe('Assignee component', () => { }); }; + const findAllAvatarLinks = () => wrapper.findAllComponents(AssigneeAvatarLink); const findComponentTextNoUsers = () => wrapper.find('[data-testid="no-value"]'); const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *'); @@ -148,7 +150,7 @@ describe('Assignee component', () => { editable: true, }); - expect(wrapper.findAll('.user-item').length).toBe(users.length); + expect(findAllAvatarLinks()).toHaveLength(users.length); expect(wrapper.find('.user-list-more').exists()).toBe(false); }); @@ -178,9 +180,9 @@ describe('Assignee component', () => { users, }); - const userItems = wrapper.findAll('.user-list .user-item a'); + const userItems = findAllAvatarLinks(); - expect(userItems.length).toBe(3); + expect(userItems).toHaveLength(3); expect(userItems.at(0).attributes('title')).toBe(users[2].name); }); diff --git a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js index 9f6878db785..6b80224083a 100644 --- a/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js +++ b/spec/frontend/sidebar/components/reviewers/uncollapsed_reviewer_list_spec.js @@ -26,9 +26,9 @@ describe('UncollapsedReviewerList component', () => { }); describe('single reviewer', () => { - beforeEach(() => { - const user = userDataMock(); + const user = userDataMock(); + beforeEach(() => { createComponent({ users: [user], }); @@ -39,6 +39,7 @@ describe('UncollapsedReviewerList component', () => { }); it('shows one user with avatar, username and author name', () => { + expect(wrapper.text()).toContain(user.name); expect(wrapper.text()).toContain(`@root`); }); @@ -56,11 +57,18 @@ describe('UncollapsedReviewerList component', () => { }); describe('multiple reviewers', () => { - beforeEach(() => { - const user = userDataMock(); + const user = userDataMock(); + const user2 = { + ...user, + id: 2, + name: 'nonrooty-nonrootersen', + username: 'hello-world', + approved: true, + }; + beforeEach(() => { createComponent({ - users: [user, { ...user, id: 2, username: 'hello-world', approved: true }], + users: [user, user2], }); }); @@ -69,7 +77,9 @@ describe('UncollapsedReviewerList component', () => { }); it('shows both users with avatar, username and author name', () => { + expect(wrapper.text()).toContain(user.name); expect(wrapper.text()).toContain(`@root`); + expect(wrapper.text()).toContain(user2.name); expect(wrapper.text()).toContain(`@hello-world`); }); diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index 7455f684380..8437ee1b723 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -27,6 +27,7 @@ describe('sidebar labels', () => { labelsManagePath: '/gitlab-org/gitlab-test/-/labels', projectIssuesPath: '/gitlab-org/gitlab-test/-/issues', projectPath: 'gitlab-org/gitlab-test', + fullPath: 'gitlab-org/gitlab-test', }; const $apollo = { @@ -110,10 +111,9 @@ describe('sidebar labels', () => { mutation: updateIssueLabelsMutation, variables: { input: { - addLabelIds: [40], iid: defaultProps.iid, projectPath: defaultProps.projectPath, - removeLabelIds: [26, 55], + labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)], }, }, }; diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/todo_spec.js index ff6da3abad0..6829e688c65 100644 --- a/spec/frontend/sidebar/todo_spec.js +++ b/spec/frontend/sidebar/todo_spec.js @@ -27,7 +27,7 @@ describe('SidebarTodo', () => { it.each` state | classes ${false} | ${['gl-button', 'btn', 'btn-default', 'btn-todo', 'issuable-header-btn', 'float-right']} - ${true} | ${['btn-blank', 'btn-todo', 'sidebar-collapsed-icon', 'dont-change-state']} + ${true} | ${['btn-blank', 'btn-todo', 'sidebar-collapsed-icon', 'js-dont-change-state']} `('returns todo button classes for when `collapsed` prop is `$state`', ({ state, classes }) => { createComponent({ collapsed: state }); expect(wrapper.find('button').classes()).toStrictEqual(classes); diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index b7b638b5137..af61f4ea54f 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -41,19 +41,23 @@ describe('Snippet view app', () => { }, }); } + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findEmbedDropdown = () => wrapper.findComponent(EmbedDropdown); + afterEach(() => { wrapper.destroy(); }); it('renders loader while the query is in flight', () => { createComponent({ loading: true }); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(true); }); - it('renders all simple components after the query is finished', () => { + it('renders all simple components required after the query is finished', () => { createComponent(); - expect(wrapper.find(SnippetHeader).exists()).toBe(true); - expect(wrapper.find(SnippetTitle).exists()).toBe(true); + expect(wrapper.findComponent(SnippetHeader).exists()).toBe(true); + expect(wrapper.findComponent(SnippetTitle).exists()).toBe(true); }); it('renders embed dropdown component if visibility allows', () => { @@ -65,7 +69,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.find(EmbedDropdown).exists()).toBe(true); + expect(findEmbedDropdown().exists()).toBe(true); }); it('renders correct snippet-blob components', () => { @@ -98,7 +102,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.find(EmbedDropdown).exists()).toBe(isRendered); + expect(findEmbedDropdown().exists()).toBe(isRendered); }); }); @@ -120,7 +124,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.find(CloneDropdownButton).exists()).toBe(isRendered); + expect(wrapper.findComponent(CloneDropdownButton).exists()).toBe(isRendered); }, ); }); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index fb95be3a77c..552a1c6fcde 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -1,23 +1,30 @@ import { GlButton, GlModal, GlDropdown } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; +import MockAdapter from 'axios-mock-adapter'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; -import SnippetHeader from '~/snippets/components/snippet_header.vue'; +import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue'; import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; +import axios from '~/lib/utils/axios_utils'; +import createFlash, { FLASH_TYPES } from '~/flash'; + +jest.mock('~/flash'); describe('Snippet header component', () => { let wrapper; let snippet; let mutationTypes; let mutationVariables; + let mock; let errorMsg; let err; const originalRelativeUrlRoot = gon.relative_url_root; const reportAbusePath = '/-/snippets/42/mark_as_spam'; + const canReportSpam = true; const GlEmoji = { template: '<img/>' }; @@ -47,6 +54,7 @@ describe('Snippet header component', () => { mocks: { $apollo }, provide: { reportAbusePath, + canReportSpam, ...provide, }, propsData: { @@ -118,10 +126,13 @@ describe('Snippet header component', () => { RESOLVE: jest.fn(() => Promise.resolve({ data: { destroySnippet: { errors: [] } } })), REJECT: jest.fn(() => Promise.reject(err)), }; + + mock = new MockAdapter(axios); }); afterEach(() => { wrapper.destroy(); + mock.restore(); gon.relative_url_root = originalRelativeUrlRoot; }); @@ -186,7 +197,6 @@ describe('Snippet header component', () => { { category: 'primary', disabled: false, - href: reportAbusePath, text: 'Submit as spam', variant: 'default', }, @@ -205,7 +215,6 @@ describe('Snippet header component', () => { text: 'Delete', }, { - href: reportAbusePath, text: 'Submit as spam', title: 'Submit as spam', }, @@ -249,6 +258,31 @@ describe('Snippet header component', () => { ); }); + describe('submit snippet as spam', () => { + beforeEach(async () => { + createComponent(); + }); + + it.each` + request | variant | text + ${200} | ${'SUCCESS'} | ${i18n.snippetSpamSuccess} + ${500} | ${'DANGER'} | ${i18n.snippetSpamFailure} + `( + 'renders a "$variant" flash message with "$text" message for a request with a "$request" response', + async ({ request, variant, text }) => { + const submitAsSpamBtn = findButtons().at(2); + mock.onPost(reportAbusePath).reply(request); + submitAsSpamBtn.trigger('click'); + await waitForPromises(); + + expect(createFlash).toHaveBeenLastCalledWith({ + message: expect.stringContaining(text), + type: FLASH_TYPES[variant], + }); + }, + ); + }); + describe('with guest user', () => { beforeEach(() => { createComponent({ @@ -258,6 +292,7 @@ describe('Snippet header component', () => { }, provide: { reportAbusePath: null, + canReportSpam: false, }, }); }); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 4d1b0f54e42..2c8e0fff848 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -6,7 +6,7 @@ import { setGlobalDateToFakeDate } from 'helpers/fake_date'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; import Translate from '~/vue_shared/translate'; -import { getJSONFixture, loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures'; +import { loadHTMLFixture, setHTMLFixture } from './__helpers__/fixtures'; import { initializeTestTimeout } from './__helpers__/timeout'; import customMatchers from './matchers'; import { setupManualMocks } from './mocks/mocks_helper'; @@ -43,7 +43,6 @@ Vue.use(Translate); // convenience wrapper for migration from Karma Object.assign(global, { - getJSONFixture, loadFixtures: loadHTMLFixture, setFixtures: setHTMLFixture, }); diff --git a/spec/frontend/tracking/get_standard_context_spec.js b/spec/frontend/tracking/get_standard_context_spec.js index b7bdc56b801..ada914b586c 100644 --- a/spec/frontend/tracking/get_standard_context_spec.js +++ b/spec/frontend/tracking/get_standard_context_spec.js @@ -1,5 +1,13 @@ -import { SNOWPLOW_JS_SOURCE } from '~/tracking/constants'; +import { SNOWPLOW_JS_SOURCE, GOOGLE_ANALYTICS_ID_COOKIE_NAME } from '~/tracking/constants'; import getStandardContext from '~/tracking/get_standard_context'; +import { setCookie, removeCookie } from '~/lib/utils/common_utils'; + +const TEST_GA_ID = 'GA1.2.345678901.234567891'; +const TEST_BASE_DATA = { + source: SNOWPLOW_JS_SOURCE, + google_analytics_id: '', + extra: {}, +}; describe('~/tracking/get_standard_context', () => { beforeEach(() => { @@ -10,10 +18,7 @@ describe('~/tracking/get_standard_context', () => { it('returns default object if called without server context', () => { expect(getStandardContext()).toStrictEqual({ schema: undefined, - data: { - source: SNOWPLOW_JS_SOURCE, - extra: {}, - }, + data: TEST_BASE_DATA, }); }); @@ -28,9 +33,8 @@ describe('~/tracking/get_standard_context', () => { expect(getStandardContext()).toStrictEqual({ schema: 'iglu:com.gitlab/gitlab_standard', data: { + ...TEST_BASE_DATA, environment: 'testing', - source: SNOWPLOW_JS_SOURCE, - extra: {}, }, }); }); @@ -50,4 +54,15 @@ describe('~/tracking/get_standard_context', () => { expect(getStandardContext({ extra }).data.extra).toBe(extra); }); + + describe('with Google Analytics cookie present', () => { + afterEach(() => { + removeCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME); + }); + + it('appends Google Analytics ID', () => { + setCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME, TEST_GA_ID); + expect(getStandardContext().data.google_analytics_id).toBe(TEST_GA_ID); + }); + }); }); diff --git a/spec/frontend/tracking/tracking_initialization_spec.js b/spec/frontend/tracking/tracking_initialization_spec.js new file mode 100644 index 00000000000..2b70aacc4cb --- /dev/null +++ b/spec/frontend/tracking/tracking_initialization_spec.js @@ -0,0 +1,140 @@ +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData, getAllExperimentContexts } from '~/experimentation/utils'; +import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; +import getStandardContext from '~/tracking/get_standard_context'; + +jest.mock('~/experimentation/utils', () => ({ + getExperimentData: jest.fn(), + getAllExperimentContexts: jest.fn(), +})); + +describe('Tracking', () => { + let standardContext; + let snowplowSpy; + let bindDocumentSpy; + let trackLoadEventsSpy; + let enableFormTracking; + let setAnonymousUrlsSpy; + + beforeAll(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = { + schema: 'iglu:com.gitlab/gitlab_standard', + data: { + environment: 'testing', + source: 'unknown', + extra: {}, + }, + }; + + standardContext = getStandardContext(); + }); + + beforeEach(() => { + getExperimentData.mockReturnValue(undefined); + getAllExperimentContexts.mockReturnValue([]); + + window.snowplow = window.snowplow || (() => {}); + window.snowplowOptions = { + namespace: 'gl_test', + hostname: 'app.test.com', + cookieDomain: '.test.com', + }; + + snowplowSpy = jest.spyOn(window, 'snowplow'); + }); + + describe('initUserTracking', () => { + it('calls through to get a new tracker with the expected options', () => { + initUserTracking(); + expect(snowplowSpy).toHaveBeenCalledWith('newTracker', 'gl_test', 'app.test.com', { + namespace: 'gl_test', + hostname: 'app.test.com', + cookieDomain: '.test.com', + appId: '', + userFingerprint: false, + respectDoNotTrack: true, + forceSecureTracker: true, + eventMethod: 'post', + contexts: { webPage: true, performanceTiming: true }, + formTracking: false, + linkClickTracking: false, + pageUnloadTimer: 10, + formTrackingConfig: { + fields: { allow: [] }, + forms: { allow: [] }, + }, + }); + }); + }); + + describe('initDefaultTrackers', () => { + beforeEach(() => { + bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); + trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); + enableFormTracking = jest + .spyOn(Tracking, 'enableFormTracking') + .mockImplementation(() => null); + setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null); + }); + + it('should activate features based on what has been enabled', () => { + initDefaultTrackers(); + expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); + expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); + expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); + + window.snowplowOptions = { + ...window.snowplowOptions, + formTracking: true, + linkClickTracking: true, + formTrackingConfig: { forms: { whitelist: ['foo'] }, fields: { whitelist: ['bar'] } }, + }; + + initDefaultTrackers(); + expect(enableFormTracking).toHaveBeenCalledWith(window.snowplowOptions.formTrackingConfig); + expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); + }); + + it('binds the document event handling', () => { + initDefaultTrackers(); + expect(bindDocumentSpy).toHaveBeenCalled(); + }); + + it('tracks page loaded events', () => { + initDefaultTrackers(); + expect(trackLoadEventsSpy).toHaveBeenCalled(); + }); + + it('calls the anonymized URLs method', () => { + initDefaultTrackers(); + expect(setAnonymousUrlsSpy).toHaveBeenCalled(); + }); + + describe('when there are experiment contexts', () => { + const experimentContexts = [ + { + schema: TRACKING_CONTEXT_SCHEMA, + data: { experiment: 'experiment1', variant: 'control' }, + }, + { + schema: TRACKING_CONTEXT_SCHEMA, + data: { experiment: 'experiment_two', variant: 'candidate' }, + }, + ]; + + beforeEach(() => { + getAllExperimentContexts.mockReturnValue(experimentContexts); + }); + + it('includes those contexts alongside the standard context', () => { + initDefaultTrackers(); + expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [ + standardContext, + ...experimentContexts, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index 21fed51ff10..b7a2e4f4f51 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -8,16 +8,16 @@ import getStandardContext from '~/tracking/get_standard_context'; jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn(), - getAllExperimentContexts: jest.fn(), + getAllExperimentContexts: jest.fn().mockReturnValue([]), })); +const TEST_CATEGORY = 'root:index'; +const TEST_ACTION = 'generic'; +const TEST_LABEL = 'button'; + describe('Tracking', () => { let standardContext; let snowplowSpy; - let bindDocumentSpy; - let trackLoadEventsSpy; - let enableFormTracking; - let setAnonymousUrlsSpy; beforeAll(() => { window.gl = window.gl || {}; @@ -30,132 +30,46 @@ describe('Tracking', () => { extra: {}, }, }; + window.snowplowOptions = { + namespace: 'gl_test', + hostname: 'app.test.com', + cookieDomain: '.test.com', + formTracking: true, + linkClickTracking: true, + formTrackingConfig: { forms: { allow: ['foo'] }, fields: { allow: ['bar'] } }, + }; standardContext = getStandardContext(); + window.snowplow = window.snowplow || (() => {}); + document.body.dataset.page = TEST_CATEGORY; + + initUserTracking(); + initDefaultTrackers(); }); beforeEach(() => { getExperimentData.mockReturnValue(undefined); getAllExperimentContexts.mockReturnValue([]); - window.snowplow = window.snowplow || (() => {}); - window.snowplowOptions = { - namespace: '_namespace_', - hostname: 'app.gitfoo.com', - cookieDomain: '.gitfoo.com', - }; snowplowSpy = jest.spyOn(window, 'snowplow'); }); - describe('initUserTracking', () => { - it('calls through to get a new tracker with the expected options', () => { - initUserTracking(); - expect(snowplowSpy).toHaveBeenCalledWith('newTracker', '_namespace_', 'app.gitfoo.com', { - namespace: '_namespace_', - hostname: 'app.gitfoo.com', - cookieDomain: '.gitfoo.com', - appId: '', - userFingerprint: false, - respectDoNotTrack: true, - forceSecureTracker: true, - eventMethod: 'post', - contexts: { webPage: true, performanceTiming: true }, - formTracking: false, - linkClickTracking: false, - pageUnloadTimer: 10, - formTrackingConfig: { - fields: { allow: [] }, - forms: { allow: [] }, - }, - }); - }); - }); - - describe('initDefaultTrackers', () => { - beforeEach(() => { - bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); - trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); - enableFormTracking = jest - .spyOn(Tracking, 'enableFormTracking') - .mockImplementation(() => null); - setAnonymousUrlsSpy = jest.spyOn(Tracking, 'setAnonymousUrls').mockImplementation(() => null); - }); - - it('should activate features based on what has been enabled', () => { - initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [standardContext]); - expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); - expect(snowplowSpy).not.toHaveBeenCalledWith('enableLinkClickTracking'); - - window.snowplowOptions = { - ...window.snowplowOptions, - formTracking: true, - linkClickTracking: true, - formTrackingConfig: { forms: { whitelist: ['foo'] }, fields: { whitelist: ['bar'] } }, - }; - - initDefaultTrackers(); - expect(enableFormTracking).toHaveBeenCalledWith(window.snowplowOptions.formTrackingConfig); - expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); - }); - - it('binds the document event handling', () => { - initDefaultTrackers(); - expect(bindDocumentSpy).toHaveBeenCalled(); - }); - - it('tracks page loaded events', () => { - initDefaultTrackers(); - expect(trackLoadEventsSpy).toHaveBeenCalled(); - }); - - it('calls the anonymized URLs method', () => { - initDefaultTrackers(); - expect(setAnonymousUrlsSpy).toHaveBeenCalled(); - }); - - describe('when there are experiment contexts', () => { - const experimentContexts = [ - { - schema: TRACKING_CONTEXT_SCHEMA, - data: { experiment: 'experiment1', variant: 'control' }, - }, - { - schema: TRACKING_CONTEXT_SCHEMA, - data: { experiment: 'experiment_two', variant: 'candidate' }, - }, - ]; - - beforeEach(() => { - getAllExperimentContexts.mockReturnValue(experimentContexts); - }); - - it('includes those contexts alongside the standard context', () => { - initDefaultTrackers(); - expect(snowplowSpy).toHaveBeenCalledWith('trackPageView', null, [ - standardContext, - ...experimentContexts, - ]); - }); - }); - }); - describe('.event', () => { afterEach(() => { window.doNotTrack = undefined; navigator.doNotTrack = undefined; navigator.msDoNotTrack = undefined; + jest.clearAllMocks(); }); it('tracks to snowplow (our current tracking system)', () => { - Tracking.event('_category_', '_eventName_', { label: '_label_' }); + Tracking.event(TEST_CATEGORY, TEST_ACTION, { label: TEST_LABEL }); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', - '_category_', - '_eventName_', - '_label_', + TEST_CATEGORY, + TEST_ACTION, + TEST_LABEL, undefined, undefined, [standardContext], @@ -165,12 +79,12 @@ describe('Tracking', () => { it('allows adding extra data to the default context', () => { const extra = { foo: 'bar' }; - Tracking.event('_category_', '_eventName_', { extra }); + Tracking.event(TEST_CATEGORY, TEST_ACTION, { extra }); expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', - '_category_', - '_eventName_', + TEST_CATEGORY, + TEST_ACTION, undefined, undefined, undefined, @@ -188,28 +102,28 @@ describe('Tracking', () => { it('skips tracking if snowplow is unavailable', () => { window.snowplow = false; - Tracking.event('_category_', '_eventName_'); + Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (general spec)', () => { window.doNotTrack = '1'; - Tracking.event('_category_', '_eventName_'); + Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (firefox legacy)', () => { navigator.doNotTrack = 'yes'; - Tracking.event('_category_', '_eventName_'); + Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); it('skips tracking if the user does not want to be tracked (IE legacy)', () => { navigator.msDoNotTrack = '1'; - Tracking.event('_category_', '_eventName_'); + Tracking.event(TEST_CATEGORY, TEST_ACTION); expect(snowplowSpy).not.toHaveBeenCalled(); }); @@ -237,7 +151,7 @@ describe('Tracking', () => { ); }); - it('does not add empty form whitelist rules', () => { + it('does not add empty form allow rules', () => { Tracking.enableFormTracking({ fields: { allow: ['input-class1'] } }); expect(snowplowSpy).toHaveBeenCalledWith( @@ -287,7 +201,7 @@ describe('Tracking', () => { describe('.flushPendingEvents', () => { it('flushes any pending events', () => { Tracking.initialized = false; - Tracking.event('_category_', '_eventName_', { label: '_label_' }); + Tracking.event(TEST_CATEGORY, TEST_ACTION, { label: TEST_LABEL }); expect(snowplowSpy).not.toHaveBeenCalled(); @@ -295,9 +209,9 @@ describe('Tracking', () => { expect(snowplowSpy).toHaveBeenCalledWith( 'trackStructEvent', - '_category_', - '_eventName_', - '_label_', + TEST_CATEGORY, + TEST_ACTION, + TEST_LABEL, undefined, undefined, [standardContext], @@ -332,14 +246,13 @@ describe('Tracking', () => { }); }); - it('appends the hash/fragment to the pseudonymized URL', () => { - const hash = 'first-heading'; + it('does not appends the hash/fragment to the pseudonymized URL', () => { window.gl.snowplowPseudonymizedPageUrl = TEST_HOST; - window.location.hash = hash; + window.location.hash = 'first-heading'; Tracking.setAnonymousUrls(); - expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', `${TEST_HOST}#${hash}`); + expect(snowplowSpy).toHaveBeenCalledWith('setCustomUrl', TEST_HOST); }); it('does not set the referrer URL by default', () => { @@ -409,84 +322,79 @@ describe('Tracking', () => { }); }); - describe.each` - term - ${'event'} - ${'action'} - `('tracking interface events with data-track-$term', ({ term }) => { + describe('tracking interface events with data-track-action', () => { let eventSpy; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); - Tracking.bindDocument('_category_'); // only happens once setHTMLFixture(` - <input data-track-${term}="click_input1" data-track-label="_label_" value=0 /> - <input data-track-${term}="click_input2" data-track-value=0 value=0/> - <input type="checkbox" data-track-${term}="toggle_checkbox" value=1 checked/> - <input class="dropdown" data-track-${term}="toggle_dropdown"/> - <div data-track-${term}="nested_event"><span class="nested"></span></div> - <input data-track-bogus="click_bogusinput" data-track-label="_label_" value="_value_"/> - <input data-track-${term}="click_input3" data-track-experiment="example" value="_value_"/> - <input data-track-${term}="event_with_extra" data-track-extra='{ "foo": "bar" }' /> - <input data-track-${term}="event_with_invalid_extra" data-track-extra="invalid_json" /> + <input data-track-action="click_input1" data-track-label="button" value="0" /> + <input data-track-action="click_input2" data-track-value="0" value="0" /> + <input type="checkbox" data-track-action="toggle_checkbox" value=1 checked /> + <input class="dropdown" data-track-action="toggle_dropdown"/> + <div data-track-action="nested_event"><span class="nested"></span></div> + <input data-track-bogus="click_bogusinput" data-track-label="button" value="1" /> + <input data-track-action="click_input3" data-track-experiment="example" value="1" /> + <input data-track-action="event_with_extra" data-track-extra='{ "foo": "bar" }' /> + <input data-track-action="event_with_invalid_extra" data-track-extra="invalid_json" /> `); }); - it(`binds to clicks on elements matching [data-track-${term}]`, () => { - document.querySelector(`[data-track-${term}="click_input1"]`).click(); + it(`binds to clicks on elements matching [data-track-action]`, () => { + document.querySelector(`[data-track-action="click_input1"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input1', { - label: '_label_', + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'click_input1', { + label: TEST_LABEL, value: '0', }); }); - it(`does not bind to clicks on elements without [data-track-${term}]`, () => { + it(`does not bind to clicks on elements without [data-track-action]`, () => { document.querySelector('[data-track-bogus="click_bogusinput"]').click(); expect(eventSpy).not.toHaveBeenCalled(); }); it('allows value override with the data-track-value attribute', () => { - document.querySelector(`[data-track-${term}="click_input2"]`).click(); + document.querySelector(`[data-track-action="click_input2"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input2', { + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'click_input2', { value: '0', }); }); it('handles checkbox values correctly', () => { - const checkbox = document.querySelector(`[data-track-${term}="toggle_checkbox"]`); + const checkbox = document.querySelector(`[data-track-action="toggle_checkbox"]`); checkbox.click(); // unchecking - expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'toggle_checkbox', { value: 0, }); checkbox.click(); // checking - expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_checkbox', { + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'toggle_checkbox', { value: '1', }); }); it('handles bootstrap dropdowns', () => { - const dropdown = document.querySelector(`[data-track-${term}="toggle_dropdown"]`); + const dropdown = document.querySelector(`[data-track-action="toggle_dropdown"]`); dropdown.dispatchEvent(new Event('show.bs.dropdown', { bubbles: true })); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_show', {}); + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'toggle_dropdown_show', {}); dropdown.dispatchEvent(new Event('hide.bs.dropdown', { bubbles: true })); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'toggle_dropdown_hide', {}); + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'toggle_dropdown_hide', {}); }); it('handles nested elements inside an element with tracking', () => { document.querySelector('span.nested').click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'nested_event', {}); + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'nested_event', {}); }); it('includes experiment data if linked to an experiment', () => { @@ -497,54 +405,50 @@ describe('Tracking', () => { }; getExperimentData.mockReturnValue(mockExperimentData); - document.querySelector(`[data-track-${term}="click_input3"]`).click(); + document.querySelector(`[data-track-action="click_input3"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'click_input3', { - value: '_value_', + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'click_input3', { + value: '1', context: { schema: TRACKING_CONTEXT_SCHEMA, data: mockExperimentData }, }); }); it('supports extra data as JSON', () => { - document.querySelector(`[data-track-${term}="event_with_extra"]`).click(); + document.querySelector(`[data-track-action="event_with_extra"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_extra', { + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'event_with_extra', { extra: { foo: 'bar' }, }); }); it('ignores extra if provided JSON is invalid', () => { - document.querySelector(`[data-track-${term}="event_with_invalid_extra"]`).click(); + document.querySelector(`[data-track-action="event_with_invalid_extra"]`).click(); - expect(eventSpy).toHaveBeenCalledWith('_category_', 'event_with_invalid_extra', {}); + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, 'event_with_invalid_extra', {}); }); }); - describe.each` - term - ${'event'} - ${'action'} - `('tracking page loaded events with -$term', ({ term }) => { + describe('tracking page loaded events with -action', () => { let eventSpy; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); setHTMLFixture(` - <div data-track-${term}="click_link" data-track-label="all_nested_links"> - <input data-track-${term}="render" data-track-label="label1" value=1 data-track-property="_property_"/> - <span data-track-${term}="render" data-track-label="label2" data-track-value=1> + <div data-track-action="click_link" data-track-label="all_nested_links"> + <input data-track-action="render" data-track-label="label1" value=1 data-track-property="_property_" /> + <span data-track-action="render" data-track-label="label2" data-track-value="1"> <a href="#" id="link">Something</a> </span> - <input data-track-${term}="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_"/> + <input data-track-action="_render_bogus_" data-track-label="label3" value="_value_" data-track-property="_property_" /> </div> `); - Tracking.trackLoadEvents('_category_'); // only happens once + Tracking.trackLoadEvents(TEST_CATEGORY); }); - it(`sends tracking events when [data-track-${term}="render"] is on an element`, () => { + it(`sends tracking events when [data-track-action="render"] is on an element`, () => { expect(eventSpy.mock.calls).toEqual([ [ - '_category_', + TEST_CATEGORY, 'render', { label: 'label1', @@ -553,7 +457,7 @@ describe('Tracking', () => { }, ], [ - '_category_', + TEST_CATEGORY, 'render', { label: 'label2', @@ -576,16 +480,16 @@ describe('Tracking', () => { eventSpy.mockClear(); }); - it(`avoids using ancestor [data-track-${term}="render"] tracking configurations`, () => { + it(`avoids using ancestor [data-track-action="render"] tracking configurations`, () => { link.dispatchEvent(new Event(event, { bubbles: true })); expect(eventSpy).not.toHaveBeenCalledWith( - '_category_', + TEST_CATEGORY, `render${actionSuffix}`, expect.any(Object), ); expect(eventSpy).toHaveBeenCalledWith( - '_category_', + TEST_CATEGORY, `click_link${actionSuffix}`, expect.objectContaining({ label: 'all_nested_links' }), ); diff --git a/spec/frontend/tracking/utils_spec.js b/spec/frontend/tracking/utils_spec.js new file mode 100644 index 00000000000..d6f2c5095b4 --- /dev/null +++ b/spec/frontend/tracking/utils_spec.js @@ -0,0 +1,99 @@ +import { + renameKey, + getReferrersCache, + addExperimentContext, + addReferrersCacheEntry, + filterOldReferrersCacheEntries, +} from '~/tracking/utils'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { REFERRER_TTL, URLS_CACHE_STORAGE_KEY } from '~/tracking/constants'; +import { TEST_HOST } from 'helpers/test_constants'; + +jest.mock('~/experimentation/utils', () => ({ + getExperimentData: jest.fn().mockReturnValue({}), +})); + +describe('~/tracking/utils', () => { + beforeEach(() => { + window.gl = window.gl || {}; + window.gl.snowplowStandardContext = {}; + }); + + describe('addExperimentContext', () => { + const options = { + category: 'root:index', + action: 'generic', + }; + + it('returns same options if no experiment is provided', () => { + expect(addExperimentContext({ options })).toStrictEqual({ options }); + }); + + it('adds experiment if provided', () => { + const experiment = 'TEST_EXPERIMENT_NAME'; + + expect(addExperimentContext({ experiment, ...options })).toStrictEqual({ + ...options, + context: { data: {}, schema: TRACKING_CONTEXT_SCHEMA }, + }); + }); + }); + + describe('renameKey', () => { + it('renames a given key', () => { + expect(renameKey({ allow: [] }, 'allow', 'permit')).toStrictEqual({ permit: [] }); + }); + }); + + describe('referrers cache', () => { + describe('filterOldReferrersCacheEntries', () => { + it('removes entries with old or no timestamp', () => { + const now = Date.now(); + const cache = [{ timestamp: now }, { timestamp: now - REFERRER_TTL }, { referrer: '' }]; + + expect(filterOldReferrersCacheEntries(cache)).toStrictEqual([{ timestamp: now }]); + }); + }); + + describe('getReferrersCache', () => { + beforeEach(() => { + localStorage.removeItem(URLS_CACHE_STORAGE_KEY); + }); + + it('returns an empty array if cache is not found', () => { + expect(getReferrersCache()).toHaveLength(0); + }); + + it('returns an empty array if cache is invalid', () => { + localStorage.setItem(URLS_CACHE_STORAGE_KEY, 'Invalid JSON'); + + expect(getReferrersCache()).toHaveLength(0); + }); + + it('returns parsed entries if valid', () => { + localStorage.setItem( + URLS_CACHE_STORAGE_KEY, + JSON.stringify([{ referrer: '', timestamp: Date.now() }]), + ); + + expect(getReferrersCache()).toHaveLength(1); + }); + }); + + describe('addReferrersCacheEntry', () => { + it('unshifts entry and adds timestamp', () => { + const now = Date.now(); + + addReferrersCacheEntry([{ referrer: '', originalUrl: TEST_HOST, timestamp: now }], { + referrer: TEST_HOST, + }); + + const cache = getReferrersCache(); + + expect(cache).toHaveLength(2); + expect(cache[0].referrer).toBe(TEST_HOST); + expect(cache[0].timestamp).toBeDefined(); + }); + }); + }); +}); diff --git a/spec/frontend/users_select/test_helper.js b/spec/frontend/users_select/test_helper.js index c5adbe9bb09..59edde48eab 100644 --- a/spec/frontend/users_select/test_helper.js +++ b/spec/frontend/users_select/test_helper.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { memoize, cloneDeep } from 'lodash'; -import { getFixture, getJSONFixture } from 'helpers/fixtures'; +import usersFixture from 'test_fixtures/autocomplete/users.json'; +import { getFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import UsersSelect from '~/users_select'; @@ -15,7 +16,7 @@ const getUserSearchHTML = memoize((fixturePath) => { return el.outerHTML; }); -const getUsersFixture = memoize(() => getJSONFixture('autocomplete/users.json')); +const getUsersFixture = () => usersFixture; export const getUsersFixtureAt = (idx) => getUsersFixture()[idx]; diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js index ef712ec23a6..c9dea4394f9 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js @@ -61,9 +61,7 @@ describe('MRWidget approvals summary', () => { it('render message', () => { const names = toNounSeriesText(testRulesLeft()); - expect(wrapper.text()).toContain( - `Requires ${TEST_APPROVALS_LEFT} more approvals from ${names}.`, - ); + expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} approvals from ${names}.`); }); }); @@ -75,7 +73,9 @@ describe('MRWidget approvals summary', () => { }); it('renders message', () => { - expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} more approvals.`); + expect(wrapper.text()).toContain( + `Requires ${TEST_APPROVALS_LEFT} approvals from eligible users`, + ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js b/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js new file mode 100644 index 00000000000..d5d779d7a34 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/extensions/actions_spec.js @@ -0,0 +1,35 @@ +import { GlButton, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Actions from '~/vue_merge_request_widget/components/extensions/actions.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(Actions, { + propsData: { ...propsData, widget: 'test' }, + }); +} + +describe('MR widget extension actions', () => { + afterEach(() => { + wrapper.destroy(); + }); + + describe('tertiaryButtons', () => { + it('renders buttons', () => { + factory({ + tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], + }); + + expect(wrapper.findAllComponents(GlButton)).toHaveLength(1); + }); + + it('renders tertiary actions in dropdown', () => { + factory({ + tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], + }); + + expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js index 8f6fe3cd37a..63df63a9b00 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js @@ -1,4 +1,7 @@ -import { registerExtension, extensions } from '~/vue_merge_request_widget/components/extensions'; +import { + registerExtension, + registeredExtensions, +} from '~/vue_merge_request_widget/components/extensions'; import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue'; describe('MR widget extension registering', () => { @@ -14,7 +17,7 @@ describe('MR widget extension registering', () => { }, }); - expect(extensions[0]).toEqual( + expect(registeredExtensions.extensions[0]).toEqual( expect.objectContaining({ extends: ExtensionBase, name: 'Test', diff --git a/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js b/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js new file mode 100644 index 00000000000..f3aa5bb774f --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js @@ -0,0 +1,36 @@ +import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(StatusIcon, { + propsData, + }); +} + +describe('MR widget extensions status icon', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('renders loading icon', () => { + factory({ name: 'test', isLoading: true, iconName: 'failed' }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders status icon', () => { + factory({ name: 'test', isLoading: false, iconName: 'failed' }); + + expect(wrapper.findComponent(GlIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlIcon).props('name')).toBe('status-failed'); + }); + + it('sets aria-label for status icon', () => { + factory({ name: 'test', isLoading: false, iconName: 'failed' }); + + expect(wrapper.findComponent(GlIcon).props('ariaLabel')).toBe('Failed test'); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js index 5ec719b17d6..efe2bf75c3f 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; import DeploymentList from '~/vue_merge_request_widget/components/deployment/deployment_list.vue'; @@ -12,12 +13,14 @@ describe('MrWidgetPipelineContainer', () => { let mock; const factory = (props = {}) => { - wrapper = mount(MrWidgetPipelineContainer, { - propsData: { - mr: { ...mockStore }, - ...props, - }, - }); + wrapper = extendedWrapper( + mount(MrWidgetPipelineContainer, { + propsData: { + mr: { ...mockStore }, + ...props, + }, + }), + ); }; beforeEach(() => { @@ -30,6 +33,7 @@ describe('MrWidgetPipelineContainer', () => { }); const findDeploymentList = () => wrapper.findComponent(DeploymentList); + const findCIErrorMessage = () => wrapper.findByTestId('ci-error-message'); describe('when pre merge', () => { beforeEach(() => { @@ -69,15 +73,21 @@ describe('MrWidgetPipelineContainer', () => { beforeEach(() => { factory({ isPostMerge: true, + mr: { + ...mockStore, + pipeline: {}, + ciStatus: undefined, + }, }); }); it('renders pipeline', () => { expect(wrapper.find(MrWidgetPipeline).exists()).toBe(true); + expect(findCIErrorMessage().exists()).toBe(false); expect(wrapper.find(MrWidgetPipeline).props()).toMatchObject({ pipeline: mockStore.mergePipeline, pipelineCoverageDelta: mockStore.pipelineCoverageDelta, - ciStatus: mockStore.ciStatus, + ciStatus: mockStore.mergePipeline.details.status.text, hasCi: mockStore.hasCI, sourceBranch: mockStore.targetBranch, sourceBranchLink: mockStore.targetBranch, @@ -92,7 +102,6 @@ describe('MrWidgetPipelineContainer', () => { targetBranch: 'Foo<script>alert("XSS")</script>', }, }); - expect(wrapper.find(MrWidgetPipeline).props().sourceBranchLink).toBe('Foo'); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js index b31a75f30d3..2ff94a547f4 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlSprintf } from '@gitlab/ui'; import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; describe('Commits header component', () => { @@ -6,6 +7,9 @@ describe('Commits header component', () => { const createComponent = (props) => { wrapper = shallowMount(CommitsHeader, { + stubs: { + GlSprintf, + }, propsData: { isSquashEnabled: false, targetBranch: 'main', diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index e41fb815c8d..f0fbb1d5851 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -45,6 +45,8 @@ const createTestMr = (customConfig) => { preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY, availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY], mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs', + transitionStateMachine: (transition) => eventHub.$emit('StateMachineValueChanged', transition), + translateStateToMachine: () => this.transitionStateMachine(), }; Object.assign(mr, customConfig.mr); @@ -304,6 +306,9 @@ describe('ReadyToMerge', () => { setImmediate(() => { expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(eventHub.$emit).toHaveBeenCalledWith('StateMachineValueChanged', { + transition: 'start-auto-merge', + }); const params = wrapper.vm.service.merge.mock.calls[0][0]; @@ -341,10 +346,15 @@ describe('ReadyToMerge', () => { it('should handle merge action accepted case', (done) => { createComponent(); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest.spyOn(wrapper.vm.service, 'merge').mockReturnValue(returnPromise('success')); jest.spyOn(wrapper.vm, 'initiateMergePolling').mockImplementation(() => {}); wrapper.vm.handleMergeButtonClick(); + expect(eventHub.$emit).toHaveBeenCalledWith('StateMachineValueChanged', { + transition: 'start-merge', + }); + setImmediate(() => { expect(wrapper.vm.isMakingRequest).toBeTruthy(); expect(wrapper.vm.initiateMergePolling).toHaveBeenCalled(); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index 61e44140efc..be15e4df66d 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; -import createFlash from '~/flash'; import WorkInProgress from '~/vue_merge_request_widget/components/states/work_in_progress.vue'; +import toast from '~/vue_shared/plugins/global_toast'; import eventHub from '~/vue_merge_request_widget/event_hub'; -jest.mock('~/flash'); +jest.mock('~/vue_shared/plugins/global_toast'); const createComponent = () => { const Component = Vue.extend(WorkInProgress); @@ -63,10 +63,7 @@ describe('Wip', () => { setImmediate(() => { expect(vm.isMakingRequest).toBeTruthy(); expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); - expect(createFlash).toHaveBeenCalledWith({ - message: 'Marked as ready. Merging is now allowed.', - type: 'notice', - }); + expect(toast).toHaveBeenCalledWith('Marked as ready. Merging is now allowed.'); done(); }); }); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index f356f6fb5bf..34a741cf8f2 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -280,7 +280,7 @@ export default { merge_train_index: 1, security_reports_docs_path: 'security-reports-docs-path', sast_comparison_path: '/sast_comparison_path', - secret_scanning_comparison_path: '/secret_scanning_comparison_path', + secret_detection_comparison_path: '/secret_detection_comparison_path', gitpod_enabled: true, show_gitpod_button: true, gitpod_url: 'http://gitpod.localhost', diff --git a/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js b/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js index bd22183cbea..913d5860b48 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_how_to_merge_modal_spec.js @@ -8,11 +8,9 @@ describe('MRWidgetHowToMerge', () => { function mountComponent({ data = {}, props = {} } = {}) { wrapper = shallowMount(MrWidgetHowToMergeModal, { data() { - return { ...data }; - }, - propsData: { - ...props, + return data; }, + propsData: props, stubs: {}, }); } @@ -57,4 +55,16 @@ describe('MRWidgetHowToMerge', () => { mountComponent({ props: { isFork: true } }); expect(findInstructionsFields().at(0).text()).toContain('FETCH_HEAD'); }); + + it('escapes the target branch name shell-secure', () => { + mountComponent({ props: { targetBranch: '";echo$IFS"you_shouldnt_run_this' } }); + + expect(findInstructionsFields().at(1).text()).toContain('\'";echo$IFS"you_shouldnt_run_this\''); + }); + + it('escapes the source branch name shell-secure', () => { + mountComponent({ props: { sourceBranch: 'branch-of-$USER' } }); + + expect(findInstructionsFields().at(0).text()).toContain("'branch-of-$USER'"); + }); }); diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index c50cf7cb076..5aba6982886 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -1,13 +1,19 @@ +import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import axios from '~/lib/utils/axios_utils'; import { setFaviconOverlay } from '~/lib/utils/favicon'; import notify from '~/lib/utils/notify'; import SmartInterval from '~/smart_interval'; +import { + registerExtension, + registeredExtensions, +} from '~/vue_merge_request_widget/components/extensions'; import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; @@ -15,6 +21,7 @@ import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; import mockData from './mock_data'; +import testExtension from './test_extension'; jest.mock('~/smart_interval'); @@ -879,4 +886,48 @@ describe('MrWidgetOptions', () => { }); }); }); + + describe('mock extension', () => { + beforeEach(() => { + registerExtension(testExtension); + + createComponent(); + }); + + afterEach(() => { + registeredExtensions.extensions = []; + }); + + it('renders collapsed data', async () => { + await waitForPromises(); + + expect(wrapper.text()).toContain('Test extension summary count: 1'); + }); + + it('renders full data', async () => { + await waitForPromises(); + + wrapper + .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') + .trigger('click'); + + await Vue.nextTick(); + + const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]'); + expect(collapsedSection.exists()).toBe(true); + expect(collapsedSection.text()).toContain('Hello world'); + + // Renders icon in the row + expect(collapsedSection.find(GlIcon).exists()).toBe(true); + expect(collapsedSection.find(GlIcon).props('name')).toBe('status-failed'); + + // Renders badge in the row + expect(collapsedSection.find(GlBadge).exists()).toBe(true); + expect(collapsedSection.find(GlBadge).text()).toBe('Closed'); + + // Renders a link in the row + expect(collapsedSection.find(GlLink).exists()).toBe(true); + expect(collapsedSection.find(GlLink).text()).toBe('GitLab.com'); + }); + }); }); diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index bf0179aa425..febcfcd4019 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -162,7 +162,7 @@ describe('MergeRequestStore', () => { expect(store.securityReportsDocsPath).toBe('security-reports-docs-path'); }); - it.each(['sast_comparison_path', 'secret_scanning_comparison_path'])( + it.each(['sast_comparison_path', 'secret_detection_comparison_path'])( 'should set %s path', (property) => { // Ensure something is set in the mock data diff --git a/spec/frontend/vue_mr_widget/test_extension.js b/spec/frontend/vue_mr_widget/test_extension.js new file mode 100644 index 00000000000..a29a4d2fb46 --- /dev/null +++ b/spec/frontend/vue_mr_widget/test_extension.js @@ -0,0 +1,37 @@ +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; + +export default { + name: 'WidgetTestExtension', + props: ['targetProjectFullPath'], + computed: { + summary({ count, targetProjectFullPath }) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count }) { + return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; + }, + }, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.resolve([ + { + id: 1, + text: 'Hello world', + icon: { + name: EXTENSION_ICONS.failed, + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + }, + ]); + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index c7758b0faef..44b4c0398cd 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -4,12 +4,12 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" right="true" - showhighlighteditemstitle="true" size="medium" text="Clone" variant="info" @@ -35,6 +35,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <b-form-input-stub class="gl-form-input" debounce="0" + formatter="[Function]" readonly="true" type="text" value="ssh://foo.bar" @@ -78,6 +79,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <b-form-input-stub class="gl-form-input" debounce="0" + formatter="[Function]" readonly="true" type="text" value="http://foo.bar" diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap index f2ff12b2acd..2b89e36344d 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -4,12 +4,12 @@ exports[`SplitButton renders actionItems 1`] = ` <gl-dropdown-stub category="primary" clearalltext="Clear all" + clearalltextclass="gl-px-5" headertext="" hideheaderborder="true" highlighteditemstitle="Selected" highlighteditemstitleclass="gl-px-5" menu-class="" - showhighlighteditemstitle="true" size="medium" split="true" text="professor" diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js index c6c351a7f3f..3277aab43f0 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js @@ -1,25 +1,16 @@ import { shallowMount } from '@vue/test-utils'; -import waitForPromises from 'helpers/wait_for_promises'; import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants'; import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; -import SourceEditor from '~/vue_shared/components/source_editor.vue'; describe('Blob Simple Viewer component', () => { let wrapper; const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`; const blobHash = 'foo-bar'; - function createComponent( - content = contentMock, - isRawContent = false, - isRefactorFlagEnabled = false, - ) { + function createComponent(content = contentMock, isRawContent = false) { wrapper = shallowMount(SimpleViewer, { provide: { blobHash, - glFeatures: { - refactorBlobViewer: isRefactorFlagEnabled, - }, }, propsData: { content, @@ -94,32 +85,4 @@ describe('Blob Simple Viewer component', () => { }); }); }); - - describe('Vue refactoring to use Source Editor', () => { - const findSourceEditor = () => wrapper.find(SourceEditor); - - it.each` - doesRender | condition | isRawContent | isRefactorFlagEnabled - ${'Does not'} | ${'rawContent is not specified'} | ${false} | ${true} - ${'Does not'} | ${'feature flag is disabled is not specified'} | ${true} | ${false} - ${'Does not'} | ${'both, the FF and rawContent are not specified'} | ${false} | ${false} - ${'Does'} | ${'both, the FF and rawContent are specified'} | ${true} | ${true} - `( - '$doesRender render Source Editor component in readonly mode when $condition', - async ({ isRawContent, isRefactorFlagEnabled } = {}) => { - createComponent('raw content', isRawContent, isRefactorFlagEnabled); - await waitForPromises(); - - if (isRawContent && isRefactorFlagEnabled) { - expect(findSourceEditor().exists()).toBe(true); - - expect(findSourceEditor().props('value')).toBe('raw content'); - expect(findSourceEditor().props('fileName')).toBe('test.js'); - expect(findSourceEditor().props('editorOptions')).toEqual({ readOnly: true }); - } else { - expect(findSourceEditor().exists()).toBe(false); - } - }, - ); - }); }); diff --git a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js index d30f36ec63c..fef50bdaccc 100644 --- a/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js +++ b/spec/frontend/vue_shared/components/color_picker/color_picker_spec.js @@ -111,15 +111,13 @@ describe('ColorPicker', () => { gon.suggested_label_colors = {}; createComponent(shallowMount); - expect(description()).toBe('Choose any color'); + expect(description()).toBe('Enter any color.'); expect(presetColors().exists()).toBe(false); }); it('shows the suggested colors', () => { createComponent(shallowMount); - expect(description()).toBe( - 'Choose any color. Or you can choose one of the suggested colors below', - ); + expect(description()).toBe('Enter any color or choose one of the suggested colors below.'); expect(presetColors()).toHaveLength(4); }); diff --git a/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js b/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js index 175d79dd1c2..194681a6138 100644 --- a/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js +++ b/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlAlert, GlSprintf } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import Component from '~/vue_shared/components/dismissible_feedback_alert.vue'; @@ -8,20 +8,13 @@ describe('Dismissible Feedback Alert', () => { let wrapper; - const defaultProps = { - featureName: 'Dependency List', - feedbackLink: 'https://gitlab.link', - }; - + const featureName = 'Dependency List'; const STORAGE_DISMISSAL_KEY = 'dependency_list_feedback_dismissed'; - const createComponent = ({ props, shallow } = {}) => { - const mountFn = shallow ? shallowMount : mount; - + const createComponent = ({ mountFn = shallowMount } = {}) => { wrapper = mountFn(Component, { propsData: { - ...defaultProps, - ...props, + featureName, }, stubs: { GlSprintf, @@ -34,8 +27,8 @@ describe('Dismissible Feedback Alert', () => { wrapper = null; }); - const findAlert = () => wrapper.find(GlAlert); - const findLink = () => wrapper.find(GlLink); + const createFullComponent = () => createComponent({ mountFn: mount }); + const findAlert = () => wrapper.findComponent(GlAlert); describe('with default', () => { beforeEach(() => { @@ -46,17 +39,6 @@ describe('Dismissible Feedback Alert', () => { expect(findAlert().exists()).toBe(true); }); - it('contains feature name', () => { - expect(findAlert().text()).toContain(defaultProps.featureName); - }); - - it('contains provided link', () => { - const link = findLink(); - - expect(link.attributes('href')).toBe(defaultProps.feedbackLink); - expect(link.attributes('target')).toBe('_blank'); - }); - it('should have the storage key set', () => { expect(wrapper.vm.storageKey).toBe(STORAGE_DISMISSAL_KEY); }); @@ -65,7 +47,7 @@ describe('Dismissible Feedback Alert', () => { describe('dismissible', () => { describe('after dismissal', () => { beforeEach(() => { - createComponent({ shallow: false }); + createFullComponent(); findAlert().vm.$emit('dismiss'); }); @@ -81,7 +63,7 @@ describe('Dismissible Feedback Alert', () => { describe('already dismissed', () => { it('should not show the alert once dismissed', async () => { localStorage.setItem(STORAGE_DISMISSAL_KEY, 'true'); - createComponent({ shallow: false }); + createFullComponent(); await wrapper.vm.$nextTick(); expect(findAlert().exists()).toBe(false); diff --git a/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js new file mode 100644 index 00000000000..996df34f2ff --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown_keyboard_navigation_spec.js @@ -0,0 +1,141 @@ +import { shallowMount } from '@vue/test-utils'; +import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue'; +import { UP_KEY_CODE, DOWN_KEY_CODE, TAB_KEY_CODE } from '~/lib/utils/keycodes'; + +const MOCK_INDEX = 0; +const MOCK_MAX = 10; +const MOCK_MIN = 0; +const MOCK_DEFAULT_INDEX = 0; + +describe('DropdownKeyboardNavigation', () => { + let wrapper; + + const defaultProps = { + index: MOCK_INDEX, + max: MOCK_MAX, + min: MOCK_MIN, + defaultIndex: MOCK_DEFAULT_INDEX, + }; + + const createComponent = (props) => { + wrapper = shallowMount(DropdownKeyboardNavigation, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const helpers = { + arrowDown: () => { + document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: DOWN_KEY_CODE })); + }, + arrowUp: () => { + document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: UP_KEY_CODE })); + }, + tab: () => { + document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: TAB_KEY_CODE })); + }, + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('onInit', () => { + beforeEach(() => { + createComponent(); + }); + + it('should $emit @change with the default index', async () => { + expect(wrapper.emitted('change')[0]).toStrictEqual([MOCK_DEFAULT_INDEX]); + }); + + it('should $emit @change with the default index when max changes', async () => { + wrapper.setProps({ max: 20 }); + await wrapper.vm.$nextTick(); + // The first @change`call happens on created() so we test for the second [1] + expect(wrapper.emitted('change')[1]).toStrictEqual([MOCK_DEFAULT_INDEX]); + }); + }); + + describe('keydown events', () => { + let incrementSpy; + + beforeEach(() => { + createComponent(); + incrementSpy = jest.spyOn(wrapper.vm, 'increment'); + }); + + afterEach(() => { + incrementSpy.mockRestore(); + }); + + it('onKeydown-Down calls increment(1)', () => { + helpers.arrowDown(); + + expect(incrementSpy).toHaveBeenCalledWith(1); + }); + + it('onKeydown-Up calls increment(-1)', () => { + helpers.arrowUp(); + + expect(incrementSpy).toHaveBeenCalledWith(-1); + }); + + it('onKeydown-Tab $emits @tab event', () => { + helpers.tab(); + + expect(wrapper.emitted('tab')).toHaveLength(1); + }); + }); + + describe('increment', () => { + describe('when max is 0', () => { + beforeEach(() => { + createComponent({ max: 0 }); + }); + + it('does not $emit any @change events', () => { + helpers.arrowDown(); + + // The first @change`call happens on created() so we test that we only have 1 call + expect(wrapper.emitted('change')).toHaveLength(1); + }); + }); + + describe.each` + keyboardAction | direction | index | max | min + ${helpers.arrowDown} | ${1} | ${10} | ${10} | ${0} + ${helpers.arrowUp} | ${-1} | ${0} | ${10} | ${0} + `('moving out of bounds', ({ keyboardAction, direction, index, max, min }) => { + beforeEach(() => { + createComponent({ index, max, min }); + keyboardAction(); + }); + + it(`in ${direction} direction does not $emit any @change events`, () => { + // The first @change`call happens on created() so we test that we only have 1 call + expect(wrapper.emitted('change')).toHaveLength(1); + }); + }); + + describe.each` + keyboardAction | direction | index | max | min + ${helpers.arrowDown} | ${1} | ${0} | ${10} | ${0} + ${helpers.arrowUp} | ${-1} | ${10} | ${10} | ${0} + `('moving in bounds', ({ keyboardAction, direction, index, max, min }) => { + beforeEach(() => { + createComponent({ index, max, min }); + keyboardAction(); + }); + + it(`in ${direction} direction $emits @change event with the correct index ${ + index + direction + }`, () => { + // The first @change`call happens on created() so we test for the second [1] + expect(wrapper.emitted('change')[1]).toStrictEqual([index + direction]); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index 134c6c8b929..ae02c554e13 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -141,7 +141,62 @@ export const mockEpicToken = { token: EpicToken, operators: OPERATOR_IS_ONLY, idProperty: 'iid', - fetchEpics: () => Promise.resolve({ data: mockEpics }), + fullPath: 'gitlab-org', +}; + +export const mockEpicNode1 = { + __typename: 'Epic', + parent: null, + id: 'gid://gitlab/Epic/40', + iid: '2', + title: 'Marketing epic', + description: 'Mock epic description', + state: 'opened', + startDate: '2017-12-25', + dueDate: '2018-02-15', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/1', + hasChildren: false, + hasParent: false, + confidential: false, +}; + +export const mockEpicNode2 = { + __typename: 'Epic', + parent: null, + id: 'gid://gitlab/Epic/41', + iid: '3', + title: 'Another marketing', + startDate: '2017-12-26', + dueDate: '2018-03-10', + state: 'opened', + webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/2', +}; + +export const mockGroupEpicsQueryResponse = { + data: { + group: { + id: 'gid://gitlab/Group/1', + name: 'Gitlab Org', + epics: { + edges: [ + { + node: { + ...mockEpicNode1, + }, + __typename: 'EpicEdge', + }, + { + node: { + ...mockEpicNode2, + }, + __typename: 'EpicEdge', + }, + ], + __typename: 'EpicConnection', + }, + __typename: 'Group', + }, + }, }; export const mockReactionEmojiToken = { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index d3e1bfef561..14fcffd3c50 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -57,7 +57,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, data() { return { ...data }; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index eb1dbed52cc..f9ce0338d2f 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -67,7 +67,7 @@ function createComponent({ provide: { portalName: 'fake target', alignSuggestions: jest.fn(), - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, slots, @@ -206,26 +206,50 @@ describe('BaseToken', () => { describe('events', () => { let wrapperWithNoStubs; - beforeEach(() => { - wrapperWithNoStubs = createComponent({ - stubs: { Portal: true }, - }); - }); - afterEach(() => { wrapperWithNoStubs.destroy(); }); - it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { - jest.useFakeTimers(); + describe('when activeToken has been selected', () => { + beforeEach(() => { + wrapperWithNoStubs = createComponent({ + props: { + ...mockProps, + getActiveTokenValue: () => ({ title: '' }), + suggestionsLoading: true, + }, + stubs: { Portal: true }, + }); + }); + it('does not emit `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { + jest.useFakeTimers(); - wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' }); - await wrapperWithNoStubs.vm.$nextTick(); + wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' }); + await wrapperWithNoStubs.vm.$nextTick(); - jest.runAllTimers(); + jest.runAllTimers(); - expect(wrapperWithNoStubs.emitted('fetch-suggestions')).toBeTruthy(); - expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']); + expect(wrapperWithNoStubs.emitted('fetch-suggestions')).toEqual([['']]); + }); + }); + + describe('when activeToken has not been selected', () => { + beforeEach(() => { + wrapperWithNoStubs = createComponent({ + stubs: { Portal: true }, + }); + }); + it('emits `fetch-suggestions` event on component after a delay when component emits `input` event', async () => { + jest.useFakeTimers(); + + wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' }); + await wrapperWithNoStubs.vm.$nextTick(); + + jest.runAllTimers(); + + expect(wrapperWithNoStubs.emitted('fetch-suggestions')).toBeTruthy(); + expect(wrapperWithNoStubs.emitted('fetch-suggestions')[2]).toEqual(['foo']); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index 09eac636cae..f3e8b2d0c1b 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -42,7 +42,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index c2d61fd9f05..36071c900df 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -48,7 +48,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js index 68ed46fc3a2..6ee5d50d396 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js @@ -1,15 +1,21 @@ -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { GlFilteredSearchTokenSegment } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; +import searchEpicsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql'; import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { mockEpicToken, mockEpics } from '../mock_data'; +import { mockEpicToken, mockEpics, mockGroupEpicsQueryResponse } from '../mock_data'; jest.mock('~/flash'); +Vue.use(VueApollo); const defaultStubs = { Portal: true, @@ -21,31 +27,39 @@ const defaultStubs = { }, }; -function createComponent(options = {}) { - const { - config = mockEpicToken, - value = { data: '' }, - active = false, - stubs = defaultStubs, - } = options; - return mount(EpicToken, { - propsData: { - config, - value, - active, - }, - provide: { - portalName: 'fake target', - alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', - }, - stubs, - }); -} - describe('EpicToken', () => { let mock; let wrapper; + let fakeApollo; + + const findBaseToken = () => wrapper.findComponent(BaseToken); + + function createComponent( + options = {}, + epicsQueryHandler = jest.fn().mockResolvedValue(mockGroupEpicsQueryResponse), + ) { + fakeApollo = createMockApollo([[searchEpicsQuery, epicsQueryHandler]]); + const { + config = mockEpicToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(EpicToken, { + apolloProvider: fakeApollo, + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + suggestionsListClass: 'custom-class', + }, + stubs, + }); + } beforeEach(() => { mock = new MockAdapter(axios); @@ -71,23 +85,20 @@ describe('EpicToken', () => { describe('methods', () => { describe('fetchEpicsBySearchTerm', () => { - it('calls `config.fetchEpics` with provided searchTerm param', () => { - jest.spyOn(wrapper.vm.config, 'fetchEpics'); + it('calls fetchEpics with provided searchTerm param', () => { + jest.spyOn(wrapper.vm, 'fetchEpics'); - wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); + findBaseToken().vm.$emit('fetch-suggestions', 'foo'); - expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith({ - epicPath: '', - search: 'foo', - }); + expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo'); }); it('sets response to `epics` when request is successful', async () => { - jest.spyOn(wrapper.vm.config, 'fetchEpics').mockResolvedValue({ + jest.spyOn(wrapper.vm, 'fetchEpics').mockResolvedValue({ data: mockEpics, }); - wrapper.vm.fetchEpicsBySearchTerm({}); + findBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); @@ -95,9 +106,9 @@ describe('EpicToken', () => { }); it('calls `createFlash` with flash error message when request fails', async () => { - jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); + jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({}); - wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); + findBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); @@ -107,9 +118,9 @@ describe('EpicToken', () => { }); it('sets `loading` to false when request completes', async () => { - jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); + jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({}); - wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); + findBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); @@ -123,15 +134,15 @@ describe('EpicToken', () => { beforeEach(async () => { wrapper = createComponent({ - value: { data: `${mockEpics[0].group_full_path}::&${mockEpics[0].iid}` }, + value: { data: `${mockEpics[0].title}::&${mockEpics[0].iid}` }, data: { epics: mockEpics }, }); await wrapper.vm.$nextTick(); }); - it('renders gl-filtered-search-token component', () => { - expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + it('renders BaseToken component', () => { + expect(findBaseToken().exists()).toBe(true); }); it('renders token item when value is selected', () => { @@ -142,9 +153,9 @@ describe('EpicToken', () => { }); it.each` - value | valueType | tokenValueString - ${`${mockEpics[0].group_full_path}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} - ${`${mockEpics[1].group_full_path}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} + value | valueType | tokenValueString + ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} + ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} `('renders token item when selection is a $valueType', async ({ value, tokenValueString }) => { wrapper.setProps({ value: { data: value }, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js index a609aaa1c4e..af90ee93543 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js @@ -21,7 +21,7 @@ describe('IterationToken', () => { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index a348344b9dd..f55fb2836e3 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -48,7 +48,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, listeners, diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index bfb593bf82d..936841651d1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -48,7 +48,7 @@ function createComponent(options = {}) { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, stubs, }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js index e788c742736..4277899f8db 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js @@ -19,7 +19,7 @@ describe('WeightToken', () => { provide: { portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, - suggestionsListClass: 'custom-class', + suggestionsListClass: () => 'custom-class', }, }); diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index 2658fa4a706..f74b9b37197 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -94,10 +94,6 @@ describe('IssueAssigneesComponent', () => { expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Assigned to Terrell Graham'); }); - it('renders component root element with class `issue-assignees`', () => { - expect(wrapper.element.classList.contains('issue-assignees')).toBe(true); - }); - it('renders assignee', () => { const data = findAvatars().wrappers.map((x) => ({ ...x.props(), diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index ba2450b56c9..9bc2aad1895 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -60,7 +60,7 @@ describe('Suggestion Diff component', () => { expect(findHelpButton().exists()).toBe(true); }); - it('renders apply suggestion and add to batch buttons', () => { + it('renders add to batch button when more than 1 suggestion', () => { createComponent({ suggestionsCount: 2, }); @@ -68,8 +68,7 @@ describe('Suggestion Diff component', () => { const applyBtn = findApplyButton(); const addToBatchBtn = findAddToBatchButton(); - expect(applyBtn.exists()).toBe(true); - expect(applyBtn.html().includes('Apply suggestion')).toBe(true); + expect(applyBtn.exists()).toBe(false); expect(addToBatchBtn.exists()).toBe(true); expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true); @@ -85,7 +84,7 @@ describe('Suggestion Diff component', () => { describe('when apply suggestion is clicked', () => { beforeEach(() => { - createComponent(); + createComponent({ batchSuggestionsCount: 0 }); findApplyButton().vm.$emit('apply'); }); @@ -140,11 +139,11 @@ describe('Suggestion Diff component', () => { describe('apply suggestions is clicked', () => { it('emits applyBatch', () => { - createComponent({ isBatched: true }); + createComponent({ isBatched: true, batchSuggestionsCount: 2 }); - findApplyBatchButton().vm.$emit('click'); + findApplyButton().vm.$emit('apply'); - expect(wrapper.emitted().applyBatch).toEqual([[]]); + expect(wrapper.emitted().applyBatch).toEqual([[undefined]]); }); }); @@ -155,23 +154,24 @@ describe('Suggestion Diff component', () => { isBatched: true, }); - const applyBatchBtn = findApplyBatchButton(); + const applyBatchBtn = findApplyButton(); const removeFromBatchBtn = findRemoveFromBatchButton(); expect(removeFromBatchBtn.exists()).toBe(true); expect(removeFromBatchBtn.html().includes('Remove from batch')).toBe(true); expect(applyBatchBtn.exists()).toBe(true); - expect(applyBatchBtn.html().includes('Apply suggestions')).toBe(true); + expect(applyBatchBtn.html().includes('Apply suggestion')).toBe(true); expect(applyBatchBtn.html().includes(String('9'))).toBe(true); }); it('hides add to batch and apply buttons', () => { createComponent({ isBatched: true, + batchSuggestionsCount: 9, }); - expect(findApplyButton().exists()).toBe(false); + expect(findApplyButton().exists()).toBe(true); expect(findAddToBatchButton().exists()).toBe(false); }); @@ -215,9 +215,8 @@ describe('Suggestion Diff component', () => { }); it('disables apply suggestion and hides add to batch button', () => { - expect(findApplyButton().exists()).toBe(true); + expect(findApplyButton().exists()).toBe(false); expect(findAddToBatchButton().exists()).toBe(false); - expect(findApplyButton().attributes('disabled')).toBe('true'); }); }); @@ -225,7 +224,7 @@ describe('Suggestion Diff component', () => { const findTooltip = () => getBinding(findApplyButton().element, 'gl-tooltip'); it('renders correct tooltip message when button is applicable', () => { - createComponent(); + createComponent({ batchSuggestionsCount: 0 }); const tooltip = findTooltip(); expect(tooltip.modifiers.viewport).toBe(true); @@ -234,7 +233,7 @@ describe('Suggestion Diff component', () => { it('renders the inapplicable reason in the tooltip when button is not applicable', () => { const inapplicableReason = 'lorem'; - createComponent({ canApply: false, inapplicableReason }); + createComponent({ canApply: false, inapplicableReason, batchSuggestionsCount: 0 }); const tooltip = findTooltip(); expect(tooltip.modifiers.viewport).toBe(true); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js index 5bd6bda2d2c..af27e953776 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js @@ -77,7 +77,7 @@ describe('Suggestion Diff component', () => { it.each` event | childArgs | args ${'apply'} | ${['test-event']} | ${[{ callback: 'test-event', suggestionId }]} - ${'applyBatch'} | ${[]} | ${[]} + ${'applyBatch'} | ${['test-event']} | ${['test-event']} ${'addToBatch'} | ${[]} | ${[suggestionId]} ${'removeFromBatch'} | ${[]} | ${[suggestionId]} `('emits $event event on sugestion diff header $event', ({ event, childArgs, args }) => { diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index ab028ea52b7..1ed7844b395 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -1,4 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; +// eslint-disable-next-line import/no-deprecated +import { getJSONFixture } from 'helpers/fixtures'; import { trimText } from 'helpers/text_helper'; import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; @@ -11,6 +13,7 @@ describe('ProjectListItem component', () => { let vm; let options; + // eslint-disable-next-line import/no-deprecated const project = getJSONFixture('static/projects.json')[0]; beforeEach(() => { diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js index 06b00a8e196..1f97d3ff3fa 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js @@ -2,6 +2,8 @@ import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import { head } from 'lodash'; import Vue from 'vue'; +// eslint-disable-next-line import/no-deprecated +import { getJSONFixture } from 'helpers/fixtures'; import { trimText } from 'helpers/text_helper'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; @@ -11,6 +13,7 @@ const localVue = createLocalVue(); describe('ProjectSelector component', () => { let wrapper; let vm; + // eslint-disable-next-line import/no-deprecated const allProjects = getJSONFixture('static/projects.json'); const searchResults = allProjects.slice(0, 5); let selected = []; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index 14e0c8a2278..d9b7cd5afa2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -157,9 +157,9 @@ describe('LabelsSelect Mutations', () => { beforeEach(() => { labels = [ - { id: 1, title: 'scoped::test', set: true }, - { id: 2, set: false, title: 'scoped::one' }, - { id: 3, title: '' }, + { id: 1, title: 'scoped' }, + { id: 2, title: 'scoped::one', set: false }, + { id: 3, title: 'scoped::test', set: true }, { id: 4, title: '' }, ]; }); @@ -189,9 +189,9 @@ describe('LabelsSelect Mutations', () => { }); expect(state.labels).toEqual([ - { id: 1, title: 'scoped::test', set: false }, - { id: 2, set: true, title: 'scoped::one', touched: true }, - { id: 3, title: '' }, + { id: 1, title: 'scoped' }, + { id: 2, title: 'scoped::one', set: true, touched: true }, + { id: 3, title: 'scoped::test', set: false }, { id: 4, title: '' }, ]); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js index 843298a1406..8931584e12c 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js @@ -5,13 +5,14 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { labelsQueries } from '~/sidebar/constants'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; -import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import { mockSuggestedColors, createLabelSuccessfulResponse, - labelsQueryResponse, + workspaceLabelsQueryResponse, } from './mock_data'; jest.mock('~/flash'); @@ -47,11 +48,14 @@ describe('DropdownContentsCreateView', () => { findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); }; - const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => { + const createComponent = ({ + mutationHandler = createLabelSuccessHandler, + issuableType = IssuableType.Issue, + } = {}) => { const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); mockApollo.clients.defaultClient.cache.writeQuery({ - query: projectLabelsQuery, - data: labelsQueryResponse.data, + query: labelsQueries[issuableType].workspaceQuery, + data: workspaceLabelsQueryResponse.data, variables: { fullPath: '', searchTerm: '', @@ -61,6 +65,10 @@ describe('DropdownContentsCreateView', () => { wrapper = shallowMount(DropdownContentsCreateView, { localVue, apolloProvider: mockApollo, + propsData: { + issuableType, + fullPath: '', + }, }); }; @@ -135,15 +143,6 @@ describe('DropdownContentsCreateView', () => { expect(findCreateButton().props('disabled')).toBe(false); }); - it('calls a mutation with correct parameters on Create button click', () => { - findCreateButton().vm.$emit('click'); - expect(createLabelSuccessHandler).toHaveBeenCalledWith({ - color: '#009966', - projectPath: '', - title: 'Test title', - }); - }); - it('renders a loader spinner after Create button click', async () => { findCreateButton().vm.$emit('click'); await nextTick(); @@ -162,6 +161,30 @@ describe('DropdownContentsCreateView', () => { }); }); + it('calls a mutation with `projectPath` variable on the issue', () => { + createComponent(); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + projectPath: '', + title: 'Test title', + }); + }); + + it('calls a mutation with `groupPath` variable on the epic', () => { + createComponent({ issuableType: IssuableType.Epic }); + fillLabelAttributes(); + findCreateButton().vm.$emit('click'); + + expect(createLabelSuccessHandler).toHaveBeenCalledWith({ + color: '#009966', + groupPath: '', + title: 'Test title', + }); + }); + it('calls createFlash is mutation has a user-recoverable error', async () => { createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler }); fillLabelAttributes(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js index 537bbc8e71e..fac3331a2b8 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -1,36 +1,43 @@ -import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlLoadingIcon, + GlSearchBoxByType, + GlDropdownItem, + GlIntersectionObserver, +} from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; -import { mockConfig, labelsQueryResponse } from './mock_data'; +import { mockConfig, workspaceLabelsQueryResponse } from './mock_data'; jest.mock('~/flash'); const localVue = createLocalVue(); localVue.use(VueApollo); -const selectedLabels = [ +const localSelectedLabels = [ { - id: 28, - title: 'Bug', - description: 'Label for bugs', - color: '#FF0000', - textColor: '#FFFFFF', + color: '#2f7b2e', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', }, ]; describe('DropdownContentsLabelsView', () => { let wrapper; - const successfulQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse); + const successfulQueryHandler = jest.fn().mockResolvedValue(workspaceLabelsQueryResponse); + + const findFirstLabel = () => wrapper.findAllComponents(GlDropdownItem).at(0); const createComponent = ({ initialState = mockConfig, @@ -43,14 +50,13 @@ describe('DropdownContentsLabelsView', () => { localVue, apolloProvider: mockApollo, provide: { - projectPath: 'test', - iid: 1, variant: DropdownVariant.Sidebar, ...injected, }, propsData: { ...initialState, - selectedLabels, + localSelectedLabels, + issuableType: IssuableType.Issue, }, stubs: { GlSearchBoxByType, @@ -65,23 +71,31 @@ describe('DropdownContentsLabelsView', () => { const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findLabels = () => wrapper.findAllComponents(LabelItem); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findObserver = () => wrapper.findComponent(GlIntersectionObserver); const findLabelsList = () => wrapper.find('[data-testid="labels-list"]'); const findNoResultsMessage = () => wrapper.find('[data-testid="no-results"]'); + async function makeObserverAppear() { + await findObserver().vm.$emit('appear'); + } + describe('when loading labels', () => { it('renders disabled search input field', async () => { createComponent(); + await makeObserverAppear(); expect(findSearchInput().props('disabled')).toBe(true); }); it('renders loading icon', async () => { createComponent(); + await makeObserverAppear(); expect(findLoadingIcon().exists()).toBe(true); }); it('does not render labels list', async () => { createComponent(); + await makeObserverAppear(); expect(findLabelsList().exists()).toBe(false); }); }); @@ -89,6 +103,7 @@ describe('DropdownContentsLabelsView', () => { describe('when labels are loaded', () => { beforeEach(async () => { createComponent(); + await makeObserverAppear(); await waitForPromises(); }); @@ -118,6 +133,7 @@ describe('DropdownContentsLabelsView', () => { }, }), }); + await makeObserverAppear(); findSearchInput().vm.$emit('input', '123'); await waitForPromises(); await nextTick(); @@ -127,8 +143,26 @@ describe('DropdownContentsLabelsView', () => { it('calls `createFlash` when fetching labels failed', async () => { createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); + await makeObserverAppear(); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); await waitForPromises(); + expect(createFlash).toHaveBeenCalled(); }); + + it('emits an `input` event on label click', async () => { + createComponent(); + await makeObserverAppear(); + await waitForPromises(); + findFirstLabel().trigger('click'); + + expect(wrapper.emitted('input')[0][0]).toEqual(expect.arrayContaining(localSelectedLabels)); + }); + + it('does not trigger query when component did not appear', () => { + createComponent(); + expect(findLoadingIcon().exists()).toBe(false); + expect(findLabelsList().exists()).toBe(false); + expect(successfulQueryHandler).not.toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js index a1b40a891ec..36704ac5ef3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js @@ -1,6 +1,5 @@ -import { GlDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; - +import { nextTick } from 'vue'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; @@ -8,10 +7,26 @@ import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_s import { mockLabels } from './mock_data'; +const showDropdown = jest.fn(); + +const GlDropdownStub = { + template: ` + <div data-testid="dropdown"> + <slot name="header"></slot> + <slot></slot> + <slot name="footer"></slot> + </div> + `, + methods: { + show: showDropdown, + hide: jest.fn(), + }, +}; + describe('DropdownContent', () => { let wrapper; - const createComponent = ({ props = {}, injected = {} } = {}) => { + const createComponent = ({ props = {}, injected = {}, data = {} } = {}) => { wrapper = shallowMount(DropdownContents, { propsData: { labelsCreateTitle: 'test', @@ -22,38 +37,112 @@ describe('DropdownContent', () => { footerManageLabelTitle: 'manage', dropdownButtonText: 'Labels', variant: 'sidebar', + issuableType: 'issue', + fullPath: 'test', ...props, }, + data() { + return { + ...data, + }; + }, provide: { allowLabelCreate: true, labelsManagePath: 'foo/bar', ...injected, }, stubs: { - GlDropdown, + GlDropdown: GlDropdownStub, }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); + const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView); + const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView); + const findDropdown = () => wrapper.findComponent(GlDropdownStub); + const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); + const findDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]'); const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]'); + it('calls dropdown `show` method on `isVisible` prop change', async () => { + createComponent(); + await wrapper.setProps({ + isVisible: true, + }); + + expect(findDropdown().emitted('show')).toBeUndefined(); + }); + + it('does not emit `setLabels` event on dropdown hide if labels did not change', () => { + createComponent(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setLabels')).toBeUndefined(); + }); + + it('emits `setLabels` event on dropdown hide if labels changed on non-sidebar widget', async () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + const updatedLabel = { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }; + findLabelsView().vm.$emit('input', [updatedLabel]); + await nextTick(); + findDropdown().vm.$emit('hide'); + + expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]); + }); + + it('emits `setLabels` event on visibility change if labels changed on sidebar widget', async () => { + createComponent({ props: { variant: DropdownVariant.Standalone, isVisible: true } }); + const updatedLabel = { + id: 28, + title: 'Bug', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }; + findLabelsView().vm.$emit('input', [updatedLabel]); + wrapper.setProps({ isVisible: false }); + await nextTick(); + + expect(wrapper.emitted('setLabels')).toEqual([[[updatedLabel]]]); + }); + + it('does not render header on standalone variant', () => { + createComponent({ props: { variant: DropdownVariant.Standalone } }); + + expect(findDropdownHeader().exists()).toBe(false); + }); + + it('renders header on embedded variant', () => { + createComponent({ props: { variant: DropdownVariant.Embedded } }); + + expect(findDropdownHeader().exists()).toBe(true); + }); + + it('renders header on sidebar variant', () => { + createComponent(); + + expect(findDropdownHeader().exists()).toBe(true); + }); + describe('Create view', () => { beforeEach(() => { - wrapper.vm.toggleDropdownContentsCreateView(); + createComponent({ data: { showDropdownContentsCreateView: true } }); }); it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => { - expect(wrapper.findComponent(DropdownContentsCreateView).exists()).toBe(true); + expect(findCreateView().exists()).toBe(true); }); it('does not render footer', () => { @@ -67,11 +156,31 @@ describe('DropdownContent', () => { it('renders go back button', () => { expect(findGoBackButton().exists()).toBe(true); }); + + it('changes the view to Labels view on back button click', async () => { + findGoBackButton().vm.$emit('click', new MouseEvent('click')); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + expect(findLabelsView().exists()).toBe(true); + }); + + it('changes the view to Labels view on `hideCreateView` event', async () => { + findCreateView().vm.$emit('hideCreateView'); + await nextTick(); + + expect(findCreateView().exists()).toBe(false); + expect(findLabelsView().exists()).toBe(true); + }); }); describe('Labels view', () => { + beforeEach(() => { + createComponent(); + }); + it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => { - expect(wrapper.findComponent(DropdownContentsLabelsView).exists()).toBe(true); + expect(findLabelsView().exists()).toBe(true); }); it('renders footer on sidebar dropdown', () => { @@ -109,19 +218,12 @@ describe('DropdownContent', () => { expect(findCreateLabelButton().exists()).toBe(true); }); - it('triggers `toggleDropdownContent` method on create label button click', () => { - jest.spyOn(wrapper.vm, 'toggleDropdownContent').mockImplementation(() => {}); + it('changes the view to Create on create label button click', async () => { findCreateLabelButton().trigger('click'); - expect(wrapper.vm.toggleDropdownContent).toHaveBeenCalled(); + await nextTick(); + expect(findLabelsView().exists()).toBe(false); }); }); }); - - describe('template', () => { - it('renders component container element with classes `gl-w-full gl-mt-2` and no styles', () => { - expect(wrapper.attributes('class')).toContain('gl-w-full gl-mt-2'); - expect(wrapper.attributes('style')).toBeUndefined(); - }); - }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index a18511fa21d..b5441d711a5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -1,28 +1,55 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; -import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue'; +import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import { mockConfig, issuableLabelsQueryResponse } from './mock_data'; -import { mockConfig } from './mock_data'; +jest.mock('~/flash'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); +const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); describe('LabelsSelectRoot', () => { let wrapper; - const createComponent = (config = mockConfig, slots = {}) => { + const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); + const findDropdownValue = () => wrapper.findComponent(DropdownValue); + const findDropdownContents = () => wrapper.findComponent(DropdownContents); + + const createComponent = ({ + config = mockConfig, + slots = {}, + queryHandler = successfulQueryHandler, + } = {}) => { + const mockApollo = createMockApollo([[issueLabelsQuery, queryHandler]]); + wrapper = shallowMount(LabelsSelectRoot, { slots, - propsData: config, + apolloProvider: mockApollo, + localVue, + propsData: { + ...config, + issuableType: IssuableType.Issue, + }, stubs: { - DropdownContents, SidebarEditableItem, }, provide: { - iid: '1', - projectPath: 'test', canUpdate: true, allowLabelEdit: true, + allowLabelCreate: true, + labelsManagePath: 'test', }, }); }; @@ -42,33 +69,63 @@ describe('LabelsSelectRoot', () => { ${'embedded'} | ${'is-embedded'} `( 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', - ({ variant, cssClass }) => { + async ({ variant, cssClass }) => { createComponent({ - ...mockConfig, - variant, + config: { ...mockConfig, variant }, }); - return wrapper.vm.$nextTick(() => { - expect(wrapper.classes()).toContain(cssClass); - }); + await nextTick(); + expect(wrapper.classes()).toContain(cssClass); }, ); - it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { - createComponent(); - await wrapper.vm.$nextTick; - expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); - }); + describe('if dropdown variant is `sidebar`', () => { + it('renders sidebar editable item', () => { + createComponent(); + expect(findSidebarEditableItem().exists()).toBe(true); + }); + + it('passes true `loading` prop to sidebar editable item when loading labels', () => { + createComponent(); + expect(findSidebarEditableItem().props('loading')).toBe(true); + }); - it('renders `dropdown-value` component', async () => { - createComponent(mockConfig, { - default: 'None', + describe('when labels are fetched successfully', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('passes true `loading` prop to sidebar editable item', () => { + expect(findSidebarEditableItem().props('loading')).toBe(false); + }); + + it('renders dropdown value component when query labels is resolved', () => { + expect(findDropdownValue().exists()).toBe(true); + expect(findDropdownValue().props('selectedLabels')).toEqual( + issuableLabelsQueryResponse.data.workspace.issuable.labels.nodes, + ); + }); + + it('emits `onLabelRemove` event on dropdown value label remove event', () => { + const label = { id: 'gid://gitlab/ProjectLabel/1' }; + findDropdownValue().vm.$emit('onLabelRemove', label); + expect(wrapper.emitted('onLabelRemove')).toEqual([[label]]); + }); }); - await wrapper.vm.$nextTick; - const valueComp = wrapper.find(DropdownValue); + it('creates flash with error message when query is rejected', async () => { + createComponent({ queryHandler: errorQueryHandler }); + await waitForPromises(); + expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); + }); + }); + + it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event', async () => { + const label = { id: 'gid://gitlab/ProjectLabel/1' }; + createComponent(); - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); + findDropdownContents().vm.$emit('setLabels', [label]); + expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[label]]]); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js index fceaabec2d0..23a457848d9 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js @@ -34,6 +34,8 @@ export const mockLabels = [ ]; export const mockConfig = { + iid: '1', + fullPath: 'test', allowMultiselect: true, labelsListTitle: 'Assign labels', labelsCreateTitle: 'Create label', @@ -86,7 +88,7 @@ export const createLabelSuccessfulResponse = { }, }; -export const labelsQueryResponse = { +export const workspaceLabelsQueryResponse = { data: { workspace: { labels: { @@ -108,3 +110,23 @@ export const labelsQueryResponse = { }, }, }; + +export const issuableLabelsQueryResponse = { + data: { + workspace: { + issuable: { + id: '1', + labels: { + nodes: [ + { + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + }, + ], + }, + }, + }, + }, +}; diff --git a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap index af4fa462cbf..0f1e118d44c 100644 --- a/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap +++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap @@ -45,6 +45,7 @@ exports[`Upload dropzone component correctly overrides description and drop mess > <div class="mw-50 gl-text-center" + style="display: none;" > <h3 class="" @@ -61,7 +62,6 @@ exports[`Upload dropzone component correctly overrides description and drop mess <div class="mw-50 gl-text-center" - style="display: none;" > <h3 class="" @@ -146,7 +146,6 @@ exports[`Upload dropzone component when dragging renders correct template when d <div class="mw-50 gl-text-center" - style="" > <h3 class="" @@ -231,7 +230,6 @@ exports[`Upload dropzone component when dragging renders correct template when d <div class="mw-50 gl-text-center" - style="" > <h3 class="" @@ -299,6 +297,7 @@ exports[`Upload dropzone component when dragging renders correct template when d > <div class="mw-50 gl-text-center" + style="" > <h3 class="" @@ -383,6 +382,7 @@ exports[`Upload dropzone component when dragging renders correct template when d > <div class="mw-50 gl-text-center" + style="" > <h3 class="" @@ -467,6 +467,7 @@ exports[`Upload dropzone component when dragging renders correct template when d > <div class="mw-50 gl-text-center" + style="" > <h3 class="" @@ -551,6 +552,7 @@ exports[`Upload dropzone component when no slot provided renders default dropzon > <div class="mw-50 gl-text-center" + style="display: none;" > <h3 class="" @@ -567,7 +569,6 @@ exports[`Upload dropzone component when no slot provided renders default dropzon <div class="mw-50 gl-text-center" - style="display: none;" > <h3 class="" @@ -603,6 +604,7 @@ exports[`Upload dropzone component when slot provided renders dropzone with slot > <div class="mw-50 gl-text-center" + style="display: none;" > <h3 class="" @@ -619,7 +621,6 @@ exports[`Upload dropzone component when slot provided renders dropzone with slot <div class="mw-50 gl-text-center" - style="display: none;" > <h3 class="" diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js new file mode 100644 index 00000000000..a92f058f311 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list_spec.js @@ -0,0 +1,116 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; + +const mockSchedules = [ + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'Schedule 1', + url: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', + projectName: 'Shell', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', + }, + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'Schedule 2', + url: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', + projectName: 'UI', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', + }, +]; +const mockPolicies = [ + { + type: OBSTACLE_TYPES.escalationPolicies, + name: 'Policy 1', + url: 'http://gitlab.com/gitlab-org/gitlab-ui/-/escalation-policies', + projectName: 'UI', + projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', + }, +]; +const mockObstacles = mockSchedules.concat(mockPolicies); + +const userName = "O'User"; + +describe('User deletion obstacles list', () => { + let wrapper; + + function createComponent(props) { + wrapper = extendedWrapper( + shallowMount(UserDeletionObstaclesList, { + propsData: { + obstacles: mockObstacles, + userName, + ...props, + }, + stubs: { + GlSprintf, + }, + }), + ); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findLinks = () => wrapper.findAllComponents(GlLink); + const findTitle = () => wrapper.findByTestId('title'); + const findFooter = () => wrapper.findByTestId('footer'); + const findObstacles = () => wrapper.findByTestId('obstacles-list'); + + describe.each` + isCurrentUser | titleText | footerText + ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} + ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} + `('when current user', ({ isCurrentUser, titleText, footerText }) => { + it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call management`, async () => { + createComponent({ + isCurrentUser, + }); + + expect(findTitle().text()).toBe(titleText); + expect(findFooter().text()).toBe(footerText); + }); + }); + + describe.each(mockObstacles)( + 'renders all obstacles', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the project name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`in Project ${projectName}`); + expect(findLinks().at(1).attributes('href')).toBe(projectUrl); + }); + }, + ); + + describe.each(mockSchedules)( + 'renders on-call schedules', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the schedule name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`On-call schedule ${name}`); + expect(findLinks().at(0).attributes('href')).toBe(url); + }); + }, + ); + + describe.each(mockPolicies)( + 'renders escalation policies', + ({ type, name, url, projectName, projectUrl }) => { + it(`includes the policy name and link for ${name}`, () => { + createComponent({ obstacles: [{ type, name, url, projectName, projectUrl }] }); + const msg = findObstacles().text(); + + expect(msg).toContain(`Escalation policy ${name}`); + expect(findLinks().at(0).attributes('href')).toBe(url); + }); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js b/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js new file mode 100644 index 00000000000..99f739098f7 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_deletion_obstacles/utils_spec.js @@ -0,0 +1,43 @@ +import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; + +describe('parseUserDeletionObstacles', () => { + const mockObstacles = [{ name: 'Obstacle' }]; + const expectedSchedule = { name: 'Obstacle', type: OBSTACLE_TYPES.oncallSchedules }; + const expectedPolicy = { name: 'Obstacle', type: OBSTACLE_TYPES.escalationPolicies }; + + it('is undefined when user is not available', () => { + expect(parseUserDeletionObstacles()).toHaveLength(0); + }); + + it('is empty when obstacles are not available for user', () => { + expect(parseUserDeletionObstacles({})).toHaveLength(0); + }); + + it('is empty when user has no obstacles to deletion', () => { + const input = { oncallSchedules: [], escalationPolicies: [] }; + + expect(parseUserDeletionObstacles(input)).toHaveLength(0); + }); + + it('returns obstacles with type when user is part of on-call schedules', () => { + const input = { oncallSchedules: mockObstacles, escalationPolicies: [] }; + const expectedOutput = [expectedSchedule]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); + + it('returns obstacles with type when user is part of escalation policies', () => { + const input = { oncallSchedules: [], escalationPolicies: mockObstacles }; + const expectedOutput = [expectedPolicy]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); + + it('returns obstacles with type when user have every obstacle type', () => { + const input = { oncallSchedules: mockObstacles, escalationPolicies: mockObstacles }; + const expectedOutput = [expectedSchedule, expectedPolicy]; + + expect(parseUserDeletionObstacles(input)).toEqual(expectedOutput); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index 926223e0670..09633daf587 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -9,6 +9,7 @@ const DEFAULT_PROPS = { username: 'root', name: 'Administrator', location: 'Vienna', + localTime: '2:30 PM', bot: false, bio: null, workInformation: null, @@ -31,10 +32,11 @@ describe('User Popover Component', () => { wrapper.destroy(); }); - const findUserStatus = () => wrapper.find('.js-user-status'); + const findUserStatus = () => wrapper.findByTestId('user-popover-status'); const findTarget = () => document.querySelector('.js-user-link'); const findUserName = () => wrapper.find(UserNameWithStatus); const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link'); + const findUserLocalTime = () => wrapper.findByTestId('user-popover-local-time'); const createWrapper = (props = {}, options = {}) => { wrapper = mountExtended(UserPopover, { @@ -71,7 +73,6 @@ describe('User Popover Component', () => { expect(wrapper.text()).toContain(DEFAULT_PROPS.user.name); expect(wrapper.text()).toContain(DEFAULT_PROPS.user.username); - expect(wrapper.text()).toContain(DEFAULT_PROPS.user.location); }); it('shows icon for location', () => { @@ -164,6 +165,25 @@ describe('User Popover Component', () => { }); }); + describe('local time', () => { + it('should show local time when it is available', () => { + createWrapper(); + + expect(findUserLocalTime().exists()).toBe(true); + }); + + it('should not show local time when it is not available', () => { + const user = { + ...DEFAULT_PROPS.user, + localTime: null, + }; + + createWrapper({ user }); + + expect(findUserLocalTime().exists()).toBe(false); + }); + }); + describe('status data', () => { it('should show only message', () => { const user = { ...DEFAULT_PROPS.user, status: { message_html: 'Hello World' } }; @@ -256,5 +276,11 @@ describe('User Popover Component', () => { const securityBotDocsLink = findSecurityBotDocsLink(); expect(securityBotDocsLink.text()).toBe('Learn more about %<>\';"'); }); + + it('does not display local time', () => { + createWrapper({ user: SECURITY_BOT_USER }); + + expect(findUserLocalTime().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 5fe4eeb6061..92938b2717f 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -160,4 +160,26 @@ describe('Web IDE link component', () => { expect(findLocalStorageSync().props('value')).toBe(ACTION_GITPOD.key); }); }); + + describe('edit actions', () => { + it.each([ + { + props: { showWebIdeButton: true, showEditButton: false }, + expectedEventPayload: 'ide', + }, + { + props: { showWebIdeButton: false, showEditButton: true }, + expectedEventPayload: 'simple', + }, + ])( + 'emits the correct event when an action handler is called', + async ({ props, expectedEventPayload }) => { + createComponent({ ...props, needsToFork: true }); + + findActionsButton().props('actions')[0].handle(); + + expect(wrapper.emitted('edit')).toEqual([[expectedEventPayload]]); + }, + ); + }); }); diff --git a/spec/frontend/vue_shared/directives/validation_spec.js b/spec/frontend/vue_shared/directives/validation_spec.js index 51ee73cabde..dcd3a44a6fc 100644 --- a/spec/frontend/vue_shared/directives/validation_spec.js +++ b/spec/frontend/vue_shared/directives/validation_spec.js @@ -4,11 +4,13 @@ import validation, { initForm } from '~/vue_shared/directives/validation'; describe('validation directive', () => { let wrapper; - const createComponentFactory = ({ inputAttributes, template, data }) => { - const defaultInputAttributes = { - type: 'text', - required: true, - }; + const createComponentFactory = (options) => { + const { + inputAttributes = { type: 'text', required: true }, + template, + data, + feedbackMap = {}, + } = options; const defaultTemplate = ` <form> @@ -18,11 +20,11 @@ describe('validation directive', () => { const component = { directives: { - validation: validation(), + validation: validation(feedbackMap), }, data() { return { - attributes: inputAttributes || defaultInputAttributes, + attributes: inputAttributes, ...data, }; }, @@ -32,8 +34,10 @@ describe('validation directive', () => { wrapper = shallowMount(component, { attachTo: document.body }); }; - const createComponent = ({ inputAttributes, showValidation, template } = {}) => - createComponentFactory({ + const createComponent = (options = {}) => { + const { inputAttributes, showValidation, template, feedbackMap } = options; + + return createComponentFactory({ inputAttributes, data: { showValidation, @@ -48,10 +52,14 @@ describe('validation directive', () => { }, }, template, + feedbackMap, }); + }; + + const createComponentWithInitForm = (options = {}) => { + const { inputAttributes, feedbackMap } = options; - const createComponentWithInitForm = ({ inputAttributes } = {}) => - createComponentFactory({ + return createComponentFactory({ inputAttributes, data: { form: initForm({ @@ -68,7 +76,9 @@ describe('validation directive', () => { <input v-validation:[form.showValidation] name="exampleField" v-bind="attributes" /> </form> `, + feedbackMap, }); + }; afterEach(() => { wrapper.destroy(); @@ -209,6 +219,111 @@ describe('validation directive', () => { }); }); + describe('with custom feedbackMap', () => { + const customMessage = 'Please fill out the name field.'; + const template = ` + <form> + <div v-validation:[showValidation]> + <input name="exampleField" v-bind="attributes" /> + </div> + </form> + `; + beforeEach(() => { + const feedbackMap = { + valueMissing: { + isInvalid: (el) => el.validity?.valueMissing, + message: customMessage, + }, + }; + + createComponent({ + template, + inputAttributes: { + required: true, + }, + feedbackMap, + }); + }); + + describe('with invalid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(''); + }); + + it('should set correct field state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: false, + feedback: customMessage, + }); + }); + }); + + describe('with valid value', () => { + beforeEach(() => { + setValueAndTriggerValidation('hello'); + }); + + it('set the correct state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: true, + feedback: '', + }); + }); + }); + }); + + describe('with validation-message present on the element', () => { + const customMessage = 'The name field is required.'; + const template = ` + <form> + <div v-validation:[showValidation]> + <input name="exampleField" v-bind="attributes" validation-message="${customMessage}" /> + </div> + </form> + `; + beforeEach(() => { + const feedbackMap = { + valueMissing: { + isInvalid: (el) => el.validity?.valueMissing, + }, + }; + + createComponent({ + template, + inputAttributes: { + required: true, + }, + feedbackMap, + }); + }); + + describe('with invalid value', () => { + beforeEach(() => { + setValueAndTriggerValidation(''); + }); + + it('should set correct field state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: false, + feedback: customMessage, + }); + }); + }); + + describe('with valid value', () => { + beforeEach(() => { + setValueAndTriggerValidation('hello'); + }); + + it('set the correct state', () => { + expect(getFormData().fields.exampleField).toEqual({ + state: true, + feedback: '', + }); + }); + }); + }); + describe('component using initForm', () => { it('sets the form fields correctly', () => { createComponentWithInitForm(); diff --git a/spec/frontend/vue_shared/oncall_schedules_list_spec.js b/spec/frontend/vue_shared/oncall_schedules_list_spec.js deleted file mode 100644 index f83a5187b8b..00000000000 --- a/spec/frontend/vue_shared/oncall_schedules_list_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { GlLink, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; - -const mockSchedules = [ - { - name: 'Schedule 1', - scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/-/oncall_schedules', - projectName: 'Shell', - projectUrl: 'http://gitlab.com/gitlab-org/gitlab-shell/', - }, - { - name: 'Schedule 2', - scheduleUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/-/oncall_schedules', - projectName: 'UI', - projectUrl: 'http://gitlab.com/gitlab-org/gitlab-ui/', - }, -]; - -const userName = "O'User"; - -describe('On-call schedules list', () => { - let wrapper; - - function createComponent(props) { - wrapper = extendedWrapper( - shallowMount(OncallSchedulesList, { - propsData: { - schedules: mockSchedules, - userName, - ...props, - }, - stubs: { - GlSprintf, - }, - }), - ); - } - - afterEach(() => { - wrapper.destroy(); - }); - - const findLinks = () => wrapper.findAllComponents(GlLink); - const findTitle = () => wrapper.findByTestId('title'); - const findFooter = () => wrapper.findByTestId('footer'); - const findSchedules = () => wrapper.findByTestId('schedules-list'); - - describe.each` - isCurrentUser | titleText | footerText - ${true} | ${'You are currently a part of:'} | ${'Removing yourself may put your on-call team at risk of missing a notification.'} - ${false} | ${`User ${userName} is currently part of:`} | ${'Removing this user may put their on-call team at risk of missing a notification.'} - `('when current user ', ({ isCurrentUser, titleText, footerText }) => { - it(`${isCurrentUser ? 'is' : 'is not'} a part of on-call schedule`, async () => { - createComponent({ - isCurrentUser, - }); - - expect(findTitle().text()).toBe(titleText); - expect(findFooter().text()).toBe(footerText); - }); - }); - - describe.each(mockSchedules)( - 'renders each on-call schedule data', - ({ name, scheduleUrl, projectName, projectUrl }) => { - beforeEach(() => { - createComponent({ schedules: [{ name, scheduleUrl, projectName, projectUrl }] }); - }); - - it(`renders schedule ${name}'s name and link`, () => { - const msg = findSchedules().text(); - - expect(msg).toContain(`On-call schedule ${name}`); - expect(findLinks().at(0).attributes('href')).toBe(scheduleUrl); - }); - - it(`renders project ${projectName}'s name and link`, () => { - const msg = findSchedules().text(); - - expect(msg).toContain(`in Project ${projectName}`); - expect(findLinks().at(1).attributes('href')).toBe(projectUrl); - }); - }, - ); -}); diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js index 06631710509..cdaeec78e47 100644 --- a/spec/frontend/vue_shared/security_reports/mock_data.js +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -314,7 +314,7 @@ export const sastDiffSuccessMock = { head_report_created_at: '2020-01-10T10:00:00.000Z', }; -export const secretScanningDiffSuccessMock = { +export const secretDetectionDiffSuccessMock = { added: [mockFindings[0], mockFindings[1]], fixed: [mockFindings[2]], base_report_created_at: '2020-01-01T10:00:00.000Z', diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js index 4d579fa61df..68a97103d3a 100644 --- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -12,7 +12,7 @@ import { securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse, securityReportMergeRequestDownloadPathsQueryResponse, sastDiffSuccessMock, - secretScanningDiffSuccessMock, + secretDetectionDiffSuccessMock, } from 'jest/vue_shared/security_reports/mock_data'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -31,7 +31,7 @@ Vue.use(VueApollo); Vue.use(Vuex); const SAST_COMPARISON_PATH = '/sast.json'; -const SECRET_SCANNING_COMPARISON_PATH = '/secret_detection.json'; +const SECRET_DETECTION_COMPARISON_PATH = '/secret_detection.json'; describe('Security reports app', () => { let wrapper; @@ -175,12 +175,12 @@ describe('Security reports app', () => { const SAST_SUCCESS_MESSAGE = 'Security scanning detected 1 potential vulnerability 1 Critical 0 High and 0 Others'; - const SECRET_SCANNING_SUCCESS_MESSAGE = + const SECRET_DETECTION_SUCCESS_MESSAGE = 'Security scanning detected 2 potential vulnerabilities 1 Critical 1 High and 0 Others'; describe.each` - reportType | pathProp | path | successResponse | successMessage - ${REPORT_TYPE_SAST} | ${'sastComparisonPath'} | ${SAST_COMPARISON_PATH} | ${sastDiffSuccessMock} | ${SAST_SUCCESS_MESSAGE} - ${REPORT_TYPE_SECRET_DETECTION} | ${'secretScanningComparisonPath'} | ${SECRET_SCANNING_COMPARISON_PATH} | ${secretScanningDiffSuccessMock} | ${SECRET_SCANNING_SUCCESS_MESSAGE} + reportType | pathProp | path | successResponse | successMessage + ${REPORT_TYPE_SAST} | ${'sastComparisonPath'} | ${SAST_COMPARISON_PATH} | ${sastDiffSuccessMock} | ${SAST_SUCCESS_MESSAGE} + ${REPORT_TYPE_SECRET_DETECTION} | ${'secretDetectionComparisonPath'} | ${SECRET_DETECTION_COMPARISON_PATH} | ${secretDetectionDiffSuccessMock} | ${SECRET_DETECTION_SUCCESS_MESSAGE} `( 'given a $pathProp and $reportType artifact', ({ pathProp, path, successResponse, successMessage }) => { diff --git a/spec/frontend/vue_shared/security_reports/store/getters_spec.js b/spec/frontend/vue_shared/security_reports/store/getters_spec.js index 97746c7c38b..bcc8955ba02 100644 --- a/spec/frontend/vue_shared/security_reports/store/getters_spec.js +++ b/spec/frontend/vue_shared/security_reports/store/getters_spec.js @@ -8,7 +8,7 @@ import { summaryCounts, } from '~/vue_shared/security_reports/store/getters'; import createSastState from '~/vue_shared/security_reports/store/modules/sast/state'; -import createSecretScanningState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; +import createSecretDetectionState from '~/vue_shared/security_reports/store/modules/secret_detection/state'; import createState from '~/vue_shared/security_reports/store/state'; import { groupedTextBuilder } from '~/vue_shared/security_reports/store/utils'; import { CRITICAL, HIGH, LOW } from '~/vulnerabilities/constants'; @@ -21,7 +21,7 @@ describe('Security reports getters', () => { beforeEach(() => { state = createState(); state.sast = createSastState(); - state.secretDetection = createSecretScanningState(); + state.secretDetection = createSecretDetectionState(); }); describe('summaryCounts', () => { |