diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 09:55:51 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 09:55:51 +0000 |
commit | e8d2c2579383897a1dd7f9debd359abe8ae8373d (patch) | |
tree | c42be41678c2586d49a75cabce89322082698334 /spec/frontend | |
parent | fc845b37ec3a90aaa719975f607740c22ba6a113 (diff) | |
download | gitlab-ce-e8d2c2579383897a1dd7f9debd359abe8ae8373d.tar.gz |
Add latest changes from gitlab-org/gitlab@14-1-stable-eev14.1.0-rc42
Diffstat (limited to 'spec/frontend')
365 files changed, 9804 insertions, 7118 deletions
diff --git a/spec/frontend/__helpers__/dom_shims/inner_text.js b/spec/frontend/__helpers__/dom_shims/inner_text.js index 2b8201eed31..a48f0fee689 100644 --- a/spec/frontend/__helpers__/dom_shims/inner_text.js +++ b/spec/frontend/__helpers__/dom_shims/inner_text.js @@ -5,7 +5,7 @@ Object.defineProperty(global.Element.prototype, 'innerText', { return this.textContent; }, set(value) { - this.textContext = value; + this.textContent = value; }, configurable: true, // make it so that it doesn't blow chunks on re-running tests with things like --watch }); diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap index 1eb9ccc9c6c..10437c48f88 100644 --- a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap +++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap @@ -16,6 +16,7 @@ exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` > <gl-tabs-stub contentclass="pt-0" + queryparamname="tab" theme="indigo" value="0" > diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index 5db5b8a90a9..67d9bac8580 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -39,37 +39,12 @@ describe('Action components', () => { await nextTick(); - const div = wrapper.find('div'); - expect(div.attributes('data-path')).toBe('/test'); - expect(div.attributes('data-modal-attributes')).toContain('John Doe'); + expect(wrapper.attributes('data-path')).toBe('/test'); + expect(wrapper.attributes('data-modal-attributes')).toContain('John Doe'); expect(findDropdownItem().exists()).toBe(true); }); }); - describe('LINK_ACTIONS', () => { - it.each` - action | method - ${'Approve'} | ${'put'} - ${'Reject'} | ${'delete'} - `( - 'renders a dropdown item link with method "$method" for "$action"', - async ({ action, method }) => { - initComponent({ - component: Actions[action], - props: { - path: '/test', - }, - }); - - await nextTick(); - - const item = wrapper.find(GlDropdownItem); - expect(item.attributes('href')).toBe('/test'); - expect(item.attributes('data-method')).toContain(method); - }, - ); - }); - describe('DELETE_ACTION_COMPONENTS', () => { const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }]; it.each(DELETE_ACTIONS)('renders a dropdown item for "%s"', async (action) => { diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 4c644a0d05f..5e367891337 100644 --- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -10,6 +10,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`] <oncall-schedules-list-stub schedules="schedule1,schedule2" + username="username" /> <p> diff --git a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index 93d9ee43179..fee74764645 100644 --- a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -1,6 +1,6 @@ import { GlButton, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import DeleteUserModal from '~/pages/admin/users/components/delete_user_modal.vue'; +import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import ModalStub from './stubs/modal_stub'; diff --git a/spec/frontend/pages/admin/users/components/stubs/modal_stub.js b/spec/frontend/admin/users/components/modals/stubs/modal_stub.js index 4dc55e909a0..4dc55e909a0 100644 --- a/spec/frontend/pages/admin/users/components/stubs/modal_stub.js +++ b/spec/frontend/admin/users/components/modals/stubs/modal_stub.js diff --git a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js b/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js index 3669bc40d7e..65ce242662b 100644 --- a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js +++ b/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import UserModalManager from '~/pages/admin/users/components/user_modal_manager.vue'; +import UserModalManager from '~/admin/users/components/modals/user_modal_manager.vue'; import ModalStub from './stubs/modal_stub'; describe('Users admin page Modal Manager', () => { diff --git a/spec/frontend/admin/users/components/user_actions_spec.js b/spec/frontend/admin/users/components/user_actions_spec.js index debe964e7aa..43313424553 100644 --- a/spec/frontend/admin/users/components/user_actions_spec.js +++ b/spec/frontend/admin/users/components/user_actions_spec.js @@ -1,4 +1,5 @@ import { GlDropdownDivider } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Actions from '~/admin/users/components/actions'; import AdminUserActions from '~/admin/users/components/user_actions.vue'; @@ -6,7 +7,7 @@ import { I18N_USER_ACTIONS } from '~/admin/users/constants'; import { generateUserPaths } from '~/admin/users/utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import { CONFIRMATION_ACTIONS, DELETE_ACTIONS, LINK_ACTIONS, LDAP, EDIT } from '../constants'; +import { CONFIRMATION_ACTIONS, DELETE_ACTIONS, LDAP, EDIT } from '../constants'; import { users, paths } from '../mock_data'; describe('AdminUserActions component', () => { @@ -20,7 +21,7 @@ describe('AdminUserActions component', () => { findUserActions(id).find('[data-testid="dropdown-toggle"]'); const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider); - const initComponent = ({ actions = [] } = {}) => { + const initComponent = ({ actions = [], showButtonLabels } = {}) => { wrapper = shallowMountExtended(AdminUserActions, { propsData: { user: { @@ -28,6 +29,10 @@ describe('AdminUserActions component', () => { actions, }, paths, + showButtonLabels, + }, + directives: { + GlTooltip: createMockDirective(), }, }); }; @@ -62,7 +67,7 @@ describe('AdminUserActions component', () => { describe('actions dropdown', () => { describe('when there are actions', () => { - const actions = [EDIT, ...LINK_ACTIONS]; + const actions = [EDIT, ...CONFIRMATION_ACTIONS]; beforeEach(() => { initComponent({ actions }); @@ -72,19 +77,6 @@ describe('AdminUserActions component', () => { expect(findActionsDropdown().exists()).toBe(true); }); - describe('when there are actions that should render as links', () => { - beforeEach(() => { - initComponent({ actions: LINK_ACTIONS }); - }); - - it.each(LINK_ACTIONS)('renders an action component item for "%s"', (action) => { - const component = wrapper.find(Actions[capitalizeFirstCharacter(action)]); - - expect(component.props('path')).toBe(userPaths[action]); - expect(component.text()).toBe(I18N_USER_ACTIONS[action]); - }); - }); - describe('when there are actions that require confirmation', () => { beforeEach(() => { initComponent({ actions: CONFIRMATION_ACTIONS }); @@ -157,4 +149,42 @@ describe('AdminUserActions component', () => { }); }); }); + + describe('when `showButtonLabels` prop is `false`', () => { + beforeEach(() => { + initComponent({ actions: [EDIT, ...CONFIRMATION_ACTIONS] }); + }); + + it('does not render "Edit" button label', () => { + const tooltip = getBinding(findEditButton().element, 'gl-tooltip'); + + expect(findEditButton().text()).toBe(''); + expect(findEditButton().attributes('aria-label')).toBe(I18N_USER_ACTIONS.edit); + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe(I18N_USER_ACTIONS.edit); + }); + + it('does not render "User administration" dropdown button label', () => { + expect(findActionsDropdown().props('text')).toBe(I18N_USER_ACTIONS.userAdministration); + expect(findActionsDropdown().props('textSrOnly')).toBe(true); + }); + }); + + describe('when `showButtonLabels` prop is `true`', () => { + beforeEach(() => { + initComponent({ actions: [EDIT, ...CONFIRMATION_ACTIONS], showButtonLabels: true }); + }); + + it('renders "Edit" button label', () => { + const tooltip = getBinding(findEditButton().element, 'gl-tooltip'); + + expect(findEditButton().text()).toBe(I18N_USER_ACTIONS.edit); + expect(tooltip).not.toBeDefined(); + }); + + it('renders "User administration" dropdown button label', () => { + expect(findActionsDropdown().props('text')).toBe(I18N_USER_ACTIONS.userAdministration); + expect(findActionsDropdown().props('textSrOnly')).toBe(false); + }); + }); }); diff --git a/spec/frontend/admin/users/constants.js b/spec/frontend/admin/users/constants.js index 60abdc6c248..d341eb03b1b 100644 --- a/spec/frontend/admin/users/constants.js +++ b/spec/frontend/admin/users/constants.js @@ -7,13 +7,23 @@ const ACTIVATE = 'activate'; const DEACTIVATE = 'deactivate'; const REJECT = 'reject'; const APPROVE = 'approve'; +const BAN = 'ban'; +const UNBAN = 'unban'; export const EDIT = 'edit'; export const LDAP = 'ldapBlocked'; -export const LINK_ACTIONS = [APPROVE, REJECT]; - -export const CONFIRMATION_ACTIONS = [ACTIVATE, BLOCK, DEACTIVATE, UNLOCK, UNBLOCK]; +export const CONFIRMATION_ACTIONS = [ + ACTIVATE, + BLOCK, + DEACTIVATE, + UNLOCK, + UNBLOCK, + BAN, + UNBAN, + APPROVE, + REJECT, +]; export const DELETE_ACTIONS = [DELETE, DELETE_WITH_CONTRIBUTIONS]; diff --git a/spec/frontend/admin/users/index_spec.js b/spec/frontend/admin/users/index_spec.js index 20b60bd8640..06dbadd6d3d 100644 --- a/spec/frontend/admin/users/index_spec.js +++ b/spec/frontend/admin/users/index_spec.js @@ -1,7 +1,8 @@ import { createWrapper } from '@vue/test-utils'; -import { initAdminUsersApp } from '~/admin/users'; +import { initAdminUsersApp, initAdminUserActions } from '~/admin/users'; import AdminUsersApp from '~/admin/users/components/app.vue'; -import { users, paths } from './mock_data'; +import UserActions from '~/admin/users/components/user_actions.vue'; +import { users, user, paths } from './mock_data'; describe('initAdminUsersApp', () => { let wrapper; @@ -14,15 +15,12 @@ describe('initAdminUsersApp', () => { el.setAttribute('data-users', JSON.stringify(users)); el.setAttribute('data-paths', JSON.stringify(paths)); - document.body.appendChild(el); - wrapper = createWrapper(initAdminUsersApp(el)); }); afterEach(() => { wrapper.destroy(); wrapper = null; - el.remove(); el = null; }); @@ -33,3 +31,31 @@ describe('initAdminUsersApp', () => { }); }); }); + +describe('initAdminUserActions', () => { + let wrapper; + let el; + + const findUserActions = () => wrapper.find(UserActions); + + beforeEach(() => { + el = document.createElement('div'); + el.setAttribute('data-user', JSON.stringify(user)); + el.setAttribute('data-paths', JSON.stringify(paths)); + + wrapper = createWrapper(initAdminUserActions(el)); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + el = null; + }); + + it('parses and passes props', () => { + expect(findUserActions().props()).toMatchObject({ + user, + paths, + }); + }); +}); diff --git a/spec/frontend/admin/users/mock_data.js b/spec/frontend/admin/users/mock_data.js index 4689ab36773..ded3e6f7edf 100644 --- a/spec/frontend/admin/users/mock_data.js +++ b/spec/frontend/admin/users/mock_data.js @@ -18,6 +18,8 @@ export const users = [ }, ]; +export const user = users[0]; + export const paths = { edit: '/admin/users/id/edit', approve: '/admin/users/id/approve', @@ -30,6 +32,8 @@ export const paths = { delete: '/admin/users/id', deleteWithContributions: '/admin/users/id', adminUser: '/admin/users/id', + ban: '/admin/users/id/ban', + unban: '/admin/users/id/unban', }; export const createGroupCountResponse = (groupCounts) => ({ diff --git a/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js b/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js new file mode 100644 index 00000000000..75ef9d9db94 --- /dev/null +++ b/spec/frontend/analytics/devops_report/components/service_ping_disabled_spec.js @@ -0,0 +1,61 @@ +import { GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ServicePingDisabled from '~/analytics/devops_report/components/service_ping_disabled.vue'; + +describe('~/analytics/devops_report/components/service_ping_disabled.vue', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + const createWrapper = ({ isAdmin = false } = {}) => { + wrapper = shallowMountExtended(ServicePingDisabled, { + provide: { + isAdmin, + svgPath: TEST_HOST, + docsLink: TEST_HOST, + primaryButtonPath: TEST_HOST, + }, + stubs: { GlEmptyState, GlSprintf }, + }); + }; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findMessageForRegularUsers = () => wrapper.findComponent(GlSprintf); + const findDocsLink = () => wrapper.findByTestId('docs-link'); + const findPowerOnButton = () => wrapper.findByTestId('power-on-button'); + + it('renders empty state with provided SVG path', () => { + createWrapper(); + + expect(findEmptyState().props('svgPath')).toBe(TEST_HOST); + }); + + describe('for regular users', () => { + beforeEach(() => { + createWrapper({ isAdmin: false }); + }); + + it('renders message without power-on button', () => { + expect(findMessageForRegularUsers().exists()).toBe(true); + expect(findPowerOnButton().exists()).toBe(false); + }); + + it('renders docs link', () => { + expect(findDocsLink().exists()).toBe(true); + expect(findDocsLink().attributes('href')).toBe(TEST_HOST); + }); + }); + + describe('for admins', () => { + beforeEach(() => { + createWrapper({ isAdmin: true }); + }); + + it('renders power-on button', () => { + expect(findPowerOnButton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/analytics/shared/components/daterange_spec.js b/spec/frontend/analytics/shared/components/daterange_spec.js new file mode 100644 index 00000000000..854582abb82 --- /dev/null +++ b/spec/frontend/analytics/shared/components/daterange_spec.js @@ -0,0 +1,120 @@ +import { GlDaterangePicker } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { useFakeDate } from 'helpers/fake_date'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import Daterange from '~/analytics/shared/components/daterange.vue'; + +const defaultProps = { + startDate: new Date(2019, 8, 1), + endDate: new Date(2019, 8, 11), +}; + +describe('Daterange component', () => { + useFakeDate(2019, 8, 25); + + let wrapper; + + const factory = (props = defaultProps) => { + wrapper = mount(Daterange, { + propsData: { + ...defaultProps, + ...props, + }, + directives: { GlTooltip: createMockDirective() }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDaterangePicker = () => wrapper.find(GlDaterangePicker); + + const findDateRangeIndicator = () => wrapper.find('.daterange-indicator'); + + describe('template', () => { + describe('when show is false', () => { + it('does not render the daterange picker', () => { + factory({ show: false }); + expect(findDaterangePicker().exists()).toBe(false); + }); + }); + + describe('when show is true', () => { + it('renders the daterange picker', () => { + factory({ show: true }); + expect(findDaterangePicker().exists()).toBe(true); + }); + }); + + describe('with a minDate being set', () => { + it('emits the change event with the minDate when the user enters a start date before the minDate', () => { + const startDate = new Date('2019-09-01'); + const endDate = new Date('2019-09-30'); + const minDate = new Date('2019-06-01'); + + factory({ show: true, startDate, endDate, minDate }); + + const input = findDaterangePicker().find('input'); + + input.setValue('2019-01-01'); + input.trigger('change'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted().change).toEqual([[{ startDate: minDate, endDate }]]); + }); + }); + }); + + describe('with a maxDateRange being set', () => { + beforeEach(() => { + factory({ maxDateRange: 30 }); + }); + + it('displays the max date range indicator', () => { + expect(findDateRangeIndicator().exists()).toBe(true); + }); + + it('displays the correct number of selected days in the indicator', () => { + expect(findDateRangeIndicator().find('span').text()).toBe('10 days selected'); + }); + + it('displays a tooltip', () => { + const icon = wrapper.find('[data-testid="helper-icon"]'); + const tooltip = getBinding(icon.element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(icon.attributes('title')).toBe( + 'Showing data for workflow items created in this date range. Date range cannot exceed 30 days.', + ); + }); + }); + }); + + describe('computed', () => { + describe('dateRange', () => { + beforeEach(() => { + factory({ show: true }); + }); + + describe('set', () => { + it('emits the change event with an object containing startDate and endDate', () => { + const startDate = new Date('2019-10-01'); + const endDate = new Date('2019-10-05'); + wrapper.vm.dateRange = { startDate, endDate }; + + expect(wrapper.emitted().change).toEqual([[{ startDate, endDate }]]); + }); + }); + + describe('get', () => { + it("returns value of dateRange from state's startDate and endDate", () => { + expect(wrapper.vm.dateRange).toEqual({ + startDate: defaultProps.startDate, + endDate: defaultProps.endDate, + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/analytics/shared/components/metric_card_spec.js b/spec/frontend/analytics/shared/components/metric_card_spec.js deleted file mode 100644 index 7f587d227ab..00000000000 --- a/spec/frontend/analytics/shared/components/metric_card_spec.js +++ /dev/null @@ -1,129 +0,0 @@ -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import MetricCard from '~/analytics/shared/components/metric_card.vue'; - -const metrics = [ - { key: 'first_metric', value: 10, label: 'First metric', unit: 'days', link: 'some_link' }, - { key: 'second_metric', value: 20, label: 'Yet another metric' }, - { key: 'third_metric', value: null, label: 'Null metric without value', unit: 'parsecs' }, - { key: 'fourth_metric', value: '-', label: 'Metric without value', unit: 'parsecs' }, -]; - -const defaultProps = { - title: 'My fancy title', - isLoading: false, - metrics, -}; - -describe('MetricCard', () => { - let wrapper; - - const factory = (props = defaultProps) => { - wrapper = mount(MetricCard, { - propsData: { - ...defaultProps, - ...props, - }, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findTitle = () => wrapper.find({ ref: 'title' }); - const findLoadingIndicator = () => wrapper.find(GlSkeletonLoading); - const findMetricsWrapper = () => wrapper.find({ ref: 'metricsWrapper' }); - const findMetricItem = () => wrapper.findAll({ ref: 'metricItem' }); - const findTooltip = () => wrapper.find('[data-testid="tooltip"]'); - - describe('template', () => { - it('renders the title', () => { - factory(); - - expect(findTitle().text()).toContain('My fancy title'); - }); - - describe('when isLoading is true', () => { - beforeEach(() => { - factory({ isLoading: true }); - }); - - it('displays a loading indicator', () => { - expect(findLoadingIndicator().exists()).toBe(true); - }); - - it('does not display the metrics container', () => { - expect(findMetricsWrapper().exists()).toBe(false); - }); - }); - - describe('when isLoading is false', () => { - beforeEach(() => { - factory({ isLoading: false }); - }); - - it('does not display a loading indicator', () => { - expect(findLoadingIndicator().exists()).toBe(false); - }); - - it('displays the metrics container', () => { - expect(findMetricsWrapper().exists()).toBe(true); - }); - - it('renders two metrics', () => { - expect(findMetricItem()).toHaveLength(metrics.length); - }); - - describe('with tooltip text', () => { - const tooltipText = 'This is a tooltip'; - const tooltipMetric = { - key: 'fifth_metric', - value: '-', - label: 'Metric with tooltip', - unit: 'parsecs', - tooltipText, - }; - - beforeEach(() => { - factory({ - isLoading: false, - metrics: [tooltipMetric], - }); - }); - - it('will render a tooltip', () => { - const tt = getBinding(findTooltip().element, 'gl-tooltip'); - expect(tt.value.title).toEqual(tooltipText); - }); - }); - - describe.each` - columnIndex | label | value | unit | link - ${0} | ${'First metric'} | ${10} | ${' days'} | ${'some_link'} - ${1} | ${'Yet another metric'} | ${20} | ${''} | ${null} - ${2} | ${'Null metric without value'} | ${'-'} | ${''} | ${null} - ${3} | ${'Metric without value'} | ${'-'} | ${''} | ${null} - `('metric columns', ({ columnIndex, label, value, unit, link }) => { - it(`renders ${value}${unit} ${label} with URL ${link}`, () => { - const allMetricItems = findMetricItem(); - const metricItem = allMetricItems.at(columnIndex); - const text = metricItem.text(); - - expect(text).toContain(`${value}${unit}`); - expect(text).toContain(label); - - if (link) { - expect(metricItem.find('a').attributes('href')).toBe(link); - } else { - expect(metricItem.find('a').exists()).toBe(false); - } - }); - }); - }); - }); -}); diff --git a/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js new file mode 100644 index 00000000000..2537b8fb816 --- /dev/null +++ b/spec/frontend/analytics/shared/components/projects_dropdown_filter_spec.js @@ -0,0 +1,264 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; +import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; +import getProjects from '~/analytics/shared/graphql/projects.query.graphql'; + +const projects = [ + { + id: 'gid://gitlab/Project/1', + name: 'Gitlab Test', + fullPath: 'gitlab-org/gitlab-test', + avatarUrl: `${TEST_HOST}/images/home/nasa.svg`, + }, + { + id: 'gid://gitlab/Project/2', + name: 'Gitlab Shell', + fullPath: 'gitlab-org/gitlab-shell', + avatarUrl: null, + }, + { + id: 'gid://gitlab/Project/3', + name: 'Foo', + fullPath: 'gitlab-org/foo', + avatarUrl: null, + }, +]; + +const defaultMocks = { + $apollo: { + query: jest.fn().mockResolvedValue({ + data: { group: { projects: { nodes: projects } } }, + }), + }, +}; + +let spyQuery; + +describe('ProjectsDropdownFilter component', () => { + let wrapper; + + const createComponent = (props = {}) => { + spyQuery = defaultMocks.$apollo.query; + wrapper = mount(ProjectsDropdownFilter, { + mocks: { ...defaultMocks }, + propsData: { + groupId: 1, + groupNamespace: 'gitlab-org', + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdown = () => wrapper.find(GlDropdown); + + const findDropdownItems = () => + findDropdown() + .findAll(GlDropdownItem) + .filter((w) => w.text() !== 'No matching results'); + + const findDropdownAtIndex = (index) => findDropdownItems().at(index); + + const findDropdownButton = () => findDropdown().find('.dropdown-toggle'); + const findDropdownButtonAvatar = () => findDropdown().find('.gl-avatar'); + const findDropdownButtonAvatarAtIndex = (index) => + findDropdownAtIndex(index).find('img.gl-avatar'); + const findDropdownButtonIdentIconAtIndex = (index) => + findDropdownAtIndex(index).find('div.gl-avatar-identicon'); + + const findDropdownNameAtIndex = (index) => + findDropdownAtIndex(index).find('[data-testid="project-name"'); + const findDropdownFullPathAtIndex = (index) => + findDropdownAtIndex(index).find('[data-testid="project-full-path"]'); + + const selectDropdownItemAtIndex = (index) => + findDropdownAtIndex(index).find('button').trigger('click'); + + const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id); + + describe('queryParams are applied when fetching data', () => { + beforeEach(() => { + createComponent({ + queryParams: { + first: 50, + includeSubgroups: true, + }, + }); + }); + + it('applies the correct queryParams when making an api call', async () => { + wrapper.setData({ searchTerm: 'gitlab' }); + + expect(spyQuery).toHaveBeenCalledTimes(1); + + await wrapper.vm.$nextTick(() => { + expect(spyQuery).toHaveBeenCalledWith({ + query: getProjects, + variables: { + search: 'gitlab', + groupFullPath: wrapper.vm.groupNamespace, + first: 50, + includeSubgroups: true, + }, + }); + }); + }); + }); + + describe('when passed a an array of defaultProject as prop', () => { + beforeEach(() => { + createComponent({ + defaultProjects: [projects[0]], + }); + }); + + it("displays the defaultProject's name", () => { + expect(findDropdownButton().text()).toContain(projects[0].name); + }); + + it("renders the defaultProject's avatar", () => { + expect(findDropdownButtonAvatar().exists()).toBe(true); + }); + + it('marks the defaultProject as selected', () => { + expect(findDropdownAtIndex(0).props('isChecked')).toBe(true); + }); + }); + + describe('when multiSelect is false', () => { + beforeEach(() => { + createComponent({ multiSelect: false }); + }); + + describe('displays the correct information', () => { + it('contains 3 items', () => { + expect(findDropdownItems()).toHaveLength(3); + }); + + it('renders an avatar when the project has an avatarUrl', () => { + expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); + expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false); + }); + + it("renders an identicon when the project doesn't have an avatarUrl", () => { + expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); + expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true); + }); + + it('renders the project name', () => { + projects.forEach((project, index) => { + expect(findDropdownNameAtIndex(index).text()).toBe(project.name); + }); + }); + + it('renders the project fullPath', () => { + projects.forEach((project, index) => { + expect(findDropdownFullPathAtIndex(index).text()).toBe(project.fullPath); + }); + }); + }); + + describe('on project click', () => { + it('should emit the "selected" event with the selected project', () => { + selectDropdownItemAtIndex(0); + + expect(wrapper.emitted().selected).toEqual([[[projects[0]]]]); + }); + + it('should change selection when new project is clicked', () => { + selectDropdownItemAtIndex(1); + + expect(wrapper.emitted().selected).toEqual([[[projects[1]]]]); + }); + + it('selection should be emptied when a project is deselected', () => { + selectDropdownItemAtIndex(0); // Select the item + selectDropdownItemAtIndex(0); // deselect it + + expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[]]]); + }); + + it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => { + selectDropdownItemAtIndex(0); + + await wrapper.vm.$nextTick().then(() => { + expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); + expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false); + }); + }); + + it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => { + selectDropdownItemAtIndex(1); + + await wrapper.vm.$nextTick().then(() => { + expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); + expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true); + }); + }); + }); + }); + + describe('when multiSelect is true', () => { + beforeEach(() => { + createComponent({ multiSelect: true }); + }); + + describe('displays the correct information', () => { + it('contains 3 items', () => { + expect(findDropdownItems()).toHaveLength(3); + }); + + it('renders an avatar when the project has an avatarUrl', () => { + expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true); + expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false); + }); + + it("renders an identicon when the project doesn't have an avatarUrl", () => { + expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false); + expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true); + }); + + it('renders the project name', () => { + projects.forEach((project, index) => { + expect(findDropdownNameAtIndex(index).text()).toBe(project.name); + }); + }); + + it('renders the project fullPath', () => { + projects.forEach((project, index) => { + expect(findDropdownFullPathAtIndex(index).text()).toBe(project.fullPath); + }); + }); + }); + + describe('on project click', () => { + it('should add to selection when new project is clicked', () => { + selectDropdownItemAtIndex(0); + selectDropdownItemAtIndex(1); + + expect(selectedIds()).toEqual([projects[0].id, projects[1].id]); + }); + + it('should remove from selection when clicked again', () => { + selectDropdownItemAtIndex(0); + expect(selectedIds()).toEqual([projects[0].id]); + + selectDropdownItemAtIndex(0); + expect(selectedIds()).toEqual([]); + }); + + it('renders the correct placeholder text when multiple projects are selected', async () => { + selectDropdownItemAtIndex(0); + selectDropdownItemAtIndex(1); + + await wrapper.vm.$nextTick().then(() => { + expect(findDropdownButton().text()).toBe('2 projects selected'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/analytics/shared/utils_spec.js b/spec/frontend/analytics/shared/utils_spec.js new file mode 100644 index 00000000000..e3293f2d8bd --- /dev/null +++ b/spec/frontend/analytics/shared/utils_spec.js @@ -0,0 +1,24 @@ +import { filterBySearchTerm } from '~/analytics/shared/utils'; + +describe('filterBySearchTerm', () => { + const data = [ + { name: 'eins', title: 'one' }, + { name: 'zwei', title: 'two' }, + { name: 'drei', title: 'three' }, + ]; + const searchTerm = 'rei'; + + it('filters data by `name` for the provided search term', () => { + expect(filterBySearchTerm(data, searchTerm)).toEqual([data[2]]); + }); + + it('with no search term returns the data', () => { + ['', null].forEach((search) => { + expect(filterBySearchTerm(data, search)).toEqual(data); + }); + }); + + it('with a key, filters by the provided key', () => { + expect(filterBySearchTerm(data, 'ne', 'title')).toEqual([data[0]]); + }); +}); diff --git a/spec/frontend/analytics/usage_trends/components/instance_counts_spec.js b/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js index 707d2cc310f..703767dab47 100644 --- a/spec/frontend/analytics/usage_trends/components/instance_counts_spec.js +++ b/spec/frontend/analytics/usage_trends/components/usage_counts_spec.js @@ -1,5 +1,6 @@ +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; import { shallowMount } from '@vue/test-utils'; -import MetricCard from '~/analytics/shared/components/metric_card.vue'; import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue'; import { mockUsageCounts } from '../mock_data'; @@ -27,18 +28,18 @@ describe('UsageCounts', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findMetricCard = () => wrapper.find(MetricCard); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoading); + const findAllSingleStats = () => wrapper.findAllComponents(GlSingleStat); describe('while loading', () => { beforeEach(() => { createComponent({ loading: true }); }); - it('displays the metric card with isLoading=true', () => { - expect(findMetricCard().props('isLoading')).toBe(true); + it('displays a loading indicator', () => { + expect(findSkeletonLoader().exists()).toBe(true); }); }); @@ -47,8 +48,15 @@ describe('UsageCounts', () => { createComponent({ data: { counts: mockUsageCounts } }); }); - it('passes the counts data to the metric card', () => { - expect(findMetricCard().props('metrics')).toEqual(mockUsageCounts); + it.each` + index | value | title + ${0} | ${mockUsageCounts[0].value} | ${mockUsageCounts[0].label} + ${1} | ${mockUsageCounts[1].value} | ${mockUsageCounts[1].label} + `('renders a GlSingleStat for "$title"', ({ index, value, title }) => { + const singleStat = findAllSingleStats().at(index); + + expect(singleStat.props('value')).toBe(`${value}`); + expect(singleStat.props('title')).toBe(title); }); }); }); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index f708d8c7728..c3e5a2973d7 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1481,7 +1481,7 @@ describe('Api', () => { 'Content-Type': 'application/json', }; - describe('when usage data increment counter is called with feature flag disabled', () => { + describe('when service data increment counter is called with feature flag disabled', () => { beforeEach(() => { gon.features = { ...gon.features, usageDataApi: false }; }); @@ -1495,7 +1495,7 @@ describe('Api', () => { }); }); - describe('when usage data increment counter is called', () => { + describe('when service data increment counter is called', () => { beforeEach(() => { gon.features = { ...gon.features, usageDataApi: true }; }); @@ -1526,7 +1526,7 @@ describe('Api', () => { window.gon.current_user_id = 1; }); - describe('when usage data increment unique users is called with feature flag disabled', () => { + describe('when service data increment unique users is called with feature flag disabled', () => { beforeEach(() => { gon.features = { ...gon.features, usageDataApi: false }; }); @@ -1541,7 +1541,7 @@ describe('Api', () => { }); }); - describe('when usage data increment unique users is called', () => { + describe('when service data increment unique users is called', () => { beforeEach(() => { gon.features = { ...gon.features, usageDataApi: true }; }); diff --git a/spec/frontend/batch_comments/components/draft_note_spec.js b/spec/frontend/batch_comments/components/draft_note_spec.js index c2d488a465e..5d22823e974 100644 --- a/spec/frontend/batch_comments/components/draft_note_spec.js +++ b/spec/frontend/batch_comments/components/draft_note_spec.js @@ -1,5 +1,6 @@ import { getByRole } from '@testing-library/dom'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { stubComponent } from 'helpers/stub_component'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import { createStore } from '~/batch_comments/stores'; import NoteableNote from '~/notes/components/noteable_note.vue'; @@ -8,6 +9,14 @@ import { createDraft } from '../mock_data'; const localVue = createLocalVue(); +const NoteableNoteStub = stubComponent(NoteableNote, { + template: ` + <div> + <slot name="note-header-info">Test</slot> + </div> + `, +}); + describe('Batch comments draft note component', () => { let store; let wrapper; @@ -26,6 +35,9 @@ describe('Batch comments draft note component', () => { store, propsData, localVue, + stubs: { + NoteableNote: NoteableNoteStub, + }, }); jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(); diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap index 53815820bbe..dfa6b99080b 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_filepath_spec.js.snap @@ -10,7 +10,7 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` cssclasses="mr-2" filemode="" filename="foo/bar/dummy.md" - size="18" + size="16" /> <strong diff --git a/spec/frontend/blob/components/blob_edit_content_spec.js b/spec/frontend/blob/components/blob_edit_content_spec.js index 7de8d9236ed..9fc2356c018 100644 --- a/spec/frontend/blob/components/blob_edit_content_spec.js +++ b/spec/frontend/blob/components/blob_edit_content_spec.js @@ -3,7 +3,7 @@ import { nextTick } from 'vue'; import BlobEditContent from '~/blob/components/blob_edit_content.vue'; import * as utils from '~/blob/utils'; -jest.mock('~/editor/editor_lite'); +jest.mock('~/editor/source_editor'); describe('Blob Header Editing', () => { let wrapper; @@ -26,7 +26,7 @@ describe('Blob Header Editing', () => { } beforeEach(() => { - jest.spyOn(utils, 'initEditorLite').mockImplementation(() => ({ + jest.spyOn(utils, 'initSourceEditor').mockImplementation(() => ({ onDidChangeModelContent, updateModelLanguage, getValue, @@ -68,9 +68,9 @@ describe('Blob Header Editing', () => { expect(wrapper.find('#editor').exists()).toBe(true); }); - it('initialises Editor Lite', () => { + it('initialises Source Editor', () => { const el = wrapper.find({ ref: 'editor' }).element; - expect(utils.initEditorLite).toHaveBeenCalledWith({ + expect(utils.initSourceEditor).toHaveBeenCalledWith({ el, blobPath: fileName, blobGlobalId: fileGlobalId, diff --git a/spec/frontend/blob/csv/csv_viewer_spec.js b/spec/frontend/blob/csv/csv_viewer_spec.js new file mode 100644 index 00000000000..abb914b8f57 --- /dev/null +++ b/spec/frontend/blob/csv/csv_viewer_spec.js @@ -0,0 +1,75 @@ +import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { getAllByRole } from '@testing-library/dom'; +import { shallowMount, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import CSVViewer from '~/blob/csv/csv_viewer.vue'; + +const validCsv = 'one,two,three'; +const brokenCsv = '{\n "json": 1,\n "key": [1, 2, 3]\n}'; + +describe('app/assets/javascripts/blob/csv/csv_viewer.vue', () => { + let wrapper; + + const createComponent = ({ csv = validCsv, mountFunction = shallowMount } = {}) => { + wrapper = mountFunction(CSVViewer, { + propsData: { + csv, + }, + }); + }; + + const findCsvTable = () => wrapper.findComponent(GlTable); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findComponent(GlAlert); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render loading spinner', () => { + createComponent(); + + expect(findLoadingIcon().props()).toMatchObject({ + size: 'lg', + }); + }); + + describe('when the CSV contains errors', () => { + it('should render alert', async () => { + createComponent({ csv: brokenCsv }); + await nextTick; + + expect(findAlert().props()).toMatchObject({ + variant: 'danger', + }); + }); + }); + + describe('when the CSV contains no errors', () => { + it('should not render alert', async () => { + createComponent(); + await nextTick; + + expect(findAlert().exists()).toBe(false); + }); + + it('renders the CSV table with the correct attributes', async () => { + createComponent(); + await nextTick; + + expect(findCsvTable().attributes()).toMatchObject({ + 'empty-text': 'No CSV data to display.', + items: validCsv, + }); + }); + + it('renders the CSV table with the correct content', async () => { + createComponent({ mountFunction: mount }); + await nextTick; + + expect(getAllByRole(wrapper.element, 'row', { name: /One/i })).toHaveLength(1); + expect(getAllByRole(wrapper.element, 'row', { name: /Two/i })).toHaveLength(1); + expect(getAllByRole(wrapper.element, 'row', { name: /Three/i })).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/blob/utils_spec.js b/spec/frontend/blob/utils_spec.js index 3ff2e47e0b6..a543c0060cb 100644 --- a/spec/frontend/blob/utils_spec.js +++ b/spec/frontend/blob/utils_spec.js @@ -1,10 +1,10 @@ import * as utils from '~/blob/utils'; -import Editor from '~/editor/editor_lite'; +import Editor from '~/editor/source_editor'; -jest.mock('~/editor/editor_lite'); +jest.mock('~/editor/source_editor'); describe('Blob utilities', () => { - describe('initEditorLite', () => { + describe('initSourceEditor', () => { let editorEl; const blobPath = 'foo.txt'; const blobContent = 'Foo bar'; @@ -15,8 +15,8 @@ describe('Blob utilities', () => { }); describe('Monaco editor', () => { - it('initializes the Editor Lite', () => { - utils.initEditorLite({ el: editorEl }); + it('initializes the Source Editor', () => { + utils.initSourceEditor({ el: editorEl }); expect(Editor).toHaveBeenCalledWith({ scrollbar: { alwaysConsumeMouseWheel: false, @@ -34,7 +34,7 @@ describe('Blob utilities', () => { expect(Editor.prototype.createInstance).not.toHaveBeenCalled(); - utils.initEditorLite(params); + utils.initSourceEditor(params); expect(Editor.prototype.createInstance).toHaveBeenCalledWith(params); }, diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index e4f145ae81b..6a24b76abc8 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -6,6 +6,10 @@ import { setTestTimeout } from 'helpers/timeout'; import BlobViewer from '~/blob/viewer/index'; import axios from '~/lib/utils/axios_utils'; +const execImmediately = (callback) => { + callback(); +}; + describe('Blob viewer', () => { let blob; let mock; @@ -17,6 +21,7 @@ describe('Blob viewer', () => { setTestTimeout(2000); beforeEach(() => { + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); $.fn.extend(jQueryMock); mock = new MockAdapter(axios); diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js index eecc54be35b..8986dfbfa9c 100644 --- a/spec/frontend/blob_edit/blob_bundle_spec.js +++ b/spec/frontend/blob_edit/blob_bundle_spec.js @@ -3,21 +3,21 @@ import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; import blobBundle from '~/blob_edit/blob_bundle'; -import EditorLite from '~/blob_edit/edit_blob'; +import SourceEditor from '~/blob_edit/edit_blob'; jest.mock('~/blob_edit/edit_blob'); describe('BlobBundle', () => { - it('does not load EditorLite by default', () => { + it('does not load SourceEditor by default', () => { blobBundle(); - expect(EditorLite).not.toHaveBeenCalled(); + expect(SourceEditor).not.toHaveBeenCalled(); }); - it('loads EditorLite for the edit screen', async () => { + it('loads SourceEditor for the edit screen', async () => { setFixtures(`<div class="js-edit-blob-form"></div>`); blobBundle(); await waitForPromises(); - expect(EditorLite).toHaveBeenCalled(); + expect(SourceEditor).toHaveBeenCalled(); }); describe('No Suggest Popover', () => { diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js index 3134feedcf3..2be72ded0a2 100644 --- a/spec/frontend/blob_edit/edit_blob_spec.js +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -1,12 +1,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import EditBlob from '~/blob_edit/edit_blob'; -import EditorLite from '~/editor/editor_lite'; -import { FileTemplateExtension } from '~/editor/extensions/editor_file_template_ext'; -import { EditorMarkdownExtension } from '~/editor/extensions/editor_markdown_ext'; +import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; +import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; +import SourceEditor from '~/editor/source_editor'; -jest.mock('~/editor/editor_lite'); -jest.mock('~/editor/extensions/editor_markdown_ext'); -jest.mock('~/editor/extensions/editor_file_template_ext'); +jest.mock('~/editor/source_editor'); +jest.mock('~/editor/extensions/source_editor_markdown_ext'); +jest.mock('~/editor/extensions/source_editor_file_template_ext'); describe('Blob Editing', () => { const useMock = jest.fn(); @@ -24,7 +24,7 @@ describe('Blob Editing', () => { <textarea id="file-content"></textarea> </form> `); - jest.spyOn(EditorLite.prototype, 'createInstance').mockReturnValue(mockInstance); + jest.spyOn(SourceEditor.prototype, 'createInstance').mockReturnValue(mockInstance); }); afterEach(() => { EditorMarkdownExtension.mockClear(); diff --git a/spec/frontend/boards/board_card_inner_spec.js b/spec/frontend/boards/board_card_inner_spec.js index 15ea5d4eec4..87f9a68f5dd 100644 --- a/spec/frontend/boards/board_card_inner_spec.js +++ b/spec/frontend/boards/board_card_inner_spec.js @@ -1,7 +1,7 @@ -import { GlLabel, GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; +import { GlLabel, GlLoadingIcon, GlTooltip } from '@gitlab/ui'; import { range } from 'lodash'; import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import BoardBlockedIcon from '~/boards/components/board_blocked_icon.vue'; import BoardCardInner from '~/boards/components/board_card_inner.vue'; import { issuableTypes } from '~/boards/constants'; @@ -35,8 +35,16 @@ describe('Board card component', () => { let store; const findBoardBlockedIcon = () => wrapper.find(BoardBlockedIcon); - - const createStore = () => { + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findEpicCountablesTotalTooltip = () => wrapper.findComponent(GlTooltip); + const findEpicCountables = () => wrapper.findByTestId('epic-countables'); + const findEpicCountablesBadgeIssues = () => wrapper.findByTestId('epic-countables-counts-issues'); + const findEpicCountablesBadgeWeight = () => wrapper.findByTestId('epic-countables-weight-issues'); + const findEpicBadgeProgress = () => wrapper.findByTestId('epic-progress'); + const findEpicCountablesTotalWeight = () => wrapper.findByTestId('epic-countables-total-weight'); + const findEpicProgressTooltip = () => wrapper.findByTestId('epic-progress-tooltip-content'); + + const createStore = ({ isEpicBoard = false } = {}) => { store = new Vuex.Store({ ...defaultStore, state: { @@ -45,16 +53,14 @@ describe('Board card component', () => { }, getters: { isGroupBoard: () => true, - isEpicBoard: () => false, + isEpicBoard: () => isEpicBoard, isProjectBoard: () => false, }, }); }; const createWrapper = (props = {}) => { - createStore(); - - wrapper = mount(BoardCardInner, { + wrapper = mountExtended(BoardCardInner, { store, propsData: { list, @@ -88,6 +94,7 @@ describe('Board card component', () => { weight: 1, }; + createStore(); createWrapper({ item: issue, list }); }); @@ -414,7 +421,108 @@ describe('Board card component', () => { }, }); - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('is an epic board', () => { + const descendantCounts = { + closedEpics: 0, + closedIssues: 0, + openedEpics: 0, + openedIssues: 0, + }; + + const descendantWeightSum = { + closedIssues: 0, + openedIssues: 0, + }; + + beforeEach(() => { + createStore({ isEpicBoard: true }); + }); + + it('should render if the item has issues', () => { + createWrapper({ + item: { + ...issue, + descendantCounts, + descendantWeightSum, + hasIssues: true, + }, + }); + + expect(findEpicCountables().exists()).toBe(true); + }); + + it('should not render if the item does not have issues', () => { + createWrapper({ + item: { + ...issue, + descendantCounts, + descendantWeightSum, + hasIssues: false, + }, + }); + + expect(findEpicCountablesBadgeIssues().exists()).toBe(false); + }); + + it('shows render item countBadge, weights, and progress correctly', () => { + createWrapper({ + item: { + ...issue, + descendantCounts: { + ...descendantCounts, + openedIssues: 1, + }, + descendantWeightSum: { + closedIssues: 10, + openedIssues: 5, + }, + hasIssues: true, + }, + }); + + expect(findEpicCountablesBadgeIssues().text()).toBe('1'); + expect(findEpicCountablesBadgeWeight().text()).toBe('15'); + expect(findEpicBadgeProgress().text()).toBe('67%'); + }); + + it('does not render progress when weight is zero', () => { + createWrapper({ + item: { + ...issue, + descendantCounts: { + ...descendantCounts, + openedIssues: 1, + }, + descendantWeightSum, + hasIssues: true, + }, + }); + + expect(findEpicBadgeProgress().exists()).toBe(false); + }); + + it('renders the tooltip with the correct data', () => { + createWrapper({ + item: { + ...issue, + descendantCounts, + descendantWeightSum: { + closedIssues: 10, + openedIssues: 5, + }, + hasIssues: true, + }, + }); + + const tooltip = findEpicCountablesTotalTooltip(); + expect(tooltip).toBeDefined(); + + expect(findEpicCountablesTotalWeight().text()).toBe('15'); + expect(findEpicProgressTooltip().text()).toBe('10 of 15 weight completed'); }); }); }); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index 915b470df8d..c440c110094 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -1,34 +1,57 @@ -/* global List */ -/* global ListIssue */ -import MockAdapter from 'axios-mock-adapter'; -import Sortable from 'sortablejs'; -import Vue from 'vue'; -import BoardList from '~/boards/components/board_list_deprecated.vue'; -import '~/boards/models/issue'; -import '~/boards/models/list'; -import store from '~/boards/stores'; -import boardsStore from '~/boards/stores/boards_store'; -import axios from '~/lib/utils/axios_utils'; -import { listObj, boardsMockInterceptor } from './mock_data'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; -window.Sortable = Sortable; +import BoardCard from '~/boards/components/board_card.vue'; +import BoardList from '~/boards/components/board_list.vue'; +import BoardNewIssue from '~/boards/components/board_new_issue.vue'; +import defaultState from '~/boards/stores/state'; +import { mockList, mockIssuesByListId, issues } from './mock_data'; export default function createComponent({ - done, listIssueProps = {}, componentProps = {}, listProps = {}, -}) { - const el = document.createElement('div'); + actions = {}, + getters = {}, + provide = {}, + state = defaultState, + stubs = { + BoardNewIssue, + BoardCard, + }, +} = {}) { + const localVue = createLocalVue(); + localVue.use(Vuex); - document.body.appendChild(el); - const mock = new MockAdapter(axios); - mock.onAny().reply(boardsMockInterceptor); - boardsStore.create(); + const store = new Vuex.Store({ + state: { + boardItemsByListId: mockIssuesByListId, + boardItems: issues, + pageInfoByListId: { + 'gid://gitlab/List/1': { hasNextPage: true }, + 'gid://gitlab/List/2': {}, + }, + listsFlags: { + 'gid://gitlab/List/1': {}, + 'gid://gitlab/List/2': {}, + }, + selectedBoardItems: [], + ...state, + }, + getters: { + isGroupBoard: () => false, + isProjectBoard: () => true, + isEpicBoard: () => false, + ...getters, + }, + actions, + }); - const BoardListComp = Vue.extend(BoardList); - const list = new List({ ...listObj, ...listProps }); - const issue = new ListIssue({ + const list = { + ...mockList, + ...listProps, + }; + const issue = { title: 'Testing', id: 1, iid: 1, @@ -36,31 +59,31 @@ export default function createComponent({ labels: [], assignees: [], ...listIssueProps, - }); - if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { - list.issuesSize = 1; + }; + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) { + list.issuesCount = 1; } - list.issues.push(issue); - const component = new BoardListComp({ - el, + const component = shallowMount(BoardList, { + localVue, store, propsData: { disabled: false, list, - issues: list.issues, - loading: false, + boardItems: [issue], + canAdminList: true, ...componentProps, }, provide: { groupId: null, rootPath: '/', + weightFeatureAvailable: false, + boardWeight: null, + canAdminList: true, + ...provide, }, - }).$mount(); - - Vue.nextTick(() => { - done(); + stubs, }); - return { component, mock }; + return component; } diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 76629c96f22..a3b1810ab80 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -1,95 +1,9 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import Vuex from 'vuex'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; +import createComponent from 'jest/boards/board_list_helper'; import BoardCard from '~/boards/components/board_card.vue'; -import BoardList from '~/boards/components/board_list.vue'; -import BoardNewIssue from '~/boards/components/board_new_issue.vue'; import eventHub from '~/boards/eventhub'; -import defaultState from '~/boards/stores/state'; -import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const actions = { - fetchItemsForList: jest.fn(), -}; - -const createStore = (state = defaultState) => { - return new Vuex.Store({ - state, - actions, - getters: { - isGroupBoard: () => false, - isProjectBoard: () => true, - isEpicBoard: () => false, - }, - }); -}; - -const createComponent = ({ - listIssueProps = {}, - componentProps = {}, - listProps = {}, - state = {}, -} = {}) => { - const store = createStore({ - boardItemsByListId: mockIssuesByListId, - boardItems: issues, - pageInfoByListId: { - 'gid://gitlab/List/1': { hasNextPage: true }, - 'gid://gitlab/List/2': {}, - }, - listsFlags: { - 'gid://gitlab/List/1': {}, - 'gid://gitlab/List/2': {}, - }, - selectedBoardItems: [], - ...state, - }); - const list = { - ...mockList, - ...listProps, - }; - const issue = { - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [], - assignees: [], - ...listIssueProps, - }; - if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) { - list.issuesCount = 1; - } - - const component = shallowMount(BoardList, { - localVue, - propsData: { - disabled: false, - list, - boardItems: [issue], - canAdminList: true, - ...componentProps, - }, - store, - provide: { - groupId: null, - rootPath: '/', - weightFeatureAvailable: false, - boardWeight: null, - canAdminList: true, - }, - stubs: { - BoardCard, - BoardNewIssue, - }, - }); - - return component; -}; +import { mockIssues } from './mock_data'; describe('Board list component', () => { let wrapper; @@ -101,7 +15,6 @@ describe('Board list component', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('When Expanded', () => { @@ -176,6 +89,10 @@ describe('Board list component', () => { }); describe('load more issues', () => { + const actions = { + fetchItemsForList: jest.fn(), + }; + beforeEach(() => { wrapper = createComponent({ listProps: { issuesCount: 25 }, @@ -184,6 +101,7 @@ describe('Board list component', () => { it('does not load issues if already loading', () => { wrapper = createComponent({ + actions, state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, }); wrapper.vm.listRef.dispatchEvent(new Event('scroll')); diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js index 289905a1948..d45b6e35a45 100644 --- a/spec/frontend/boards/boards_util_spec.js +++ b/spec/frontend/boards/boards_util_spec.js @@ -1,4 +1,35 @@ -import { filterVariables } from '~/boards/boards_util'; +import { formatIssueInput, filterVariables } from '~/boards/boards_util'; + +describe('formatIssueInput', () => { + it('correctly merges boardConfig into the issue', () => { + const boardConfig = { + labels: [ + { + type: 'GroupLabel', + id: 44, + }, + ], + assigneeId: '55', + milestoneId: 66, + weight: 1, + }; + + const issueInput = { + labelIds: ['gid://gitlab/GroupLabel/5'], + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + }; + + const result = formatIssueInput(issueInput, boardConfig); + expect(result).toEqual({ + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + labelIds: ['gid://gitlab/GroupLabel/5', 'gid://gitlab/GroupLabel/44'], + assigneeIds: ['gid://gitlab/User/55'], + milestoneId: 'gid://gitlab/Milestone/66', + }); + }); +}); describe('filterVariables', () => { it.each([ diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index 4e523d636cd..f1964daa8b2 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -15,6 +15,10 @@ describe('Board Column Component', () => { wrapper = null; }); + const initStore = () => { + store = createStore(); + }; + const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => { const boardId = '1'; @@ -29,8 +33,6 @@ describe('Board Column Component', () => { listMock.assignee = {}; } - store = createStore(); - wrapper = shallowMount(BoardColumn, { store, propsData: { @@ -47,6 +49,10 @@ describe('Board Column Component', () => { const isCollapsed = () => wrapper.classes('is-collapsed'); describe('Given different list types', () => { + beforeEach(() => { + initStore(); + }); + it('is expandable when List Type is `backlog`', () => { createComponent({ listType: ListType.backlog }); @@ -79,4 +85,31 @@ describe('Board Column Component', () => { expect(wrapper.element.scrollIntoView).toHaveBeenCalled(); }); }); + + describe('on mount', () => { + beforeEach(async () => { + initStore(); + jest.spyOn(store, 'dispatch').mockImplementation(); + }); + + describe('when list is collapsed', () => { + it('does not call fetchItemsForList when', async () => { + createComponent({ collapsed: true }); + + await nextTick(); + + expect(store.dispatch).toHaveBeenCalledTimes(0); + }); + }); + + describe('when the list is not collapsed', () => { + it('calls fetchItemsForList when', async () => { + createComponent({ collapsed: false }); + + await nextTick(); + + expect(store.dispatch).toHaveBeenCalledWith('fetchItemsForList', { listId: 300 }); + }); + }); + }); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 10d739c65f5..8a8250205d0 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -1,5 +1,6 @@ import { GlDrawer } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { MountingPortal } from 'portal-vue'; import Vuex from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import { stubComponent } from 'helpers/stub_component'; @@ -9,7 +10,8 @@ import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.v import { ISSUABLE } from '~/boards/constants'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; -import { mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; +import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; +import { mockActiveIssue, mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; describe('BoardContentSidebar', () => { let wrapper; @@ -25,7 +27,7 @@ describe('BoardContentSidebar', () => { }, getters: { activeBoardItem: () => { - return { ...mockIssue, epic: null }; + return { ...mockActiveIssue, epic: null }; }, groupPathForActiveIssue: () => mockIssueGroupPath, projectPathForActiveIssue: () => mockIssueProjectPath, @@ -90,6 +92,14 @@ describe('BoardContentSidebar', () => { expect(wrapper.findComponent(GlDrawer).exists()).toBe(true); }); + it('confirms we render MountingPortal', () => { + expect(wrapper.find(MountingPortal).props()).toMatchObject({ + mountTo: '#js-right-sidebar-portal', + append: true, + name: 'board-content-sidebar', + }); + }); + it('does not render GlDrawer when isSidebarOpen is false', () => { createStore({ mockGetters: { isSidebarOpen: () => false } }); createComponent(); @@ -101,6 +111,10 @@ describe('BoardContentSidebar', () => { expect(wrapper.findComponent(GlDrawer).props('open')).toBe(true); }); + it('renders SidebarTodoWidget', () => { + expect(wrapper.findComponent(SidebarTodoWidget).exists()).toBe(true); + }); + it('renders BoardSidebarLabelsSelect', () => { expect(wrapper.findComponent(BoardSidebarLabelsSelect).exists()).toBe(true); }); @@ -138,7 +152,7 @@ describe('BoardContentSidebar', () => { expect(toggleBoardItem).toHaveBeenCalledTimes(1); expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), { - boardItem: { ...mockIssue, epic: null }, + boardItem: { ...mockActiveIssue, epic: null }, sidebarType: ISSUABLE, }); }); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 8c1a7bd3947..5a799b6388e 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -1,5 +1,6 @@ import { GlAlert } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Draggable from 'vuedraggable'; import Vuex from 'vuex'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; @@ -8,8 +9,7 @@ import BoardColumnDeprecated from '~/boards/components/board_column_deprecated.v import BoardContent from '~/boards/components/board_content.vue'; import { mockLists, mockListsWithModel } from '../mock_data'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); const actions = { moveList: jest.fn(), @@ -44,7 +44,6 @@ describe('BoardContent', () => { ...state, }); wrapper = shallowMount(BoardContent, { - localVue, propsData: { lists: mockListsWithModel, disabled: false, diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 80d740458dc..3966c3e6b87 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -12,8 +12,8 @@ import { createStore } from '~/boards/stores'; import { visitUrl } from '~/lib/utils/url_utility'; jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), visitUrl: jest.fn().mockName('visitUrlMock'), - stripFinalUrlSegment: jest.requireActual('~/lib/utils/url_utility').stripFinalUrlSegment, })); const currentBoard = { diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js index 464331b6e30..20a08be6c19 100644 --- a/spec/frontend/boards/components/board_settings_sidebar_spec.js +++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js @@ -3,6 +3,7 @@ import { GlDrawer, GlLabel } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { MountingPortal } from 'portal-vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; @@ -51,6 +52,16 @@ describe('BoardSettingsSidebar', () => { wrapper.destroy(); }); + it('finds a MountingPortal component', () => { + createComponent(); + + expect(wrapper.find(MountingPortal).props()).toMatchObject({ + mountTo: '#js-right-sidebar-portal', + append: true, + name: 'board-settings-sidebar', + }); + }); + describe('when sidebarType is "list"', () => { it('finds a GlDrawer component', () => { createComponent(); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js new file mode 100644 index 00000000000..0e3cf59901e --- /dev/null +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -0,0 +1,44 @@ +import { shallowMount } from '@vue/test-utils'; +import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; +import IssueBoardFilteredSpec from '~/boards/components/issue_board_filtered_search.vue'; +import { BoardType } from '~/boards/constants'; +import issueBoardFilters from '~/boards/issue_board_filters'; +import { mockTokens } from '../mock_data'; + +describe('IssueBoardFilter', () => { + let wrapper; + + const createComponent = ({ initialFilterParams = {} } = {}) => { + wrapper = shallowMount(IssueBoardFilteredSpec, { + provide: { initialFilterParams }, + props: { fullPath: '', boardType: '' }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('finds BoardFilteredSearch', () => { + expect(wrapper.find(BoardFilteredSearch).exists()).toBe(true); + }); + + it.each([[BoardType.group], [BoardType.project]])( + 'when boardType is %s we pass the correct tokens to BoardFilteredSearch', + (boardType) => { + const { fetchAuthors, fetchLabels } = issueBoardFilters({}, '', boardType); + + const tokens = mockTokens(fetchLabels, fetchAuthors); + + expect(wrapper.find(BoardFilteredSearch).props('tokens').toString()).toBe( + tokens.toString(), + ); + }, + ); + }); +}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index bcaca9522e4..6ac4db8cdaa 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -5,6 +5,9 @@ import Vue from 'vue'; import '~/boards/models/list'; import { ListType } from '~/boards/constants'; import boardsStore from '~/boards/stores/boards_store'; +import { __ } from '~/locale'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; export const boardObj = { id: 1, @@ -179,6 +182,7 @@ export const mockIssue = { export const mockActiveIssue = { ...mockIssue, + fullId: 'gid://gitlab/Issue/436', id: 436, iid: '27', subscribed: false, @@ -287,7 +291,7 @@ export const setMockEndpoints = (opts = {}) => { export const mockList = { id: 'gid://gitlab/List/1', - title: 'Backlog', + title: 'Open', position: -Infinity, listType: 'backlog', collapsed: false, @@ -526,3 +530,44 @@ export const mockMoveData = { originalIssue: { foo: 'bar' }, ...mockMoveIssueParams, }; + +export const mockTokens = (fetchLabels, fetchAuthors) => [ + { + icon: 'labels', + title: __('Label'), + type: 'label_name', + operators: [ + { value: '=', description: 'is' }, + { value: '!=', description: 'is not' }, + ], + token: LabelToken, + unique: false, + symbol: '~', + fetchLabels, + }, + { + icon: 'pencil', + title: __('Author'), + type: 'author_username', + operators: [ + { value: '=', description: 'is' }, + { value: '!=', description: 'is not' }, + ], + symbol: '@', + token: AuthorToken, + unique: true, + fetchAuthors, + }, + { + icon: 'user', + title: __('Assignee'), + type: 'assignee_username', + operators: [ + { value: '=', description: 'is' }, + { value: '!=', description: 'is not' }, + ], + token: AuthorToken, + unique: true, + fetchAuthors, + }, +]; diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index b28412f2127..5e16e389ddc 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -492,6 +492,63 @@ describe('moveList', () => { }); describe('updateList', () => { + const listId = 'gid://gitlab/List/1'; + const createState = (boardItemsByListId = {}) => ({ + fullPath: 'gitlab-org', + fullBoardId: 'gid://gitlab/Board/1', + boardType: 'group', + disabled: false, + boardLists: [{ type: 'closed' }], + issuableType: issuableTypes.issue, + boardItemsByListId, + }); + + describe('when state doesnt have list items', () => { + it('calls fetchItemsByList', async () => { + const dispatch = jest.fn(); + + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + updateBoardList: { + errors: [], + list: { + id: listId, + }, + }, + }, + }); + + await actions.updateList({ commit: () => {}, state: createState(), dispatch }, { listId }); + + expect(dispatch.mock.calls).toEqual([['fetchItemsForList', { listId }]]); + }); + }); + + describe('when state has list items', () => { + it('doesnt call fetchItemsByList', async () => { + const commit = jest.fn(); + const dispatch = jest.fn(); + + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + updateBoardList: { + errors: [], + list: { + id: listId, + }, + }, + }, + }); + + await actions.updateList( + { commit, state: createState({ [listId]: [] }), dispatch }, + { listId }, + ); + + expect(dispatch.mock.calls).toEqual([]); + }); + }); + it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', (done) => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { @@ -502,19 +559,10 @@ describe('updateList', () => { }, }); - const state = { - fullPath: 'gitlab-org', - fullBoardId: 'gid://gitlab/Board/1', - boardType: 'group', - disabled: false, - boardLists: [{ type: 'closed' }], - issuableType: issuableTypes.issue, - }; - testAction( actions.updateList, { listId: 'gid://gitlab/List/1', position: 1 }, - state, + createState(), [{ type: types.UPDATE_LIST_FAILURE }], [], done, @@ -667,6 +715,19 @@ describe('fetchItemsForList', () => { [listId]: pageInfo, }; + describe('when list id is undefined', () => { + it('does not call the query', async () => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + await actions.fetchItemsForList( + { state, getters: () => {}, commit: () => {} }, + { listId: undefined }, + ); + + expect(gqlClient.query).toHaveBeenCalledTimes(0); + }); + }); + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); @@ -1111,16 +1172,13 @@ describe('updateIssueOrder', () => { describe('setAssignees', () => { const node = { username: 'name' }; - const projectPath = 'h/h'; - const refPath = `${projectPath}#3`; - const iid = '1'; describe('when succeeds', () => { it('calls the correct mutation with the correct values', (done) => { testAction( actions.setAssignees, - [node], - { activeBoardItem: { iid, referencePath: refPath }, commit: () => {} }, + { assignees: [node], iid: '1' }, + { commit: () => {} }, [ { type: 'UPDATE_BOARD_ITEM_BY_ID', diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index e7efb21bee5..c0774dd3ae1 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -92,7 +92,7 @@ describe('Boards - Getters', () => { it.each` id | expected ${'1'} | ${'issue'} - ${''} | ${{}} + ${''} | ${{ id: '', iid: '', fullId: '' }} `('returns $expected when $id is passed to state', ({ id, expected }) => { const state = { boardItems: { 1: 'issue' }, activeId: id }; diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index 5b38f04e77b..37f0969a39a 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -35,6 +35,7 @@ describe('Board Store Mutations', () => { describe('SET_INITIAL_BOARD_DATA', () => { it('Should set initial Boards data to state', () => { + const allowSubEpics = true; const boardId = 1; const fullPath = 'gitlab-org'; const boardType = 'group'; @@ -45,6 +46,7 @@ describe('Board Store Mutations', () => { const issuableType = issuableTypes.issue; mutations[types.SET_INITIAL_BOARD_DATA](state, { + allowSubEpics, boardId, fullPath, boardType, @@ -53,6 +55,7 @@ describe('Board Store Mutations', () => { issuableType, }); + expect(state.allowSubEpics).toBe(allowSubEpics); expect(state.boardId).toEqual(boardId); expect(state.fullPath).toEqual(fullPath); expect(state.boardType).toEqual(boardType); diff --git a/spec/frontend/branches/components/delete_branch_button_spec.js b/spec/frontend/branches/components/delete_branch_button_spec.js index acbc83a9bdc..b029f34c3d7 100644 --- a/spec/frontend/branches/components/delete_branch_button_spec.js +++ b/spec/frontend/branches/components/delete_branch_button_spec.js @@ -34,7 +34,7 @@ describe('Delete branch button', () => { expect(findDeleteButton().attributes()).toMatchObject({ title: 'Delete branch', - variant: 'danger', + variant: 'default', icon: 'remove', }); }); @@ -44,7 +44,7 @@ describe('Delete branch button', () => { expect(findDeleteButton().attributes()).toMatchObject({ title: 'Delete protected branch', - variant: 'danger', + variant: 'default', icon: 'remove', }); }); @@ -78,7 +78,7 @@ describe('Delete branch button', () => { expect(findDeleteButton().attributes()).toMatchObject({ title: 'Delete branch', - variant: 'danger', + variant: 'default', }); }); diff --git a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js index df81b78d010..553ca52f9ce 100644 --- a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js +++ b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor'; +import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error'; import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -25,22 +26,24 @@ describe('registerCaptchaModalInterceptor', () => { let mock; beforeEach(() => { + waitForCaptchaToBeSolved.mockRejectedValue(new UnsolvedCaptchaError()); + mock = new MockAdapter(axios); - mock.onAny('/no-captcha').reply(200, AXIOS_RESPONSE); - mock.onAny('/error').reply(404, AXIOS_RESPONSE); - mock.onAny('/captcha').reply((config) => { + mock.onAny('/endpoint-without-captcha').reply(200, AXIOS_RESPONSE); + mock.onAny('/endpoint-with-unrelated-error').reply(404, AXIOS_RESPONSE); + mock.onAny('/endpoint-with-captcha').reply((config) => { if (!supportedMethods.includes(config.method)) { return [httpStatusCodes.METHOD_NOT_ALLOWED, { method: config.method }]; } - try { - const { captcha_response, spam_log_id, ...rest } = JSON.parse(config.data); - // eslint-disable-next-line babel/camelcase - if (captcha_response === CAPTCHA_RESPONSE && spam_log_id === SPAM_LOG_ID) { - return [httpStatusCodes.OK, { ...rest, method: config.method, CAPTCHA_SUCCESS }]; - } - } catch (e) { - return [httpStatusCodes.BAD_REQUEST, { method: config.method }]; + const data = JSON.parse(config.data); + const { + 'X-GitLab-Captcha-Response': captchaResponse, + 'X-GitLab-Spam-Log-Id': spamLogId, + } = config.headers; + + if (captchaResponse === CAPTCHA_RESPONSE && spamLogId === SPAM_LOG_ID) { + return [httpStatusCodes.OK, { ...data, method: config.method, CAPTCHA_SUCCESS }]; } return [httpStatusCodes.CONFLICT, NEEDS_CAPTCHA_RESPONSE]; @@ -56,7 +59,7 @@ describe('registerCaptchaModalInterceptor', () => { describe.each([...supportedMethods, ...unsupportedMethods])('For HTTP method %s', (method) => { it('successful requests are passed through', async () => { - const { data, status } = await axios[method]('/no-captcha'); + const { data, status } = await axios[method]('/endpoint-without-captcha'); expect(status).toEqual(httpStatusCodes.OK); expect(data).toEqual(AXIOS_RESPONSE); @@ -64,7 +67,7 @@ describe('registerCaptchaModalInterceptor', () => { }); it('error requests without needs_captcha_response_errors are passed through', async () => { - await expect(() => axios[method]('/error')).rejects.toThrow( + await expect(() => axios[method]('/endpoint-with-unrelated-error')).rejects.toThrow( expect.objectContaining({ response: expect.objectContaining({ status: httpStatusCodes.NOT_FOUND, @@ -79,21 +82,35 @@ describe('registerCaptchaModalInterceptor', () => { describe.each(supportedMethods)('For HTTP method %s', (method) => { describe('error requests with needs_captcha_response_errors', () => { const submittedData = { ID: 12345 }; + const submittedHeaders = { 'Submitted-Header': 67890 }; it('re-submits request if captcha was solved correctly', async () => { - waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE); - const { data: returnedData } = await axios[method]('/captcha', submittedData); + waitForCaptchaToBeSolved.mockResolvedValueOnce(CAPTCHA_RESPONSE); + const axiosResponse = await axios[method]('/endpoint-with-captcha', submittedData, { + headers: submittedHeaders, + }); + const { + data: returnedData, + config: { headers: returnedHeaders }, + } = axiosResponse; expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); expect(returnedData).toEqual({ ...submittedData, CAPTCHA_SUCCESS, method }); + expect(returnedHeaders).toEqual( + expect.objectContaining({ + ...submittedHeaders, + 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE, + 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID, + }), + ); expect(mock.history[method]).toHaveLength(2); }); it('does not re-submit request if captcha was not solved', async () => { - const error = new Error('Captcha not solved'); - waitForCaptchaToBeSolved.mockRejectedValue(error); - await expect(() => axios[method]('/captcha', submittedData)).rejects.toThrow(error); + await expect(() => axios[method]('/endpoint-with-captcha', submittedData)).rejects.toThrow( + new UnsolvedCaptchaError(), + ); expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); expect(mock.history[method]).toHaveLength(1); @@ -103,7 +120,7 @@ describe('registerCaptchaModalInterceptor', () => { describe.each(unsupportedMethods)('For HTTP method %s', (method) => { it('ignores captcha response', async () => { - await expect(() => axios[method]('/captcha')).rejects.toThrow( + await expect(() => axios[method]('/endpoint-with-captcha')).rejects.toThrow( expect.objectContaining({ response: expect.objectContaining({ status: httpStatusCodes.METHOD_NOT_ALLOWED, diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js index 8a065436da0..36d860b1ccd 100644 --- a/spec/frontend/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci_lint/components/ci_lint_spec.js @@ -4,7 +4,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import CiLint from '~/ci_lint/components/ci_lint.vue'; import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; import lintCIMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql'; -import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; import { mockLintDataValid } from '../mock_data'; describe('CI Lint', () => { @@ -35,7 +35,7 @@ describe('CI Lint', () => { }); }; - const findEditor = () => wrapper.find(EditorLite); + const findEditor = () => wrapper.find(SourceEditor); const findAlert = () => wrapper.find(GlAlert); const findCiLintResults = () => wrapper.find(CiLintResults); const findValidateBtn = () => wrapper.find('[data-testid="ci-lint-validate"]'); diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index cd0eda2ab49..42990334f0a 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -2,15 +2,12 @@ import MockAdapter from 'axios-mock-adapter'; import { loadHTMLFixture } from 'helpers/fixtures'; import { setTestTimeout } from 'helpers/timeout'; import Clusters from '~/clusters/clusters_bundle'; -import { APPLICATION_STATUS, APPLICATIONS, RUNNER } from '~/clusters/constants'; import axios from '~/lib/utils/axios_utils'; import initProjectSelectDropdown from '~/project_select'; jest.mock('~/lib/utils/poll'); jest.mock('~/project_select'); -const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS; - describe('Clusters', () => { setTestTimeout(1000); @@ -57,67 +54,6 @@ describe('Clusters', () => { }); }); - describe('checkForNewInstalls', () => { - const INITIAL_APP_MAP = { - helm: { status: null, title: 'Helm Tiller' }, - ingress: { status: null, title: 'Ingress' }, - runner: { status: null, title: 'GitLab Runner' }, - }; - - it('does not show alert when things transition from initial null state to something', () => { - cluster.checkForNewInstalls(INITIAL_APP_MAP, { - ...INITIAL_APP_MAP, - helm: { status: INSTALLABLE, title: 'Helm Tiller' }, - }); - - const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text'); - - expect(flashMessage).toBeNull(); - }); - - it('shows an alert when something gets newly installed', () => { - cluster.checkForNewInstalls( - { - ...INITIAL_APP_MAP, - helm: { status: INSTALLING, title: 'Helm Tiller' }, - }, - { - ...INITIAL_APP_MAP, - helm: { status: INSTALLED, title: 'Helm Tiller' }, - }, - ); - - const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text'); - - expect(flashMessage).not.toBeNull(); - expect(flashMessage.textContent.trim()).toEqual( - 'Helm Tiller was successfully installed on your Kubernetes cluster', - ); - }); - - it('shows an alert when multiple things gets newly installed', () => { - cluster.checkForNewInstalls( - { - ...INITIAL_APP_MAP, - helm: { status: INSTALLING, title: 'Helm Tiller' }, - ingress: { status: INSTALLABLE, title: 'Ingress' }, - }, - { - ...INITIAL_APP_MAP, - helm: { status: INSTALLED, title: 'Helm Tiller' }, - ingress: { status: INSTALLED, title: 'Ingress' }, - }, - ); - - const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text'); - - expect(flashMessage).not.toBeNull(); - expect(flashMessage.textContent.trim()).toEqual( - 'Helm Tiller, Ingress was successfully installed on your Kubernetes cluster', - ); - }); - }); - describe('updateContainer', () => { const { location } = window; @@ -237,77 +173,6 @@ describe('Clusters', () => { }); }); - describe('installApplication', () => { - it.each(APPLICATIONS)('tries to install %s', (applicationId, done) => { - jest.spyOn(cluster.service, 'installApplication').mockResolvedValue(); - - cluster.store.state.applications[applicationId].status = INSTALLABLE; - - const params = {}; - if (applicationId === 'knative') { - params.hostname = 'test-example.com'; - } - - // eslint-disable-next-line promise/valid-params - cluster - .installApplication({ id: applicationId, params }) - .then(() => { - expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING); - expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, params); - done(); - }) - .catch(); - }); - - it('sets error request status when the request fails', () => { - jest - .spyOn(cluster.service, 'installApplication') - .mockRejectedValueOnce(new Error('STUBBED ERROR')); - - cluster.store.state.applications.helm.status = INSTALLABLE; - - const promise = cluster.installApplication({ id: 'helm' }); - - return promise.then(() => { - expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE); - expect(cluster.store.state.applications.helm.installFailed).toBe(true); - - expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); - }); - }); - }); - - describe('uninstallApplication', () => { - it.each(APPLICATIONS)('tries to uninstall %s', (applicationId) => { - jest.spyOn(cluster.service, 'uninstallApplication').mockResolvedValueOnce(); - - cluster.store.state.applications[applicationId].status = INSTALLED; - - cluster.uninstallApplication({ id: applicationId }); - - expect(cluster.store.state.applications[applicationId].status).toEqual(UNINSTALLING); - expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); - expect(cluster.service.uninstallApplication).toHaveBeenCalledWith(applicationId); - }); - - it('sets error request status when the uninstall request fails', () => { - jest - .spyOn(cluster.service, 'uninstallApplication') - .mockRejectedValueOnce(new Error('STUBBED ERROR')); - - cluster.store.state.applications.helm.status = INSTALLED; - - const promise = cluster.uninstallApplication({ id: 'helm' }); - - return promise.then(() => { - expect(cluster.store.state.applications.helm.status).toEqual(INSTALLED); - expect(cluster.store.state.applications.helm.uninstallFailed).toBe(true); - expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); - }); - }); - }); - describe('fetch cluster environments success', () => { beforeEach(() => { jest.spyOn(cluster.store, 'toggleFetchEnvironments').mockReturnThis(); @@ -328,7 +193,6 @@ describe('Clusters', () => { describe('handleClusterStatusSuccess', () => { beforeEach(() => { jest.spyOn(cluster.store, 'updateStateFromServer').mockReturnThis(); - jest.spyOn(cluster, 'checkForNewInstalls').mockReturnThis(); jest.spyOn(cluster, 'updateContainer').mockReturnThis(); cluster.handleClusterStatusSuccess({ data: {} }); }); @@ -337,38 +201,8 @@ describe('Clusters', () => { expect(cluster.store.updateStateFromServer).toHaveBeenCalled(); }); - it('checks for new installable apps', () => { - expect(cluster.checkForNewInstalls).toHaveBeenCalled(); - }); - it('updates message containers', () => { expect(cluster.updateContainer).toHaveBeenCalled(); }); }); - - describe('updateApplication', () => { - const params = { version: '1.0.0' }; - let storeUpdateApplication; - let installApplication; - - beforeEach(() => { - storeUpdateApplication = jest.spyOn(cluster.store, 'updateApplication'); - installApplication = jest.spyOn(cluster.service, 'installApplication'); - - cluster.updateApplication({ id: RUNNER, params }); - }); - - afterEach(() => { - storeUpdateApplication.mockRestore(); - installApplication.mockRestore(); - }); - - it('calls store updateApplication method', () => { - expect(storeUpdateApplication).toHaveBeenCalledWith(RUNNER); - }); - - it('sends installApplication request', () => { - expect(installApplication).toHaveBeenCalledWith(RUNNER, params); - }); - }); }); diff --git a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap deleted file mode 100644 index c2ace1b4e30..00000000000 --- a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap +++ /dev/null @@ -1,105 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Applications Cert-Manager application shows the correct description 1`] = ` -<p - data-testid="certManagerDescription" -> - Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates. Installing Cert-Manager on your cluster will issue a certificate by - <a - class="gl-link" - href="https://letsencrypt.org/" - rel="noopener noreferrer" - target="_blank" - > - Let's Encrypt - </a> - and ensure that certificates are valid and up-to-date. -</p> -`; - -exports[`Applications Cilium application shows the correct description 1`] = ` -<p - data-testid="ciliumDescription" -> - Protect your clusters with GitLab Container Network Policies by enforcing how pods communicate with each other and other network endpoints. - <a - class="gl-link" - href="cilium-help-path" - rel="noopener" - target="_blank" - > - Learn more about configuring Network Policies here. - </a> -</p> -`; - -exports[`Applications Crossplane application shows the correct description 1`] = ` -<p - data-testid="crossplaneDescription" -> - Crossplane enables declarative provisioning of managed services from your cloud of choice using - <code> - kubectl - </code> - or - <a - class="gl-link" - href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane" - rel="noopener noreferrer" - target="_blank" - > - GitLab Integration - </a> - . Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on. -</p> -`; - -exports[`Applications Ingress application shows the correct warning message 1`] = ` -<span - data-testid="ingressCostWarning" -> - Installing Ingress may incur additional costs. Learn more about - <a - class="gl-link" - href="https://cloud.google.com/compute/pricing#lb" - rel="noopener noreferrer" - target="_blank" - > - pricing - </a> - . -</span> -`; - -exports[`Applications Knative application shows the correct description 1`] = ` -<span - data-testid="installed-via" -> - installed via - <a - class="gl-link" - href="" - rel="noopener" - target="_blank" - > - Cloud Run - </a> -</span> -`; - -exports[`Applications Prometheus application shows the correct description 1`] = ` -<span - data-testid="prometheusDescription" -> - Prometheus is an open-source monitoring system with - <a - class="gl-link" - href="https://docs.gitlab.com/ee/user/project/integrations/prometheus.html" - rel="noopener noreferrer" - target="_blank" - > - GitLab Integration - </a> - to monitor deployed applications. -</span> -`; 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 e5e336eb3d5..0e1fe790771 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 @@ -156,7 +156,6 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ <!----> </div> - </ul> </div> diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js deleted file mode 100644 index 6bad1db542b..00000000000 --- a/spec/frontend/clusters/components/application_row_spec.js +++ /dev/null @@ -1,505 +0,0 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import ApplicationRow from '~/clusters/components/application_row.vue'; -import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue'; -import UpdateApplicationConfirmationModal from '~/clusters/components/update_application_confirmation_modal.vue'; -import { APPLICATION_STATUS, ELASTIC_STACK } from '~/clusters/constants'; -import eventHub from '~/clusters/event_hub'; - -import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; - -describe('Application Row', () => { - let wrapper; - - afterEach(() => { - wrapper.destroy(); - }); - - const mountComponent = (data) => { - wrapper = shallowMount(ApplicationRow, { - stubs: { GlSprintf }, - propsData: { - ...DEFAULT_APPLICATION_STATE, - ...data, - }, - }); - }; - - describe('Title', () => { - it('shows title', () => { - mountComponent({ titleLink: null }); - - const title = wrapper.find('.js-cluster-application-title'); - - expect(title.element).toBeInstanceOf(HTMLSpanElement); - expect(title.text()).toEqual(DEFAULT_APPLICATION_STATE.title); - }); - - it('shows title link', () => { - expect(DEFAULT_APPLICATION_STATE.titleLink).toBeDefined(); - mountComponent(); - const title = wrapper.find('.js-cluster-application-title'); - - expect(title.element).toBeInstanceOf(HTMLAnchorElement); - expect(title.text()).toEqual(DEFAULT_APPLICATION_STATE.title); - }); - }); - - describe('Install button', () => { - const button = () => wrapper.find('.js-cluster-application-install-button'); - const checkButtonState = (label, loading, disabled) => { - expect(button().text()).toEqual(label); - expect(button().props('loading')).toEqual(loading); - expect(button().props('disabled')).toEqual(disabled); - }; - - it('has indeterminate state on page load', () => { - mountComponent({ status: null }); - - expect(button().text()).toBe(''); - }); - - it('has install button', () => { - mountComponent(); - - expect(button().exists()).toBe(true); - }); - - it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => { - mountComponent({ status: APPLICATION_STATUS.NOT_INSTALLABLE }); - - checkButtonState('Install', false, true); - }); - - it('has enabled "Install" when APPLICATION_STATUS.INSTALLABLE', () => { - mountComponent({ status: APPLICATION_STATUS.INSTALLABLE }); - - checkButtonState('Install', false, false); - }); - - it('has loading "Installing" when APPLICATION_STATUS.INSTALLING', () => { - mountComponent({ status: APPLICATION_STATUS.INSTALLING }); - - checkButtonState('Installing', true, true); - }); - - it('has disabled "Install" when APPLICATION_STATUS.UNINSTALLED', () => { - mountComponent({ status: APPLICATION_STATUS.UNINSTALLED }); - - checkButtonState('Install', false, true); - }); - - it('has disabled "Externally installed" when APPLICATION_STATUS.EXTERNALLY_INSTALLED', () => { - mountComponent({ status: APPLICATION_STATUS.EXTERNALLY_INSTALLED }); - - checkButtonState('Externally installed', false, true); - }); - - it('has disabled "Installed" when application is installed and not uninstallable', () => { - mountComponent({ - status: APPLICATION_STATUS.INSTALLED, - installed: true, - uninstallable: false, - }); - - checkButtonState('Installed', false, true); - }); - - it('hides when application is installed and uninstallable', () => { - mountComponent({ - status: APPLICATION_STATUS.INSTALLED, - installed: true, - uninstallable: true, - }); - - expect(button().exists()).toBe(false); - }); - - it('has enabled "Install" when install fails', () => { - mountComponent({ - status: APPLICATION_STATUS.INSTALLABLE, - installFailed: true, - }); - - checkButtonState('Install', false, false); - }); - - it('has disabled "Install" when installation disabled', () => { - mountComponent({ - status: APPLICATION_STATUS.INSTALLABLE, - installable: false, - }); - - checkButtonState('Install', false, true); - }); - - it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => { - mountComponent({ status: APPLICATION_STATUS.INSTALLABLE }); - - checkButtonState('Install', false, false); - }); - - it('clicking install button emits event', () => { - const spy = jest.spyOn(eventHub, '$emit'); - mountComponent({ status: APPLICATION_STATUS.INSTALLABLE }); - - button().vm.$emit('click'); - - expect(spy).toHaveBeenCalledWith('installApplication', { - id: DEFAULT_APPLICATION_STATE.id, - params: {}, - }); - }); - - it('clicking install button when installApplicationRequestParams are provided emits event', () => { - const spy = jest.spyOn(eventHub, '$emit'); - mountComponent({ - status: APPLICATION_STATUS.INSTALLABLE, - installApplicationRequestParams: { hostname: 'jupyter' }, - }); - - button().vm.$emit('click'); - - expect(spy).toHaveBeenCalledWith('installApplication', { - id: DEFAULT_APPLICATION_STATE.id, - params: { hostname: 'jupyter' }, - }); - }); - - it('clicking disabled install button emits nothing', () => { - const spy = jest.spyOn(eventHub, '$emit'); - mountComponent({ status: APPLICATION_STATUS.INSTALLING }); - - expect(button().props('disabled')).toEqual(true); - - button().vm.$emit('click'); - - expect(spy).not.toHaveBeenCalled(); - }); - }); - - describe('Uninstall button', () => { - it('displays button when app is installed and uninstallable', () => { - mountComponent({ - installed: true, - uninstallable: true, - status: APPLICATION_STATUS.NOT_INSTALLABLE, - }); - const uninstallButton = wrapper.find('.js-cluster-application-uninstall-button'); - - expect(uninstallButton.exists()).toBe(true); - }); - - it('displays a success toast message if application uninstall was successful', async () => { - mountComponent({ - title: 'GitLab Runner', - uninstallSuccessful: false, - }); - - wrapper.vm.$toast = { show: jest.fn() }; - wrapper.setProps({ uninstallSuccessful: true }); - - await wrapper.vm.$nextTick(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( - 'GitLab Runner uninstalled successfully.', - ); - }); - }); - - describe('when confirmation modal triggers confirm event', () => { - it('triggers uninstallApplication event', () => { - jest.spyOn(eventHub, '$emit'); - mountComponent(); - wrapper.find(UninstallApplicationConfirmationModal).vm.$emit('confirm'); - - expect(eventHub.$emit).toHaveBeenCalledWith('uninstallApplication', { - id: DEFAULT_APPLICATION_STATE.id, - }); - }); - }); - - describe('Update button', () => { - const button = () => wrapper.find('.js-cluster-application-update-button'); - - it('has indeterminate state on page load', () => { - mountComponent(); - - expect(button().exists()).toBe(false); - }); - - it('has enabled "Update" when "updateAvailable" is true', () => { - mountComponent({ updateAvailable: true }); - - expect(button().exists()).toBe(true); - expect(button().text()).toContain('Update'); - }); - - it('has enabled "Retry update" when update process fails', () => { - mountComponent({ - status: APPLICATION_STATUS.INSTALLED, - updateFailed: true, - }); - - expect(button().exists()).toBe(true); - expect(button().text()).toContain('Retry update'); - }); - - it('has disabled "Updating" when APPLICATION_STATUS.UPDATING', () => { - mountComponent({ status: APPLICATION_STATUS.UPDATING }); - - expect(button().exists()).toBe(true); - expect(button().text()).toContain('Updating'); - }); - - it('clicking update button emits event', () => { - const spy = jest.spyOn(eventHub, '$emit'); - mountComponent({ - status: APPLICATION_STATUS.INSTALLED, - updateAvailable: true, - }); - - button().vm.$emit('click'); - - expect(spy).toHaveBeenCalledWith('updateApplication', { - id: DEFAULT_APPLICATION_STATE.id, - params: {}, - }); - }); - - it('clicking disabled update button emits nothing', () => { - const spy = jest.spyOn(eventHub, '$emit'); - mountComponent({ status: APPLICATION_STATUS.UPDATING }); - - button().vm.$emit('click'); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('displays an error message if application update failed', () => { - mountComponent({ - title: 'GitLab Runner', - status: APPLICATION_STATUS.INSTALLED, - updateFailed: true, - }); - const failureMessage = wrapper.find('.js-cluster-application-update-details'); - - expect(failureMessage.exists()).toBe(true); - expect(failureMessage.text()).toContain( - 'Update failed. Please check the logs and try again.', - ); - }); - - it('displays a success toast message if application update was successful', async () => { - mountComponent({ - title: 'GitLab Runner', - updateSuccessful: false, - }); - - wrapper.vm.$toast = { show: jest.fn() }; - wrapper.setProps({ updateSuccessful: true }); - - await wrapper.vm.$nextTick(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('GitLab Runner updated successfully.'); - }); - - describe('when updating does not require confirmation', () => { - beforeEach(() => mountComponent({ updateAvailable: true })); - - it('the modal is not rendered', () => { - expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false); - }); - - it('the correct button is rendered', () => { - expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true); - }); - }); - - describe('when updating requires confirmation', () => { - beforeEach(() => { - mountComponent({ - updateAvailable: true, - id: ELASTIC_STACK, - version: '1.1.2', - }); - }); - - it('displays a modal', () => { - expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(true); - }); - - it('the correct button is rendered', () => { - expect(wrapper.find("[data-qa-selector='update_button_with_confirmation']").exists()).toBe( - true, - ); - }); - - it('triggers updateApplication event', () => { - jest.spyOn(eventHub, '$emit'); - wrapper.find(UpdateApplicationConfirmationModal).vm.$emit('confirm'); - - expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', { - id: ELASTIC_STACK, - params: {}, - }); - }); - }); - - describe('updating Elastic Stack special case', () => { - it('needs confirmation if version is lower than 3.0.0', () => { - mountComponent({ - updateAvailable: true, - id: ELASTIC_STACK, - version: '1.1.2', - }); - - expect(wrapper.find("[data-qa-selector='update_button_with_confirmation']").exists()).toBe( - true, - ); - expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(true); - }); - - it('does not need confirmation is version is 3.0.0', () => { - mountComponent({ - updateAvailable: true, - id: ELASTIC_STACK, - version: '3.0.0', - }); - - expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true); - expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false); - }); - - it('does not need confirmation if version is higher than 3.0.0', () => { - mountComponent({ - updateAvailable: true, - id: ELASTIC_STACK, - version: '5.2.1', - }); - - expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true); - expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false); - }); - }); - }); - - describe('Version', () => { - const updateDetails = () => wrapper.find('.js-cluster-application-update-details'); - const versionEl = () => wrapper.find('.js-cluster-application-update-version'); - - it('displays a version number if application has been updated', () => { - const version = '0.1.45'; - mountComponent({ - status: APPLICATION_STATUS.INSTALLED, - updateSuccessful: true, - version, - }); - - expect(updateDetails().text()).toBe(`Updated to chart v${version}`); - }); - - it('contains a link to the chart repo if application has been updated', () => { - const version = '0.1.45'; - const chartRepo = 'https://gitlab.com/gitlab-org/charts/gitlab-runner'; - mountComponent({ - status: APPLICATION_STATUS.INSTALLED, - updateSuccessful: true, - chartRepo, - version, - }); - - expect(versionEl().attributes('href')).toEqual(chartRepo); - expect(versionEl().props('target')).toEqual('_blank'); - }); - - it('does not display a version number if application update failed', () => { - const version = '0.1.45'; - mountComponent({ - status: APPLICATION_STATUS.INSTALLED, - updateFailed: true, - version, - }); - - expect(updateDetails().text()).toBe('Update failed'); - expect(versionEl().exists()).toBe(false); - }); - - it('displays updating when the application update is currently updating', () => { - mountComponent({ - status: APPLICATION_STATUS.UPDATING, - updateSuccessful: true, - version: '1.2.3', - }); - - expect(updateDetails().text()).toBe('Updating'); - expect(versionEl().exists()).toBe(false); - }); - }); - - describe('Error block', () => { - const generalErrorMessage = () => wrapper.find('.js-cluster-application-general-error-message'); - - describe('when nothing fails', () => { - it('does not show error block', () => { - mountComponent(); - - expect(generalErrorMessage().exists()).toBe(false); - }); - }); - - describe('when install or uninstall fails', () => { - const statusReason = 'We broke it 0.0'; - const requestReason = 'We broke the request 0.0'; - - beforeEach(() => { - mountComponent({ - status: APPLICATION_STATUS.ERROR, - statusReason, - requestReason, - installFailed: true, - }); - }); - - it('shows status reason if it is available', () => { - const statusErrorMessage = wrapper.find('.js-cluster-application-status-error-message'); - - expect(statusErrorMessage.text()).toEqual(statusReason); - }); - - it('shows request reason if it is available', () => { - const requestErrorMessage = wrapper.find('.js-cluster-application-request-error-message'); - - expect(requestErrorMessage.text()).toEqual(requestReason); - }); - }); - - describe('when install fails', () => { - beforeEach(() => { - mountComponent({ - status: APPLICATION_STATUS.ERROR, - installFailed: true, - }); - }); - - it('shows a general message indicating the installation failed', () => { - expect(generalErrorMessage().text()).toEqual( - `Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`, - ); - }); - }); - - describe('when uninstall fails', () => { - beforeEach(() => { - mountComponent({ - status: APPLICATION_STATUS.ERROR, - uninstallFailed: true, - }); - }); - - it('shows a general message indicating the uninstalling failed', () => { - expect(generalErrorMessage().text()).toEqual( - `Something went wrong while uninstalling ${DEFAULT_APPLICATION_STATE.title}`, - ); - }); - }); - }); -}); diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js deleted file mode 100644 index 511f5fc1d89..00000000000 --- a/spec/frontend/clusters/components/applications_spec.js +++ /dev/null @@ -1,510 +0,0 @@ -import { shallowMount, mount } from '@vue/test-utils'; -import ApplicationRow from '~/clusters/components/application_row.vue'; -import Applications from '~/clusters/components/applications.vue'; -import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; -import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; -import { CLUSTER_TYPE, PROVIDER_TYPE } from '~/clusters/constants'; -import eventHub from '~/clusters/event_hub'; -import { APPLICATIONS_MOCK_STATE } from '../services/mock_data'; - -describe('Applications', () => { - let wrapper; - - beforeEach(() => { - gon.features = gon.features || {}; - }); - - const createComponent = ({ applications, type, propsData } = {}, isShallow) => { - const mountMethod = isShallow ? shallowMount : mount; - - wrapper = mountMethod(Applications, { - stubs: { ApplicationRow }, - propsData: { - type, - applications: { ...APPLICATIONS_MOCK_STATE, ...applications }, - ...propsData, - }, - }); - }; - - const createShallowComponent = (options) => createComponent(options, true); - const findByTestId = (id) => wrapper.find(`[data-testid="${id}"]`); - afterEach(() => { - wrapper.destroy(); - }); - - describe('Project cluster applications', () => { - beforeEach(() => { - createComponent({ type: CLUSTER_TYPE.PROJECT }); - }); - - it('renders a row for Ingress', () => { - expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); - }); - - it('renders a row for Cert-Manager', () => { - expect(wrapper.find('.js-cluster-application-row-cert_manager').exists()).toBe(true); - }); - - it('renders a row for Crossplane', () => { - expect(wrapper.find('.js-cluster-application-row-crossplane').exists()).toBe(true); - }); - - it('renders a row for Prometheus', () => { - expect(wrapper.find('.js-cluster-application-row-prometheus').exists()).toBe(true); - }); - - it('renders a row for GitLab Runner', () => { - expect(wrapper.find('.js-cluster-application-row-runner').exists()).toBe(true); - }); - - it('renders a row for Jupyter', () => { - expect(wrapper.find('.js-cluster-application-row-jupyter').exists()).toBe(true); - }); - - it('renders a row for Knative', () => { - expect(wrapper.find('.js-cluster-application-row-knative').exists()).toBe(true); - }); - - it('renders a row for Elastic Stack', () => { - expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); - }); - - it('renders a row for Cilium', () => { - expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); - }); - }); - - describe('Group cluster applications', () => { - beforeEach(() => { - createComponent({ type: CLUSTER_TYPE.GROUP }); - }); - - it('renders a row for Ingress', () => { - expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); - }); - - it('renders a row for Cert-Manager', () => { - expect(wrapper.find('.js-cluster-application-row-cert_manager').exists()).toBe(true); - }); - - it('renders a row for Crossplane', () => { - expect(wrapper.find('.js-cluster-application-row-crossplane').exists()).toBe(true); - }); - - it('renders a row for Prometheus', () => { - expect(wrapper.find('.js-cluster-application-row-prometheus').exists()).toBe(true); - }); - - it('renders a row for GitLab Runner', () => { - expect(wrapper.find('.js-cluster-application-row-runner').exists()).toBe(true); - }); - - it('renders a row for Jupyter', () => { - expect(wrapper.find('.js-cluster-application-row-jupyter').exists()).toBe(true); - }); - - it('renders a row for Knative', () => { - expect(wrapper.find('.js-cluster-application-row-knative').exists()).toBe(true); - }); - - it('renders a row for Elastic Stack', () => { - expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); - }); - - it('renders a row for Cilium', () => { - expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); - }); - }); - - describe('Instance cluster applications', () => { - beforeEach(() => { - createComponent({ type: CLUSTER_TYPE.INSTANCE }); - }); - - it('renders a row for Ingress', () => { - expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); - }); - - it('renders a row for Cert-Manager', () => { - expect(wrapper.find('.js-cluster-application-row-cert_manager').exists()).toBe(true); - }); - - it('renders a row for Crossplane', () => { - expect(wrapper.find('.js-cluster-application-row-crossplane').exists()).toBe(true); - }); - - it('renders a row for Prometheus', () => { - expect(wrapper.find('.js-cluster-application-row-prometheus').exists()).toBe(true); - }); - - it('renders a row for GitLab Runner', () => { - expect(wrapper.find('.js-cluster-application-row-runner').exists()).toBe(true); - }); - - it('renders a row for Jupyter', () => { - expect(wrapper.find('.js-cluster-application-row-jupyter').exists()).toBe(true); - }); - - it('renders a row for Knative', () => { - expect(wrapper.find('.js-cluster-application-row-knative').exists()).toBe(true); - }); - - it('renders a row for Elastic Stack', () => { - expect(wrapper.find('.js-cluster-application-row-elastic_stack').exists()).toBe(true); - }); - - it('renders a row for Cilium', () => { - expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); - }); - }); - - describe('Helm application', () => { - it('does not render a row for Helm Tiller', () => { - createComponent(); - expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(false); - }); - }); - - describe('Ingress application', () => { - it('shows the correct warning message', () => { - createComponent(); - expect(findByTestId('ingressCostWarning').element).toMatchSnapshot(); - }); - - describe('when installed', () => { - describe('with ip address', () => { - it('renders ip address with a clipboard button', () => { - createComponent({ - applications: { - ingress: { - title: 'Ingress', - status: 'installed', - externalIp: '0.0.0.0', - }, - }, - }); - - expect(wrapper.find('.js-endpoint').element.value).toEqual('0.0.0.0'); - expect(wrapper.find('.js-clipboard-btn').attributes('data-clipboard-text')).toEqual( - '0.0.0.0', - ); - }); - }); - - describe('with hostname', () => { - it('renders hostname with a clipboard button', () => { - createComponent({ - applications: { - ingress: { - title: 'Ingress', - status: 'installed', - externalHostname: 'localhost.localdomain', - }, - cert_manager: { title: 'Cert-Manager' }, - crossplane: { title: 'Crossplane', stack: '' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', hostname: '' }, - knative: { title: 'Knative', hostname: '' }, - elastic_stack: { title: 'Elastic Stack' }, - cilium: { title: 'GitLab Container Network Policies' }, - }, - }); - - expect(wrapper.find('.js-endpoint').element.value).toEqual('localhost.localdomain'); - - expect(wrapper.find('.js-clipboard-btn').attributes('data-clipboard-text')).toEqual( - 'localhost.localdomain', - ); - }); - }); - - describe('without ip address', () => { - it('renders an input text with a loading icon and an alert text', () => { - createComponent({ - applications: { - ingress: { - title: 'Ingress', - status: 'installed', - }, - }, - }); - - expect(wrapper.find('.js-ingress-ip-loading-icon').exists()).toBe(true); - expect(wrapper.find('.js-no-endpoint-message').exists()).toBe(true); - }); - }); - }); - - describe('before installing', () => { - it('does not render the IP address', () => { - createComponent(); - - expect(wrapper.text()).not.toContain('Ingress IP Address'); - expect(wrapper.find('.js-endpoint').exists()).toBe(false); - }); - }); - }); - - describe('Cert-Manager application', () => { - it('shows the correct description', () => { - createComponent(); - expect(findByTestId('certManagerDescription').element).toMatchSnapshot(); - }); - - describe('when not installed', () => { - it('renders email & allows editing', () => { - createComponent({ - applications: { - cert_manager: { - title: 'Cert-Manager', - email: 'before@example.com', - status: 'installable', - }, - }, - }); - - expect(wrapper.find('.js-email').element.value).toEqual('before@example.com'); - expect(wrapper.find('.js-email').attributes('readonly')).toBe(undefined); - }); - }); - - describe('when installed', () => { - it('renders email in readonly', () => { - createComponent({ - applications: { - cert_manager: { - title: 'Cert-Manager', - email: 'after@example.com', - status: 'installed', - }, - }, - }); - - expect(wrapper.find('.js-email').element.value).toEqual('after@example.com'); - expect(wrapper.find('.js-email').attributes('readonly')).toEqual('readonly'); - }); - }); - }); - - describe('Jupyter application', () => { - describe('with ingress installed with ip & jupyter installable', () => { - it('renders hostname active input', () => { - createComponent({ - applications: { - ingress: { - title: 'Ingress', - status: 'installed', - externalIp: '1.1.1.1', - }, - }, - }); - - expect( - wrapper.find('.js-cluster-application-row-jupyter .js-hostname').attributes('readonly'), - ).toEqual(undefined); - }); - }); - - describe('with ingress installed without external ip', () => { - it('does not render hostname input', () => { - createComponent({ - applications: { - ingress: { title: 'Ingress', status: 'installed' }, - }, - }); - - expect(wrapper.find('.js-cluster-application-row-jupyter .js-hostname').exists()).toBe( - false, - ); - }); - }); - - describe('with ingress & jupyter installed', () => { - it('renders readonly input', () => { - createComponent({ - applications: { - ingress: { - title: 'Ingress', - status: 'installed', - externalIp: '1.1.1.1', - }, - jupyter: { title: 'JupyterHub', status: 'installed', hostname: '' }, - }, - }); - - expect( - wrapper.find('.js-cluster-application-row-jupyter .js-hostname').attributes('readonly'), - ).toEqual('readonly'); - }); - }); - - describe('without ingress installed', () => { - beforeEach(() => { - createComponent(); - }); - - it('does not render input', () => { - expect(wrapper.find('.js-cluster-application-row-jupyter .js-hostname').exists()).toBe( - false, - ); - }); - }); - }); - - describe('Prometheus application', () => { - it('shows the correct description', () => { - createComponent(); - expect(findByTestId('prometheusDescription').element).toMatchSnapshot(); - }); - }); - - describe('Knative application', () => { - const availableDomain = { - id: 4, - domain: 'newhostname.com', - }; - const propsData = { - applications: { - knative: { - title: 'Knative', - hostname: 'example.com', - status: 'installed', - externalIp: '1.1.1.1', - installed: true, - availableDomains: [availableDomain], - pagesDomain: null, - }, - }, - }; - let knativeDomainEditor; - - beforeEach(() => { - createShallowComponent(propsData); - jest.spyOn(eventHub, '$emit'); - - knativeDomainEditor = wrapper.find(KnativeDomainEditor); - }); - - it('shows the correct description', async () => { - createComponent(); - wrapper.setProps({ - providerType: PROVIDER_TYPE.GCP, - preInstalledKnative: true, - }); - - await wrapper.vm.$nextTick(); - - expect(findByTestId('installed-via').element).toMatchSnapshot(); - }); - - it('emits saveKnativeDomain event when knative domain editor emits save event', () => { - propsData.applications.knative.hostname = availableDomain.domain; - propsData.applications.knative.pagesDomain = availableDomain; - knativeDomainEditor.vm.$emit('save'); - - expect(eventHub.$emit).toHaveBeenCalledWith('saveKnativeDomain', { - id: 'knative', - params: { - hostname: availableDomain.domain, - pages_domain_id: availableDomain.id, - }, - }); - }); - - it('emits saveKnativeDomain event when knative domain editor emits save event with custom domain', () => { - const newHostName = 'someothernewhostname.com'; - propsData.applications.knative.hostname = newHostName; - propsData.applications.knative.pagesDomain = null; - knativeDomainEditor.vm.$emit('save'); - - expect(eventHub.$emit).toHaveBeenCalledWith('saveKnativeDomain', { - id: 'knative', - params: { - hostname: newHostName, - pages_domain_id: undefined, - }, - }); - }); - - it('emits setKnativeHostname event when knative domain editor emits change event', () => { - wrapper.find(KnativeDomainEditor).vm.$emit('set', { - domain: availableDomain.domain, - domainId: availableDomain.id, - }); - - expect(eventHub.$emit).toHaveBeenCalledWith('setKnativeDomain', { - id: 'knative', - domain: availableDomain.domain, - domainId: availableDomain.id, - }); - }); - }); - - describe('Crossplane application', () => { - const propsData = { - applications: { - crossplane: { - title: 'Crossplane', - stack: { - code: '', - }, - }, - }, - }; - - beforeEach(() => createShallowComponent(propsData)); - - it('renders the correct Component', () => { - const crossplane = wrapper.find(CrossplaneProviderStack); - expect(crossplane.exists()).toBe(true); - }); - - it('shows the correct description', () => { - createComponent(); - expect(findByTestId('crossplaneDescription').element).toMatchSnapshot(); - }); - }); - - describe('Elastic Stack application', () => { - describe('with elastic stack installable', () => { - it('renders the install button enabled', () => { - createComponent(); - - expect( - wrapper - .find( - '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button', - ) - .attributes('disabled'), - ).toBeUndefined(); - }); - }); - - describe('elastic stack installed', () => { - it('renders uninstall button', () => { - createComponent({ - applications: { - elastic_stack: { title: 'Elastic Stack', status: 'installed' }, - }, - }); - - expect( - wrapper - .find( - '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button', - ) - .attributes('disabled'), - ).toEqual('disabled'); - }); - }); - }); - - describe('Cilium application', () => { - it('shows the correct description', () => { - createComponent({ propsData: { ciliumHelpPath: 'cilium-help-path' } }); - expect(findByTestId('ciliumDescription').element).toMatchSnapshot(); - }); - }); -}); diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js deleted file mode 100644 index 207eb071171..00000000000 --- a/spec/frontend/clusters/components/knative_domain_editor_spec.js +++ /dev/null @@ -1,179 +0,0 @@ -import { GlDropdownItem, GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; -import { APPLICATION_STATUS } from '~/clusters/constants'; - -const { UPDATING } = APPLICATION_STATUS; - -describe('KnativeDomainEditor', () => { - let wrapper; - let knative; - - const createComponent = (props = {}) => { - wrapper = shallowMount(KnativeDomainEditor, { - propsData: { ...props }, - }); - }; - - beforeEach(() => { - knative = { - title: 'Knative', - hostname: 'example.com', - installed: true, - }; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('knative has an assigned IP address', () => { - beforeEach(() => { - knative.externalIp = '1.1.1.1'; - createComponent({ knative }); - }); - - it('renders ip address with a clipboard button', () => { - expect(wrapper.find('.js-knative-endpoint').exists()).toBe(true); - expect(wrapper.find('.js-knative-endpoint').element.value).toEqual(knative.externalIp); - }); - - it('displays ip address clipboard button', () => { - expect(wrapper.find('.js-knative-endpoint-clipboard-btn').attributes('text')).toEqual( - knative.externalIp, - ); - }); - - it('renders domain & allows editing', () => { - const domainNameInput = wrapper.find('.js-knative-domainname'); - - expect(domainNameInput.element.value).toEqual(knative.hostname); - expect(domainNameInput.attributes('readonly')).toBeFalsy(); - }); - - it('renders an update/save Knative domain button', () => { - expect(wrapper.find('.js-knative-save-domain-button').exists()).toBe(true); - }); - }); - - describe('knative without ip address', () => { - beforeEach(() => { - knative.externalIp = null; - createComponent({ knative }); - }); - - it('renders an input text with a loading icon', () => { - expect(wrapper.find('.js-knative-ip-loading-icon').exists()).toBe(true); - }); - - it('renders message indicating there is not IP address assigned', () => { - expect(wrapper.find('.js-no-knative-endpoint-message').exists()).toBe(true); - }); - }); - - describe('clicking save changes button', () => { - beforeEach(() => { - createComponent({ knative }); - }); - - it('triggers save event and pass current knative hostname', () => { - wrapper.find(GlButton).vm.$emit('click'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('save').length).toEqual(1); - }); - }); - }); - - describe('when knative domain name was saved successfully', () => { - beforeEach(() => { - createComponent({ knative }); - }); - - it('displays toast indicating a successful update', () => { - wrapper.vm.$toast = { show: jest.fn() }; - wrapper.setProps({ knative: { updateSuccessful: true, ...knative } }); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( - 'Knative domain name was updated successfully.', - ); - }); - }); - }); - - describe('when knative domain name input changes', () => { - it('emits "set" event with updated domain name', () => { - const newDomain = { - id: 4, - domain: 'newhostname.com', - }; - - createComponent({ knative: { ...knative, availableDomains: [newDomain] } }); - jest.spyOn(wrapper.vm, 'selectDomain'); - - wrapper.find(GlDropdownItem).vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.selectDomain).toHaveBeenCalledWith(newDomain); - expect(wrapper.emitted('set')[0]).toEqual([ - { - domain: newDomain.domain, - domainId: newDomain.id, - }, - ]); - }); - }); - - it('emits "set" event with updated custom domain name', () => { - const newHostname = 'newhostname.com'; - - createComponent({ knative }); - jest.spyOn(wrapper.vm, 'selectCustomDomain'); - - wrapper.setData({ knativeHostname: newHostname }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.selectCustomDomain).toHaveBeenCalledWith(newHostname); - expect(wrapper.emitted('set')[0]).toEqual([ - { - domain: newHostname, - domainId: null, - }, - ]); - }); - }); - }); - - describe('when updating knative domain name failed', () => { - beforeEach(() => { - createComponent({ knative }); - }); - - it('displays an error banner indicating the operation failure', () => { - wrapper.setProps({ knative: { updateFailed: true, ...knative } }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find('.js-cluster-knative-domain-name-failure-message').exists()).toBe(true); - }); - }); - }); - - describe(`when knative status is ${UPDATING}`, () => { - beforeEach(() => { - createComponent({ knative: { status: UPDATING, ...knative } }); - }); - - it('renders loading spinner in save button', () => { - expect(wrapper.find(GlButton).props('loading')).toBe(true); - }); - - it('renders disabled save button', () => { - expect(wrapper.find(GlButton).props('disabled')).toBe(true); - }); - - it('renders save button with "Saving" label', () => { - expect(wrapper.find(GlButton).text()).toBe('Saving'); - }); - }); -}); diff --git a/spec/frontend/clusters/components/uninstall_application_button_spec.js b/spec/frontend/clusters/components/uninstall_application_button_spec.js deleted file mode 100644 index 2596820e5ac..00000000000 --- a/spec/frontend/clusters/components/uninstall_application_button_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import UninstallApplicationButton from '~/clusters/components/uninstall_application_button.vue'; -import { APPLICATION_STATUS } from '~/clusters/constants'; - -const { INSTALLED, UPDATING, UNINSTALLING } = APPLICATION_STATUS; - -describe('UninstallApplicationButton', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(UninstallApplicationButton, { - propsData: { ...props }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - describe.each` - status | loading | disabled | text - ${INSTALLED} | ${false} | ${false} | ${'Uninstall'} - ${UPDATING} | ${false} | ${true} | ${'Uninstall'} - ${UNINSTALLING} | ${true} | ${true} | ${'Uninstalling'} - `('when app status is $status', ({ loading, disabled, status, text }) => { - beforeEach(() => { - createComponent({ status }); - }); - - it(`renders a button with loading=${loading} and disabled=${disabled}`, () => { - expect(wrapper.find(GlButton).props()).toMatchObject({ loading, disabled }); - }); - - it(`renders a button with text="${text}"`, () => { - expect(wrapper.find(GlButton).text()).toBe(text); - }); - }); -}); diff --git a/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js b/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js deleted file mode 100644 index 74ae4ecc486..00000000000 --- a/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue'; -import { INGRESS } from '~/clusters/constants'; - -describe('UninstallApplicationConfirmationModal', () => { - let wrapper; - const appTitle = 'Ingress'; - - const createComponent = (props = {}) => { - wrapper = shallowMount(UninstallApplicationConfirmationModal, { - propsData: { ...props }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - beforeEach(() => { - createComponent({ application: INGRESS, applicationTitle: appTitle }); - }); - - it(`renders a modal with a title "Uninstall ${appTitle}"`, () => { - expect(wrapper.find(GlModal).attributes('title')).toEqual(`Uninstall ${appTitle}`); - }); - - it(`renders a modal with an ok button labeled "Uninstall ${appTitle}"`, () => { - expect(wrapper.find(GlModal).attributes('ok-title')).toEqual(`Uninstall ${appTitle}`); - }); - - describe('when ok button is clicked', () => { - beforeEach(() => { - jest.spyOn(wrapper.vm, 'trackUninstallButtonClick'); - wrapper.find(GlModal).vm.$emit('ok'); - }); - - it('emits confirm event', () => - wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('confirm')).toBeTruthy(); - })); - - it('calls track uninstall button click mixin', () => { - expect(wrapper.vm.trackUninstallButtonClick).toHaveBeenCalledWith(INGRESS); - }); - }); - - it('displays a warning text indicating the app will be uninstalled', () => { - expect(wrapper.text()).toContain(`You are about to uninstall ${appTitle} from your cluster.`); - }); - - it('displays a custom warning text depending on the application', () => { - expect(wrapper.text()).toContain( - `The associated load balancer and IP will be deleted and cannot be restored.`, - ); - }); -}); diff --git a/spec/frontend/clusters/components/update_application_confirmation_modal_spec.js b/spec/frontend/clusters/components/update_application_confirmation_modal_spec.js deleted file mode 100644 index e933f17a980..00000000000 --- a/spec/frontend/clusters/components/update_application_confirmation_modal_spec.js +++ /dev/null @@ -1,52 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import UpdateApplicationConfirmationModal from '~/clusters/components/update_application_confirmation_modal.vue'; -import { ELASTIC_STACK } from '~/clusters/constants'; - -describe('UpdateApplicationConfirmationModal', () => { - let wrapper; - const appTitle = 'Elastic stack'; - - const createComponent = (props = {}) => { - wrapper = shallowMount(UpdateApplicationConfirmationModal, { - propsData: { ...props }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - beforeEach(() => { - createComponent({ application: ELASTIC_STACK, applicationTitle: appTitle }); - }); - - it(`renders a modal with a title "Update ${appTitle}"`, () => { - expect(wrapper.find(GlModal).attributes('title')).toEqual(`Update ${appTitle}`); - }); - - it(`renders a modal with an ok button labeled "Update ${appTitle}"`, () => { - expect(wrapper.find(GlModal).attributes('ok-title')).toEqual(`Update ${appTitle}`); - }); - - describe('when ok button is clicked', () => { - beforeEach(() => { - wrapper.find(GlModal).vm.$emit('ok'); - }); - - it('emits confirm event', () => - wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('confirm')).toBeTruthy(); - })); - - it('displays a warning text indicating the app will be updated', () => { - expect(wrapper.text()).toContain(`You are about to update ${appTitle} on your cluster.`); - }); - - it('displays a custom warning text depending on the application', () => { - expect(wrapper.text()).toContain( - `Your Elasticsearch cluster will be re-created during this upgrade. Your logs will be re-indexed, and you will lose historical logs from hosts terminated in the last 30 days.`, - ); - }); - }); -}); diff --git a/spec/frontend/clusters/services/application_state_machine_spec.js b/spec/frontend/clusters/services/application_state_machine_spec.js deleted file mode 100644 index 4e731e331c2..00000000000 --- a/spec/frontend/clusters/services/application_state_machine_spec.js +++ /dev/null @@ -1,206 +0,0 @@ -import { - APPLICATION_STATUS, - UNINSTALL_EVENT, - UPDATE_EVENT, - INSTALL_EVENT, -} from '~/clusters/constants'; -import transitionApplicationState from '~/clusters/services/application_state_machine'; - -const { - NO_STATUS, - SCHEDULED, - NOT_INSTALLABLE, - INSTALLABLE, - INSTALLING, - INSTALLED, - ERROR, - UPDATING, - UPDATED, - UPDATE_ERRORED, - UNINSTALLING, - UNINSTALL_ERRORED, - UNINSTALLED, - PRE_INSTALLED, - EXTERNALLY_INSTALLED, -} = APPLICATION_STATUS; - -const NO_EFFECTS = 'no effects'; - -describe('applicationStateMachine', () => { - const noEffectsToEmptyObject = (effects) => (typeof effects === 'string' ? {} : effects); - - describe(`current state is ${NO_STATUS}`, () => { - it.each` - expectedState | event | effects - ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS} - ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} - ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} - ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS} - ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} - ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} - ${UPDATING} | ${UPDATING} | ${NO_EFFECTS} - ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS} - ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} - ${UNINSTALLING} | ${UNINSTALLING} | ${NO_EFFECTS} - ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }} - ${UNINSTALLED} | ${UNINSTALLED} | ${NO_EFFECTS} - ${PRE_INSTALLED} | ${PRE_INSTALLED} | ${NO_EFFECTS} - ${EXTERNALLY_INSTALLED} | ${EXTERNALLY_INSTALLED} | ${NO_EFFECTS} - `(`transitions to $expectedState on $event event and applies $effects`, (data) => { - const { expectedState, event, effects } = data; - const currentAppState = { - status: NO_STATUS, - }; - - expect(transitionApplicationState(currentAppState, event)).toEqual({ - status: expectedState, - ...noEffectsToEmptyObject(effects), - }); - }); - }); - - describe(`current state is ${NOT_INSTALLABLE}`, () => { - it.each` - expectedState | event | effects - ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} - `(`transitions to $expectedState on $event event and applies $effects`, (data) => { - const { expectedState, event, effects } = data; - const currentAppState = { - status: NOT_INSTALLABLE, - }; - - expect(transitionApplicationState(currentAppState, event)).toEqual({ - status: expectedState, - ...noEffectsToEmptyObject(effects), - }); - }); - }); - - describe(`current state is ${INSTALLABLE}`, () => { - it.each` - expectedState | event | effects - ${INSTALLING} | ${INSTALL_EVENT} | ${{ installFailed: false }} - ${INSTALLED} | ${INSTALLED} | ${{ installFailed: false }} - ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} - ${UNINSTALLED} | ${UNINSTALLED} | ${{ installFailed: false }} - `(`transitions to $expectedState on $event event and applies $effects`, (data) => { - const { expectedState, event, effects } = data; - const currentAppState = { - status: INSTALLABLE, - }; - - expect(transitionApplicationState(currentAppState, event)).toEqual({ - status: expectedState, - ...noEffectsToEmptyObject(effects), - }); - }); - }); - - describe(`current state is ${INSTALLING}`, () => { - it.each` - expectedState | event | effects - ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} - ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} - `(`transitions to $expectedState on $event event and applies $effects`, (data) => { - const { expectedState, event, effects } = data; - const currentAppState = { - status: INSTALLING, - }; - - expect(transitionApplicationState(currentAppState, event)).toEqual({ - status: expectedState, - ...noEffectsToEmptyObject(effects), - }); - }); - }); - - describe(`current state is ${INSTALLED}`, () => { - it.each` - expectedState | event | effects - ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }} - ${UNINSTALLING} | ${UNINSTALL_EVENT} | ${{ uninstallFailed: false, uninstallSuccessful: false }} - ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} - ${UNINSTALLED} | ${UNINSTALLED} | ${NO_EFFECTS} - ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} - `(`transitions to $expectedState on $event event and applies $effects`, (data) => { - const { expectedState, event, effects } = data; - const currentAppState = { - status: INSTALLED, - }; - - expect(transitionApplicationState(currentAppState, event)).toEqual({ - status: expectedState, - ...noEffectsToEmptyObject(effects), - }); - }); - }); - - describe(`current state is ${UPDATING}`, () => { - it.each` - expectedState | event | effects - ${INSTALLED} | ${UPDATED} | ${{ updateSuccessful: true }} - ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} - `(`transitions to $expectedState on $event event and applies $effects`, (data) => { - const { expectedState, event, effects } = data; - const currentAppState = { - status: UPDATING, - }; - - expect(transitionApplicationState(currentAppState, event)).toEqual({ - status: expectedState, - ...effects, - }); - }); - }); - - describe(`current state is ${UNINSTALLING}`, () => { - it.each` - expectedState | event | effects - ${INSTALLABLE} | ${INSTALLABLE} | ${{ uninstallSuccessful: true }} - ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }} - `(`transitions to $expectedState on $event event and applies $effects`, (data) => { - const { expectedState, event, effects } = data; - const currentAppState = { - status: UNINSTALLING, - }; - - expect(transitionApplicationState(currentAppState, event)).toEqual({ - status: expectedState, - ...effects, - }); - }); - }); - - describe(`current state is ${UNINSTALLED}`, () => { - it.each` - expectedState | event | effects - ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} - ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} - `(`transitions to $expectedState on $event event and applies $effects`, (data) => { - const { expectedState, event, effects } = data; - const currentAppState = { - status: UNINSTALLED, - }; - - expect(transitionApplicationState(currentAppState, event)).toEqual({ - status: expectedState, - ...noEffectsToEmptyObject(effects), - }); - }); - }); - describe('current state is undefined', () => { - it('returns the current state without having any effects', () => { - const currentAppState = {}; - expect(transitionApplicationState(currentAppState, INSTALLABLE)).toEqual(currentAppState); - }); - }); - - describe('with event is undefined', () => { - it('returns the current state without having any effects', () => { - const currentAppState = { - status: NO_STATUS, - }; - expect(transitionApplicationState(currentAppState, undefined)).toEqual(currentAppState); - }); - }); -}); diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js deleted file mode 100644 index f95b175ca64..00000000000 --- a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; - -describe('CrossplaneProviderStack component', () => { - let wrapper; - - const defaultProps = { - stacks: [ - { - name: 'Google Cloud Platform', - code: 'gcp', - }, - { - name: 'Amazon Web Services', - code: 'aws', - }, - ], - }; - - function createComponent(props = {}) { - const propsData = { - ...defaultProps, - ...props, - }; - - wrapper = shallowMount(CrossplaneProviderStack, { - propsData, - }); - } - - beforeEach(() => { - const crossplane = { - title: 'crossplane', - stack: '', - }; - createComponent({ crossplane }); - }); - - const findDropdownElements = () => wrapper.findAll(GlDropdownItem); - const findFirstDropdownElement = () => findDropdownElements().at(0); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders all of the available stacks in the dropdown', () => { - const dropdownElements = findDropdownElements(); - - expect(dropdownElements.length).toBe(defaultProps.stacks.length); - - defaultProps.stacks.forEach((stack, index) => - expect(dropdownElements.at(index).text()).toEqual(stack.name), - ); - }); - - it('displays the correct label for the first dropdown item if a stack is selected', () => { - const crossplane = { - title: 'crossplane', - stack: 'gcp', - }; - createComponent({ crossplane }); - expect(wrapper.vm.dropdownText).toBe('Google Cloud Platform'); - }); - - it('emits the "set" event with the selected stack value', () => { - const crossplane = { - title: 'crossplane', - stack: 'gcp', - }; - createComponent({ crossplane }); - findFirstDropdownElement().vm.$emit('click'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted().set[0][0].code).toEqual('gcp'); - }); - }); - - it('renders the correct dropdown text when no stack is selected', () => { - expect(wrapper.vm.dropdownText).toBe('Select Stack'); - }); - - it('renders an external link', () => { - expect(wrapper.find(GlIcon).props('name')).toBe('external-link'); - }); -}); diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index a75fcb0cb06..cf63d5452ac 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -1,170 +1,19 @@ -import { APPLICATION_STATUS } from '~/clusters/constants'; - const CLUSTERS_MOCK_DATA = { GET: { '/gitlab-org/gitlab-shell/clusters/1/status.json': { data: { status: 'errored', status_reason: 'Failed to request to CloudPlatform.', - applications: [ - { - name: 'helm', - status: APPLICATION_STATUS.INSTALLABLE, - status_reason: null, - can_uninstall: false, - }, - { - name: 'ingress', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - external_ip: null, - external_hostname: null, - can_uninstall: false, - }, - { - name: 'runner', - status: APPLICATION_STATUS.INSTALLING, - status_reason: null, - can_uninstall: false, - }, - { - name: 'prometheus', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - can_uninstall: false, - }, - { - name: 'jupyter', - status: APPLICATION_STATUS.INSTALLING, - status_reason: 'Cannot connect', - can_uninstall: false, - }, - { - name: 'knative', - status: APPLICATION_STATUS.INSTALLING, - status_reason: 'Cannot connect', - can_uninstall: false, - }, - { - name: 'cert_manager', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - email: 'test@example.com', - can_uninstall: false, - }, - { - name: 'crossplane', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - can_uninstall: false, - }, - { - name: 'elastic_stack', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - can_uninstall: false, - }, - ], }, }, '/gitlab-org/gitlab-shell/clusters/2/status.json': { data: { status: 'errored', status_reason: 'Failed to request to CloudPlatform.', - applications: [ - { - name: 'helm', - status: APPLICATION_STATUS.INSTALLED, - status_reason: null, - }, - { - name: 'ingress', - status: APPLICATION_STATUS.INSTALLED, - status_reason: 'Cannot connect', - external_ip: '1.1.1.1', - external_hostname: null, - }, - { - name: 'runner', - status: APPLICATION_STATUS.INSTALLING, - status_reason: null, - }, - { - name: 'prometheus', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - }, - { - name: 'jupyter', - status: APPLICATION_STATUS.INSTALLABLE, - status_reason: 'Cannot connect', - }, - { - name: 'knative', - status: APPLICATION_STATUS.INSTALLABLE, - status_reason: 'Cannot connect', - }, - { - name: 'cert_manager', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - email: 'test@example.com', - }, - { - name: 'crossplane', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - stack: 'gcp', - }, - { - name: 'elastic_stack', - status: APPLICATION_STATUS.ERROR, - status_reason: 'Cannot connect', - }, - ], }, }, }, - POST: { - '/gitlab-org/gitlab-shell/clusters/1/applications/helm': {}, - '/gitlab-org/gitlab-shell/clusters/1/applications/ingress': {}, - '/gitlab-org/gitlab-shell/clusters/1/applications/crossplane': {}, - '/gitlab-org/gitlab-shell/clusters/1/applications/cert_manager': {}, - '/gitlab-org/gitlab-shell/clusters/1/applications/runner': {}, - '/gitlab-org/gitlab-shell/clusters/1/applications/prometheus': {}, - '/gitlab-org/gitlab-shell/clusters/1/applications/jupyter': {}, - '/gitlab-org/gitlab-shell/clusters/1/applications/knative': {}, - '/gitlab-org/gitlab-shell/clusters/1/applications/elastic_stack': {}, - }, -}; - -const DEFAULT_APPLICATION_STATE = { - id: 'some-app', - title: 'My App', - titleLink: 'https://about.gitlab.com/', - description: 'Some description about this interesting application!', - status: null, - statusReason: null, - requestReason: null, -}; - -const APPLICATIONS_MOCK_STATE = { - helm: { title: 'Helm Tiller', status: 'installable' }, - ingress: { - title: 'Ingress', - status: 'installable', - }, - crossplane: { title: 'Crossplane', status: 'installable', stack: '' }, - cert_manager: { title: 'Cert-Manager', status: 'installable' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', status: 'installable', hostname: '' }, - knative: { title: 'Knative ', status: 'installable', hostname: '' }, - elastic_stack: { title: 'Elastic Stack', status: 'installable' }, - cilium: { - title: 'GitLab Container Network Policies', - status: 'not_installable', - }, + POST: {}, }; -export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE, APPLICATIONS_MOCK_STATE }; +export { CLUSTERS_MOCK_DATA }; diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index cdba6fc6ab8..5e797bbf8a8 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -1,4 +1,3 @@ -import { APPLICATION_INSTALLED_STATUSES, APPLICATION_STATUS, RUNNER } from '~/clusters/constants'; import ClustersStore from '~/clusters/stores/clusters_store'; import { CLUSTERS_MOCK_DATA } from '../services/mock_data'; @@ -31,17 +30,6 @@ describe('Clusters Store', () => { }); }); - describe('updateAppProperty', () => { - it('should store new request reason', () => { - expect(store.state.applications.helm.requestReason).toEqual(null); - - const newReason = 'We broke it.'; - store.updateAppProperty('helm', 'requestReason', newReason); - - expect(store.state.applications.helm.requestReason).toEqual(newReason); - }); - }); - describe('updateStateFromServer', () => { it('should store new polling data from server', () => { const mockResponseData = @@ -50,196 +38,16 @@ describe('Clusters Store', () => { expect(store.state).toEqual({ helpPath: null, - helmHelpPath: null, - ingressHelpPath: null, environmentsHelpPath: null, clustersHelpPath: null, deployBoardsHelpPath: null, - cloudRunHelpPath: null, status: mockResponseData.status, statusReason: mockResponseData.status_reason, providerType: null, - preInstalledKnative: false, rbac: false, - applications: { - helm: { - title: 'Legacy Helm Tiller server', - status: mockResponseData.applications[0].status, - statusReason: mockResponseData.applications[0].status_reason, - requestReason: null, - installable: true, - installed: false, - installFailed: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - ingress: { - title: 'Ingress', - status: APPLICATION_STATUS.INSTALLABLE, - statusReason: mockResponseData.applications[1].status_reason, - requestReason: null, - externalIp: null, - externalHostname: null, - installable: true, - installed: false, - installFailed: true, - uninstallable: false, - updateFailed: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - runner: { - title: 'GitLab Runner', - status: mockResponseData.applications[2].status, - statusReason: mockResponseData.applications[2].status_reason, - requestReason: null, - version: mockResponseData.applications[2].version, - updateAvailable: mockResponseData.applications[2].update_available, - chartRepo: 'https://gitlab.com/gitlab-org/charts/gitlab-runner', - installable: true, - installed: false, - installFailed: false, - updateFailed: false, - updateSuccessful: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - prometheus: { - title: 'Prometheus', - status: APPLICATION_STATUS.INSTALLABLE, - statusReason: mockResponseData.applications[3].status_reason, - requestReason: null, - installable: true, - installed: false, - installFailed: true, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - jupyter: { - title: 'JupyterHub', - status: mockResponseData.applications[4].status, - statusReason: mockResponseData.applications[4].status_reason, - requestReason: null, - hostname: '', - installable: true, - installed: false, - installFailed: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - knative: { - title: 'Knative', - status: mockResponseData.applications[5].status, - statusReason: mockResponseData.applications[5].status_reason, - requestReason: null, - hostname: null, - isEditingDomain: false, - externalIp: null, - externalHostname: null, - installable: true, - installed: false, - installFailed: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - updateSuccessful: false, - updateFailed: false, - validationError: null, - }, - cert_manager: { - title: 'Cert-Manager', - status: APPLICATION_STATUS.INSTALLABLE, - installFailed: true, - statusReason: mockResponseData.applications[6].status_reason, - requestReason: null, - email: mockResponseData.applications[6].email, - installable: true, - installed: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - elastic_stack: { - title: 'Elastic Stack', - status: APPLICATION_STATUS.INSTALLABLE, - installFailed: true, - statusReason: mockResponseData.applications[7].status_reason, - requestReason: null, - installable: true, - installed: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - crossplane: { - title: 'Crossplane', - status: APPLICATION_STATUS.INSTALLABLE, - installFailed: true, - statusReason: mockResponseData.applications[8].status_reason, - requestReason: null, - installable: true, - installed: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - cilium: { - title: 'GitLab Container Network Policies', - status: null, - statusReason: null, - requestReason: null, - installable: false, - installed: false, - installFailed: false, - uninstallable: false, - uninstallSuccessful: false, - uninstallFailed: false, - validationError: null, - }, - }, environments: [], fetchingEnvironments: false, }); }); - - describe.each(APPLICATION_INSTALLED_STATUSES)( - 'given the current app status is %s', - (status) => { - it('marks application as installed', () => { - const mockResponseData = - CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data; - const runnerAppIndex = 2; - - mockResponseData.applications[runnerAppIndex].status = status; - - store.updateStateFromServer(mockResponseData); - - expect(store.state.applications[RUNNER].installed).toBe(true); - }); - }, - ); - - it('sets default hostname for jupyter when ingress has a ip address', () => { - const mockResponseData = - CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data; - - store.updateStateFromServer(mockResponseData); - - expect(store.state.applications.jupyter.hostname).toEqual( - `jupyter.${store.state.applications.ingress.externalIp}.nip.io`, - ); - }); }); }); diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index b2ef3c2138a..f4b69053e14 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -5,7 +5,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { MAX_REQUESTS } from '~/clusters_list/constants'; import * as actions from '~/clusters_list/store/actions'; import * as types from '~/clusters_list/store/mutation_types'; -import { deprecatedCreateFlash as flashError } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; import { apiData } from '../mock_data'; @@ -101,7 +101,9 @@ describe('Clusters store actions', () => { }, ], () => { - expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('error'), + }); done(); }, ); diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap index b59d1597a12..118d8ceceb9 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -13,7 +13,9 @@ exports[`Code navigation popover component renders popover 1`] = ` <gl-tabs-stub contentclass="gl-py-0" navclass="gl-hidden" + queryparamname="tab" theme="indigo" + value="0" > <gl-tab-stub title="Definition" diff --git a/spec/frontend/code_quality_walkthrough/components/step_spec.js b/spec/frontend/code_quality_walkthrough/components/step_spec.js index c397faf1f35..bdbcda5f902 100644 --- a/spec/frontend/code_quality_walkthrough/components/step_spec.js +++ b/spec/frontend/code_quality_walkthrough/components/step_spec.js @@ -4,11 +4,11 @@ import Cookies from 'js-cookie'; import Step from '~/code_quality_walkthrough/components/step.vue'; import { EXPERIMENT_NAME, STEPS } from '~/code_quality_walkthrough/constants'; import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; -import { getParameterByName } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), getParameterByName: jest.fn(), })); diff --git a/spec/frontend/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js deleted file mode 100644 index 7c659822672..00000000000 --- a/spec/frontend/collapsed_sidebar_todo_spec.js +++ /dev/null @@ -1,171 +0,0 @@ -/* eslint-disable no-new */ -import MockAdapter from 'axios-mock-adapter'; -import { clone } from 'lodash'; -import waitForPromises from 'helpers/wait_for_promises'; -import { TEST_HOST } from 'spec/test_constants'; -import axios from '~/lib/utils/axios_utils'; -import Sidebar from '~/right_sidebar'; -import { fixTitle } from '~/tooltips'; - -jest.mock('~/tooltips'); - -describe('Issuable right sidebar collapsed todo toggle', () => { - const fixtureName = 'issues/open-issue.html'; - const jsonFixtureName = 'todos/todos.json'; - let mock; - - beforeEach(() => { - const todoData = getJSONFixture(jsonFixtureName); - new Sidebar(); - loadFixtures(fixtureName); - - document.querySelector('.js-right-sidebar').classList.toggle('right-sidebar-expanded'); - document.querySelector('.js-right-sidebar').classList.toggle('right-sidebar-collapsed'); - - mock = new MockAdapter(axios); - - mock.onPost(`${TEST_HOST}/frontend-fixtures/issues-project/todos`).reply(() => { - const response = clone(todoData); - - return [200, response]; - }); - - mock.onDelete(/(.*)\/dashboard\/todos\/\d+$/).reply(() => { - const response = clone(todoData); - delete response.delete_path; - - return [200, response]; - }); - }); - - afterEach(() => { - mock.restore(); - }); - - it('shows add todo button', () => { - expect(document.querySelector('.js-issuable-todo.sidebar-collapsed-icon')).not.toBeNull(); - - expect( - document - .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg') - .getAttribute('data-testid'), - ).toBe('todo-add-icon'); - - expect( - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), - ).toBeNull(); - }); - - it('sets default tooltip title', () => { - expect( - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('title'), - ).toBe('Add a to do'); - }); - - it('toggle todo state', (done) => { - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); - - setImmediate(() => { - expect( - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), - ).not.toBeNull(); - - expect( - document - .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg.todo-undone') - .getAttribute('data-testid'), - ).toBe('todo-done-icon'); - - done(); - }); - }); - - it('toggle todo state of expanded todo toggle', (done) => { - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); - - setImmediate(() => { - expect( - document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(), - ).toBe('Mark as done'); - - done(); - }); - }); - - it('toggles todo button tooltip', (done) => { - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); - - setImmediate(() => { - const el = document.querySelector('.js-issuable-todo.sidebar-collapsed-icon'); - - expect(el.getAttribute('title')).toBe('Mark as done'); - expect(fixTitle).toHaveBeenCalledWith(el); - - done(); - }); - }); - - it('marks todo as done', (done) => { - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); - - waitForPromises() - .then(() => { - expect( - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), - ).not.toBeNull(); - - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); - }) - .then(waitForPromises) - .then(() => { - expect( - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), - ).toBeNull(); - - expect( - document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(), - ).toBe('Add a to do'); - }) - .then(done) - .catch(done.fail); - }); - - it('updates aria-label to Mark as done', (done) => { - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); - - setImmediate(() => { - expect( - document - .querySelector('.js-issuable-todo.sidebar-collapsed-icon') - .getAttribute('aria-label'), - ).toBe('Mark as done'); - - done(); - }); - }); - - it('updates aria-label to add todo', (done) => { - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); - - waitForPromises() - .then(() => { - expect( - document - .querySelector('.js-issuable-todo.sidebar-collapsed-icon') - .getAttribute('aria-label'), - ).toBe('Mark as done'); - - document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').click(); - }) - .then(waitForPromises) - .then(() => { - expect( - document - .querySelector('.js-issuable-todo.sidebar-collapsed-icon') - .getAttribute('aria-label'), - ).toBe('Add a to do'); - }) - .then(done) - .catch(done.fail); - }); -}); diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js index a56f761269a..8082b8524e7 100644 --- a/spec/frontend/commit/commit_pipeline_status_component_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Visibility from 'visibilityjs'; import { getJSONFixture } from 'helpers/fixtures'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; @@ -170,7 +170,7 @@ describe('Commit pipeline status component', () => { }); it('displays flash error message', () => { - expect(flash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index 4bf6727af3b..1defb3d586c 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -66,7 +66,7 @@ describe('Pipelines table in Commits and Merge requests', () => { describe('with pipelines', () => { beforeEach(async () => { - mock.onGet('endpoint.json').reply(200, [pipeline]); + mock.onGet('endpoint.json').reply(200, [pipeline], { 'x-total': 10 }); createComponent(); @@ -110,7 +110,7 @@ describe('Pipelines table in Commits and Merge requests', () => { document.body.appendChild(element); element.addEventListener('update-pipelines-count', (event) => { - expect(event.detail.pipelines).toEqual([pipeline]); + expect(event.detail.pipelineCount).toEqual(10); done(); }); diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index 59c4190ad3a..563e80e04c1 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -1,5 +1,7 @@ +import { GlAlert } from '@gitlab/ui'; import { EditorContent } from '@tiptap/vue-2'; -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContentEditor from '~/content_editor/components/content_editor.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import { createContentEditor } from '~/content_editor/services/create_content_editor'; @@ -8,8 +10,11 @@ describe('ContentEditor', () => { let wrapper; let editor; + const findEditorElement = () => wrapper.findByTestId('content-editor'); + const findErrorAlert = () => wrapper.findComponent(GlAlert); + const createWrapper = async (contentEditor) => { - wrapper = shallowMount(ContentEditor, { + wrapper = shallowMountExtended(ContentEditor, { propsData: { contentEditor, }, @@ -49,7 +54,7 @@ describe('ContentEditor', () => { editor.tiptapEditor.isFocused = isFocused; createWrapper(editor); - expect(wrapper.classes()).toStrictEqual(classes); + expect(findEditorElement().classes()).toStrictEqual(classes); }, ); @@ -57,6 +62,30 @@ describe('ContentEditor', () => { editor.tiptapEditor.isFocused = true; createWrapper(editor); - expect(wrapper.classes()).toContain('is-focused'); + expect(findEditorElement().classes()).toContain('is-focused'); + }); + + describe('displaying error', () => { + const error = 'Content Editor error'; + + beforeEach(async () => { + createWrapper(editor); + + editor.tiptapEditor.emit('error', error); + + await nextTick(); + }); + + it('displays error notifications from the tiptap editor', () => { + expect(findErrorAlert().text()).toBe(error); + }); + + it('allows dismissing an error alert', async () => { + findErrorAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findErrorAlert().exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js index a49efa34017..d848adcbff8 100644 --- a/spec/frontend/content_editor/components/toolbar_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -1,33 +1,17 @@ import { GlButton } from '@gitlab/ui'; -import { Extension } from '@tiptap/core'; import { shallowMount } from '@vue/test-utils'; import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; -import { createContentEditor } from '~/content_editor/services/create_content_editor'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/toolbar_button', () => { let wrapper; let tiptapEditor; - let toggleFooSpy; const CONTENT_TYPE = 'bold'; const ICON_NAME = 'bold'; const LABEL = 'Bold'; const buildEditor = () => { - toggleFooSpy = jest.fn(); - tiptapEditor = createContentEditor({ - extensions: [ - { - tiptapExtension: Extension.create({ - addCommands() { - return { - toggleFoo: () => toggleFooSpy, - }; - }, - }), - }, - ], - renderMarkdown: () => true, - }).tiptapEditor; + tiptapEditor = createTestEditor(); jest.spyOn(tiptapEditor, 'isActive'); }; @@ -78,20 +62,28 @@ describe('content_editor/components/toolbar_button', () => { describe('when button is clicked', () => { it('executes the content type command when executeCommand = true', async () => { - buildWrapper({ editorCommand: 'toggleFoo' }); + const editorCommand = 'toggleFoo'; + const mockCommands = mockChainedCommands(tiptapEditor, [editorCommand, 'focus', 'run']); + + buildWrapper({ editorCommand }); await findButton().trigger('click'); - expect(toggleFooSpy).toHaveBeenCalled(); + expect(mockCommands[editorCommand]).toHaveBeenCalled(); + expect(mockCommands.focus).toHaveBeenCalled(); + expect(mockCommands.run).toHaveBeenCalled(); expect(wrapper.emitted().execute).toHaveLength(1); }); it('does not executes the content type command when executeCommand = false', async () => { + const editorCommand = 'toggleFoo'; + const mockCommands = mockChainedCommands(tiptapEditor, [editorCommand, 'run']); + buildWrapper(); await findButton().trigger('click'); - expect(toggleFooSpy).not.toHaveBeenCalled(); + expect(mockCommands[editorCommand]).not.toHaveBeenCalled(); expect(wrapper.emitted().execute).toHaveLength(1); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_image_button_spec.js b/spec/frontend/content_editor/components/toolbar_image_button_spec.js new file mode 100644 index 00000000000..701dcf83476 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_image_button_spec.js @@ -0,0 +1,78 @@ +import { GlButton, GlFormInputGroup } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarImageButton from '~/content_editor/components/toolbar_image_button.vue'; +import { configure as configureImageExtension } from '~/content_editor/extensions/image'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; + +describe('content_editor/components/toolbar_image_button', () => { + let wrapper; + let editor; + + const buildWrapper = () => { + wrapper = mountExtended(ToolbarImageButton, { + propsData: { + tiptapEditor: editor, + }, + }); + }; + + const findImageURLInput = () => + wrapper.findComponent(GlFormInputGroup).find('input[type="text"]'); + const findApplyImageButton = () => wrapper.findComponent(GlButton); + + const selectFile = async (file) => { + const input = wrapper.find({ ref: 'fileSelector' }); + + // override the property definition because `input.files` isn't directly modifyable + Object.defineProperty(input.element, 'files', { value: [file], writable: true }); + await input.trigger('change'); + }; + + beforeEach(() => { + const { tiptapExtension: Image } = configureImageExtension({ + renderMarkdown: jest.fn(), + uploadsPath: '/uploads/', + }); + + editor = createTestEditor({ + extensions: [Image], + }); + + buildWrapper(); + }); + + afterEach(() => { + editor.destroy(); + wrapper.destroy(); + }); + + it('sets the image to the value in the URL input when "Insert" button is clicked', async () => { + const commands = mockChainedCommands(editor, ['focus', 'setImage', 'run']); + + await findImageURLInput().setValue('https://example.com/img.jpg'); + await findApplyImageButton().trigger('click'); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.setImage).toHaveBeenCalledWith({ + alt: 'img', + src: 'https://example.com/img.jpg', + canonicalSrc: 'https://example.com/img.jpg', + }); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'url' }]); + }); + + it('uploads the selected image when file input changes', async () => { + const commands = mockChainedCommands(editor, ['focus', 'uploadImage', 'run']); + const file = new File(['foo'], 'foo.png', { type: 'image/png' }); + + await selectFile(file); + + expect(commands.focus).toHaveBeenCalled(); + expect(commands.uploadImage).toHaveBeenCalledWith({ file }); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'image', value: 'upload' }]); + }); +}); diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js index 812e769c891..576a2912f72 100644 --- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownDivider, GlFormInputGroup, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownDivider, GlButton, GlFormInputGroup } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; import { tiptapExtension as Link } from '~/content_editor/extensions/link'; @@ -16,9 +16,6 @@ describe('content_editor/components/toolbar_link_button', () => { propsData: { tiptapEditor: editor, }, - stubs: { - GlFormInputGroup, - }, }); }; const findDropdown = () => wrapper.findComponent(GlDropdown); @@ -45,9 +42,8 @@ describe('content_editor/components/toolbar_link_button', () => { }); describe('when there is an active link', () => { - beforeEach(() => { - jest.spyOn(editor, 'isActive'); - editor.isActive.mockReturnValueOnce(true); + beforeEach(async () => { + jest.spyOn(editor, 'isActive').mockReturnValueOnce(true); buildWrapper(); }); @@ -78,8 +74,36 @@ describe('content_editor/components/toolbar_link_button', () => { expect(commands.focus).toHaveBeenCalled(); expect(commands.unsetLink).toHaveBeenCalled(); - expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' }); + expect(commands.setLink).toHaveBeenCalledWith({ + href: 'https://example', + canonicalSrc: 'https://example', + }); expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); + }); + + describe('on selection update', () => { + it('updates link input box with canonical-src if present', async () => { + jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({ + canonicalSrc: 'uploads/my-file.zip', + href: '/username/my-project/uploads/abcdefgh133535/my-file.zip', + }); + + await editor.emit('selectionUpdate', { editor }); + + expect(findLinkURLInput().element.value).toEqual('uploads/my-file.zip'); + }); + + it('updates link input box with link href otherwise', async () => { + jest.spyOn(editor, 'getAttributes').mockReturnValueOnce({ + href: 'https://gitlab.com', + }); + + await editor.emit('selectionUpdate', { editor }); + + expect(findLinkURLInput().element.value).toEqual('https://gitlab.com'); + }); }); }); @@ -106,8 +130,13 @@ describe('content_editor/components/toolbar_link_button', () => { await findApplyLinkButton().trigger('click'); expect(commands.focus).toHaveBeenCalled(); - expect(commands.setLink).toHaveBeenCalledWith({ href: 'https://example' }); + expect(commands.setLink).toHaveBeenCalledWith({ + href: 'https://example', + canonicalSrc: 'https://example', + }); expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute[0]).toEqual([{ contentType: 'link' }]); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_table_button_spec.js b/spec/frontend/content_editor/components/toolbar_table_button_spec.js new file mode 100644 index 00000000000..237b2848246 --- /dev/null +++ b/spec/frontend/content_editor/components/toolbar_table_button_spec.js @@ -0,0 +1,109 @@ +import { GlDropdown, GlButton } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ToolbarTableButton from '~/content_editor/components/toolbar_table_button.vue'; +import { tiptapExtension as Table } from '~/content_editor/extensions/table'; +import { tiptapExtension as TableCell } from '~/content_editor/extensions/table_cell'; +import { tiptapExtension as TableHeader } from '~/content_editor/extensions/table_header'; +import { tiptapExtension as TableRow } from '~/content_editor/extensions/table_row'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; + +describe('content_editor/components/toolbar_table_button', () => { + let wrapper; + let editor; + + const buildWrapper = () => { + wrapper = mountExtended(ToolbarTableButton, { + propsData: { + tiptapEditor: editor, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const getNumButtons = () => findDropdown().findAllComponents(GlButton).length; + + beforeEach(() => { + editor = createTestEditor({ + extensions: [Table, TableCell, TableRow, TableHeader], + }); + + buildWrapper(); + }); + + afterEach(() => { + editor.destroy(); + wrapper.destroy(); + }); + + it('renders a grid of 3x3 buttons to create a table', () => { + expect(getNumButtons()).toBe(9); // 3 x 3 + }); + + describe.each` + row | col | numButtons | tableSize + ${1} | ${2} | ${9} | ${'1x2'} + ${2} | ${2} | ${9} | ${'2x2'} + ${2} | ${3} | ${12} | ${'2x3'} + ${3} | ${2} | ${12} | ${'3x2'} + ${3} | ${3} | ${16} | ${'3x3'} + `('button($row, $col) in the table creator grid', ({ row, col, numButtons, tableSize }) => { + describe('on mouse over', () => { + beforeEach(async () => { + const button = wrapper.findByTestId(`table-${row}-${col}`); + await button.trigger('mouseover'); + }); + + it('marks all rows and cols before it as active', () => { + const prevRow = Math.max(1, row - 1); + const prevCol = Math.max(1, col - 1); + expect(wrapper.findByTestId(`table-${prevRow}-${prevCol}`).element).toHaveClass( + 'gl-bg-blue-50!', + ); + }); + + it('shows a help text indicating the size of the table being inserted', () => { + expect(findDropdown().element).toHaveText(`Insert a ${tableSize} table.`); + }); + + it('adds another row and col of buttons to create a bigger table', () => { + expect(getNumButtons()).toBe(numButtons); + }); + }); + + describe('on click', () => { + let commands; + + beforeEach(async () => { + commands = mockChainedCommands(editor, ['focus', 'insertTable', 'run']); + + const button = wrapper.findByTestId(`table-${row}-${col}`); + await button.trigger('mouseover'); + await button.trigger('click'); + }); + + it('inserts a table with $tableSize rows and cols', () => { + expect(commands.focus).toHaveBeenCalled(); + expect(commands.insertTable).toHaveBeenCalledWith({ + rows: row, + cols: col, + withHeaderRow: true, + }); + expect(commands.run).toHaveBeenCalled(); + + expect(wrapper.emitted().execute).toHaveLength(1); + }); + }); + }); + + it('does not create more buttons than a 8x8 grid', async () => { + for (let i = 3; i < 8; i += 1) { + expect(getNumButtons()).toBe(i * i); + + // eslint-disable-next-line no-await-in-loop + await wrapper.findByTestId(`table-${i}-${i}`).trigger('mouseover'); + expect(findDropdown().element).toHaveText(`Insert a ${i}x${i} table.`); + } + + expect(getNumButtons()).toBe(64); // 8x8 (and not 9x9) + }); +}); diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js index 8c54f6bb8bb..9a46e27404f 100644 --- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js @@ -2,21 +2,16 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; -import { createTestContentEditorExtension, createTestEditor } from '../test_utils'; +import { tiptapExtension as Heading } from '~/content_editor/extensions/heading'; +import { createTestEditor, mockChainedCommands } from '../test_utils'; describe('content_editor/components/toolbar_headings_dropdown', () => { let wrapper; let tiptapEditor; - let commandMocks; const buildEditor = () => { - const testExtension = createTestContentEditorExtension({ - commands: TEXT_STYLE_DROPDOWN_ITEMS.map((item) => item.editorCommand), - }); - - commandMocks = testExtension.commandMocks; tiptapEditor = createTestEditor({ - extensions: [testExtension.tiptapExtension], + extensions: [Heading], }); jest.spyOn(tiptapEditor, 'isActive'); @@ -104,9 +99,12 @@ describe('content_editor/components/toolbar_headings_dropdown', () => { TEXT_STYLE_DROPDOWN_ITEMS.forEach((textStyle, index) => { const { editorCommand, commandParams } = textStyle; + const commands = mockChainedCommands(tiptapEditor, [editorCommand, 'focus', 'run']); wrapper.findAllComponents(GlDropdownItem).at(index).vm.$emit('click'); - expect(commandMocks[editorCommand]).toHaveBeenCalledWith(commandParams || {}); + expect(commands[editorCommand]).toHaveBeenCalledWith(commandParams || {}); + expect(commands.focus).toHaveBeenCalled(); + expect(commands.run).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index 0d55fa730ae..5411793cd5e 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -39,17 +39,19 @@ describe('content_editor/components/top_toolbar', () => { }); describe.each` - testId | controlProps - ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} - ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} - ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} - ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} - ${'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' }} - ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} - ${'text-styles'} | ${{}} - ${'link'} | ${{}} + testId | controlProps + ${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }} + ${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }} + ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }} + ${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }} + ${'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' }} + ${'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'} | ${{}} + ${'link'} | ${{}} + ${'image'} | ${{}} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { buildWrapper(); diff --git a/spec/frontend/content_editor/components/wrappers/image_spec.js b/spec/frontend/content_editor/components/wrappers/image_spec.js new file mode 100644 index 00000000000..7b057f9cabc --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/image_spec.js @@ -0,0 +1,66 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ImageWrapper from '~/content_editor/components/wrappers/image.vue'; + +describe('content/components/wrappers/image', () => { + let wrapper; + + const createWrapper = async (nodeAttrs = {}) => { + wrapper = shallowMountExtended(ImageWrapper, { + propsData: { + node: { + attrs: nodeAttrs, + }, + }, + }); + }; + const findImage = () => wrapper.findByTestId('image'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-wrapper with display-inline-block class', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-display-inline-block'); + }); + + it('renders an image that displays the node src', () => { + const src = 'foobar.png'; + + createWrapper({ src }); + + expect(findImage().attributes().src).toBe(src); + }); + + describe('when uploading', () => { + beforeEach(() => { + createWrapper({ uploading: true }); + }); + + it('renders a gl-loading-icon component', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('adds gl-opacity-5 class selector to image', () => { + expect(findImage().classes()).toContain('gl-opacity-5'); + }); + }); + + describe('when not uploading', () => { + beforeEach(() => { + createWrapper({ uploading: false }); + }); + + it('does not render a gl-loading-icon component', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('does not add gl-opacity-5 class selector to image', () => { + expect(findImage().classes()).not.toContain('gl-opacity-5'); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/hard_break_spec.js b/spec/frontend/content_editor/extensions/hard_break_spec.js new file mode 100644 index 00000000000..ebd58e60b0c --- /dev/null +++ b/spec/frontend/content_editor/extensions/hard_break_spec.js @@ -0,0 +1,46 @@ +import { tiptapExtension as HardBreak } from '~/content_editor/extensions/hard_break'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/hard_break', () => { + let tiptapEditor; + let eq; + let doc; + let p; + let hardBreak; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [HardBreak] }); + + ({ + builders: { doc, p, hardBreak }, + eq, + } = createDocBuilder({ + tiptapEditor, + names: { hardBreak: { nodeType: HardBreak.name } }, + })); + }); + + describe('Shift-Enter shortcut', () => { + it('inserts a hard break when shortcut is executed', () => { + const initialDoc = doc(p('')); + const expectedDoc = doc(p(hardBreak())); + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.keyboardShortcut('Shift-Enter'); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + }); + + describe('Mod-Enter shortcut', () => { + it('does not insert a hard break when shortcut is executed', () => { + const initialDoc = doc(p('')); + const expectedDoc = initialDoc; + + tiptapEditor.commands.setContent(initialDoc.toJSON()); + tiptapEditor.commands.keyboardShortcut('Mod-Enter'); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/horizontal_rule_spec.js b/spec/frontend/content_editor/extensions/horizontal_rule_spec.js new file mode 100644 index 00000000000..a1bc7f0e8ed --- /dev/null +++ b/spec/frontend/content_editor/extensions/horizontal_rule_spec.js @@ -0,0 +1,20 @@ +import { hrInputRuleRegExp } from '~/content_editor/extensions/horizontal_rule'; + +describe('content_editor/extensions/horizontal_rule', () => { + describe.each` + input | matches + ${'---'} | ${true} + ${'--'} | ${false} + ${'---x'} | ${false} + ${' ---x'} | ${false} + ${' --- '} | ${false} + ${'x---x'} | ${false} + ${'x---'} | ${false} + `('hrInputRuleRegExp', ({ input, matches }) => { + it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => { + const match = new RegExp(hrInputRuleRegExp).test(input); + + expect(match).toBe(matches); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/image_spec.js b/spec/frontend/content_editor/extensions/image_spec.js new file mode 100644 index 00000000000..922966b813a --- /dev/null +++ b/spec/frontend/content_editor/extensions/image_spec.js @@ -0,0 +1,193 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { once } from 'lodash'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as Image from '~/content_editor/extensions/image'; +import httpStatus from '~/lib/utils/http_status'; +import { loadMarkdownApiResult } from '../markdown_processing_examples'; +import { createTestEditor, createDocBuilder } from '../test_utils'; + +describe('content_editor/extensions/image', () => { + let tiptapEditor; + let eq; + let doc; + let p; + let image; + let renderMarkdown; + let mock; + const uploadsPath = '/uploads/'; + const validFile = new File(['foo'], 'foo.png', { type: 'image/png' }); + const invalidFile = new File(['foo'], 'bar.html', { type: 'text/html' }); + + beforeEach(() => { + renderMarkdown = jest + .fn() + .mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image').body); + + const { tiptapExtension } = Image.configure({ renderMarkdown, uploadsPath }); + + tiptapEditor = createTestEditor({ extensions: [tiptapExtension] }); + + ({ + builders: { doc, p, image }, + eq, + } = createDocBuilder({ + tiptapEditor, + names: { image: { nodeType: tiptapExtension.name } }, + })); + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + it.each` + file | valid | description + ${validFile} | ${true} | ${'handles paste event when mime type is valid'} + ${invalidFile} | ${false} | ${'does not handle paste event when mime type is invalid'} + `('$description', ({ file, valid }) => { + const pasteEvent = Object.assign(new Event('paste'), { + clipboardData: { + files: [file], + }, + }); + let handled; + + tiptapEditor.view.someProp('handlePaste', (eventHandler) => { + handled = eventHandler(tiptapEditor.view, pasteEvent); + }); + + expect(handled).toBe(valid); + }); + + it.each` + file | valid | description + ${validFile} | ${true} | ${'handles drop event when mime type is valid'} + ${invalidFile} | ${false} | ${'does not handle drop event when mime type is invalid'} + `('$description', ({ file, valid }) => { + const dropEvent = Object.assign(new Event('drop'), { + dataTransfer: { + files: [file], + }, + }); + let handled; + + tiptapEditor.view.someProp('handleDrop', (eventHandler) => { + handled = eventHandler(tiptapEditor.view, dropEvent); + }); + + expect(handled).toBe(valid); + }); + + it('handles paste event when mime type is correct', () => { + const pasteEvent = Object.assign(new Event('paste'), { + clipboardData: { + files: [new File(['foo'], 'foo.png', { type: 'image/png' })], + }, + }); + const handled = tiptapEditor.view.someProp('handlePaste', (eventHandler) => { + return eventHandler(tiptapEditor.view, pasteEvent); + }); + + expect(handled).toBe(true); + }); + + describe('uploadImage command', () => { + describe('when file has correct mime type', () => { + let initialDoc; + const base64EncodedFile = 'data:image/png;base64,Zm9v'; + + beforeEach(() => { + initialDoc = doc(p('')); + tiptapEditor.commands.setContent(initialDoc.toJSON()); + }); + + describe('when uploading image succeeds', () => { + const successResponse = { + link: { + markdown: '[image](/uploads/25265/image.png)', + }, + }; + + beforeEach(() => { + mock.onPost().reply(httpStatus.OK, successResponse); + }); + + it('inserts an image with src set to the encoded image file and uploading true', (done) => { + const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); + + tiptapEditor.on( + 'update', + once(() => { + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + done(); + }), + ); + + tiptapEditor.commands.uploadImage({ file: validFile }); + }); + + it('updates the inserted image with canonicalSrc when upload is successful', async () => { + const expectedDoc = doc( + p( + image({ + canonicalSrc: 'test-file.png', + src: base64EncodedFile, + alt: 'test file', + uploading: false, + }), + ), + ); + + tiptapEditor.commands.uploadImage({ file: validFile }); + + await waitForPromises(); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + }); + + describe('when uploading image request fails', () => { + beforeEach(() => { + mock.onPost().reply(httpStatus.INTERNAL_SERVER_ERROR); + }); + + it('resets the doc to orginal state', async () => { + const expectedDoc = doc(p('')); + + tiptapEditor.commands.uploadImage({ file: validFile }); + + await waitForPromises(); + + expect(eq(tiptapEditor.state.doc, expectedDoc)).toBe(true); + }); + + it('emits an error event that includes an error message', (done) => { + tiptapEditor.commands.uploadImage({ file: validFile }); + + tiptapEditor.on('error', (message) => { + expect(message).toBe('An error occurred while uploading the image. Please try again.'); + done(); + }); + }); + }); + }); + + describe('when file does not have correct mime type', () => { + let initialDoc; + + beforeEach(() => { + initialDoc = doc(p('')); + tiptapEditor.commands.setContent(initialDoc.toJSON()); + }); + + it('does not start the upload image process', () => { + tiptapEditor.commands.uploadImage({ file: invalidFile }); + + expect(eq(tiptapEditor.state.doc, initialDoc)).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/markdown_processing_examples.js b/spec/frontend/content_editor/markdown_processing_examples.js index 12bf2cbb747..12eed00f3c6 100644 --- a/spec/frontend/content_editor/markdown_processing_examples.js +++ b/spec/frontend/content_editor/markdown_processing_examples.js @@ -1,7 +1,6 @@ import fs from 'fs'; import path from 'path'; import jsYaml from 'js-yaml'; -import { toArray } from 'lodash'; import { getJSONFixture } from 'helpers/fixtures'; export const loadMarkdownApiResult = (testName) => { @@ -15,5 +14,5 @@ export const loadMarkdownApiExamples = () => { const apiMarkdownYamlText = fs.readFileSync(apiMarkdownYamlPath); const apiMarkdownExampleObjects = jsYaml.safeLoad(apiMarkdownYamlText); - return apiMarkdownExampleObjects.map((example) => toArray(example)); + return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]); }; diff --git a/spec/frontend/content_editor/markdown_processing_spec.js b/spec/frontend/content_editor/markdown_processing_spec.js index cb34476d680..028cd6a8271 100644 --- a/spec/frontend/content_editor/markdown_processing_spec.js +++ b/spec/frontend/content_editor/markdown_processing_spec.js @@ -3,11 +3,15 @@ import { loadMarkdownApiExamples, loadMarkdownApiResult } from './markdown_proce describe('markdown processing', () => { // Ensure we generate same markdown that was provided to Markdown API. - it.each(loadMarkdownApiExamples())('correctly handles %s', async (testName, markdown) => { - const { html } = loadMarkdownApiResult(testName); - const contentEditor = createContentEditor({ renderMarkdown: () => html }); - await contentEditor.setSerializedContent(markdown); + it.each(loadMarkdownApiExamples())( + 'correctly handles %s (context: %s)', + async (name, context, markdown) => { + const testName = context ? `${context}_${name}` : name; + const { html, body } = loadMarkdownApiResult(testName); + const contentEditor = createContentEditor({ renderMarkdown: () => html || body }); + await contentEditor.setSerializedContent(markdown); - expect(contentEditor.getSerializedContent()).toBe(markdown); - }); + expect(contentEditor.getSerializedContent()).toBe(markdown); + }, + ); }); diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js index 59b2fab6d54..b614efd954a 100644 --- a/spec/frontend/content_editor/services/create_content_editor_spec.js +++ b/spec/frontend/content_editor/services/create_content_editor_spec.js @@ -5,10 +5,11 @@ import { createTestContentEditorExtension } from '../test_utils'; describe('content_editor/services/create_editor', () => { let renderMarkdown; let editor; + const uploadsPath = '/uploads'; beforeEach(() => { renderMarkdown = jest.fn(); - editor = createContentEditor({ renderMarkdown }); + editor = createContentEditor({ renderMarkdown, uploadsPath }); }); it('sets gl-outline-0! class selector to the tiptapEditor instance', () => { @@ -48,4 +49,13 @@ describe('content_editor/services/create_editor', () => { it('throws an error when a renderMarkdown fn is not provided', () => { expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); }); + + it('provides uploadsPath and renderMarkdown function to Image extension', () => { + expect( + editor.tiptapEditor.extensionManager.extensions.find((e) => e.name === 'image').options, + ).toMatchObject({ + uploadsPath, + renderMarkdown, + }); + }); }); diff --git a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js index cf74b5c56c9..64f3d8df6e0 100644 --- a/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js +++ b/spec/frontend/content_editor/services/track_input_rules_and_shortcuts_spec.js @@ -1,26 +1,23 @@ -import { BulletList } from '@tiptap/extension-bullet-list'; -import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; -import { Document } from '@tiptap/extension-document'; -import { Heading } from '@tiptap/extension-heading'; -import { ListItem } from '@tiptap/extension-list-item'; -import { Paragraph } from '@tiptap/extension-paragraph'; -import { Text } from '@tiptap/extension-text'; -import { Editor } from '@tiptap/vue-2'; import { mockTracking } from 'helpers/tracking_helper'; import { KEYBOARD_SHORTCUT_TRACKING_ACTION, INPUT_RULE_TRACKING_ACTION, CONTENT_EDITOR_TRACKING_LABEL, } from '~/content_editor/constants'; +import { tiptapExtension as BulletList } from '~/content_editor/extensions/bullet_list'; +import { tiptapExtension as CodeBlockLowlight } from '~/content_editor/extensions/code_block_highlight'; +import { tiptapExtension as Heading } from '~/content_editor/extensions/heading'; +import { tiptapExtension as ListItem } from '~/content_editor/extensions/list_item'; import trackInputRulesAndShortcuts from '~/content_editor/services/track_input_rules_and_shortcuts'; import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; +import { createTestEditor } from '../test_utils'; describe('content_editor/services/track_input_rules_and_shortcuts', () => { let trackingSpy; let editor; let trackedExtensions; const HEADING_TEXT = 'Heading text'; - const extensions = [Document, Paragraph, Text, Heading, CodeBlockLowlight, BulletList, ListItem]; + const extensions = [Heading, CodeBlockLowlight, BulletList, ListItem]; beforeEach(() => { trackingSpy = mockTracking(undefined, null, jest.spyOn); @@ -29,7 +26,7 @@ describe('content_editor/services/track_input_rules_and_shortcuts', () => { describe('given the heading extension is instrumented', () => { beforeEach(() => { trackedExtensions = extensions.map(trackInputRulesAndShortcuts); - editor = new Editor({ + editor = createTestEditor({ extensions: extensions.map(trackInputRulesAndShortcuts), }); }); diff --git a/spec/frontend/content_editor/services/upload_file_spec.js b/spec/frontend/content_editor/services/upload_file_spec.js new file mode 100644 index 00000000000..87c5298079e --- /dev/null +++ b/spec/frontend/content_editor/services/upload_file_spec.js @@ -0,0 +1,46 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { uploadFile } from '~/content_editor/services/upload_file'; +import httpStatus from '~/lib/utils/http_status'; + +describe('content_editor/services/upload_file', () => { + const uploadsPath = '/uploads'; + const file = new File(['content'], 'file.txt'); + // TODO: Replace with automated fixture + const renderedAttachmentLinkFixture = + '<a href="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"><img data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"></a></p>'; + const successResponse = { + link: { + markdown: '[GitLab](https://gitlab.com)', + }, + }; + const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); + let mock; + let renderMarkdown; + let renderedMarkdown; + + beforeEach(() => { + const formData = new FormData(); + formData.append('file', file); + + renderedMarkdown = parseHTML(renderedAttachmentLinkFixture); + + mock = new MockAdapter(axios); + mock.onPost(uploadsPath, formData).reply(httpStatus.OK, successResponse); + renderMarkdown = jest.fn().mockResolvedValue(renderedAttachmentLinkFixture); + }); + + afterEach(() => { + mock.restore(); + }); + + it('returns src and canonicalSrc of uploaded file', async () => { + const response = await uploadFile({ uploadsPath, renderMarkdown, file }); + + expect(renderMarkdown).toHaveBeenCalledWith(successResponse.link.markdown); + expect(response).toEqual({ + src: renderedMarkdown.querySelector('a').getAttribute('href'), + canonicalSrc: renderedMarkdown.querySelector('a').dataset.canonicalSrc, + }); + }); +}); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 8e73aef678b..090e1d92218 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -3,6 +3,16 @@ import { Document } from '@tiptap/extension-document'; import { Paragraph } from '@tiptap/extension-paragraph'; import { Text } from '@tiptap/extension-text'; import { Editor } from '@tiptap/vue-2'; +import { builders, eq } from 'prosemirror-test-builder'; + +export const createDocBuilder = ({ tiptapEditor, names = {} }) => { + const docBuilders = builders(tiptapEditor.schema, { + p: { nodeType: 'paragraph' }, + ...names, + }); + + return { eq, builders: docBuilders }; +}; /** * Creates an instance of the Tiptap Editor class @@ -15,7 +25,7 @@ import { Editor } from '@tiptap/vue-2'; * include in the editor * @returns An instance of a Tiptap’s Editor class */ -export const createTestEditor = ({ extensions = [] }) => { +export const createTestEditor = ({ extensions = [] } = {}) => { return new Editor({ extensions: [Document, Text, Paragraph, ...extensions], }); diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js index 82b6492b779..a4054ab1fc8 100644 --- a/spec/frontend/contributors/store/actions_spec.js +++ b/spec/frontend/contributors/store/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/contributors/stores/actions'; import * as types from '~/contributors/stores/mutation_types'; -import { deprecatedCreateFlash as flashError } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; jest.mock('~/flash.js'); @@ -47,7 +47,9 @@ describe('Contributors store actions', () => { [{ type: types.SET_LOADING_STATE, payload: true }], [], () => { - expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('error'), + }); mock.restore(); done(); }, diff --git a/spec/frontend/cycle_analytics/filter_bar_spec.js b/spec/frontend/cycle_analytics/filter_bar_spec.js new file mode 100644 index 00000000000..407f21bd956 --- /dev/null +++ b/spec/frontend/cycle_analytics/filter_bar_spec.js @@ -0,0 +1,224 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import Vuex from 'vuex'; +import { + filterMilestones, + filterLabels, +} from 'jest/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data'; +import FilterBar from '~/cycle_analytics/components/filter_bar.vue'; +import storeConfig from '~/cycle_analytics/store'; +import * as commonUtils from '~/lib/utils/common_utils'; +import * as urlUtils from '~/lib/utils/url_utility'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import * as utils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import initialFiltersState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const milestoneTokenType = 'milestone'; +const labelsTokenType = 'labels'; +const authorTokenType = 'author'; +const assigneesTokenType = 'assignees'; + +const initialFilterBarState = { + selectedMilestone: null, + selectedAuthor: null, + selectedAssigneeList: null, + selectedLabelList: null, +}; + +const defaultParams = { + milestone_title: null, + 'not[milestone_title]': null, + author_username: null, + 'not[author_username]': null, + assignee_username: null, + 'not[assignee_username]': null, + label_name: null, + 'not[label_name]': null, +}; + +async function shouldMergeUrlParams(wrapper, result) { + await wrapper.vm.$nextTick(); + expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(result, window.location.href, { + spreadArrays: true, + }); + expect(commonUtils.historyPushState).toHaveBeenCalled(); +} + +describe('Filter bar', () => { + let wrapper; + let store; + let mock; + + let setFiltersMock; + + const createStore = (initialState = {}) => { + setFiltersMock = jest.fn(); + + return new Vuex.Store({ + modules: { + filters: { + namespaced: true, + state: { + ...initialFiltersState(), + ...initialState, + }, + actions: { + setFilters: setFiltersMock, + }, + }, + }, + }); + }; + + const createComponent = (initialStore) => { + return shallowMount(FilterBar, { + localVue, + store: initialStore, + propsData: { + groupPath: 'foo', + }, + stubs: { + UrlSync, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + const selectedMilestone = [filterMilestones[0]]; + const selectedLabelList = [filterLabels[0]]; + + const findFilteredSearch = () => wrapper.findComponent(FilteredSearchBar); + const getSearchToken = (type) => + findFilteredSearch() + .props('tokens') + .find((token) => token.type === type); + + describe('default', () => { + beforeEach(() => { + store = createStore(); + wrapper = createComponent(store); + }); + + it('renders FilteredSearchBar component', () => { + expect(findFilteredSearch().exists()).toBe(true); + }); + }); + + describe('when the state has data', () => { + beforeEach(() => { + store = createStore({ + milestones: { data: selectedMilestone }, + labels: { data: selectedLabelList }, + authors: { data: [] }, + assignees: { data: [] }, + }); + wrapper = createComponent(store); + }); + + it('displays the milestone and label token', () => { + const tokens = findFilteredSearch().props('tokens'); + + expect(tokens).toHaveLength(4); + expect(tokens[0].type).toBe(milestoneTokenType); + expect(tokens[1].type).toBe(labelsTokenType); + expect(tokens[2].type).toBe(authorTokenType); + expect(tokens[3].type).toBe(assigneesTokenType); + }); + + it('provides the initial milestone token', () => { + const { initialMilestones: milestoneToken } = getSearchToken(milestoneTokenType); + + expect(milestoneToken).toHaveLength(selectedMilestone.length); + }); + + it('provides the initial label token', () => { + const { initialLabels: labelToken } = getSearchToken(labelsTokenType); + + expect(labelToken).toHaveLength(selectedLabelList.length); + }); + }); + + describe('when the user interacts', () => { + beforeEach(() => { + store = createStore({ + milestones: { data: filterMilestones }, + labels: { data: filterLabels }, + }); + wrapper = createComponent(store); + jest.spyOn(utils, 'processFilters'); + }); + + it('clicks on the search button, setFilters is dispatched', () => { + const filters = [ + { type: 'milestone', value: { data: selectedMilestone[0].title, operator: '=' } }, + { type: 'labels', value: { data: selectedLabelList[0].title, operator: '=' } }, + ]; + + findFilteredSearch().vm.$emit('onFilter', filters); + + expect(utils.processFilters).toHaveBeenCalledWith(filters); + + expect(setFiltersMock).toHaveBeenCalledWith(expect.anything(), { + selectedLabelList: [{ value: selectedLabelList[0].title, operator: '=' }], + selectedMilestone: { value: selectedMilestone[0].title, operator: '=' }, + selectedAssigneeList: [], + selectedAuthor: null, + }); + }); + }); + + describe.each([ + ['selectedMilestone', 'milestone_title', { value: '12.0', operator: '=' }, '12.0'], + ['selectedAuthor', 'author_username', { value: 'rootUser', operator: '=' }, 'rootUser'], + [ + 'selectedLabelList', + 'label_name', + [ + { value: 'Afternix', operator: '=' }, + { value: 'Brouceforge', operator: '=' }, + ], + ['Afternix', 'Brouceforge'], + ], + [ + 'selectedAssigneeList', + 'assignee_username', + [ + { value: 'rootUser', operator: '=' }, + { value: 'secondaryUser', operator: '=' }, + ], + ['rootUser', 'secondaryUser'], + ], + ])('with a %s updates the %s url parameter', (stateKey, paramKey, payload, result) => { + beforeEach(() => { + commonUtils.historyPushState = jest.fn(); + urlUtils.mergeUrlParams = jest.fn(); + + mock = new MockAdapter(axios); + wrapper = createComponent(storeConfig); + + wrapper.vm.$store.dispatch('filters/setFilters', { + ...initialFilterBarState, + [stateKey]: payload, + }); + }); + it(`sets the ${paramKey} url parameter`, () => { + return shouldMergeUrlParams(wrapper, { + ...defaultParams, + [paramKey]: result, + }); + }); + }); +}); diff --git a/spec/frontend/cycle_analytics/formatted_stage_count_spec.js b/spec/frontend/cycle_analytics/formatted_stage_count_spec.js new file mode 100644 index 00000000000..1228b8511ea --- /dev/null +++ b/spec/frontend/cycle_analytics/formatted_stage_count_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import Component from '~/cycle_analytics/components/formatted_stage_count.vue'; + +describe('Formatted Stage Count', () => { + let wrapper = null; + + const createComponent = (stageCount = null) => { + wrapper = shallowMount(Component, { + propsData: { + stageCount, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + stageCount | expectedOutput + ${null} | ${'-'} + ${1} | ${'1 item'} + ${10} | ${'10 items'} + ${1000} | ${'1,000 items'} + ${1001} | ${'1,000+ items'} + `('returns "$expectedOutput" for stageCount=$stageCount', ({ stageCount, expectedOutput }) => { + createComponent(stageCount); + expect(wrapper.text()).toContain(expectedOutput); + }); +}); diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js index 242ea1932fb..4e6471d5f7b 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/cycle_analytics/mock_data.js @@ -1,5 +1,10 @@ -import { DEFAULT_VALUE_STREAM } from '~/cycle_analytics/constants'; +import { TEST_HOST } from 'helpers/test_constants'; +import { DEFAULT_VALUE_STREAM, DEFAULT_DAYS_IN_PAST } from '~/cycle_analytics/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { getDateInPast } from '~/lib/utils/datetime_utility'; + +export const createdBefore = new Date(2019, 0, 14); +export const createdAfter = getDateInPast(createdBefore, DEFAULT_DAYS_IN_PAST); export const getStageByTitle = (stages, title) => stages.find((stage) => stage.title && stage.title.toLowerCase().trim() === title) || {}; @@ -169,6 +174,15 @@ export const stageMedians = { staging: 388800, }; +export const formattedStageMedians = { + issue: '2d', + plan: '1d', + review: '1w', + code: '1d', + test: '3d', + staging: '4d', +}; + export const allowedStages = [issueStage, planStage, codeStage]; export const transformedProjectStagePathData = [ @@ -212,6 +226,31 @@ export const transformedProjectStagePathData = [ export const selectedValueStream = DEFAULT_VALUE_STREAM; +export const group = { + id: 1, + name: 'foo', + path: 'foo', + full_path: 'foo', + avatar_url: `${TEST_HOST}/images/home/nasa.svg`, +}; + +export const currentGroup = convertObjectPropsToCamelCase(group, { deep: true }); + +export const selectedProjects = [ + { + id: 'gid://gitlab/Project/1', + name: 'cool project', + pathWithNamespace: 'group/cool-project', + avatarUrl: null, + }, + { + id: 'gid://gitlab/Project/2', + name: 'another cool project', + pathWithNamespace: 'group/another-cool-project', + avatarUrl: null, + }, +]; + export const rawValueStreamStages = [ { title: 'Issue', diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js index 4f37e1266fb..8a8dd374f8e 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/cycle_analytics/store/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/cycle_analytics/store/actions'; import httpStatusCodes from '~/lib/utils/http_status'; -import { selectedStage, selectedValueStream } from '../mock_data'; +import { allowedStages, selectedStage, selectedValueStream } from '../mock_data'; const mockRequestPath = 'some/cool/path'; const mockFullPath = '/namespace/-/analytics/value_stream_analytics/value_streams'; @@ -25,6 +25,10 @@ const mockRequestedDataMutations = [ }, ]; +const features = { + cycleAnalyticsForGroups: true, +}; + describe('Project Value Stream Analytics actions', () => { let state; let mock; @@ -175,6 +179,7 @@ describe('Project Value Stream Analytics actions', () => { beforeEach(() => { state = { + features, fullPath: mockFullPath, }; mock = new MockAdapter(axios); @@ -187,9 +192,33 @@ describe('Project Value Stream Analytics actions', () => { state, payload: {}, expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], - expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }], + expectedActions: [ + { type: 'receiveValueStreamsSuccess' }, + { type: 'setSelectedStage' }, + { type: 'fetchStageMedians' }, + ], })); + describe('with cycleAnalyticsForGroups=false', () => { + beforeEach(() => { + state = { + features: { cycleAnalyticsForGroups: false }, + fullPath: mockFullPath, + }; + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + }); + + it("does not dispatch the 'fetchStageMedians' request", () => + testAction({ + action: actions.fetchValueStreams, + state, + payload: {}, + expectedMutations: [{ type: 'REQUEST_VALUE_STREAMS' }], + expectedActions: [{ type: 'receiveValueStreamsSuccess' }, { type: 'setSelectedStage' }], + })); + }); + describe('with a failing request', () => { beforeEach(() => { mock = new MockAdapter(axios); @@ -280,4 +309,59 @@ describe('Project Value Stream Analytics actions', () => { })); }); }); + + describe('fetchStageMedians', () => { + const mockValueStreamPath = /median/; + + const stageMediansPayload = [ + { id: 'issue', value: null }, + { id: 'plan', value: null }, + { id: 'code', value: null }, + ]; + + const stageMedianError = new Error( + `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`, + ); + + beforeEach(() => { + state = { + fullPath: mockFullPath, + selectedValueStream, + stages: allowedStages, + }; + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.OK); + }); + + it(`commits the 'REQUEST_STAGE_MEDIANS' and 'RECEIVE_STAGE_MEDIANS_SUCCESS' mutations`, () => + testAction({ + action: actions.fetchStageMedians, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_STAGE_MEDIANS' }, + { type: 'RECEIVE_STAGE_MEDIANS_SUCCESS', payload: stageMediansPayload }, + ], + expectedActions: [], + })); + + describe('with a failing request', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST); + }); + + it(`commits the 'RECEIVE_VALUE_STREAM_STAGES_ERROR' mutation`, () => + testAction({ + action: actions.fetchStageMedians, + state, + payload: {}, + expectedMutations: [ + { type: 'REQUEST_STAGE_MEDIANS' }, + { type: 'RECEIVE_STAGE_MEDIANS_ERROR', payload: stageMedianError }, + ], + expectedActions: [], + })); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js index 88e1a13f506..77b19280517 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/cycle_analytics/store/mutations_spec.js @@ -1,3 +1,5 @@ +import { useFakeDate } from 'helpers/fake_date'; +import { DEFAULT_DAYS_TO_DISPLAY } from '~/cycle_analytics/constants'; import * as types from '~/cycle_analytics/store/mutation_types'; import mutations from '~/cycle_analytics/store/mutations'; import { @@ -9,15 +11,23 @@ import { selectedValueStream, rawValueStreamStages, valueStreamStages, + rawStageMedians, + formattedStageMedians, } from '../mock_data'; let state; const mockRequestPath = 'fake/request/path'; -const mockStartData = '2021-04-20'; +const mockCreatedAfter = '2020-06-18'; +const mockCreatedBefore = '2020-07-18'; +const features = { + cycleAnalyticsForGroups: true, +}; describe('Project Value Stream Analytics mutations', () => { + useFakeDate(2020, 6, 18); + beforeEach(() => { - state = {}; + state = { features }; }); afterEach(() => { @@ -46,6 +56,8 @@ describe('Project Value Stream Analytics mutations', () => { ${types.RECEIVE_STAGE_DATA_ERROR} | ${'selectedStageEvents'} | ${[]} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'hasError'} | ${true} ${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true} + ${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}} + ${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}} `('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => { mutations[mutation](state, {}); @@ -53,15 +65,19 @@ describe('Project Value Stream Analytics mutations', () => { }); it.each` - mutation | payload | stateKey | value - ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} - ${types.SET_DATE_RANGE} | ${{ startDate: mockStartData }} | ${'startDate'} | ${mockStartData} - ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} - ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} - ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} - ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} - ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} - ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} + mutation | payload | stateKey | value + ${types.INITIALIZE_VSA} | ${{ requestPath: mockRequestPath }} | ${'requestPath'} | ${mockRequestPath} + ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'startDate'} | ${DEFAULT_DAYS_TO_DISPLAY} + ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdAfter'} | ${mockCreatedAfter} + ${types.SET_DATE_RANGE} | ${{ startDate: DEFAULT_DAYS_TO_DISPLAY }} | ${'createdBefore'} | ${mockCreatedBefore} + ${types.SET_LOADING} | ${true} | ${'isLoading'} | ${true} + ${types.SET_LOADING} | ${false} | ${'isLoading'} | ${false} + ${types.SET_SELECTED_VALUE_STREAM} | ${selectedValueStream} | ${'selectedValueStream'} | ${selectedValueStream} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'summary'} | ${convertedData.summary} + ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} + ${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages} + ${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]} + ${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians} `( '$mutation with $payload will set $stateKey to $value', ({ mutation, payload, stateKey, value }) => { @@ -92,4 +108,35 @@ describe('Project Value Stream Analytics mutations', () => { }, ); }); + + describe('with cycleAnalyticsForGroups=false', () => { + useFakeDate(2020, 6, 18); + + beforeEach(() => { + state = { features: { cycleAnalyticsForGroups: false } }; + }); + + const formattedMedians = { + code: '2d', + issue: '-', + plan: '21h', + review: '-', + staging: '2d', + test: '4h', + }; + + it.each` + mutation | payload | stateKey | value + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS} | ${rawData} | ${'medians'} | ${formattedMedians} + ${types.REQUEST_CYCLE_ANALYTICS_DATA} | ${{}} | ${'medians'} | ${{}} + ${types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR} | ${{}} | ${'medians'} | ${{}} + `( + '$mutation with $payload will set $stateKey to $value', + ({ mutation, payload, stateKey, value }) => { + mutations[mutation](state, payload); + + expect(state).toMatchObject({ [stateKey]: value }); + }, + ); + }); }); diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js index 15137bb0571..1fecdfc0539 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/cycle_analytics/utils_spec.js @@ -1,3 +1,4 @@ +import { useFakeDate } from 'helpers/fake_date'; import { decorateEvents, decorateData, @@ -6,6 +7,7 @@ import { medianTimeToParsedSeconds, formatMedianValues, filterStagesByHiddenStatus, + calculateFormattedDayInPast, } from '~/cycle_analytics/utils'; import { selectedStage, @@ -149,4 +151,12 @@ describe('Value stream analytics utils', () => { expect(filterStagesByHiddenStatus(mockStages, isHidden)).toEqual(result); }); }); + + describe('calculateFormattedDayInPast', () => { + useFakeDate(1815, 11, 10); + + it('will return 2 dates, now and past', () => { + expect(calculateFormattedDayInPast(5)).toEqual({ now: '1815-12-10', past: '1815-12-05' }); + }); + }); }); diff --git a/spec/frontend/cycle_analytics/value_stream_filters_spec.js b/spec/frontend/cycle_analytics/value_stream_filters_spec.js new file mode 100644 index 00000000000..6e96a6d756a --- /dev/null +++ b/spec/frontend/cycle_analytics/value_stream_filters_spec.js @@ -0,0 +1,91 @@ +import { shallowMount } from '@vue/test-utils'; +import Daterange from '~/analytics/shared/components/daterange.vue'; +import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; +import FilterBar from '~/cycle_analytics/components/filter_bar.vue'; +import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; +import { + createdAfter as startDate, + createdBefore as endDate, + currentGroup, + selectedProjects, +} from './mock_data'; + +function createComponent(props = {}) { + return shallowMount(ValueStreamFilters, { + propsData: { + selectedProjects, + groupId: currentGroup.id, + groupPath: currentGroup.fullPath, + startDate, + endDate, + ...props, + }, + }); +} + +describe('ValueStreamFilters', () => { + let wrapper; + + const findProjectsDropdown = () => wrapper.findComponent(ProjectsDropdownFilter); + const findDateRangePicker = () => wrapper.findComponent(Daterange); + const findFilterBar = () => wrapper.findComponent(FilterBar); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('will render the filter bar', () => { + expect(findFilterBar().exists()).toBe(true); + }); + + it('will render the projects dropdown', () => { + expect(findProjectsDropdown().exists()).toBe(true); + expect(wrapper.findComponent(ProjectsDropdownFilter).props()).toEqual( + expect.objectContaining({ + queryParams: wrapper.vm.projectsQueryParams, + multiSelect: wrapper.vm.$options.multiProjectSelect, + }), + ); + }); + + it('will render the date range picker', () => { + expect(findDateRangePicker().exists()).toBe(true); + }); + + it('will emit `selectProject` when a project is selected', () => { + findProjectsDropdown().vm.$emit('selected'); + + expect(wrapper.emitted('selectProject')).not.toBeUndefined(); + }); + + it('will emit `setDateRange` when the date range changes', () => { + findDateRangePicker().vm.$emit('change'); + + expect(wrapper.emitted('setDateRange')).not.toBeUndefined(); + }); + + describe('hasDateRangeFilter = false', () => { + beforeEach(() => { + wrapper = createComponent({ hasDateRangeFilter: false }); + }); + + it('will not render the date range picker', () => { + expect(findDateRangePicker().exists()).toBe(false); + }); + }); + + describe('hasProjectFilter = false', () => { + beforeEach(() => { + wrapper = createComponent({ hasProjectFilter: false }); + }); + + it('will not render the project dropdown', () => { + expect(findProjectsDropdown().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap index 084a7e5d712..4ecf82a4714 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -6,7 +6,7 @@ exports[`Design note component should match the snapshot 1`] = ` id="note_123" > <user-avatar-link-stub - imgalt="" + imgalt="foo-bar" imgcssclasses="" imgsize="40" imgsrc="" @@ -22,7 +22,8 @@ exports[`Design note component should match the snapshot 1`] = ` <div> <gl-link-stub class="js-user-link" - data-user-id="author-id" + data-user-id="1" + data-username="foo-bar" > <span class="note-header-author-name gl-font-weight-bold" @@ -35,7 +36,7 @@ exports[`Design note component should match the snapshot 1`] = ` <span class="note-headline-light" > - @ + @foo-bar </span> </gl-link-stub> diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 1cd556eabb4..3f5f5bcdfa7 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -9,7 +9,8 @@ const scrollIntoViewMock = jest.fn(); const note = { id: 'gid://gitlab/DiffNote/123', author: { - id: 'author-id', + id: 'gid://gitlab/User/1', + username: 'foo-bar', }, body: 'test', userPermissions: { diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js index 20686d0ae6c..757bf50c527 100644 --- a/spec/frontend/design_management/components/design_todo_button_spec.js +++ b/spec/frontend/design_management/components/design_todo_button_spec.js @@ -2,7 +2,7 @@ import { shallowMount, mount } from '@vue/test-utils'; import DesignTodoButton from '~/design_management/components/design_todo_button.vue'; import createDesignTodoMutation from '~/design_management/graphql/mutations/create_design_todo.mutation.graphql'; import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; -import TodoButton from '~/vue_shared/components/todo_button.vue'; +import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; import mockDesign from '../mock_data/design'; const mockDesignWithPendingTodos = { diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 11c88c3d0f5..1332e872246 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -20,7 +20,7 @@ import { import { DESIGN_TRACKING_PAGE_NAME, DESIGN_SNOWPLOW_EVENT_TYPES, - DESIGN_USAGE_PING_EVENT_TYPES, + DESIGN_SERVICE_PING_EVENT_TYPES, } from '~/design_management/utils/tracking'; import createFlash from '~/flash'; import mockAllVersions from '../../mock_data/all_versions'; @@ -391,7 +391,7 @@ describe('Design management design index page', () => { }); describe('with usage_data_design_action enabled', () => { - it('tracks design view usage ping', () => { + it('tracks design view service ping', () => { createComponent( { loading: true }, { @@ -402,13 +402,13 @@ describe('Design management design index page', () => { ); expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); expect(Api.trackRedisHllUserEvent).toHaveBeenCalledWith( - DESIGN_USAGE_PING_EVENT_TYPES.DESIGN_ACTION, + DESIGN_SERVICE_PING_EVENT_TYPES.DESIGN_ACTION, ); }); }); describe('with usage_data_design_action disabled', () => { - it("doesn't track design view usage ping", () => { + it("doesn't track design view service ping", () => { createComponent({ loading: true }); expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(0); }); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index 8a1c5547581..b5eb3e1713c 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -6,14 +6,19 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { TEST_HOST } from 'spec/test_constants'; import App from '~/diffs/components/app.vue'; -import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import CommitWidget from '~/diffs/components/commit_widget.vue'; import CompareVersions from '~/diffs/components/compare_versions.vue'; import DiffFile from '~/diffs/components/diff_file.vue'; -import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.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'; +import MergeConflictWarning from '~/diffs/components/merge_conflict_warning.vue'; +/* eslint-enable import/order */ + import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import createDiffsStore from '../create_diffs_store'; @@ -541,6 +546,43 @@ describe('diffs/components/app', () => { expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false); }); }); + + describe('merge conflicts', () => { + it('should render the merge conflicts banner if viewing the whole changeset and there are conflicts', () => { + createComponent({}, ({ state }) => { + Object.assign(state.diffs, { + latestDiff: true, + startVersion: null, + hasConflicts: true, + canMerge: false, + conflictResolutionPath: 'path', + }); + }); + + expect(wrapper.find(MergeConflictWarning).exists()).toBe(true); + }); + + it.each` + prop | value + ${'latestDiff'} | ${false} + ${'startVersion'} | ${'notnull'} + ${'hasConflicts'} | ${false} + `( + "should not render if any of the MR properties aren't correct - like $prop: $value", + ({ prop, value }) => { + createComponent({}, ({ state }) => { + Object.assign(state.diffs, { + latestDiff: true, + startVersion: null, + hasConflicts: true, + [prop]: value, + }); + }); + + expect(wrapper.find(MergeConflictWarning).exists()).toBe(false); + }, + ); + }); }); it('should display commit widget if store has a commit', () => { diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js index 77c2e19cb68..46caeb01132 100644 --- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js +++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js @@ -1,10 +1,13 @@ import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; import Vuex from 'vuex'; import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '~/diffs/constants'; import eventHub from '~/diffs/event_hub'; import createStore from '~/diffs/store/modules'; +import file from '../mock_data/diff_file'; + const propsData = { limited: true, mergeable: true, @@ -12,6 +15,13 @@ const propsData = { }; const limitedClasses = CENTERED_LIMITED_CONTAINER_CLASSES.split(' '); +async function files(store, count) { + const copies = Array(count).fill(file); + store.state.diffs.diffFiles.push(...copies); + + return nextTick(); +} + describe('CollapsedFilesWarning', () => { const localVue = createLocalVue(); let store; @@ -42,48 +52,63 @@ describe('CollapsedFilesWarning', () => { wrapper.destroy(); }); - it.each` - limited | containerClasses - ${true} | ${limitedClasses} - ${false} | ${[]} - `( - 'has the correct container classes when limited is $limited', - ({ limited, containerClasses }) => { - createComponent({ limited }); - - expect(wrapper.classes()).toEqual(['col-12'].concat(containerClasses)); - }, - ); - - it.each` - present | dismissed - ${false} | ${true} - ${true} | ${false} - `('toggles the alert when dismissed is $dismissed', ({ present, dismissed }) => { - createComponent({ dismissed }); - - expect(wrapper.find('[data-testid="root"]').exists()).toBe(present); - }); + describe('when there is more than one file', () => { + it.each` + limited | containerClasses + ${true} | ${limitedClasses} + ${false} | ${[]} + `( + 'has the correct container classes when limited is $limited', + async ({ limited, containerClasses }) => { + createComponent({ limited }); + await files(store, 2); + + expect(wrapper.classes()).toEqual(['col-12'].concat(containerClasses)); + }, + ); - it('dismisses the component when the alert "x" is clicked', async () => { - createComponent({}, { full: true }); + it.each` + present | dismissed + ${false} | ${true} + ${true} | ${false} + `('toggles the alert when dismissed is $dismissed', async ({ present, dismissed }) => { + createComponent({ dismissed }); + await files(store, 2); - expect(wrapper.find('[data-testid="root"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="root"]').exists()).toBe(present); + }); - getAlertCloseButton().element.click(); + it('dismisses the component when the alert "x" is clicked', async () => { + createComponent({}, { full: true }); + await files(store, 2); - await wrapper.vm.$nextTick(); + expect(wrapper.find('[data-testid="root"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="root"]').exists()).toBe(false); - }); + getAlertCloseButton().element.click(); - it(`emits the \`${EVT_EXPAND_ALL_FILES}\` event when the alert action button is clicked`, () => { - createComponent({}, { full: true }); + await wrapper.vm.$nextTick(); - jest.spyOn(eventHub, '$emit'); + expect(wrapper.find('[data-testid="root"]').exists()).toBe(false); + }); - getAlertActionButton().vm.$emit('click'); + it(`emits the \`${EVT_EXPAND_ALL_FILES}\` event when the alert action button is clicked`, async () => { + createComponent({}, { full: true }); + await files(store, 2); - expect(eventHub.$emit).toHaveBeenCalledWith(EVT_EXPAND_ALL_FILES); + jest.spyOn(eventHub, '$emit'); + + getAlertActionButton().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith(EVT_EXPAND_ALL_FILES); + }); + }); + + describe('when there is a single file', () => { + it('should not display', async () => { + createComponent(); + await files(store, 1); + + expect(wrapper.find('[data-testid="root"]').exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index 7012889440c..0a7dfc02c65 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -4,8 +4,6 @@ import Vuex from 'vuex'; import DiffContentComponent from '~/diffs/components/diff_content.vue'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; import DiffView from '~/diffs/components/diff_view.vue'; -import InlineDiffView from '~/diffs/components/inline_diff_view.vue'; -import ParallelDiffView from '~/diffs/components/parallel_diff_view.vue'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import { diffViewerModes } from '~/ide/constants'; import NoteForm from '~/notes/components/note_form.vue'; @@ -107,25 +105,10 @@ describe('DiffContent', () => { }); const textDiffFile = { ...defaultProps.diffFile, viewer: { name: diffViewerModes.text } }; - it('should render diff inline view if `isInlineView` is true', () => { - isInlineViewGetterMock.mockReturnValue(true); - createComponent({ props: { diffFile: textDiffFile } }); - - expect(wrapper.find(InlineDiffView).exists()).toBe(true); - }); - - it('should render parallel view if `isParallelView` getter is true', () => { - isParallelViewGetterMock.mockReturnValue(true); - createComponent({ props: { diffFile: textDiffFile } }); - - expect(wrapper.find(ParallelDiffView).exists()).toBe(true); - }); it('should render diff view if `unifiedDiffComponents` are true', () => { - isParallelViewGetterMock.mockReturnValue(true); createComponent({ props: { diffFile: textDiffFile }, - provide: { glFeatures: { unifiedDiffComponents: true } }, }); expect(wrapper.find(DiffView).exists()).toBe(true); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 1e8ad9344f2..99dda8d5deb 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -110,7 +110,6 @@ const findLoader = (wrapper) => wrapper.find('[data-testid="loader-icon"]'); const findToggleButton = (wrapper) => wrapper.find('[data-testid="expand-button"]'); const toggleFile = (wrapper) => findDiffHeader(wrapper).vm.$emit('toggleFile'); -const isDisplayNone = (element) => element.style.display === 'none'; const getReadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataReadable)); const getUnreadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataUnreadable)); @@ -305,9 +304,7 @@ describe('DiffFile', () => { it('should not have any content at all', async () => { await wrapper.vm.$nextTick(); - Array.from(findDiffContentArea(wrapper).element.children).forEach((child) => { - expect(isDisplayNone(child)).toBe(true); - }); + expect(findDiffContentArea(wrapper).element.children.length).toBe(0); }); it('should not have the class `has-body` to present the header differently', () => { diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index 137cc7e3f86..c0c92908701 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -8,6 +8,12 @@ import diffsModule from '~/diffs/store/modules'; import { findInteropAttributes } from '../find_interop_attributes'; import diffFileMockData from '../mock_data/diff_file'; +const showCommentForm = jest.fn(); +const enterdragging = jest.fn(); +const stopdragging = jest.fn(); +const setHighlightedRow = jest.fn(); +let wrapper; + describe('DiffRow', () => { const testLines = [ { @@ -29,7 +35,7 @@ describe('DiffRow', () => { }, ]; - const createWrapper = ({ props, state, actions, isLoggedIn = true }) => { + const createWrapper = ({ props, state = {}, actions, isLoggedIn = true }) => { Vue.use(Vuex); const diffs = diffsModule(); @@ -43,11 +49,25 @@ describe('DiffRow', () => { getters, }); + window.gon = { current_user_id: isLoggedIn ? 1 : 0 }; + const coverageFileData = state.coverageFiles?.files ? state.coverageFiles.files : {}; + const propsData = { fileHash: 'abc', filePath: 'abc', line: {}, index: 0, + isHighlighted: false, + fileLineCoverage: (file, line) => { + const hits = coverageFileData[file]?.[line]; + if (hits) { + return { text: `Test coverage: ${hits} hits`, class: 'coverage' }; + } else if (hits === 0) { + return { text: 'No test coverage', class: 'no-coverage' }; + } + + return {}; + }, ...props, }; @@ -55,49 +75,37 @@ describe('DiffRow', () => { glFeatures: { dragCommentSelection: true }, }; - return shallowMount(DiffRow, { propsData, store, provide }); + return shallowMount(DiffRow, { + propsData, + store, + provide, + listeners: { + enterdragging, + stopdragging, + setHighlightedRow, + showCommentForm, + }, + }); }; - it('isHighlighted returns true given line.left', () => { - const props = { - line: { - left: { - line_code: 'abc', - }, - }, - }; - const state = { highlightedRow: 'abc' }; - const wrapper = createWrapper({ props, state }); - expect(wrapper.vm.isHighlighted).toBe(true); - }); + afterEach(() => { + wrapper.destroy(); + wrapper = null; - it('isHighlighted returns true given line.right', () => { - const props = { - line: { - right: { - line_code: 'abc', - }, - }, - }; - const state = { highlightedRow: 'abc' }; - const wrapper = createWrapper({ props, state }); - expect(wrapper.vm.isHighlighted).toBe(true); - }); + window.gon = {}; + showCommentForm.mockReset(); + enterdragging.mockReset(); + stopdragging.mockReset(); + setHighlightedRow.mockReset(); - it('isHighlighted returns false given line.left', () => { - const props = { - line: { - left: { - line_code: 'abc', - }, - }, - }; - const wrapper = createWrapper({ props }); - expect(wrapper.vm.isHighlighted).toBe(false); + Object.values(DiffRow).forEach(({ cache }) => { + if (cache) { + cache.clear(); + } + }); }); - const getCommentButton = (wrapper, side) => - wrapper.find(`[data-testid="${side}-comment-button"]`); + const getCommentButton = (side) => wrapper.find(`[data-testid="${side}-comment-button"]`); describe.each` side @@ -105,33 +113,30 @@ describe('DiffRow', () => { ${'right'} `('$side side', ({ side }) => { it(`renders empty cells if ${side} is unavailable`, () => { - const wrapper = createWrapper({ props: { line: testLines[2], inline: false } }); + wrapper = createWrapper({ props: { line: testLines[2], inline: false } }); expect(wrapper.find(`[data-testid="${side}-line-number"]`).exists()).toBe(false); expect(wrapper.find(`[data-testid="${side}-empty-cell"]`).exists()).toBe(true); }); describe('comment button', () => { - const showCommentForm = jest.fn(); let line; beforeEach(() => { - showCommentForm.mockReset(); // https://eslint.org/docs/rules/prefer-destructuring#when-not-to-use-it // eslint-disable-next-line prefer-destructuring line = testLines[3]; }); it('renders', () => { - const wrapper = createWrapper({ props: { line, inline: false } }); - expect(getCommentButton(wrapper, side).exists()).toBe(true); + wrapper = createWrapper({ props: { line, inline: false } }); + expect(getCommentButton(side).exists()).toBe(true); }); it('responds to click and keyboard events', async () => { - const wrapper = createWrapper({ + wrapper = createWrapper({ props: { line, inline: false }, - actions: { showCommentForm }, }); - const commentButton = getCommentButton(wrapper, side); + const commentButton = getCommentButton(side); await commentButton.trigger('click'); await commentButton.trigger('keydown.enter'); @@ -142,11 +147,10 @@ describe('DiffRow', () => { it('ignores click and keyboard events when comments are disabled', async () => { line[side].commentsDisabled = true; - const wrapper = createWrapper({ + wrapper = createWrapper({ props: { line, inline: false }, - actions: { showCommentForm }, }); - const commentButton = getCommentButton(wrapper, side); + const commentButton = getCommentButton(side); await commentButton.trigger('click'); await commentButton.trigger('keydown.enter'); @@ -157,19 +161,20 @@ describe('DiffRow', () => { }); it('renders avatars', () => { - const wrapper = createWrapper({ props: { line: testLines[0], inline: false } }); + wrapper = createWrapper({ props: { line: testLines[0], inline: false } }); + expect(wrapper.find(`[data-testid="${side}-discussions"]`).exists()).toBe(true); }); }); it('renders left line numbers', () => { - const wrapper = createWrapper({ props: { line: testLines[0] } }); + wrapper = createWrapper({ props: { line: testLines[0] } }); const lineNumber = testLines[0].left.old_line; expect(wrapper.find(`[data-linenumber="${lineNumber}"]`).exists()).toBe(true); }); it('renders right line numbers', () => { - const wrapper = createWrapper({ props: { line: testLines[0] } }); + wrapper = createWrapper({ props: { line: testLines[0] } }); const lineNumber = testLines[0].right.new_line; expect(wrapper.find(`[data-linenumber="${lineNumber}"]`).exists()).toBe(true); }); @@ -186,12 +191,10 @@ describe('DiffRow', () => { ${'left'} ${'right'} `('emits `enterdragging` onDragEnter $side side', ({ side }) => { - const expectation = { ...line[side], index: 0 }; - const wrapper = createWrapper({ props: { line } }); + wrapper = createWrapper({ props: { line } }); fireEvent.dragEnter(getByTestId(wrapper.element, `${side}-side`)); - expect(wrapper.emitted().enterdragging).toBeTruthy(); - expect(wrapper.emitted().enterdragging[0]).toEqual([expectation]); + expect(enterdragging).toHaveBeenCalledWith({ ...line[side], index: 0 }); }); it.each` @@ -199,10 +202,10 @@ describe('DiffRow', () => { ${'left'} ${'right'} `('emits `stopdragging` onDrop $side side', ({ side }) => { - const wrapper = createWrapper({ props: { line } }); + wrapper = createWrapper({ props: { line } }); fireEvent.dragEnd(getByTestId(wrapper.element, `${side}-side`)); - expect(wrapper.emitted().stopdragging).toBeTruthy(); + expect(stopdragging).toHaveBeenCalled(); }); }); @@ -231,7 +234,7 @@ describe('DiffRow', () => { it('for lines with coverage', () => { const coverageFiles = { files: { [name]: { [line]: 5 } } }; - const wrapper = createWrapper({ props, state: { coverageFiles } }); + wrapper = createWrapper({ props, state: { coverageFiles } }); const coverage = wrapper.find('.line-coverage.right-side'); expect(coverage.attributes('title')).toContain('Test coverage: 5 hits'); @@ -240,7 +243,7 @@ describe('DiffRow', () => { it('for lines without coverage', () => { const coverageFiles = { files: { [name]: { [line]: 0 } } }; - const wrapper = createWrapper({ props, state: { coverageFiles } }); + wrapper = createWrapper({ props, state: { coverageFiles } }); const coverage = wrapper.find('.line-coverage.right-side'); expect(coverage.attributes('title')).toContain('No test coverage'); @@ -249,7 +252,7 @@ describe('DiffRow', () => { it('for unknown lines', () => { const coverageFiles = {}; - const wrapper = createWrapper({ props, state: { coverageFiles } }); + wrapper = createWrapper({ props, state: { coverageFiles } }); const coverage = wrapper.find('.line-coverage.right-side'); expect(coverage.attributes('title')).toBeFalsy(); @@ -267,7 +270,7 @@ describe('DiffRow', () => { ${'with parallel and no left side'} | ${{ right: { old_line: 3, new_line: 5 } }} | ${false} | ${null} | ${{ type: 'new', line: '5', newLine: '5' }} ${'with parallel and right side'} | ${{ left: { old_line: 3 }, right: { new_line: 5 } }} | ${false} | ${{ type: 'old', line: '3', oldLine: '3' }} | ${{ type: 'new', line: '5', newLine: '5' }} `('$desc, sets interop data attributes', ({ line, inline, leftSide, rightSide }) => { - const wrapper = createWrapper({ props: { line, inline } }); + wrapper = createWrapper({ props: { line, inline } }); expect(findInteropAttributes(wrapper, '[data-testid="left-side"]')).toEqual(leftSide); expect(findInteropAttributes(wrapper, '[data-testid="right-side"]')).toEqual(rightSide); diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js index 47ae3cd5867..930b8bcdb08 100644 --- a/spec/frontend/diffs/components/diff_row_utils_spec.js +++ b/spec/frontend/diffs/components/diff_row_utils_spec.js @@ -11,24 +11,21 @@ const LINE_CODE = 'abc123'; describe('isHighlighted', () => { it('should return true if line is highlighted', () => { - const state = { diffs: { highlightedRow: LINE_CODE } }; const line = { line_code: LINE_CODE }; const isCommented = false; - expect(utils.isHighlighted(state, line, isCommented)).toBe(true); + expect(utils.isHighlighted(LINE_CODE, line, isCommented)).toBe(true); }); it('should return false if line is not highlighted', () => { - const state = { diffs: { highlightedRow: 'xxx' } }; const line = { line_code: LINE_CODE }; const isCommented = false; - expect(utils.isHighlighted(state, line, isCommented)).toBe(false); + expect(utils.isHighlighted('xxx', line, isCommented)).toBe(false); }); it('should return true if isCommented is true', () => { - const state = { diffs: { highlightedRow: 'xxx' } }; const line = { line_code: LINE_CODE }; const isCommented = true; - expect(utils.isHighlighted(state, line, isCommented)).toBe(true); + expect(utils.isHighlighted('xxx', line, isCommented)).toBe(true); }); }); @@ -143,19 +140,14 @@ describe('addCommentTooltip', () => { 'Commenting on symbolic links that replace or are replaced by files is currently not supported.'; const brokenRealTooltip = 'Commenting on files that replace or are replaced by symbolic links is currently not supported.'; - const commentTooltip = 'Add a comment to this line'; const dragTooltip = 'Add a comment to this line or drag for multiple lines'; it('should return default tooltip', () => { expect(utils.addCommentTooltip()).toBeUndefined(); }); - it('should return comment tooltip', () => { - expect(utils.addCommentTooltip({})).toEqual(commentTooltip); - }); - it('should return drag comment tooltip when dragging is enabled', () => { - expect(utils.addCommentTooltip({}, true)).toEqual(dragTooltip); + expect(utils.addCommentTooltip({})).toEqual(dragTooltip); }); it('should return broken symlink tooltip', () => { @@ -258,30 +250,3 @@ describe('mapParallel', () => { expect(mapped.right).toMatchObject(rightExpectation); }); }); - -describe('mapInline', () => { - it('should assign computed properties to the line object', () => { - const content = { - diffFile: {}, - shouldRenderDraftRow: () => false, - }; - const line = { - discussions: [{}], - discussionsExpanded: true, - hasForm: true, - }; - const expectation = { - commentRowClasses: '', - hasDiscussions: true, - isContextLine: false, - isMatchLine: false, - isMetaLine: false, - renderDiscussion: true, - hasDraft: false, - hasCommentForm: true, - }; - const mapped = utils.mapInline(content)(line); - - expect(mapped).toMatchObject(expectation); - }); -}); diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js index 83b173c1f5d..3af66526050 100644 --- a/spec/frontend/diffs/components/diff_view_spec.js +++ b/spec/frontend/diffs/components/diff_view_spec.js @@ -28,7 +28,7 @@ describe('DiffView', () => { }; const diffs = { actions: { showCommentForm }, - getters: { commitId: () => 'abc123' }, + getters: { commitId: () => 'abc123', fileLineCoverage: () => ({}) }, namespaced: true, }; const notes = { @@ -41,7 +41,7 @@ describe('DiffView', () => { }); const propsData = { - diffFile: {}, + diffFile: { file_hash: '123' }, diffLines: [], ...props, }; @@ -84,15 +84,15 @@ describe('DiffView', () => { it('sets `dragStart` onStartDragging', () => { const wrapper = createWrapper({ diffLines: [{}] }); - wrapper.findComponent(DiffRow).vm.$emit('startdragging', { test: true }); - expect(wrapper.vm.dragStart).toEqual({ test: true }); + wrapper.findComponent(DiffRow).vm.$emit('startdragging', { line: { test: true } }); + expect(wrapper.vm.idState.dragStart).toEqual({ test: true }); }); it('does not call `setSelectedCommentPosition` on different chunks onDragOver', () => { const wrapper = createWrapper({ diffLines: [{}] }); const diffRow = getDiffRow(wrapper); - diffRow.$emit('startdragging', { chunk: 0 }); + diffRow.$emit('startdragging', { line: { chunk: 0 } }); diffRow.$emit('enterdragging', { chunk: 1 }); expect(setSelectedCommentPosition).not.toHaveBeenCalled(); @@ -109,7 +109,7 @@ describe('DiffView', () => { const wrapper = createWrapper({ diffLines: [{}] }); const diffRow = getDiffRow(wrapper); - diffRow.$emit('startdragging', { chunk: 1, index: start }); + diffRow.$emit('startdragging', { line: { chunk: 1, index: start } }); diffRow.$emit('enterdragging', { chunk: 1, index: end }); const arg = setSelectedCommentPosition.mock.calls[0][1]; @@ -122,11 +122,11 @@ describe('DiffView', () => { const wrapper = createWrapper({ diffLines: [{}] }); const diffRow = getDiffRow(wrapper); - diffRow.$emit('startdragging', { test: true }); - expect(wrapper.vm.dragStart).toEqual({ test: true }); + diffRow.$emit('startdragging', { line: { test: true } }); + expect(wrapper.vm.idState.dragStart).toEqual({ test: true }); diffRow.$emit('stopdragging'); - expect(wrapper.vm.dragStart).toBeNull(); + expect(wrapper.vm.idState.dragStart).toBeNull(); expect(showCommentForm).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js deleted file mode 100644 index 9c3e00cd6cf..00000000000 --- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js +++ /dev/null @@ -1,325 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; -import { mapInline } from '~/diffs/components/diff_row_utils'; -import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue'; -import { createStore } from '~/mr_notes/stores'; -import { findInteropAttributes } from '../find_interop_attributes'; -import discussionsMockData from '../mock_data/diff_discussions'; -import diffFileMockData from '../mock_data/diff_file'; - -const TEST_USER_ID = 'abc123'; -const TEST_USER = { id: TEST_USER_ID }; - -describe('InlineDiffTableRow', () => { - let wrapper; - let store; - const mockDiffContent = { - diffFile: diffFileMockData, - shouldRenderDraftRow: jest.fn(), - hasParallelDraftLeft: jest.fn(), - hasParallelDraftRight: jest.fn(), - draftForLine: jest.fn(), - }; - - const applyMap = mapInline(mockDiffContent); - const thisLine = applyMap(diffFileMockData.highlighted_diff_lines[0]); - - const createComponent = (props = {}, propsStore = store) => { - wrapper = shallowMount(InlineDiffTableRow, { - store: propsStore, - propsData: { - line: thisLine, - fileHash: diffFileMockData.file_hash, - filePath: diffFileMockData.file_path, - contextLinesPath: 'contextLinesPath', - isHighlighted: false, - ...props, - }, - }); - }; - - beforeEach(() => { - store = createStore(); - store.state.notes.userData = TEST_USER; - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('does not add hll class to line content when line does not match highlighted row', () => { - createComponent(); - expect(wrapper.find('.line_content').classes('hll')).toBe(false); - }); - - it('adds hll class to lineContent when line is the highlighted row', () => { - store.state.diffs.highlightedRow = thisLine.line_code; - createComponent({}, store); - expect(wrapper.find('.line_content').classes('hll')).toBe(true); - }); - - it('adds hll class to lineContent when line is part of a multiline comment', () => { - createComponent({ isCommented: true }); - expect(wrapper.find('.line_content').classes('hll')).toBe(true); - }); - - describe('sets coverage title and class', () => { - it('for lines with coverage', () => { - const name = diffFileMockData.file_path; - const line = thisLine.new_line; - - store.state.diffs.coverageFiles = { files: { [name]: { [line]: 5 } } }; - createComponent({}, store); - const coverage = wrapper.find('.line-coverage'); - - expect(coverage.attributes('title')).toContain('Test coverage: 5 hits'); - expect(coverage.classes('coverage')).toBe(true); - }); - - it('for lines without coverage', () => { - const name = diffFileMockData.file_path; - const line = thisLine.new_line; - - store.state.diffs.coverageFiles = { files: { [name]: { [line]: 0 } } }; - createComponent({}, store); - const coverage = wrapper.find('.line-coverage'); - - expect(coverage.attributes('title')).toContain('No test coverage'); - expect(coverage.classes('no-coverage')).toBe(true); - }); - - it('for unknown lines', () => { - store.state.diffs.coverageFiles = {}; - createComponent({}, store); - - const coverage = wrapper.find('.line-coverage'); - - expect(coverage.attributes('title')).toBeUndefined(); - expect(coverage.classes('coverage')).toBe(false); - expect(coverage.classes('no-coverage')).toBe(false); - }); - }); - - describe('Table Cells', () => { - const findNewTd = () => wrapper.find({ ref: 'newTd' }); - const findOldTd = () => wrapper.find({ ref: 'oldTd' }); - - describe('td', () => { - it('highlights when isHighlighted true', () => { - store.state.diffs.highlightedRow = thisLine.line_code; - createComponent({}, store); - - expect(findNewTd().classes()).toContain('hll'); - expect(findOldTd().classes()).toContain('hll'); - }); - - it('does not highlight when isHighlighted false', () => { - createComponent(); - - expect(findNewTd().classes()).not.toContain('hll'); - expect(findOldTd().classes()).not.toContain('hll'); - }); - }); - - describe('comment button', () => { - const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButton' }); - - it.each` - userData | expectation - ${TEST_USER} | ${true} - ${null} | ${false} - `('exists is $expectation - with userData ($userData)', ({ userData, expectation }) => { - store.state.notes.userData = userData; - createComponent({}, store); - - expect(findNoteButton().exists()).toBe(expectation); - }); - - it.each` - isHover | line | expectation - ${true} | ${{ ...thisLine, discussions: [] }} | ${true} - ${false} | ${{ ...thisLine, discussions: [] }} | ${false} - ${true} | ${{ ...thisLine, type: 'context', discussions: [] }} | ${false} - ${true} | ${{ ...thisLine, type: 'old-nonewline', discussions: [] }} | ${false} - ${true} | ${{ ...thisLine, discussions: [{}] }} | ${false} - `('visible is $expectation - line ($line)', ({ isHover, line, expectation }) => { - createComponent({ line: applyMap(line) }); - wrapper.setData({ isHover }); - - return wrapper.vm.$nextTick().then(() => { - expect(findNoteButton().isVisible()).toBe(expectation); - }); - }); - - it.each` - disabled | commentsDisabled - ${'disabled'} | ${true} - ${undefined} | ${false} - `( - 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled', - ({ disabled, commentsDisabled }) => { - createComponent({ - line: applyMap({ ...thisLine, commentsDisabled }), - }); - - wrapper.setData({ isHover: true }); - - return wrapper.vm.$nextTick().then(() => { - expect(findNoteButton().attributes('disabled')).toBe(disabled); - }); - }, - ); - - const symlinkishFileTooltip = - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.'; - const realishFileTooltip = - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.'; - const otherFileTooltip = 'Add a comment to this line'; - const findTooltip = () => wrapper.find({ ref: 'addNoteTooltip' }); - - it.each` - tooltip | commentsDisabled - ${symlinkishFileTooltip} | ${{ wasSymbolic: true }} - ${symlinkishFileTooltip} | ${{ isSymbolic: true }} - ${realishFileTooltip} | ${{ wasReal: true }} - ${realishFileTooltip} | ${{ isReal: true }} - ${otherFileTooltip} | ${false} - `( - 'has the correct tooltip when commentsDisabled=$commentsDisabled', - ({ tooltip, commentsDisabled }) => { - createComponent({ - line: applyMap({ ...thisLine, commentsDisabled }), - }); - - wrapper.setData({ isHover: true }); - - return wrapper.vm.$nextTick().then(() => { - expect(findTooltip().attributes('title')).toBe(tooltip); - }); - }, - ); - }); - - describe('line number', () => { - const findLineNumberOld = () => wrapper.find({ ref: 'lineNumberRefOld' }); - const findLineNumberNew = () => wrapper.find({ ref: 'lineNumberRefNew' }); - - it('renders line numbers in correct cells', () => { - createComponent(); - - expect(findLineNumberOld().exists()).toBe(false); - expect(findLineNumberNew().exists()).toBe(true); - }); - - describe('with lineNumber prop', () => { - const TEST_LINE_CODE = 'LC_42'; - const TEST_LINE_NUMBER = 1; - - describe.each` - lineProps | findLineNumber | expectedHref | expectedClickArg - ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} - ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} - ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE} - ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE} - `( - 'with line ($lineProps)', - ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => { - beforeEach(() => { - jest.spyOn(store, 'dispatch').mockImplementation(); - createComponent({ - line: applyMap({ ...thisLine, ...lineProps }), - }); - }); - - it('renders', () => { - expect(findLineNumber().exists()).toBe(true); - expect(findLineNumber().attributes()).toEqual({ - href: expectedHref, - 'data-linenumber': TEST_LINE_NUMBER.toString(), - }); - }); - - it('on click, dispatches setHighlightedRow', () => { - expect(store.dispatch).toHaveBeenCalledTimes(1); - - findLineNumber().trigger('click'); - - expect(store.dispatch).toHaveBeenCalledWith( - 'diffs/setHighlightedRow', - expectedClickArg, - ); - expect(store.dispatch).toHaveBeenCalledTimes(2); - }); - }, - ); - }); - }); - - describe('diff-gutter-avatars', () => { - const TEST_LINE_CODE = 'LC_42'; - const TEST_FILE_HASH = diffFileMockData.file_hash; - const findAvatars = () => wrapper.find(DiffGutterAvatars); - let line; - - beforeEach(() => { - jest.spyOn(store, 'dispatch').mockImplementation(); - - line = { - line_code: TEST_LINE_CODE, - type: 'new', - old_line: null, - new_line: 1, - discussions: [{ ...discussionsMockData }], - discussionsExpanded: true, - text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', - rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', - meta_data: null, - }; - }); - - describe('with showCommentButton', () => { - it('renders if line has discussions', () => { - createComponent({ line: applyMap(line) }); - - expect(findAvatars().props()).toEqual({ - discussions: line.discussions, - discussionsExpanded: line.discussionsExpanded, - }); - }); - - it('does notrender if line has no discussions', () => { - line.discussions = []; - createComponent({ line: applyMap(line) }); - - expect(findAvatars().exists()).toEqual(false); - }); - - it('toggles line discussion', () => { - createComponent({ line: applyMap(line) }); - - expect(store.dispatch).toHaveBeenCalledTimes(1); - - findAvatars().vm.$emit('toggleLineDiscussions'); - - expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', { - lineCode: TEST_LINE_CODE, - fileHash: TEST_FILE_HASH, - expanded: !line.discussionsExpanded, - }); - }); - }); - }); - }); - - describe('interoperability', () => { - it.each` - desc | line | expectation - ${'with type old'} | ${{ ...thisLine, type: 'old', old_line: 3, new_line: 5 }} | ${{ type: 'old', line: '3', oldLine: '3', newLine: '5' }} - ${'with type new'} | ${{ ...thisLine, type: 'new', old_line: 3, new_line: 5 }} | ${{ type: 'new', line: '5', oldLine: '3', newLine: '5' }} - `('$desc, sets interop data attributes', ({ line, expectation }) => { - createComponent({ line }); - - expect(findInteropAttributes(wrapper)).toEqual(expectation); - }); - }); -}); diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js deleted file mode 100644 index 27834804f77..00000000000 --- a/spec/frontend/diffs/components/inline_diff_view_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import '~/behaviors/markdown/render_gfm'; -import { getByText } from '@testing-library/dom'; -import { mount } from '@vue/test-utils'; -import { mapInline } from '~/diffs/components/diff_row_utils'; -import InlineDiffView from '~/diffs/components/inline_diff_view.vue'; -import { createStore } from '~/mr_notes/stores'; -import discussionsMockData from '../mock_data/diff_discussions'; -import diffFileMockData from '../mock_data/diff_file'; - -describe('InlineDiffView', () => { - let wrapper; - const getDiffFileMock = () => ({ ...diffFileMockData }); - const getDiscussionsMockData = () => [{ ...discussionsMockData }]; - const notesLength = getDiscussionsMockData()[0].notes.length; - - const setup = (diffFile, diffLines) => { - const mockDiffContent = { - diffFile, - shouldRenderDraftRow: jest.fn(), - }; - - const store = createStore(); - - store.dispatch('diffs/setInlineDiffViewType'); - wrapper = mount(InlineDiffView, { - store, - propsData: { - diffFile, - diffLines: diffLines.map(mapInline(mockDiffContent)), - }, - }); - }; - - describe('template', () => { - it('should have rendered diff lines', () => { - const diffFile = getDiffFileMock(); - setup(diffFile, diffFile.highlighted_diff_lines); - - expect(wrapper.findAll('tr.line_holder').length).toEqual(8); - expect(wrapper.findAll('tr.line_holder.new').length).toEqual(4); - expect(wrapper.findAll('tr.line_expansion.match').length).toEqual(1); - getByText(wrapper.element, /Bad dates/i); - }); - - it('should render discussions', () => { - const diffFile = getDiffFileMock(); - diffFile.highlighted_diff_lines[1].discussions = getDiscussionsMockData(); - diffFile.highlighted_diff_lines[1].discussionsExpanded = true; - setup(diffFile, diffFile.highlighted_diff_lines); - - expect(wrapper.findAll('.notes_holder').length).toEqual(1); - expect(wrapper.findAll('.notes_holder .note').length).toEqual(notesLength + 1); - getByText(wrapper.element, 'comment 5'); - wrapper.vm.$store.dispatch('setInitialNotes', []); - }); - }); -}); diff --git a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js deleted file mode 100644 index ed191d849fd..00000000000 --- a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js +++ /dev/null @@ -1,445 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; -import { mapParallel } from '~/diffs/components/diff_row_utils'; -import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; -import { createStore } from '~/mr_notes/stores'; -import { findInteropAttributes } from '../find_interop_attributes'; -import discussionsMockData from '../mock_data/diff_discussions'; -import diffFileMockData from '../mock_data/diff_file'; - -describe('ParallelDiffTableRow', () => { - const mockDiffContent = { - diffFile: diffFileMockData, - shouldRenderDraftRow: jest.fn(), - hasParallelDraftLeft: jest.fn(), - hasParallelDraftRight: jest.fn(), - draftForLine: jest.fn(), - }; - - const applyMap = mapParallel(mockDiffContent); - - describe('when one side is empty', () => { - let wrapper; - let vm; - const thisLine = diffFileMockData.parallel_diff_lines[0]; - const rightLine = diffFileMockData.parallel_diff_lines[0].right; - - beforeEach(() => { - wrapper = shallowMount(ParallelDiffTableRow, { - store: createStore(), - propsData: { - line: applyMap(thisLine), - fileHash: diffFileMockData.file_hash, - filePath: diffFileMockData.file_path, - contextLinesPath: 'contextLinesPath', - isHighlighted: false, - }, - }); - - vm = wrapper.vm; - }); - - it('does not highlight non empty line content when line does not match highlighted row', (done) => { - vm.$nextTick() - .then(() => { - expect(vm.$el.querySelector('.line_content.right-side').classList).not.toContain('hll'); - }) - .then(done) - .catch(done.fail); - }); - - it('highlights nonempty line content when line is the highlighted row', (done) => { - vm.$nextTick() - .then(() => { - vm.$store.state.diffs.highlightedRow = rightLine.line_code; - - return vm.$nextTick(); - }) - .then(() => { - expect(vm.$el.querySelector('.line_content.right-side').classList).toContain('hll'); - }) - .then(done) - .catch(done.fail); - }); - - it('highlights nonempty line content when line is part of a multiline comment', () => { - wrapper.setProps({ isCommented: true }); - return vm.$nextTick().then(() => { - expect(vm.$el.querySelector('.line_content.right-side').classList).toContain('hll'); - }); - }); - }); - - describe('when both sides have content', () => { - let vm; - const thisLine = diffFileMockData.parallel_diff_lines[2]; - const rightLine = diffFileMockData.parallel_diff_lines[2].right; - - beforeEach(() => { - vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), { - line: applyMap(thisLine), - fileHash: diffFileMockData.file_hash, - filePath: diffFileMockData.file_path, - contextLinesPath: 'contextLinesPath', - isHighlighted: false, - }).$mount(); - }); - - it('does not highlight either line when line does not match highlighted row', (done) => { - vm.$nextTick() - .then(() => { - expect(vm.$el.querySelector('.line_content.right-side').classList).not.toContain('hll'); - expect(vm.$el.querySelector('.line_content.left-side').classList).not.toContain('hll'); - }) - .then(done) - .catch(done.fail); - }); - - it('adds hll class to lineContent when line is the highlighted row', (done) => { - vm.$nextTick() - .then(() => { - vm.$store.state.diffs.highlightedRow = rightLine.line_code; - - return vm.$nextTick(); - }) - .then(() => { - expect(vm.$el.querySelector('.line_content.right-side').classList).toContain('hll'); - expect(vm.$el.querySelector('.line_content.left-side').classList).toContain('hll'); - }) - .then(done) - .catch(done.fail); - }); - - describe('sets coverage title and class', () => { - it('for lines with coverage', (done) => { - vm.$nextTick() - .then(() => { - const name = diffFileMockData.file_path; - const line = rightLine.new_line; - - vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 5 } } }; - - return vm.$nextTick(); - }) - .then(() => { - const coverage = vm.$el.querySelector('.line-coverage.right-side'); - - expect(coverage.title).toContain('Test coverage: 5 hits'); - expect(coverage.classList).toContain('coverage'); - }) - .then(done) - .catch(done.fail); - }); - - it('for lines without coverage', (done) => { - vm.$nextTick() - .then(() => { - const name = diffFileMockData.file_path; - const line = rightLine.new_line; - - vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 0 } } }; - - return vm.$nextTick(); - }) - .then(() => { - const coverage = vm.$el.querySelector('.line-coverage.right-side'); - - expect(coverage.title).toContain('No test coverage'); - expect(coverage.classList).toContain('no-coverage'); - }) - .then(done) - .catch(done.fail); - }); - - it('for unknown lines', (done) => { - vm.$nextTick() - .then(() => { - vm.$store.state.diffs.coverageFiles = {}; - - return vm.$nextTick(); - }) - .then(() => { - const coverage = vm.$el.querySelector('.line-coverage.right-side'); - - expect(coverage.title).not.toContain('Coverage'); - expect(coverage.classList).not.toContain('coverage'); - expect(coverage.classList).not.toContain('no-coverage'); - }) - .then(done) - .catch(done.fail); - }); - }); - }); - - describe('Table Cells', () => { - let wrapper; - let store; - let thisLine; - const TEST_USER_ID = 'abc123'; - const TEST_USER = { id: TEST_USER_ID }; - - const createComponent = (props = {}, propsStore = store, data = {}) => { - wrapper = shallowMount(ParallelDiffTableRow, { - store: propsStore, - propsData: { - line: thisLine, - fileHash: diffFileMockData.file_hash, - filePath: diffFileMockData.file_path, - contextLinesPath: 'contextLinesPath', - isHighlighted: false, - ...props, - }, - data() { - return data; - }, - }); - }; - - beforeEach(() => { - // eslint-disable-next-line prefer-destructuring - thisLine = diffFileMockData.parallel_diff_lines[2]; - store = createStore(); - store.state.notes.userData = TEST_USER; - }); - - afterEach(() => { - wrapper.destroy(); - }); - - const findNewTd = () => wrapper.find({ ref: 'newTd' }); - const findOldTd = () => wrapper.find({ ref: 'oldTd' }); - - describe('td', () => { - it('highlights when isHighlighted true', () => { - store.state.diffs.highlightedRow = thisLine.left.line_code; - createComponent({}, store); - - expect(findNewTd().classes()).toContain('hll'); - expect(findOldTd().classes()).toContain('hll'); - }); - - it('does not highlight when isHighlighted false', () => { - createComponent(); - - expect(findNewTd().classes()).not.toContain('hll'); - expect(findOldTd().classes()).not.toContain('hll'); - }); - }); - - describe('comment button', () => { - const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButtonLeft' }); - - it.each` - hover | line | userData | expectation - ${true} | ${{}} | ${TEST_USER} | ${true} - ${true} | ${{ line: { left: null } }} | ${TEST_USER} | ${false} - ${true} | ${{}} | ${null} | ${false} - ${false} | ${{}} | ${TEST_USER} | ${false} - `( - 'exists is $expectation - with userData ($userData)', - async ({ hover, line, userData, expectation }) => { - store.state.notes.userData = userData; - createComponent(line, store); - if (hover) await wrapper.find('.line_holder').trigger('mouseover'); - - expect(findNoteButton().exists()).toBe(expectation); - }, - ); - - it.each` - line | expectation - ${{ ...thisLine, left: { discussions: [] } }} | ${true} - ${{ ...thisLine, left: { type: 'context', discussions: [] } }} | ${false} - ${{ ...thisLine, left: { type: 'old-nonewline', discussions: [] } }} | ${false} - ${{ ...thisLine, left: { discussions: [{}] } }} | ${false} - `('visible is $expectation - line ($line)', async ({ line, expectation }) => { - createComponent({ line: applyMap(line) }, store, { - isLeftHover: true, - isCommentButtonRendered: true, - }); - - expect(findNoteButton().isVisible()).toBe(expectation); - }); - - it.each` - disabled | commentsDisabled - ${'disabled'} | ${true} - ${undefined} | ${false} - `( - 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled', - ({ disabled, commentsDisabled }) => { - thisLine.left.commentsDisabled = commentsDisabled; - createComponent({ line: { ...thisLine } }, store, { - isLeftHover: true, - isCommentButtonRendered: true, - }); - - expect(findNoteButton().attributes('disabled')).toBe(disabled); - }, - ); - - const symlinkishFileTooltip = - 'Commenting on symbolic links that replace or are replaced by files is currently not supported.'; - const realishFileTooltip = - 'Commenting on files that replace or are replaced by symbolic links is currently not supported.'; - const otherFileTooltip = 'Add a comment to this line'; - const findTooltip = () => wrapper.find({ ref: 'addNoteTooltipLeft' }); - - it.each` - tooltip | commentsDisabled - ${symlinkishFileTooltip} | ${{ wasSymbolic: true }} - ${symlinkishFileTooltip} | ${{ isSymbolic: true }} - ${realishFileTooltip} | ${{ wasReal: true }} - ${realishFileTooltip} | ${{ isReal: true }} - ${otherFileTooltip} | ${false} - `( - 'has the correct tooltip when commentsDisabled=$commentsDisabled', - ({ tooltip, commentsDisabled }) => { - thisLine.left.commentsDisabled = commentsDisabled; - createComponent({ line: { ...thisLine } }, store, { - isLeftHover: true, - isCommentButtonRendered: true, - }); - - expect(findTooltip().attributes('title')).toBe(tooltip); - }, - ); - }); - - describe('line number', () => { - const findLineNumberOld = () => wrapper.find({ ref: 'lineNumberRefOld' }); - const findLineNumberNew = () => wrapper.find({ ref: 'lineNumberRefNew' }); - - it('renders line numbers in correct cells', () => { - createComponent(); - - expect(findLineNumberOld().exists()).toBe(true); - expect(findLineNumberNew().exists()).toBe(true); - }); - - describe('with lineNumber prop', () => { - const TEST_LINE_CODE = 'LC_42'; - const TEST_LINE_NUMBER = 1; - - describe.each` - lineProps | findLineNumber | expectedHref | expectedClickArg - ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} - ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} - `( - 'with line ($lineProps)', - ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => { - beforeEach(() => { - jest.spyOn(store, 'dispatch').mockImplementation(); - Object.assign(thisLine.left, lineProps); - Object.assign(thisLine.right, lineProps); - createComponent({ - line: applyMap({ ...thisLine }), - }); - }); - - it('renders', () => { - expect(findLineNumber().exists()).toBe(true); - expect(findLineNumber().attributes()).toEqual({ - href: expectedHref, - 'data-linenumber': TEST_LINE_NUMBER.toString(), - }); - }); - - it('on click, dispatches setHighlightedRow', () => { - expect(store.dispatch).toHaveBeenCalledTimes(1); - - findLineNumber().trigger('click'); - - expect(store.dispatch).toHaveBeenCalledWith( - 'diffs/setHighlightedRow', - expectedClickArg, - ); - expect(store.dispatch).toHaveBeenCalledTimes(2); - }); - }, - ); - }); - }); - - describe('diff-gutter-avatars', () => { - const TEST_LINE_CODE = 'LC_42'; - const TEST_FILE_HASH = diffFileMockData.file_hash; - const findAvatars = () => wrapper.find(DiffGutterAvatars); - let line; - - beforeEach(() => { - jest.spyOn(store, 'dispatch').mockImplementation(); - - line = applyMap({ - left: { - line_code: TEST_LINE_CODE, - type: 'new', - old_line: null, - new_line: 1, - discussions: [{ ...discussionsMockData }], - discussionsExpanded: true, - text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', - rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', - meta_data: null, - }, - }); - }); - - describe('with showCommentButton', () => { - it('renders if line has discussions', () => { - createComponent({ line }); - - expect(findAvatars().props()).toEqual({ - discussions: line.left.discussions, - discussionsExpanded: line.left.discussionsExpanded, - }); - }); - - it('does notrender if line has no discussions', () => { - line.left.discussions = []; - createComponent({ line: applyMap(line) }); - - expect(findAvatars().exists()).toEqual(false); - }); - - it('toggles line discussion', () => { - createComponent({ line }); - - expect(store.dispatch).toHaveBeenCalledTimes(1); - - findAvatars().vm.$emit('toggleLineDiscussions'); - - expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', { - lineCode: TEST_LINE_CODE, - fileHash: TEST_FILE_HASH, - expanded: !line.left.discussionsExpanded, - }); - }); - }); - }); - - describe('interoperability', () => { - beforeEach(() => { - createComponent(); - }); - - it('adds old side interoperability data attributes', () => { - expect(findInteropAttributes(wrapper, '.line_content.left-side')).toEqual({ - type: 'old', - line: thisLine.left.old_line.toString(), - oldLine: thisLine.left.old_line.toString(), - }); - }); - - it('adds new side interoperability data attributes', () => { - expect(findInteropAttributes(wrapper, '.line_content.right-side')).toEqual({ - type: 'new', - line: thisLine.right.new_line.toString(), - newLine: thisLine.right.new_line.toString(), - }); - }); - }); - }); -}); diff --git a/spec/frontend/diffs/components/parallel_diff_view_spec.js b/spec/frontend/diffs/components/parallel_diff_view_spec.js deleted file mode 100644 index 452e1f58551..00000000000 --- a/spec/frontend/diffs/components/parallel_diff_view_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import parallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; -import ParallelDiffView from '~/diffs/components/parallel_diff_view.vue'; -import { createStore } from '~/mr_notes/stores'; -import diffFileMockData from '../mock_data/diff_file'; - -let wrapper; -const localVue = createLocalVue(); - -localVue.use(Vuex); - -function factory() { - const diffFile = { ...diffFileMockData }; - const store = createStore(); - - wrapper = shallowMount(ParallelDiffView, { - localVue, - store, - propsData: { - diffFile, - diffLines: diffFile.parallel_diff_lines, - }, - }); -} - -describe('ParallelDiffView', () => { - afterEach(() => { - wrapper.destroy(); - }); - - it('renders diff lines', () => { - factory(); - - expect(wrapper.findAll(parallelDiffTableRow).length).toBe(8); - }); -}); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 14f8e090be9..c2e5d07bcfd 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -8,7 +8,6 @@ import { DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, - DIFFS_PER_PAGE, } from '~/diffs/constants'; import { setBaseConfig, @@ -154,16 +153,16 @@ describe('DiffsStoreActions', () => { it('should fetch batch diff files', (done) => { const endpointBatch = '/fetch/diffs_batch'; - const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { next_page: 2 } }; - const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: {} }; + const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 7 } }; + const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 7 } }; mock .onGet( mergeUrlParams( { w: '1', view: 'inline', - page: 1, - per_page: DIFFS_PER_PAGE, + page: 0, + per_page: 5, }, endpointBatch, ), @@ -174,8 +173,8 @@ describe('DiffsStoreActions', () => { { w: '1', view: 'inline', - page: 2, - per_page: DIFFS_PER_PAGE, + page: 5, + per_page: 7, }, endpointBatch, ), @@ -1020,10 +1019,12 @@ describe('DiffsStoreActions', () => { const endpointUpdateUser = 'user/prefs'; let putSpy; let mock; + let gon; beforeEach(() => { mock = new MockAdapter(axios); putSpy = jest.spyOn(axios, 'put'); + gon = window.gon; mock.onPut(endpointUpdateUser).reply(200, {}); jest.spyOn(eventHub, '$emit').mockImplementation(); @@ -1031,6 +1032,7 @@ describe('DiffsStoreActions', () => { afterEach(() => { mock.restore(); + window.gon = gon; }); it('commits SET_SHOW_WHITESPACE', (done) => { @@ -1044,7 +1046,9 @@ describe('DiffsStoreActions', () => { ); }); - it('saves to the database', async () => { + it('saves to the database when the user is logged in', async () => { + window.gon = { current_user_id: 12345 }; + await setShowWhitespace( { state: { endpointUpdateUser }, commit() {} }, { showWhitespace: true, updateDatabase: true }, @@ -1053,6 +1057,17 @@ describe('DiffsStoreActions', () => { expect(putSpy).toHaveBeenCalledWith(endpointUpdateUser, { show_whitespace_in_diffs: true }); }); + it('does not try to save to the API if the user is not logged in', async () => { + window.gon = {}; + + await setShowWhitespace( + { state: { endpointUpdateUser }, commit() {} }, + { showWhitespace: true, updateDatabase: true }, + ); + + expect(putSpy).not.toHaveBeenCalled(); + }); + it('emits eventHub event', async () => { await setShowWhitespace( { state: {}, commit() {} }, diff --git a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js index dbef547c297..99f13a1c84c 100644 --- a/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js +++ b/spec/frontend/diffs/store/getters_versions_dropdowns_spec.js @@ -54,7 +54,7 @@ describe('Compare diff version dropdowns', () => { Object.defineProperty(window, 'location', { writable: true, - value: { href: `https://example.gitlab.com${diffHeadParam}` }, + value: { search: diffHeadParam }, }); expectedFirstVersion = { diff --git a/spec/frontend/editor/editor_ci_schema_ext_spec.js b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js index 2f0ecfb151e..07ac080fe08 100644 --- a/spec/frontend/editor/editor_ci_schema_ext_spec.js +++ b/spec/frontend/editor/source_editor_ci_schema_ext_spec.js @@ -1,8 +1,8 @@ import { languages } from 'monaco-editor'; import { TEST_HOST } from 'helpers/test_constants'; import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '~/editor/constants'; -import EditorLite from '~/editor/editor_lite'; -import { CiSchemaExtension } from '~/editor/extensions/editor_ci_schema_ext'; +import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; +import SourceEditor from '~/editor/source_editor'; const mockRef = 'AABBCCDD'; @@ -17,7 +17,7 @@ describe('~/editor/editor_ci_config_ext', () => { const createMockEditor = ({ blobPath = defaultBlobPath } = {}) => { setFixtures('<div id="editor"></div>'); editorEl = document.getElementById('editor'); - editor = new EditorLite(); + editor = new SourceEditor(); instance = editor.createInstance({ el: editorEl, blobPath, diff --git a/spec/frontend/editor/editor_lite_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index 59e1b8968eb..352db9d0d51 100644 --- a/spec/frontend/editor/editor_lite_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -5,7 +5,7 @@ import { EDITOR_TYPE_CODE, EDITOR_TYPE_DIFF, } from '~/editor/constants'; -import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; jest.mock('~/helpers/startup_css_helper', () => { return { @@ -22,7 +22,7 @@ jest.mock('~/helpers/startup_css_helper', () => { }; }); -describe('The basis for an Editor Lite extension', () => { +describe('The basis for an Source Editor extension', () => { const defaultLine = 3; let ext; let event; @@ -63,7 +63,7 @@ describe('The basis for an Editor Lite extension', () => { const instance = { layout: jest.fn(), }; - ext = new EditorLiteExtension({ instance }); + ext = new SourceEditorExtension({ instance }); expect(instance.layout).not.toHaveBeenCalled(); // We're waiting for the waitForCSSLoaded mock to kick in @@ -79,7 +79,7 @@ describe('The basis for an Editor Lite extension', () => { ${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined} ${'throws if only options are passed'} | ${undefined} | ${defaultOptions} `('$description', ({ instance, options } = {}) => { - EditorLiteExtension.deferRerender = jest.fn(); + SourceEditorExtension.deferRerender = jest.fn(); const originalInstance = { ...instance }; if (instance) { @@ -88,54 +88,54 @@ describe('The basis for an Editor Lite extension', () => { expect(instance[prop]).toBeUndefined(); }); // Both instance and options are passed - ext = new EditorLiteExtension({ instance, ...options }); + ext = new SourceEditorExtension({ instance, ...options }); Object.entries(options).forEach(([prop, value]) => { expect(ext[prop]).toBeUndefined(); expect(instance[prop]).toBe(value); }); } else { - ext = new EditorLiteExtension({ instance }); + ext = new SourceEditorExtension({ instance }); expect(instance).toEqual(originalInstance); } } else if (options) { // Options are passed without instance expect(() => { - ext = new EditorLiteExtension({ ...options }); + ext = new SourceEditorExtension({ ...options }); }).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); } else { // Neither options nor instance are passed expect(() => { - ext = new EditorLiteExtension(); + ext = new SourceEditorExtension(); }).not.toThrow(); } }); it('initializes the line highlighting', () => { - EditorLiteExtension.deferRerender = jest.fn(); - const spy = jest.spyOn(EditorLiteExtension, 'highlightLines'); - ext = new EditorLiteExtension({ instance: {} }); + SourceEditorExtension.deferRerender = jest.fn(); + const spy = jest.spyOn(SourceEditorExtension, 'highlightLines'); + ext = new SourceEditorExtension({ instance: {} }); expect(spy).toHaveBeenCalled(); }); it('sets up the line linking for code instance', () => { - EditorLiteExtension.deferRerender = jest.fn(); - const spy = jest.spyOn(EditorLiteExtension, 'setupLineLinking'); + SourceEditorExtension.deferRerender = jest.fn(); + const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking'); const instance = { getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_CODE), onMouseMove: jest.fn(), onMouseDown: jest.fn(), }; - ext = new EditorLiteExtension({ instance }); + ext = new SourceEditorExtension({ instance }); expect(spy).toHaveBeenCalledWith(instance); }); it('does not set up the line linking for diff instance', () => { - EditorLiteExtension.deferRerender = jest.fn(); - const spy = jest.spyOn(EditorLiteExtension, 'setupLineLinking'); + SourceEditorExtension.deferRerender = jest.fn(); + const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking'); const instance = { getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_DIFF), }; - ext = new EditorLiteExtension({ instance }); + ext = new SourceEditorExtension({ instance }); expect(spy).not.toHaveBeenCalled(); }); }); @@ -172,7 +172,7 @@ describe('The basis for an Editor Lite extension', () => { ${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${false} | ${null} `('$desc', ({ hash, shouldReveal, expectedRange } = {}) => { window.location.hash = hash; - EditorLiteExtension.highlightLines(instance); + SourceEditorExtension.highlightLines(instance); if (!shouldReveal) { expect(revealSpy).not.toHaveBeenCalled(); expect(decorationsSpy).not.toHaveBeenCalled(); @@ -194,7 +194,7 @@ describe('The basis for an Editor Lite extension', () => { decorationsSpy.mockReturnValue('foo'); window.location.hash = '#L10'; expect(instance.lineDecorations).toBeUndefined(); - EditorLiteExtension.highlightLines(instance); + SourceEditorExtension.highlightLines(instance); expect(instance.lineDecorations).toBe('foo'); }); }); @@ -208,7 +208,7 @@ describe('The basis for an Editor Lite extension', () => { }; beforeEach(() => { - EditorLiteExtension.onMouseMoveHandler(event); // generate the anchor + SourceEditorExtension.onMouseMoveHandler(event); // generate the anchor }); it.each` @@ -216,7 +216,7 @@ describe('The basis for an Editor Lite extension', () => { ${'onMouseMove'} | ${instance.onMouseMove} ${'onMouseDown'} | ${instance.onMouseDown} `('sets up the $desc listener', ({ spy } = {}) => { - EditorLiteExtension.setupLineLinking(instance); + SourceEditorExtension.setupLineLinking(instance); expect(spy).toHaveBeenCalled(); }); @@ -230,7 +230,7 @@ describe('The basis for an Editor Lite extension', () => { fn(event); }); - EditorLiteExtension.setupLineLinking(instance); + SourceEditorExtension.setupLineLinking(instance); if (shouldRemove) { expect(instance.deltaDecorations).toHaveBeenCalledWith(instance.lineDecorations, []); } else { @@ -241,7 +241,7 @@ describe('The basis for an Editor Lite extension', () => { describe('onMouseMoveHandler', () => { it('stops propagation for contextmenu event on the generated anchor', () => { - EditorLiteExtension.onMouseMoveHandler(event); + SourceEditorExtension.onMouseMoveHandler(event); const anchor = findLine(defaultLine).querySelector('a'); const contextMenuEvent = new Event('contextmenu'); @@ -253,27 +253,27 @@ describe('The basis for an Editor Lite extension', () => { it('creates an anchor if it does not exist yet', () => { expect(findLine(defaultLine).querySelector('a')).toBe(null); - EditorLiteExtension.onMouseMoveHandler(event); + SourceEditorExtension.onMouseMoveHandler(event); expect(findLine(defaultLine).querySelector('a')).not.toBe(null); }); it('does not create a new anchor if it exists', () => { - EditorLiteExtension.onMouseMoveHandler(event); + SourceEditorExtension.onMouseMoveHandler(event); expect(findLine(defaultLine).querySelector('a')).not.toBe(null); - EditorLiteExtension.createAnchor = jest.fn(); - EditorLiteExtension.onMouseMoveHandler(event); - expect(EditorLiteExtension.createAnchor).not.toHaveBeenCalled(); + SourceEditorExtension.createAnchor = jest.fn(); + SourceEditorExtension.onMouseMoveHandler(event); + expect(SourceEditorExtension.createAnchor).not.toHaveBeenCalled(); expect(findLine(defaultLine).querySelectorAll('a')).toHaveLength(1); }); it('does not create a link if the event is triggered on a wrong node', () => { setFixtures('<div class="wrong-class">3</div>'); - EditorLiteExtension.createAnchor = jest.fn(); + SourceEditorExtension.createAnchor = jest.fn(); const wrongEvent = generateEventMock({ el: document.querySelector('.wrong-class') }); - EditorLiteExtension.onMouseMoveHandler(wrongEvent); - expect(EditorLiteExtension.createAnchor).not.toHaveBeenCalled(); + SourceEditorExtension.onMouseMoveHandler(wrongEvent); + expect(SourceEditorExtension.createAnchor).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/editor/editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index 3f64dcfd7a0..943e21250b4 100644 --- a/spec/frontend/editor/editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -1,8 +1,8 @@ import { Range, Position } from 'monaco-editor'; -import EditorLite from '~/editor/editor_lite'; -import { EditorMarkdownExtension } from '~/editor/extensions/editor_markdown_ext'; +import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; +import SourceEditor from '~/editor/source_editor'; -describe('Markdown Extension for Editor Lite', () => { +describe('Markdown Extension for Source Editor', () => { let editor; let instance; let editorEl; @@ -25,7 +25,7 @@ describe('Markdown Extension for Editor Lite', () => { beforeEach(() => { setFixtures('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); - editor = new EditorLite(); + editor = new SourceEditor(); instance = editor.createInstance({ el: editorEl, blobPath: filePath, diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/source_editor_spec.js index 815457e012f..d87d373c952 100644 --- a/spec/frontend/editor/editor_lite_spec.js +++ b/spec/frontend/editor/source_editor_spec.js @@ -2,12 +2,12 @@ import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor'; import waitForPromises from 'helpers/wait_for_promises'; import { - EDITOR_LITE_INSTANCE_ERROR_NO_EL, + SOURCE_EDITOR_INSTANCE_ERROR_NO_EL, URI_PREFIX, EDITOR_READY_EVENT, } from '~/editor/constants'; -import EditorLite from '~/editor/editor_lite'; -import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; +import SourceEditor from '~/editor/source_editor'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import { joinPaths } from '~/lib/utils/url_utility'; @@ -25,7 +25,7 @@ describe('Base editor', () => { setFixtures('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); defaultArguments = { el: editorEl, blobPath, blobContent, blobGlobalId }; - editor = new EditorLite(); + editor = new SourceEditor(); }); afterEach(() => { @@ -49,7 +49,7 @@ describe('Base editor', () => { expect(editorEl.dataset.editorLoading).toBeUndefined(); }); - describe('instance of the Editor Lite', () => { + describe('instance of the Source Editor', () => { let modelSpy; let instanceSpy; const setModel = jest.fn(); @@ -58,7 +58,7 @@ describe('Base editor', () => { modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => res); }; const mockDecorateInstance = (decorations = {}) => { - jest.spyOn(EditorLite, 'convertMonacoToELInstance').mockImplementation((inst) => { + jest.spyOn(SourceEditor, 'convertMonacoToELInstance').mockImplementation((inst) => { return Object.assign(inst, decorations); }); }; @@ -76,11 +76,11 @@ describe('Base editor', () => { mockDecorateInstance(); expect(() => { editor.createInstance(); - }).toThrow(EDITOR_LITE_INSTANCE_ERROR_NO_EL); + }).toThrow(SOURCE_EDITOR_INSTANCE_ERROR_NO_EL); expect(modelSpy).not.toHaveBeenCalled(); expect(instanceSpy).not.toHaveBeenCalled(); - expect(EditorLite.convertMonacoToELInstance).not.toHaveBeenCalled(); + expect(SourceEditor.convertMonacoToELInstance).not.toHaveBeenCalled(); }); it('creates model to be supplied to Monaco editor', () => { @@ -246,7 +246,7 @@ describe('Base editor', () => { let editorEl2; let inst1; let inst2; - const readOnlyIndex = '68'; // readOnly option has the internal index of 68 in the editor's options + const readOnlyIndex = '78'; // readOnly option has the internal index of 78 in the editor's options beforeEach(() => { setFixtures('<div id="editor1"></div><div id="editor2"></div>'); @@ -261,7 +261,7 @@ describe('Base editor', () => { blobPath, }; - editor = new EditorLite(); + editor = new SourceEditor(); instanceSpy = jest.spyOn(monacoEditor, 'create'); }); @@ -304,7 +304,7 @@ describe('Base editor', () => { }); it('shares global editor options among all instances', () => { - editor = new EditorLite({ + editor = new SourceEditor({ readOnly: true, }); @@ -316,7 +316,7 @@ describe('Base editor', () => { }); it('allows overriding editor options on the instance level', () => { - editor = new EditorLite({ + editor = new SourceEditor({ readOnly: true, }); inst1 = editor.createInstance({ @@ -410,7 +410,7 @@ describe('Base editor', () => { return WithStaticMethod.computeBoo(this.base); } } - class WithStaticMethodExtended extends EditorLiteExtension { + class WithStaticMethodExtended extends SourceEditorExtension { static computeBoo(a) { return a + 1; } @@ -546,7 +546,7 @@ describe('Base editor', () => { beforeEach(() => { editorExtensionSpy = jest - .spyOn(EditorLite, 'pushToImportsArray') + .spyOn(SourceEditor, 'pushToImportsArray') .mockImplementation((arr) => { arr.push( Promise.resolve({ @@ -593,7 +593,7 @@ describe('Base editor', () => { const useSpy = jest.fn().mockImplementation(() => { calls.push('use'); }); - jest.spyOn(EditorLite, 'convertMonacoToELInstance').mockImplementation((inst) => { + jest.spyOn(SourceEditor, 'convertMonacoToELInstance').mockImplementation((inst) => { const decoratedInstance = inst; decoratedInstance.use = useSpy; return decoratedInstance; @@ -664,7 +664,7 @@ describe('Base editor', () => { it('sets default syntax highlighting theme', () => { const expectedTheme = themes.find((t) => t.name === DEFAULT_THEME); - editor = new EditorLite(); + editor = new SourceEditor(); expect(themeDefineSpy).toHaveBeenCalledWith(DEFAULT_THEME, expectedTheme.data); expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME); @@ -676,7 +676,7 @@ describe('Base editor', () => { expect(expectedTheme.name).not.toBe(DEFAULT_THEME); window.gon.user_color_scheme = expectedTheme.name; - editor = new EditorLite(); + editor = new SourceEditor(); expect(themeDefineSpy).toHaveBeenCalledWith(expectedTheme.name, expectedTheme.data); expect(themeSetSpy).toHaveBeenCalledWith(expectedTheme.name); @@ -687,7 +687,7 @@ describe('Base editor', () => { const nonExistentTheme = { name }; window.gon.user_color_scheme = nonExistentTheme.name; - editor = new EditorLite(); + editor = new SourceEditor(); expect(themeDefineSpy).not.toHaveBeenCalled(); expect(themeSetSpy).toHaveBeenCalledWith(DEFAULT_THEME); diff --git a/spec/frontend/emoji/awards_app/store/actions_spec.js b/spec/frontend/emoji/awards_app/store/actions_spec.js index e96920d1112..02b643244d2 100644 --- a/spec/frontend/emoji/awards_app/store/actions_spec.js +++ b/spec/frontend/emoji/awards_app/store/actions_spec.js @@ -5,6 +5,7 @@ import * as actions from '~/emoji/awards_app/store/actions'; import axios from '~/lib/utils/axios_utils'; jest.mock('@sentry/browser'); +jest.mock('~/vue_shared/plugins/global_toast'); describe('Awards app actions', () => { afterEach(() => { diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index d1bc11538a3..29aa416149c 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -43,6 +43,9 @@ class CustomEnvironment extends JSDOMEnvironment { }; this.global.IS_EE = IS_EE; + // Set up global `gl` object + this.global.gl = {}; + this.rejectedPromises = []; this.global.promiseRejectionHandler = (error) => { @@ -67,6 +70,24 @@ class CustomEnvironment extends JSDOMEnvironment { getEntriesByName: () => [], }); + // + // Monaco-related environment variables + // + this.global.MonacoEnvironment = { globalAPI: true }; + Object.defineProperty(this.global, 'matchMedia', { + writable: true, + value: (query) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => null, // deprecated + removeListener: () => null, // deprecated + addEventListener: () => null, + removeEventListener: () => null, + dispatchEvent: () => null, + }), + }); + this.global.PerformanceObserver = class { /* eslint-disable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */ constructor(callback) {} diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 09ab1223fd1..62806c9e44c 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -285,6 +285,17 @@ describe('Environment item', () => { it('should not render the "Upcoming deployment" column', () => { expect(findUpcomingDeployment().exists()).toBe(false); }); + + it('should set the name cell to be full width', () => { + expect(wrapper.find('[data-testid="environment-name-cell"]').classes('section-100')).toBe( + true, + ); + }); + + it('should hide non-folder properties', () => { + expect(wrapper.find('[data-testid="environment-deployment-id-cell"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="environment-build-cell"]').exists()).toBe(false); + }); }); describe('When environment can be deleted', () => { diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index 542cf58b079..1abdeff614c 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -1,3 +1,4 @@ +import { GlTabs } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -7,6 +8,7 @@ import EmptyState from '~/environments/components/empty_state.vue'; import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue'; import EnvironmentsApp from '~/environments/components/environments_app.vue'; import axios from '~/lib/utils/axios_utils'; +import * as urlUtils from '~/lib/utils/url_utility'; import { environment, folder } from './mock_data'; describe('Environment', () => { @@ -264,4 +266,18 @@ describe('Environment', () => { }); }); }); + + describe('tabs', () => { + beforeEach(() => { + mockRequest(200, { environments: [] }); + jest + .spyOn(urlUtils, 'getParameterByName') + .mockImplementation((param) => (param === 'scope' ? 'stopped' : null)); + return createWrapper(true); + }); + + it('selects the tab for the parameter', () => { + expect(wrapper.findComponent(GlTabs).attributes('value')).toBe('1'); + }); + }); }); 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 0948b08f942..799b567a2c0 100644 --- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -1,21 +1,16 @@ import { GlToggle, GlAlert } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import Vue from 'vue'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; 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'; -import { LEGACY_FLAG, NEW_VERSION_FLAG } from '~/feature_flags/constants'; import createStore from '~/feature_flags/store/edit'; import axios from '~/lib/utils/axios_utils'; -const localVue = createLocalVue(); -localVue.use(Vuex); - -const userCalloutId = 'feature_flags_new_version'; -const userCalloutsPath = `${TEST_HOST}/user_callouts`; - +Vue.use(Vuex); describe('Edit feature flag form', () => { let wrapper; let mock; @@ -25,20 +20,14 @@ describe('Edit feature flag form', () => { endpoint: `${TEST_HOST}/feature_flags.json`, }); - const factory = (opts = {}) => { + const factory = (provide = {}) => { if (wrapper) { wrapper.destroy(); wrapper = null; } wrapper = shallowMount(EditFeatureFlag, { - localVue, store, - provide: { - showUserCallout: true, - userCalloutId, - userCalloutsPath, - ...opts, - }, + provide, }); }; @@ -52,18 +41,8 @@ describe('Edit feature flag form', () => { updated_at: '2019-01-17T17:27:39.778Z', name: 'feature_flag', description: '', - version: LEGACY_FLAG, edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit', destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', - scopes: [ - { - id: 21, - active: false, - environment_scope: '*', - created_at: '2019-01-17T17:27:39.778Z', - updated_at: '2019-01-17T17:27:39.778Z', - }, - ], }); factory(); setImmediate(() => done()); @@ -74,9 +53,7 @@ describe('Edit feature flag form', () => { mock.restore(); }); - const findAlert = () => wrapper.find(GlAlert); - const findWarningGlAlert = () => - wrapper.findAll(GlAlert).filter((c) => c.props('variant') === 'warning'); + const findWarningGlAlert = () => wrapper.findComponent(GlAlert); it('should display the iid', () => { expect(wrapper.find('h3').text()).toContain('^5'); @@ -86,21 +63,13 @@ describe('Edit feature flag form', () => { expect(wrapper.find(GlToggle).exists()).toBe(true); }); - it('should set the value of the toggle to whether or not the flag is active', () => { - expect(wrapper.find(GlToggle).props('value')).toBe(true); - }); - - it('should alert users the flag is read-only', () => { - expect(findAlert().text()).toContain('GitLab is moving to a new way of managing feature flags'); - }); - describe('with error', () => { it('should render the error', () => { store.dispatch('receiveUpdateFeatureFlagError', { message: ['The name is required'] }); return wrapper.vm.$nextTick(() => { const warningGlAlert = findWarningGlAlert(); - expect(warningGlAlert.at(1).exists()).toEqual(true); - expect(warningGlAlert.at(1).text()).toContain('The name is required'); + expect(warningGlAlert.exists()).toEqual(true); + expect(warningGlAlert.text()).toContain('The name is required'); }); }); }); @@ -114,32 +83,6 @@ describe('Edit feature flag form', () => { expect(wrapper.find(Form).exists()).toEqual(true); }); - it('should set the version of the form from the feature flag', () => { - expect(wrapper.find(Form).props('version')).toBe(LEGACY_FLAG); - - mock.resetHandlers(); - - mock.onGet(`${TEST_HOST}/feature_flags.json`).replyOnce(200, { - id: 21, - iid: 5, - active: true, - created_at: '2019-01-17T17:27:39.778Z', - updated_at: '2019-01-17T17:27:39.778Z', - name: 'feature_flag', - description: '', - version: NEW_VERSION_FLAG, - edit_path: '/h5bp/html5-boilerplate/-/feature_flags/21/edit', - destroy_path: '/h5bp/html5-boilerplate/-/feature_flags/21', - strategies: [], - }); - - factory(); - - return axios.waitForAll().then(() => { - expect(wrapper.find(Form).props('version')).toBe(NEW_VERSION_FLAG); - }); - }); - it('should track when the toggle is clicked', () => { const toggle = wrapper.find(GlToggle); const spy = mockTracking('_category_', toggle.element, jest.spyOn); diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js index 816bc9b9707..d06d60ae310 100644 --- a/spec/frontend/feature_flags/components/feature_flags_table_spec.js +++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js @@ -8,9 +8,6 @@ import { ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_USER_ID, ROLLOUT_STRATEGY_GITLAB_USER_LIST, - NEW_VERSION_FLAG, - LEGACY_FLAG, - DEFAULT_PERCENT_ROLLOUT, } from '~/feature_flags/constants'; const getDefaultProps = () => ({ @@ -23,17 +20,28 @@ const getDefaultProps = () => ({ description: 'flag description', destroy_path: 'destroy/path', edit_path: 'edit/path', - version: LEGACY_FLAG, - scopes: [ + scopes: [], + strategies: [ { - id: 1, - active: true, - environmentScope: 'scope', - canUpdate: true, - protected: false, - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - shouldBeDestroyed: false, + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environment_scope: '*' }], + }, + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50' }, + scopes: [{ environment_scope: 'production' }, { environment_scope: 'staging' }], + }, + { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { userIds: '1,2,3,4' }, + scopes: [{ environment_scope: 'review/*' }], + }, + { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + parameters: {}, + user_list: { name: 'test list' }, + scopes: [{ environment_scope: '*' }], }, ], }, @@ -43,6 +51,7 @@ const getDefaultProps = () => ({ describe('Feature flag table', () => { let wrapper; let props; + let badges; const createWrapper = (propsData, opts = {}) => { wrapper = shallowMount(FeatureFlagsTable, { @@ -56,6 +65,15 @@ describe('Feature flag table', () => { beforeEach(() => { props = getDefaultProps(); + createWrapper(props, { + provide: { csrfToken: 'fakeToken' }, + }); + + badges = wrapper.findAll('[data-testid="strategy-badge"]'); + }); + + beforeEach(() => { + props = getDefaultProps(); }); afterEach(() => { @@ -97,17 +115,10 @@ describe('Feature flag table', () => { ); }); - it('should render an environments specs column', () => { - const envColumn = wrapper.find('.js-feature-flag-environments'); - - expect(envColumn).toBeDefined(); - expect(trimText(envColumn.text())).toBe('scope'); - }); - it('should render an environments specs badge with active class', () => { const envColumn = wrapper.find('.js-feature-flag-environments'); - expect(trimText(envColumn.find(GlBadge).text())).toBe('scope'); + expect(trimText(envColumn.find(GlBadge).text())).toBe('All Users: All Environments'); }); it('should render an actions column', () => { @@ -120,11 +131,13 @@ describe('Feature flag table', () => { describe('when active and with an update toggle', () => { let toggle; + let spy; beforeEach(() => { props.featureFlags[0].update_path = props.featureFlags[0].destroy_path; createWrapper(props); toggle = wrapper.find(GlToggle); + spy = mockTracking('_category_', toggle.element, jest.spyOn); }); it('should have a toggle', () => { @@ -143,123 +156,40 @@ describe('Feature flag table', () => { expect(wrapper.emitted('toggle-flag')).toEqual([[flag]]); }); }); - }); - - describe('with an active scope and a percentage rollout strategy', () => { - beforeEach(() => { - props.featureFlags[0].scopes[0].rolloutStrategy = ROLLOUT_STRATEGY_PERCENT_ROLLOUT; - props.featureFlags[0].scopes[0].rolloutPercentage = '54'; - createWrapper(props); - }); - it('should render an environments specs badge with percentage', () => { - const envColumn = wrapper.find('.js-feature-flag-environments'); + it('tracks a click', () => { + toggle.trigger('click'); - expect(trimText(envColumn.find(GlBadge).text())).toBe('scope: 54%'); + expect(spy).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'feature_flag_toggle', + }); }); }); - describe('with an inactive scope', () => { - beforeEach(() => { - props.featureFlags[0].scopes[0].active = false; - createWrapper(props); - }); - - it('should render an environments specs badge with inactive class', () => { - const envColumn = wrapper.find('.js-feature-flag-environments'); - - expect(trimText(envColumn.find(GlBadge).text())).toBe('scope'); - }); + it('shows All Environments if the environment scope is *', () => { + expect(badges.at(0).text()).toContain('All Environments'); }); - describe('with a new version flag', () => { - let toggle; - let spy; - let badges; - - beforeEach(() => { - const newVersionProps = { - ...props, - featureFlags: [ - { - id: 1, - iid: 1, - active: true, - name: 'flag name', - description: 'flag description', - destroy_path: 'destroy/path', - edit_path: 'edit/path', - update_path: 'update/path', - version: NEW_VERSION_FLAG, - scopes: [], - strategies: [ - { - name: ROLLOUT_STRATEGY_ALL_USERS, - parameters: {}, - scopes: [{ environment_scope: '*' }], - }, - { - name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - parameters: { percentage: '50' }, - scopes: [{ environment_scope: 'production' }, { environment_scope: 'staging' }], - }, - { - name: ROLLOUT_STRATEGY_USER_ID, - parameters: { userIds: '1,2,3,4' }, - scopes: [{ environment_scope: 'review/*' }], - }, - { - name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, - parameters: {}, - user_list: { name: 'test list' }, - scopes: [{ environment_scope: '*' }], - }, - ], - }, - ], - }; - createWrapper(newVersionProps, { - provide: { csrfToken: 'fakeToken', glFeatures: { featureFlagsNewVersion: true } }, - }); - - toggle = wrapper.find(GlToggle); - spy = mockTracking('_category_', toggle.element, jest.spyOn); - badges = wrapper.findAll('[data-testid="strategy-badge"]'); - }); - - it('shows All Environments if the environment scope is *', () => { - expect(badges.at(0).text()).toContain('All Environments'); - }); - - it('shows the environment scope if another is set', () => { - expect(badges.at(1).text()).toContain('production'); - expect(badges.at(1).text()).toContain('staging'); - expect(badges.at(2).text()).toContain('review/*'); - }); - - it('shows All Users for the default strategy', () => { - expect(badges.at(0).text()).toContain('All Users'); - }); - - it('shows the percent for a percent rollout', () => { - expect(badges.at(1).text()).toContain('Percent of users - 50%'); - }); + it('shows the environment scope if another is set', () => { + expect(badges.at(1).text()).toContain('production'); + expect(badges.at(1).text()).toContain('staging'); + expect(badges.at(2).text()).toContain('review/*'); + }); - it('shows the number of users for users with ID', () => { - expect(badges.at(2).text()).toContain('User IDs - 4 users'); - }); + it('shows All Users for the default strategy', () => { + expect(badges.at(0).text()).toContain('All Users'); + }); - it('shows the name of a user list for user list', () => { - expect(badges.at(3).text()).toContain('User List - test list'); - }); + it('shows the percent for a percent rollout', () => { + expect(badges.at(1).text()).toContain('Percent of users - 50%'); + }); - it('tracks a click', () => { - toggle.trigger('click'); + it('shows the number of users for users with ID', () => { + expect(badges.at(2).text()).toContain('User IDs - 4 users'); + }); - expect(spy).toHaveBeenCalledWith('_category_', 'click_button', { - label: 'feature_flag_toggle', - }); - }); + it('shows the name of a user list for user list', () => { + expect(badges.at(3).text()).toContain('User List - test list'); }); it('renders a feature flag without an iid', () => { diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js index 6c3fce68618..c0f9638390a 100644 --- a/spec/frontend/feature_flags/components/form_spec.js +++ b/spec/frontend/feature_flags/components/form_spec.js @@ -1,18 +1,12 @@ -import { GlFormTextarea, GlFormCheckbox, GlButton, GlToggle } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { uniqueId } from 'lodash'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import Api from '~/api'; -import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue'; import Form from '~/feature_flags/components/form.vue'; import Strategy from '~/feature_flags/components/strategy.vue'; import { ROLLOUT_STRATEGY_ALL_USERS, ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - INTERNAL_ID_PREFIX, - DEFAULT_PERCENT_ROLLOUT, - LEGACY_FLAG, - NEW_VERSION_FLAG, } from '~/feature_flags/constants'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; import { featureFlag, userList, allUsersStrategy } from '../mock_data'; @@ -29,15 +23,8 @@ describe('feature flag form', () => { const requiredInjections = { environmentsEndpoint: '/environments.json', projectId: '1', - glFeatures: { - featureFlagPermissions: true, - featureFlagsNewVersion: true, - }, }; - const findAddNewScopeRow = () => wrapper.findByTestId('add-new-scope'); - const findGlToggle = () => wrapper.find(GlToggle); - const factory = (props = {}, provide = {}) => { wrapper = extendedWrapper( shallowMount(Form, { @@ -100,328 +87,6 @@ describe('feature flag form', () => { it('should render description textarea', () => { expect(wrapper.find('#feature-flag-description').exists()).toBe(true); }); - - describe('scopes', () => { - it('should render scopes table', () => { - expect(wrapper.find('.js-scopes-table').exists()).toBe(true); - }); - - it('should render scopes table with a new row ', () => { - expect(findAddNewScopeRow().exists()).toBe(true); - }); - - describe('status toggle', () => { - describe('without filled text input', () => { - it('should add a new scope with the text value empty and the status', () => { - findGlToggle().vm.$emit('change', true); - - expect(wrapper.vm.formScopes).toHaveLength(1); - expect(wrapper.vm.formScopes[0].active).toEqual(true); - expect(wrapper.vm.formScopes[0].environmentScope).toEqual(''); - - expect(wrapper.vm.newScope).toEqual(''); - }); - }); - - it('has label', () => { - expect(findGlToggle().props('label')).toBe(Form.i18n.statusLabel); - }); - - it('should be disabled if the feature flag is not active', (done) => { - wrapper.setProps({ active: false }); - wrapper.vm.$nextTick(() => { - expect(findGlToggle().props('disabled')).toBe(true); - done(); - }); - }); - }); - }); - }); - - describe('with provided data', () => { - beforeEach(() => { - factory({ - ...requiredProps, - name: featureFlag.name, - description: featureFlag.description, - active: true, - version: LEGACY_FLAG, - scopes: [ - { - id: 1, - active: true, - environmentScope: 'scope', - canUpdate: true, - protected: false, - rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - rolloutPercentage: '54', - rolloutUserIds: '123', - shouldIncludeUserIds: true, - }, - { - id: 2, - active: true, - environmentScope: 'scope', - canUpdate: false, - protected: true, - rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - rolloutPercentage: '54', - rolloutUserIds: '123', - shouldIncludeUserIds: true, - }, - ], - }); - }); - - describe('scopes', () => { - it('should be possible to remove a scope', () => { - expect(wrapper.findByTestId('feature-flag-delete').exists()).toEqual(true); - }); - - it('renders empty row to add a new scope', () => { - expect(findAddNewScopeRow().exists()).toEqual(true); - }); - - it('renders the user id checkbox', () => { - expect(wrapper.find(GlFormCheckbox).exists()).toBe(true); - }); - - it('renders the user id text area', () => { - expect(wrapper.find(GlFormTextarea).exists()).toBe(true); - - expect(wrapper.find(GlFormTextarea).vm.value).toBe('123'); - }); - - describe('update scope', () => { - describe('on click on toggle', () => { - it('should update the scope', () => { - findGlToggle().vm.$emit('change', false); - - expect(wrapper.vm.formScopes[0].active).toBe(false); - }); - - it('should be disabled if the feature flag is not active', (done) => { - wrapper.setProps({ active: false }); - - wrapper.vm.$nextTick(() => { - expect(findGlToggle().props('disabled')).toBe(true); - done(); - }); - }); - }); - describe('on strategy change', () => { - it('should not include user IDs if All Users is selected', () => { - const scope = wrapper.find({ ref: 'scopeRow' }); - scope.find('select').setValue(ROLLOUT_STRATEGY_ALL_USERS); - return wrapper.vm.$nextTick().then(() => { - expect(scope.find('#rollout-user-id-0').exists()).toBe(false); - }); - }); - }); - }); - - describe('deleting an existing scope', () => { - beforeEach(() => { - wrapper.find('.js-delete-scope').vm.$emit('click'); - }); - - it('should add `shouldBeDestroyed` key the clicked scope', () => { - expect(wrapper.vm.formScopes[0].shouldBeDestroyed).toBe(true); - }); - - it('should not render deleted scopes', () => { - expect(wrapper.vm.filteredScopes).toEqual([expect.objectContaining({ id: 2 })]); - }); - }); - - describe('deleting a new scope', () => { - it('should remove the scope from formScopes', () => { - factory({ - ...requiredProps, - name: 'feature_flag_1', - description: 'this is a feature flag', - scopes: [ - { - environmentScope: 'new_scope', - active: false, - id: uniqueId(INTERNAL_ID_PREFIX), - canUpdate: true, - protected: false, - strategies: [ - { - name: ROLLOUT_STRATEGY_ALL_USERS, - parameters: {}, - }, - ], - }, - ], - }); - - wrapper.find('.js-delete-scope').vm.$emit('click'); - - expect(wrapper.vm.formScopes).toEqual([]); - }); - }); - - describe('with * scope', () => { - beforeEach(() => { - factory({ - ...requiredProps, - name: 'feature_flag_1', - description: 'this is a feature flag', - scopes: [ - { - environmentScope: '*', - active: false, - canUpdate: false, - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - }, - ], - }); - }); - - it('renders read-only name', () => { - expect(wrapper.find('.js-scope-all').exists()).toEqual(true); - }); - }); - - describe('without permission to update', () => { - it('should have the flag name input disabled', () => { - const input = wrapper.find('#feature-flag-name'); - - expect(input.element.disabled).toBe(true); - }); - - it('should have the flag discription text area disabled', () => { - const textarea = wrapper.find('#feature-flag-description'); - - expect(textarea.element.disabled).toBe(true); - }); - - it('should have the scope that cannot be updated be disabled', () => { - const row = wrapper.findAll('.gl-responsive-table-row').at(2); - - expect(row.find(EnvironmentsDropdown).vm.disabled).toBe(true); - expect(row.find(GlToggle).props('disabled')).toBe(true); - expect(row.find('.js-delete-scope').exists()).toBe(false); - }); - }); - }); - - describe('on submit', () => { - const selectFirstRolloutStrategyOption = (dropdownIndex) => { - wrapper - .findAll('select.js-rollout-strategy') - .at(dropdownIndex) - .findAll('option') - .at(1) - .setSelected(); - }; - - beforeEach(() => { - factory({ - ...requiredProps, - name: 'feature_flag_1', - active: true, - description: 'this is a feature flag', - scopes: [ - { - id: 1, - environmentScope: 'production', - canUpdate: true, - protected: true, - active: false, - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - rolloutUserIds: '', - }, - ], - }); - - return wrapper.vm.$nextTick(); - }); - - it('should emit handleSubmit with the updated data', () => { - wrapper.find('#feature-flag-name').setValue('feature_flag_2'); - - return wrapper.vm - .$nextTick() - .then(() => { - wrapper - .find('.js-new-scope-name') - .find(EnvironmentsDropdown) - .vm.$emit('selectEnvironment', 'review'); - - return wrapper.vm.$nextTick(); - }) - .then(() => { - findAddNewScopeRow().find(GlToggle).vm.$emit('change', true); - }) - .then(() => { - findGlToggle().vm.$emit('change', true); - return wrapper.vm.$nextTick(); - }) - - .then(() => { - selectFirstRolloutStrategyOption(0); - return wrapper.vm.$nextTick(); - }) - .then(() => { - selectFirstRolloutStrategyOption(2); - return wrapper.vm.$nextTick(); - }) - .then(() => { - wrapper.find('.js-rollout-percentage').setValue('55'); - - return wrapper.vm.$nextTick(); - }) - .then(() => { - wrapper.find({ ref: 'submitButton' }).vm.$emit('click'); - - const data = wrapper.emitted().handleSubmit[0][0]; - - expect(data.name).toEqual('feature_flag_2'); - expect(data.description).toEqual('this is a feature flag'); - expect(data.active).toBe(true); - - expect(data.scopes).toEqual([ - { - id: 1, - active: true, - environmentScope: 'production', - canUpdate: true, - protected: true, - rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - rolloutPercentage: '55', - rolloutUserIds: '', - shouldIncludeUserIds: false, - }, - { - id: expect.any(String), - active: false, - environmentScope: 'review', - canUpdate: true, - protected: false, - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - rolloutUserIds: '', - }, - { - id: expect.any(String), - active: true, - environmentScope: '', - canUpdate: true, - protected: false, - rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - rolloutUserIds: '', - shouldIncludeUserIds: false, - }, - ]); - }); - }); - }); }); describe('with strategies', () => { @@ -432,7 +97,6 @@ describe('feature flag form', () => { name: featureFlag.name, description: featureFlag.description, active: true, - version: NEW_VERSION_FLAG, strategies: [ { type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js index e209c14d8c7..fe98b6421d4 100644 --- a/spec/frontend/feature_flags/components/new_feature_flag_spec.js +++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js @@ -4,7 +4,6 @@ import Vuex from 'vuex'; import { TEST_HOST } from 'spec/test_constants'; import Form from '~/feature_flags/components/form.vue'; import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue'; -import { ROLLOUT_STRATEGY_ALL_USERS, DEFAULT_PERCENT_ROLLOUT } from '~/feature_flags/constants'; import createStore from '~/feature_flags/store/new'; import { allUsersStrategy } from '../mock_data'; @@ -71,20 +70,6 @@ describe('New feature flag form', () => { expect(wrapper.find(Form).exists()).toEqual(true); }); - it('should render default * row', () => { - const defaultScope = { - id: expect.any(String), - environmentScope: '*', - active: true, - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - rolloutUserIds: '', - }; - expect(wrapper.vm.scopes).toEqual([defaultScope]); - - expect(wrapper.find(Form).props('scopes')).toContainEqual(defaultScope); - }); - it('has an all users strategy by default', () => { const strategies = wrapper.find(Form).props('strategies'); diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js index 11a91e5b2a8..b5f09ac1957 100644 --- a/spec/frontend/feature_flags/mock_data.js +++ b/spec/frontend/feature_flags/mock_data.js @@ -16,86 +16,24 @@ export const featureFlag = { destroy_path: 'feature_flags/1', update_path: 'feature_flags/1', edit_path: 'feature_flags/1/edit', - scopes: [ + strategies: [ { - id: 1, - active: true, - environment_scope: '*', - can_update: true, - protected: false, - created_at: '2019-01-14T06:41:40.987Z', - updated_at: '2019-01-14T06:41:40.987Z', - strategies: [ - { - name: ROLLOUT_STRATEGY_ALL_USERS, - parameters: {}, - }, - ], + id: 9, + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ id: 17, environment_scope: '*' }], }, { - id: 2, - active: false, - environment_scope: 'production', - can_update: true, - protected: false, - created_at: '2019-01-14T06:41:40.987Z', - updated_at: '2019-01-14T06:41:40.987Z', - strategies: [ - { - name: ROLLOUT_STRATEGY_ALL_USERS, - parameters: {}, - }, - ], + id: 8, + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: {}, + scopes: [{ id: 18, environment_scope: 'review/*' }], }, { - id: 3, - active: false, - environment_scope: 'review/*', - can_update: true, - protected: false, - created_at: '2019-01-14T06:41:40.987Z', - updated_at: '2019-01-14T06:41:40.987Z', - strategies: [ - { - name: ROLLOUT_STRATEGY_ALL_USERS, - parameters: {}, - }, - ], - }, - { - id: 4, - active: true, - environment_scope: 'development', - can_update: true, - protected: false, - created_at: '2019-01-14T06:41:40.987Z', - updated_at: '2019-01-14T06:41:40.987Z', - strategies: [ - { - name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - parameters: { - percentage: '86', - }, - }, - ], - }, - { - id: 5, - active: true, - environment_scope: 'development', - can_update: true, - protected: false, - created_at: '2019-01-14T06:41:40.987Z', - updated_at: '2019-01-14T06:41:40.987Z', - strategies: [ - { - name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, - parameters: { - rollout: '42', - stickiness: 'DEFAULT', - }, - }, - ], + id: 7, + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { userIds: '1,2,3,4' }, + scopes: [{ id: 19, environment_scope: 'production' }], }, ], }; diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js index afcac53468c..12fccd79170 100644 --- a/spec/frontend/feature_flags/store/edit/actions_spec.js +++ b/spec/frontend/feature_flags/store/edit/actions_spec.js @@ -1,11 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; -import { - NEW_VERSION_FLAG, - LEGACY_FLAG, - ROLLOUT_STRATEGY_ALL_USERS, -} from '~/feature_flags/constants'; +import { ROLLOUT_STRATEGY_ALL_USERS } from '~/feature_flags/constants'; import { updateFeatureFlag, requestUpdateFeatureFlag, @@ -19,7 +15,7 @@ import { } from '~/feature_flags/store/edit/actions'; import * as types from '~/feature_flags/store/edit/mutation_types'; import state from '~/feature_flags/store/edit/state'; -import { mapStrategiesToRails, mapFromScopesViewModel } from '~/feature_flags/store/helpers'; +import { mapStrategiesToRails } from '~/feature_flags/store/helpers'; import axios from '~/lib/utils/axios_utils'; jest.mock('~/lib/utils/url_utility'); @@ -46,46 +42,9 @@ describe('Feature flags Edit Module actions', () => { describe('success', () => { it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', (done) => { const featureFlag = { - name: 'feature_flag', - description: 'feature flag', - scopes: [ - { - id: '1', - environmentScope: '*', - active: true, - shouldBeDestroyed: false, - canUpdate: true, - protected: false, - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - }, - ], - version: LEGACY_FLAG, - active: true, - }; - mock.onPut(mockedState.endpoint, mapFromScopesViewModel(featureFlag)).replyOnce(200); - - testAction( - updateFeatureFlag, - featureFlag, - mockedState, - [], - [ - { - type: 'requestUpdateFeatureFlag', - }, - { - type: 'receiveUpdateFeatureFlagSuccess', - }, - ], - done, - ); - }); - it('handles new version flags as well', (done) => { - const featureFlag = { name: 'name', description: 'description', active: true, - version: NEW_VERSION_FLAG, strategies: [ { name: ROLLOUT_STRATEGY_ALL_USERS, diff --git a/spec/frontend/feature_flags/store/helpers_spec.js b/spec/frontend/feature_flags/store/helpers_spec.js index 711e2a1286e..2a6211c8cc1 100644 --- a/spec/frontend/feature_flags/store/helpers_spec.js +++ b/spec/frontend/feature_flags/store/helpers_spec.js @@ -1,351 +1,7 @@ -import { uniqueId } from 'lodash'; -import { - ROLLOUT_STRATEGY_ALL_USERS, - ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - ROLLOUT_STRATEGY_USER_ID, - PERCENT_ROLLOUT_GROUP_ID, - INTERNAL_ID_PREFIX, - DEFAULT_PERCENT_ROLLOUT, - LEGACY_FLAG, - NEW_VERSION_FLAG, -} from '~/feature_flags/constants'; -import { - mapToScopesViewModel, - mapFromScopesViewModel, - createNewEnvironmentScope, - mapStrategiesToViewModel, - mapStrategiesToRails, -} from '~/feature_flags/store/helpers'; +import { NEW_VERSION_FLAG } from '~/feature_flags/constants'; +import { mapStrategiesToViewModel, mapStrategiesToRails } from '~/feature_flags/store/helpers'; describe('feature flags helpers spec', () => { - describe('mapToScopesViewModel', () => { - it('converts the data object from the Rails API into something more usable by Vue', () => { - const input = [ - { - id: 3, - environment_scope: 'environment_scope', - active: true, - can_update: true, - protected: true, - strategies: [ - { - name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - parameters: { - percentage: '56', - }, - }, - { - name: ROLLOUT_STRATEGY_USER_ID, - parameters: { - userIds: '123,234', - }, - }, - ], - - _destroy: true, - }, - ]; - - const expected = [ - expect.objectContaining({ - id: 3, - environmentScope: 'environment_scope', - active: true, - canUpdate: true, - protected: true, - rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - rolloutPercentage: '56', - rolloutUserIds: '123, 234', - shouldBeDestroyed: true, - }), - ]; - - const actual = mapToScopesViewModel(input); - - expect(actual).toEqual(expected); - }); - - it('returns Boolean properties even when their Rails counterparts were not provided (are `undefined`)', () => { - const input = [ - { - id: 3, - environment_scope: 'environment_scope', - }, - ]; - - const [result] = mapToScopesViewModel(input); - - expect(result).toEqual( - expect.objectContaining({ - active: false, - canUpdate: false, - protected: false, - shouldBeDestroyed: false, - }), - ); - }); - - it('returns an empty array if null or undefined is provided as a parameter', () => { - expect(mapToScopesViewModel(null)).toEqual([]); - expect(mapToScopesViewModel(undefined)).toEqual([]); - }); - - describe('with user IDs per environment', () => { - let oldGon; - - beforeEach(() => { - oldGon = window.gon; - window.gon = { features: { featureFlagsUsersPerEnvironment: true } }; - }); - - afterEach(() => { - window.gon = oldGon; - }); - - it('sets the user IDs as a comma separated string', () => { - const input = [ - { - id: 3, - environment_scope: 'environment_scope', - active: true, - can_update: true, - protected: true, - strategies: [ - { - name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - parameters: { - percentage: '56', - }, - }, - { - name: ROLLOUT_STRATEGY_USER_ID, - parameters: { - userIds: '123,234', - }, - }, - ], - - _destroy: true, - }, - ]; - - const expected = [ - { - id: 3, - environmentScope: 'environment_scope', - active: true, - canUpdate: true, - protected: true, - rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - rolloutPercentage: '56', - rolloutUserIds: '123, 234', - shouldBeDestroyed: true, - shouldIncludeUserIds: true, - }, - ]; - - const actual = mapToScopesViewModel(input); - - expect(actual).toEqual(expected); - }); - }); - }); - - describe('mapFromScopesViewModel', () => { - it('converts the object emitted from the Vue component into an object than is in the right format to be submitted to the Rails API', () => { - const input = { - name: 'name', - description: 'description', - active: true, - scopes: [ - { - id: 4, - environmentScope: 'environmentScope', - active: true, - canUpdate: true, - protected: true, - shouldBeDestroyed: true, - shouldIncludeUserIds: true, - rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - rolloutPercentage: '48', - rolloutUserIds: '123, 234', - }, - ], - }; - - const expected = { - operations_feature_flag: { - name: 'name', - description: 'description', - active: true, - version: LEGACY_FLAG, - scopes_attributes: [ - { - id: 4, - environment_scope: 'environmentScope', - active: true, - can_update: true, - protected: true, - _destroy: true, - strategies: [ - { - name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - parameters: { - groupId: PERCENT_ROLLOUT_GROUP_ID, - percentage: '48', - }, - }, - { - name: ROLLOUT_STRATEGY_USER_ID, - parameters: { - userIds: '123,234', - }, - }, - ], - }, - ], - }, - }; - - const actual = mapFromScopesViewModel(input); - - expect(actual).toEqual(expected); - }); - - it('should strip out internal IDs', () => { - const input = { - scopes: [{ id: 3 }, { id: uniqueId(INTERNAL_ID_PREFIX) }], - }; - - const result = mapFromScopesViewModel(input); - const [realId, internalId] = result.operations_feature_flag.scopes_attributes; - - expect(realId.id).toBe(3); - expect(internalId.id).toBeUndefined(); - }); - - it('returns scopes_attributes as [] if param.scopes is null or undefined', () => { - let { - operations_feature_flag: { scopes_attributes: actualScopes }, - } = mapFromScopesViewModel({ scopes: null }); - - expect(actualScopes).toEqual([]); - - ({ - operations_feature_flag: { scopes_attributes: actualScopes }, - } = mapFromScopesViewModel({ scopes: undefined })); - - expect(actualScopes).toEqual([]); - }); - describe('with user IDs per environment', () => { - it('sets the user IDs as a comma separated string', () => { - const input = { - name: 'name', - description: 'description', - active: true, - scopes: [ - { - id: 4, - environmentScope: 'environmentScope', - active: true, - canUpdate: true, - protected: true, - shouldBeDestroyed: true, - rolloutStrategy: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - rolloutPercentage: '48', - rolloutUserIds: '123, 234', - shouldIncludeUserIds: true, - }, - ], - }; - - const expected = { - operations_feature_flag: { - name: 'name', - description: 'description', - version: LEGACY_FLAG, - active: true, - scopes_attributes: [ - { - id: 4, - environment_scope: 'environmentScope', - active: true, - can_update: true, - protected: true, - _destroy: true, - strategies: [ - { - name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - parameters: { - groupId: PERCENT_ROLLOUT_GROUP_ID, - percentage: '48', - }, - }, - { - name: ROLLOUT_STRATEGY_USER_ID, - parameters: { - userIds: '123,234', - }, - }, - ], - }, - ], - }, - }; - - const actual = mapFromScopesViewModel(input); - - expect(actual).toEqual(expected); - }); - }); - }); - - describe('createNewEnvironmentScope', () => { - it('should return a new environment scope object populated with the default options', () => { - const expected = { - environmentScope: '', - active: false, - id: expect.stringContaining(INTERNAL_ID_PREFIX), - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - rolloutUserIds: '', - }; - - const actual = createNewEnvironmentScope(); - - expect(actual).toEqual(expected); - }); - - it('should return a new environment scope object with overrides applied', () => { - const overrides = { - environmentScope: 'environmentScope', - active: true, - }; - - const expected = { - environmentScope: 'environmentScope', - active: true, - id: expect.stringContaining(INTERNAL_ID_PREFIX), - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, - rolloutUserIds: '', - }; - - const actual = createNewEnvironmentScope(overrides); - - expect(actual).toEqual(expected); - }); - - it('sets canUpdate and protected when called with featureFlagPermissions=true', () => { - expect(createNewEnvironmentScope({}, true)).toEqual( - expect.objectContaining({ - canUpdate: true, - protected: false, - }), - ); - }); - }); - describe('mapStrategiesToViewModel', () => { it('should map rails casing to view model casing', () => { expect( @@ -380,14 +36,14 @@ describe('feature flags helpers spec', () => { }); it('inserts spaces between user ids', () => { - const strategy = mapStrategiesToViewModel([ + const [strategy] = mapStrategiesToViewModel([ { id: '1', name: 'userWithId', parameters: { userIds: 'user1,user2,user3' }, scopes: [], }, - ])[0]; + ]); expect(strategy.parameters).toEqual({ userIds: 'user1, user2, user3' }); }); @@ -399,7 +55,6 @@ describe('feature flags helpers spec', () => { mapStrategiesToRails({ name: 'test', description: 'test description', - version: NEW_VERSION_FLAG, active: true, strategies: [ { @@ -421,8 +76,8 @@ describe('feature flags helpers spec', () => { operations_feature_flag: { name: 'test', description: 'test description', - version: NEW_VERSION_FLAG, active: true, + version: NEW_VERSION_FLAG, strategies_attributes: [ { id: '1', @@ -447,7 +102,6 @@ describe('feature flags helpers spec', () => { mapStrategiesToRails({ name: 'test', description: 'test description', - version: NEW_VERSION_FLAG, active: true, strategies: [ { @@ -462,8 +116,8 @@ describe('feature flags helpers spec', () => { operations_feature_flag: { name: 'test', description: 'test description', - version: NEW_VERSION_FLAG, active: true, + version: NEW_VERSION_FLAG, strategies_attributes: [ { id: '1', @@ -483,7 +137,6 @@ describe('feature flags helpers spec', () => { it('removes white space between user ids', () => { const result = mapStrategiesToRails({ name: 'test', - version: NEW_VERSION_FLAG, active: true, strategies: [ { @@ -503,7 +156,6 @@ describe('feature flags helpers spec', () => { it('preserves the value of active', () => { const result = mapStrategiesToRails({ name: 'test', - version: NEW_VERSION_FLAG, active: false, strategies: [], }); diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js index ec311ef92a3..a59f99f538c 100644 --- a/spec/frontend/feature_flags/store/index/actions_spec.js +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -1,7 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; -import { mapToScopesViewModel } from '~/feature_flags/store/helpers'; import { requestFeatureFlags, receiveFeatureFlagsSuccess, @@ -255,7 +254,6 @@ describe('Feature flags actions', () => { beforeEach(() => { mockedState.featureFlags = getRequestData.feature_flags.map((flag) => ({ ...flag, - scopes: mapToScopesViewModel(flag.scopes || []), })); mock = new MockAdapter(axios); }); @@ -314,7 +312,6 @@ describe('Feature flags actions', () => { beforeEach(() => { mockedState.featureFlags = getRequestData.feature_flags.map((f) => ({ ...f, - scopes: mapToScopesViewModel(f.scopes || []), })); }); @@ -338,7 +335,6 @@ describe('Feature flags actions', () => { beforeEach(() => { mockedState.featureFlags = getRequestData.feature_flags.map((f) => ({ ...f, - scopes: mapToScopesViewModel(f.scopes || []), })); }); @@ -362,7 +358,6 @@ describe('Feature flags actions', () => { beforeEach(() => { mockedState.featureFlags = getRequestData.feature_flags.map((f) => ({ ...f, - scopes: mapToScopesViewModel(f.scopes || []), })); }); diff --git a/spec/frontend/feature_flags/store/index/mutations_spec.js b/spec/frontend/feature_flags/store/index/mutations_spec.js index b9354196c68..c19f459e124 100644 --- a/spec/frontend/feature_flags/store/index/mutations_spec.js +++ b/spec/frontend/feature_flags/store/index/mutations_spec.js @@ -1,4 +1,3 @@ -import { mapToScopesViewModel } from '~/feature_flags/store/helpers'; import * as types from '~/feature_flags/store/index/mutation_types'; import mutations from '~/feature_flags/store/index/mutations'; import state from '~/feature_flags/store/index/state'; @@ -49,15 +48,6 @@ describe('Feature flags store Mutations', () => { expect(stateCopy.hasError).toEqual(false); }); - it('should set featureFlags with the transformed data', () => { - const expected = getRequestData.feature_flags.map((flag) => ({ - ...flag, - scopes: mapToScopesViewModel(flag.scopes || []), - })); - - expect(stateCopy.featureFlags).toEqual(expected); - }); - it('should set count with the given data', () => { expect(stateCopy.count).toEqual(37); }); @@ -131,13 +121,11 @@ describe('Feature flags store Mutations', () => { beforeEach(() => { stateCopy.featureFlags = getRequestData.feature_flags.map((flag) => ({ ...flag, - scopes: mapToScopesViewModel(flag.scopes || []), })); stateCopy.count = { featureFlags: 1, userLists: 0 }; mutations[types.UPDATE_FEATURE_FLAG](stateCopy, { ...featureFlag, - scopes: mapToScopesViewModel(featureFlag.scopes || []), active: false, }); }); @@ -146,7 +134,6 @@ describe('Feature flags store Mutations', () => { expect(stateCopy.featureFlags).toEqual([ { ...featureFlag, - scopes: mapToScopesViewModel(featureFlag.scopes || []), active: false, }, ]); @@ -158,7 +145,6 @@ describe('Feature flags store Mutations', () => { stateCopy.featureFlags = getRequestData.feature_flags.map((flag) => ({ ...flag, ...flagState, - scopes: mapToScopesViewModel(flag.scopes || []), })); stateCopy.count = stateCount; @@ -174,7 +160,6 @@ describe('Feature flags store Mutations', () => { expect(stateCopy.featureFlags).toEqual([ { ...featureFlag, - scopes: mapToScopesViewModel(featureFlag.scopes || []), active: false, }, ]); @@ -185,7 +170,6 @@ describe('Feature flags store Mutations', () => { beforeEach(() => { stateCopy.featureFlags = getRequestData.feature_flags.map((flag) => ({ ...flag, - scopes: mapToScopesViewModel(flag.scopes || []), })); mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, featureFlag.id); }); @@ -194,7 +178,6 @@ describe('Feature flags store Mutations', () => { expect(stateCopy.featureFlags).toEqual([ { ...featureFlag, - scopes: mapToScopesViewModel(featureFlag.scopes || []), active: false, }, ]); diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js index 00dfb982ded..7900b200eb2 100644 --- a/spec/frontend/feature_flags/store/new/actions_spec.js +++ b/spec/frontend/feature_flags/store/new/actions_spec.js @@ -1,13 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { TEST_HOST } from 'spec/test_constants'; -import { - ROLLOUT_STRATEGY_ALL_USERS, - ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - LEGACY_FLAG, - NEW_VERSION_FLAG, -} from '~/feature_flags/constants'; -import { mapFromScopesViewModel, mapStrategiesToRails } from '~/feature_flags/store/helpers'; +import { ROLLOUT_STRATEGY_ALL_USERS } from '~/feature_flags/constants'; +import { mapStrategiesToRails } from '~/feature_flags/store/helpers'; import { createFeatureFlag, requestCreateFeatureFlag, @@ -24,33 +18,13 @@ describe('Feature flags New Module Actions', () => { let mockedState; beforeEach(() => { - mockedState = state({ endpoint: 'feature_flags.json', path: '/feature_flags' }); + mockedState = state({ endpoint: '/feature_flags.json', path: '/feature_flags' }); }); describe('createFeatureFlag', () => { let mock; - const actionParams = { - name: 'name', - description: 'description', - active: true, - version: LEGACY_FLAG, - scopes: [ - { - id: 1, - environmentScope: 'environmentScope', - active: true, - canUpdate: true, - protected: true, - shouldBeDestroyed: false, - rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, - rolloutPercentage: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, - }, - ], - }; - beforeEach(() => { - mockedState.endpoint = `${TEST_HOST}/endpoint.json`; mock = new MockAdapter(axios); }); @@ -60,33 +34,10 @@ describe('Feature flags New Module Actions', () => { describe('success', () => { it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', (done) => { - const convertedActionParams = mapFromScopesViewModel(actionParams); - - mock.onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams).replyOnce(200); - - testAction( - createFeatureFlag, - actionParams, - mockedState, - [], - [ - { - type: 'requestCreateFeatureFlag', - }, - { - type: 'receiveCreateFeatureFlagSuccess', - }, - ], - done, - ); - }); - - it('sends strategies for new style feature flags', (done) => { - const newVersionFlagParams = { + const actionParams = { name: 'name', description: 'description', active: true, - version: NEW_VERSION_FLAG, strategies: [ { name: ROLLOUT_STRATEGY_ALL_USERS, @@ -97,13 +48,11 @@ describe('Feature flags New Module Actions', () => { }, ], }; - mock - .onPost(`${TEST_HOST}/endpoint.json`, mapStrategiesToRails(newVersionFlagParams)) - .replyOnce(200); + mock.onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)).replyOnce(200); testAction( createFeatureFlag, - newVersionFlagParams, + actionParams, mockedState, [], [ @@ -121,10 +70,22 @@ describe('Feature flags New Module Actions', () => { describe('error', () => { it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', (done) => { - const convertedActionParams = mapFromScopesViewModel(actionParams); - + const actionParams = { + name: 'name', + description: 'description', + active: true, + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + id: 1, + scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }], + shouldBeDestroyed: false, + }, + ], + }; mock - .onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams) + .onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)) .replyOnce(500, { message: [] }); testAction( diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js index 1b5bffc1f9b..b87571830ca 100644 --- a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js +++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { dismiss } from '~/feature_highlight/feature_highlight_helper'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -32,9 +32,10 @@ describe('feature highlight helper', () => { await dismiss(endpoint, highlightId); - expect(Flash).toHaveBeenCalledWith( - 'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: + 'An error occurred while dismissing the feature highlight. Refresh the page and try dismissing again.', + }); }); }); }); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index c03c8f6c529..83e7f6c9b3f 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -8,12 +8,14 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; -import * as commonUtils from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { visitUrl, getParameterByName } from '~/lib/utils/url_utility'; +jest.mock('~/flash'); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), + getParameterByName: jest.fn(), visitUrl: jest.fn(), })); @@ -84,9 +86,10 @@ describe('Filtered Search Manager', () => { jest .spyOn(FilteredSearchDropdownManager.prototype, 'updateDropdownOffset') .mockImplementation(); - jest.spyOn(commonUtils, 'getParameterByName').mockReturnValue(null); jest.spyOn(FilteredSearchVisualTokens, 'unselectTokens'); + getParameterByName.mockReturnValue(null); + input = document.querySelector('.filtered-search'); tokensContainer = document.querySelector('.tokens-container'); manager = new FilteredSearchManager({ page, useDefaultState }); @@ -127,11 +130,10 @@ describe('Filtered Search Manager', () => { jest .spyOn(RecentSearchesService.prototype, 'fetch') .mockImplementation(() => Promise.reject(new RecentSearchesServiceError())); - jest.spyOn(window, 'Flash').mockImplementation(); manager.setup(); - expect(window.Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); }); }); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index 772fa7d07ed..7185f382fc1 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -1,11 +1,14 @@ import { escape } from 'lodash'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import { TEST_HOST } from 'helpers/test_constants'; -import DropdownUtils from '~/filtered_search//dropdown_utils'; +import DropdownUtils from '~/filtered_search/dropdown_utils'; import VisualTokenValue from '~/filtered_search/visual_token_value'; +import createFlash from '~/flash'; import AjaxCache from '~/lib/utils/ajax_cache'; import UsersCache from '~/lib/utils/users_cache'; +jest.mock('~/flash'); + describe('Filtered Search Visual Tokens', () => { const findElements = (tokenElement) => { const tokenNameElement = tokenElement.querySelector('.name'); @@ -43,7 +46,6 @@ describe('Filtered Search Visual Tokens', () => { }); it('ignores error if UsersCache throws', (done) => { - jest.spyOn(window, 'Flash').mockImplementation(() => {}); const dummyError = new Error('Earth rotated backwards'); const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); const tokenValue = tokenValueElement.innerText; @@ -55,7 +57,7 @@ describe('Filtered Search Visual Tokens', () => { subject .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) .then(() => { - expect(window.Flash.mock.calls.length).toBe(0); + expect(createFlash.mock.calls.length).toBe(0); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/fixtures/api_markdown.rb b/spec/frontend/fixtures/api_markdown.rb index 1c3967b2c36..94db262e4fd 100644 --- a/spec/frontend/fixtures/api_markdown.rb +++ b/spec/frontend/fixtures/api_markdown.rb @@ -4,12 +4,29 @@ require 'spec_helper' RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do include ApiHelpers + include WikiHelpers include JavaScriptFixturesHelpers + let_it_be(:user) { create(:user) } + + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :public, :repository, group: group) } + + let_it_be(:project_wiki) { create(:project_wiki, user: user) } + + 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 + + before do + sign_in(user) end markdown_examples = begin @@ -19,14 +36,27 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do end markdown_examples.each do |markdown_example| + context = markdown_example.fetch(:context, '') name = markdown_example.fetch(:name) - context "for #{name}" do + context "for #{name}#{!context.empty? ? " (context: #{context})" : ''}" do let(:markdown) { markdown_example.fetch(:markdown) } + name = "#{context}_#{name}" unless context.empty? + it "#{fixture_subdir}/#{name}.json" do - post api("/markdown"), params: { text: markdown, gfm: true } + api_url = case context + when 'project' + "/#{project.full_path}/preview_markdown" + when 'group' + "/groups/#{group.full_path}/preview_markdown" + when 'project_wiki' + "/#{project.full_path}/-/wikis/#{project_wiki_page.slug}/preview_markdown" + else + api "/markdown" + end + post api_url, params: { text: markdown, gfm: true } expect(response).to be_successful end end diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index 3274e914f03..8d8c9a1d902 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -10,8 +10,28 @@ markdown: '`code`' - name: strike markdown: '~~del~~' +- name: horizontal_rule + markdown: '---' - name: link markdown: '[GitLab](https://gitlab.com)' +- name: attachment_link + context: project_wiki + markdown: '[test-file](test-file.zip)' +- name: attachment_link + context: project + markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)' +- name: attachment_link + context: group + markdown: '[test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.zip)' +- name: attachment_image + context: project_wiki + markdown: '![test-file](test-file.png)' +- name: attachment_image + context: project + markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)' +- name: attachment_image + context: group + markdown: '![test-file](/uploads/aa45a38ec2cfe97433281b10bbff042c/test-file.png)' - name: code_block markdown: |- ```javascript @@ -54,3 +74,16 @@ markdown: |- This is a line after a\ hard break +- name: table + markdown: |- + | header | header | + |--------|--------| + | cell | cell | + | cell | cell | +- name: table_with_alignment + markdown: |- + | header | : header : | header : | + |--------|------------|----------| + | cell | cell | cell | + | cell | cell | cell | + diff --git a/spec/frontend/fixtures/application_settings.rb b/spec/frontend/fixtures/application_settings.rb index ebccecb32ba..b09bea56b94 100644 --- a/spec/frontend/fixtures/application_settings.rb +++ b/spec/frontend/fixtures/application_settings.rb @@ -34,4 +34,12 @@ RSpec.describe Admin::ApplicationSettingsController, '(JavaScript fixtures)', ty expect(response).to be_successful end + + it 'application_settings/usage.html' do + stub_application_setting(usage_ping_enabled: false) + + get :metrics_and_profiling + + expect(response).to be_successful + end end diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index 2a538352abe..f695b74ec87 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -13,6 +13,7 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co let!(:build_pipeline_without_author) { create(:ci_build, pipeline: pipeline_without_author, stage: 'test') } let_it_be(:pipeline_without_commit) { create(:ci_pipeline, status: :success, project: project, sha: '0000') } + let!(:build_pipeline_without_commit) { create(:ci_build, pipeline: pipeline_without_commit, stage: 'test') } let(:commit) { create(:commit, project: project) } diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb index 778ae218160..7873d59dbad 100644 --- a/spec/frontend/fixtures/projects.rb +++ b/spec/frontend/fixtures/projects.rb @@ -61,13 +61,12 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do clean_frontend_fixtures('graphql/projects/access_tokens') end - fragment_paths = ['graphql_shared/fragments/pageInfo.fragment.graphql'] base_input_path = 'access_tokens/graphql/queries/' base_output_path = 'graphql/projects/access_tokens/' query_name = 'get_projects.query.graphql' it "#{base_output_path}#{query_name}.json" do - query = get_graphql_query_as_string("#{base_input_path}#{query_name}", fragment_paths) + query = get_graphql_query_as_string("#{base_input_path}#{query_name}") post_graphql(query, current_user: user, variables: { search: '', first: 2 }) diff --git a/spec/frontend/fixtures/prometheus_service.rb b/spec/frontend/fixtures/prometheus_service.rb index 3a59ecf3868..c349f2a24bc 100644 --- a/spec/frontend/fixtures/prometheus_service.rb +++ b/spec/frontend/fixtures/prometheus_service.rb @@ -7,7 +7,7 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } - let!(:service) { create(:prometheus_service, project: project) } + let!(:integration) { create(:prometheus_integration, project: project) } let(:user) { project.owner } render_views @@ -28,7 +28,7 @@ RSpec.describe Projects::ServicesController, '(JavaScript fixtures)', type: :con get :edit, params: { namespace_id: namespace, project_id: project, - id: service.to_param + id: integration.to_param } expect(response).to be_successful diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb index ac34400bc01..e8f259fba15 100644 --- a/spec/frontend/fixtures/releases.rb +++ b/spec/frontend/fixtures/releases.rb @@ -133,15 +133,13 @@ RSpec.describe 'Releases (JavaScript fixtures)' do all_releases_query_path = 'releases/graphql/queries/all_releases.query.graphql' 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' - release_fragment_path = 'releases/graphql/fragments/release.fragment.graphql' - release_for_editing_fragment_path = 'releases/graphql/fragments/release_for_editing.fragment.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, [release_fragment_path]) + query = get_graphql_query_as_string(all_releases_query_path) post_graphql(query, current_user: admin, variables: { fullPath: project.full_path }) @@ -150,7 +148,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do end it "graphql/#{one_release_query_path}.json" do - query = get_graphql_query_as_string(one_release_query_path, [release_fragment_path]) + query = get_graphql_query_as_string(one_release_query_path) post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag }) @@ -159,7 +157,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do end it "graphql/#{one_release_for_editing_query_path}.json" do - query = get_graphql_query_as_string(one_release_for_editing_query_path, [release_for_editing_fragment_path]) + query = get_graphql_query_as_string(one_release_for_editing_query_path) post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag }) diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index b88fb840137..e29a58f43b9 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -36,10 +36,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do get_runners_query_name = 'get_runners.query.graphql' let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{get_runners_query_name}", [ - 'runner/graphql/runner_node.fragment.graphql', - 'graphql_shared/fragments/pageInfo.fragment.graphql' - ]) + get_graphql_query_as_string("#{query_path}#{get_runners_query_name}") end it "#{fixtures_path}#{get_runners_query_name}.json" do @@ -59,9 +56,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do get_runner_query_name = 'get_runner.query.graphql' let_it_be(:query) do - get_graphql_query_as_string("#{query_path}#{get_runner_query_name}", [ - 'runner/graphql/runner_details.fragment.graphql' - ]) + get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") end it "#{fixtures_path}#{get_runner_query_name}.json" do diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 28e8522cc12..96e5202780b 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,5 +1,4 @@ import createFlash, { - deprecatedCreateFlash, createFlashEl, createAction, hideFlash, @@ -125,120 +124,6 @@ describe('Flash', () => { }); }); - describe('deprecatedCreateFlash', () => { - const message = 'test'; - const type = 'alert'; - const parent = document; - const actionConfig = null; - const fadeTransition = false; - const addBodyClass = true; - const defaultParams = [message, type, parent, actionConfig, fadeTransition, addBodyClass]; - - describe('no flash-container', () => { - it('does not add to the DOM', () => { - const flashEl = deprecatedCreateFlash(message); - - expect(flashEl).toBeNull(); - - expect(document.querySelector('.flash-alert')).toBeNull(); - }); - }); - - describe('with flash-container', () => { - beforeEach(() => { - setFixtures( - '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>', - ); - }); - - afterEach(() => { - document.querySelector('.js-content-wrapper').remove(); - }); - - it('adds flash element into container', () => { - deprecatedCreateFlash(...defaultParams); - - expect(document.querySelector('.flash-alert')).not.toBeNull(); - - expect(document.body.className).toContain('flash-shown'); - }); - - it('adds flash into specified parent', () => { - deprecatedCreateFlash( - message, - type, - document.querySelector('.content-wrapper'), - actionConfig, - fadeTransition, - addBodyClass, - ); - - expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull(); - expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message); - }); - - it('adds container classes when inside content-wrapper', () => { - deprecatedCreateFlash(...defaultParams); - - expect(document.querySelector('.flash-text').className).toBe('flash-text'); - expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message); - }); - - it('does not add container when outside of content-wrapper', () => { - document.querySelector('.content-wrapper').className = 'js-content-wrapper'; - deprecatedCreateFlash(...defaultParams); - - expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text'); - }); - - it('removes element after clicking', () => { - deprecatedCreateFlash(...defaultParams); - - document.querySelector('.flash-alert .js-close-icon').click(); - - expect(document.querySelector('.flash-alert')).toBeNull(); - - expect(document.body.className).not.toContain('flash-shown'); - }); - - describe('with actionConfig', () => { - it('adds action link', () => { - const newActionConfig = { title: 'test' }; - deprecatedCreateFlash( - message, - type, - parent, - newActionConfig, - fadeTransition, - addBodyClass, - ); - - expect(document.querySelector('.flash-action')).not.toBeNull(); - }); - - it('calls actionConfig clickHandler on click', () => { - const newActionConfig = { - title: 'test', - clickHandler: jest.fn(), - }; - - deprecatedCreateFlash( - message, - type, - parent, - newActionConfig, - fadeTransition, - addBodyClass, - ); - - document.querySelector('.flash-action').click(); - - expect(newActionConfig.clickHandler).toHaveBeenCalled(); - }); - }); - }); - }); - describe('createFlash', () => { const message = 'test'; const type = 'alert'; diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js index 9a68115e4f6..5a05265afdc 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js @@ -1,9 +1,11 @@ +import { GlButton } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue'; import { createStore } from '~/frequent_items/store'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import { mockProject } from '../mock_data'; const localVue = createLocalVue(); @@ -15,12 +17,12 @@ describe('FrequentItemsListItemComponent', () => { let store; const findTitle = () => wrapper.find({ ref: 'frequentItemsItemTitle' }); - const findAvatar = () => wrapper.find({ ref: 'frequentItemsItemAvatar' }); + const findAvatar = () => wrapper.findComponent(ProjectAvatar); const findAllTitles = () => wrapper.findAll({ ref: 'frequentItemsItemTitle' }); const findNamespace = () => wrapper.find({ ref: 'frequentItemsItemNamespace' }); - const findAllAnchors = () => wrapper.findAll('a'); + const findAllButtons = () => wrapper.findAllComponents(GlButton); const findAllNamespace = () => wrapper.findAll({ ref: 'frequentItemsItemNamespace' }); - const findAvatarContainer = () => wrapper.findAll({ ref: 'frequentItemsItemAvatarContainer' }); + const findAllAvatars = () => wrapper.findAllComponents(ProjectAvatar); const findAllMetadataContainers = () => wrapper.findAll({ ref: 'frequentItemsItemMetadataContainer' }); @@ -91,16 +93,8 @@ describe('FrequentItemsListItemComponent', () => { createComponent(); }); - it('should render avatar if avatarUrl is present', () => { - wrapper.setProps({ avatarUrl: 'path/to/avatar.png' }); - - return wrapper.vm.$nextTick(() => { - expect(findAvatar().exists()).toBe(true); - }); - }); - - it('should not render avatar if avatarUrl is not present', () => { - expect(findAvatar().exists()).toBe(false); + it('renders avatar', () => { + expect(findAvatar().exists()).toBe(true); }); it('renders root element with the right classes', () => { @@ -109,8 +103,8 @@ describe('FrequentItemsListItemComponent', () => { it.each` name | selector | expected - ${'anchor'} | ${findAllAnchors} | ${1} - ${'avatar container'} | ${findAvatarContainer} | ${1} + ${'button'} | ${findAllButtons} | ${1} + ${'avatar container'} | ${findAllAvatars} | ${1} ${'metadata container'} | ${findAllMetadataContainers} | ${1} ${'title'} | ${findAllTitles} | ${1} ${'namespace'} | ${findAllNamespace} | ${1} @@ -119,13 +113,10 @@ describe('FrequentItemsListItemComponent', () => { }); it('tracks when item link is clicked', () => { - const link = wrapper.find('a'); - // NOTE: this listener is required to prevent the click from going through and causing: - // `Error: Not implemented: navigation ...` - link.element.addEventListener('click', (e) => { - e.preventDefault(); - }); - link.trigger('click'); + const link = wrapper.findComponent(GlButton); + + link.vm.$emit('click'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { label: 'projects_dropdown_frequent_items_list_item', }); diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js index cd2cc88fa5a..44c70f1ad4d 100644 --- a/spec/frontend/gpg_badges_spec.js +++ b/spec/frontend/gpg_badges_spec.js @@ -17,19 +17,23 @@ describe('GpgBadges', () => { }; const dummyUrl = `${TEST_HOST}/dummy/signatures`; - beforeEach(() => { - mock = new MockAdapter(axios); + const setForm = ({ utf8 = '✓', search = '' } = {}) => { setFixtures(` <form class="commits-search-form js-signature-container" data-signatures-path="${dummyUrl}" action="${dummyUrl}" method="get"> - <input name="utf8" type="hidden" value="✓"> - <input type="search" name="search" id="commits-search"class="form-control search-text-input input-short"> + <input name="utf8" type="hidden" value="${utf8}"> + <input type="search" name="search" value="${search}" id="commits-search"class="form-control search-text-input input-short"> </form> <div class="parent-container"> <div class="js-loading-gpg-badge" data-commit-sha="${dummyCommitSha}"></div> </div> `); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + setForm(); }); afterEach(() => { @@ -62,6 +66,44 @@ describe('GpgBadges', () => { .catch(done.fail); }); + it('fetches commit signatures', async () => { + mock.onGet(dummyUrl).replyOnce(200); + + await GpgBadges.fetch(); + + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0]).toMatchObject({ + params: { search: '', utf8: '✓' }, + url: dummyUrl, + }); + }); + + it('fetches commit signatures with search parameters with spaces', async () => { + mock.onGet(dummyUrl).replyOnce(200); + setForm({ search: 'my search' }); + + await GpgBadges.fetch(); + + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0]).toMatchObject({ + params: { search: 'my search', utf8: '✓' }, + url: dummyUrl, + }); + }); + + it('fetches commit signatures with search parameters with plus symbols', async () => { + mock.onGet(dummyUrl).replyOnce(200); + setForm({ search: 'my+search' }); + + await GpgBadges.fetch(); + + expect(mock.history.get).toHaveLength(1); + expect(mock.history.get[0]).toMatchObject({ + params: { search: 'my+search', utf8: '✓' }, + url: dummyUrl, + }); + }); + it('displays a loading spinner', (done) => { mock.onGet(dummyUrl).replyOnce(200); diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js index 3cb4dd41574..d5338430054 100644 --- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js +++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js @@ -114,7 +114,6 @@ describe('grafana integration component', () => { .then(() => expect(createFlash).toHaveBeenCalledWith({ message: `There was an error saving your changes. ${message}`, - type: 'alert', }), ); }); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index e559c9519f2..da0ff2a64ec 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -1,9 +1,9 @@ -import '~/flash'; import { GlModal, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import appComponent from '~/groups/components/app.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; import groupItemComponent from '~/groups/components/group_item.vue'; @@ -27,6 +27,7 @@ import { const $toast = { show: jest.fn(), }; +jest.mock('~/flash'); describe('AppComponent', () => { let wrapper; @@ -123,12 +124,12 @@ describe('AppComponent', () => { mock.onGet('/dashboard/groups.json').reply(400); jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); - jest.spyOn(window, 'Flash').mockImplementation(() => {}); - return vm.fetchGroups({}).then(() => { expect(vm.isLoading).toBe(false); expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 }); - expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred. Please try again.', + }); }); }); }); @@ -324,15 +325,13 @@ describe('AppComponent', () => { const message = 'An error occurred. Please try again.'; jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 500 }); jest.spyOn(vm.store, 'removeGroup'); - jest.spyOn(window, 'Flash').mockImplementation(() => {}); - vm.leaveGroup(); expect(vm.targetGroup.isBeingRemoved).toBe(true); expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); return waitForPromises().then(() => { expect(vm.store.removeGroup).not.toHaveBeenCalled(); - expect(window.Flash).toHaveBeenCalledWith(message); + expect(createFlash).toHaveBeenCalledWith({ message }); expect(vm.targetGroup.isBeingRemoved).toBe(false); }); }); @@ -341,15 +340,13 @@ describe('AppComponent', () => { const message = 'Failed to leave the group. Please make sure you are not the only owner.'; jest.spyOn(vm.service, 'leaveGroup').mockRejectedValue({ status: 403 }); jest.spyOn(vm.store, 'removeGroup'); - jest.spyOn(window, 'Flash').mockImplementation(() => {}); - vm.leaveGroup(childGroupItem, groupItem); expect(vm.targetGroup.isBeingRemoved).toBe(true); expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); return waitForPromises().then(() => { expect(vm.store.removeGroup).not.toHaveBeenCalled(); - expect(window.Flash).toHaveBeenCalledWith(message); + expect(createFlash).toHaveBeenCalledWith({ message }); expect(vm.targetGroup.isBeingRemoved).toBe(false); }); }); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 546cdd3cd6f..2369685f506 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -162,11 +162,11 @@ describe('GroupItemComponent', () => { wrapper = createComponent({ group }); }); - it('renders the group pending removal badge', () => { + it('renders the group pending deletion badge', () => { const badgeEl = wrapper.vm.$el.querySelector('.badge-warning'); expect(badgeEl).toBeDefined(); - expect(badgeEl.innerHTML).toContain('pending removal'); + expect(badgeEl.innerHTML).toContain('pending deletion'); }); }); @@ -176,10 +176,10 @@ describe('GroupItemComponent', () => { wrapper = createComponent({ group }); }); - it('does not render the group pending removal badge', () => { + it('does not render the group pending deletion badge', () => { const groupTextContainer = wrapper.vm.$el.querySelector('.group-text-container'); - expect(groupTextContainer).not.toContain('pending removal'); + expect(groupTextContainer).not.toContain('pending deletion'); }); it('renders `item-actions` component and passes correct props to it', () => { @@ -236,13 +236,13 @@ describe('GroupItemComponent', () => { describe('schema.org props', () => { describe('when showSchemaMarkup is disabled on the group', () => { it.each(['itemprop', 'itemtype', 'itemscope'], 'it does not set %s', (attr) => { - expect(wrapper.vm.$el.getAttribute(attr)).toBeNull(); + expect(wrapper.attributes(attr)).toBeUndefined(); }); it.each( ['.js-group-avatar', '.js-group-name', '.js-group-description'], 'it does not set `itemprop` on sub-nodes', (selector) => { - expect(wrapper.vm.$el.querySelector(selector).getAttribute('itemprop')).toBeNull(); + expect(wrapper.find(selector).attributes('itemprop')).toBeUndefined(); }, ); }); @@ -263,16 +263,16 @@ describe('GroupItemComponent', () => { ${'itemtype'} | ${'https://schema.org/Organization'} ${'itemprop'} | ${'subOrganization'} `('it does set correct $attr', ({ attr, value } = {}) => { - expect(wrapper.vm.$el.getAttribute(attr)).toBe(value); + expect(wrapper.attributes(attr)).toBe(value); }); it.each` selector | propValue - ${'[data-testid="group-avatar"]'} | ${'logo'} + ${'img'} | ${'logo'} ${'[data-testid="group-name"]'} | ${'name'} ${'[data-testid="group-description"]'} | ${'description'} `('it does set correct $selector', ({ selector, propValue } = {}) => { - expect(wrapper.vm.$el.querySelector(selector).getAttribute('itemprop')).toBe(propValue); + expect(wrapper.find(selector).attributes('itemprop')).toBe(propValue); }); }); }); diff --git a/spec/frontend/ide/components/ide_project_header_spec.js b/spec/frontend/ide/components/ide_project_header_spec.js new file mode 100644 index 00000000000..fc39651c661 --- /dev/null +++ b/spec/frontend/ide/components/ide_project_header_spec.js @@ -0,0 +1,44 @@ +import { shallowMount } from '@vue/test-utils'; +import IDEProjectHeader from '~/ide/components/ide_project_header.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; + +const mockProject = { + name: 'test proj', + avatar_url: 'https://gitlab.com', + path_with_namespace: 'path/with-namespace', + web_url: 'https://gitlab.com/project', +}; + +describe('IDE project header', () => { + let wrapper; + + const findProjectAvatar = () => wrapper.findComponent(ProjectAvatar); + const findProjectLink = () => wrapper.find('[data-testid="go-to-project-link"'); + + const createComponent = () => { + wrapper = shallowMount(IDEProjectHeader, { propsData: { project: mockProject } }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders ProjectAvatar with correct props', () => { + expect(findProjectAvatar().props()).toMatchObject({ + projectName: mockProject.name, + projectAvatarUrl: mockProject.avatar_url, + }); + }); + + it('renders a link to the project URL', () => { + const link = findProjectLink(); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(mockProject.web_url); + }); + }); +}); diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js index fce6ccf4b58..41111f5dbb4 100644 --- a/spec/frontend/ide/components/new_dropdown/modal_spec.js +++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js @@ -184,9 +184,6 @@ describe('new file modal component', () => { expect(createFlash).toHaveBeenCalledWith({ message: 'The name "test-path/test" is already taken in this directory.', - type: 'alert', - parent: expect.anything(), - actionConfig: null, fadeTransition: false, addBodyClass: true, }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 8e8fb31b15a..4bf3334ae6b 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -8,8 +8,8 @@ import waitForPromises from 'helpers/wait_for_promises'; import waitUsingRealTimer from 'helpers/wait_using_real_timer'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; -import EditorLite from '~/editor/editor_lite'; -import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext'; +import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; +import SourceEditor from '~/editor/source_editor'; import RepoEditor from '~/ide/components/repo_editor.vue'; import { leftSidebarViews, @@ -123,8 +123,8 @@ describe('RepoEditor', () => { const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]'); beforeEach(() => { - createInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_CODE_INSTANCE_FN); - createDiffInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_DIFF_INSTANCE_FN); + createInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_CODE_INSTANCE_FN); + createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN); createModelSpy = jest.spyOn(monacoEditor, 'createModel'); jest.spyOn(service, 'getFileData').mockResolvedValue(); jest.spyOn(service, 'getRawFileData').mockResolvedValue(); @@ -252,7 +252,7 @@ describe('RepoEditor', () => { ); it('installs the WebIDE extension', async () => { - const extensionSpy = jest.spyOn(EditorLite, 'instanceApplyExtension'); + const extensionSpy = jest.spyOn(SourceEditor, 'instanceApplyExtension'); await createComponent(); expect(extensionSpy).toHaveBeenCalled(); Reflect.ownKeys(EditorWebIdeExtension.prototype) @@ -640,11 +640,12 @@ describe('RepoEditor', () => { pasteImage(); await waitForFileContentChange(); + expect(vm.$store.state.entries['foo/foo.png'].rawPath.startsWith('blob:')).toBe(true); expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({ path: 'foo/foo.png', type: 'blob', - content: 'Zm9v', - rawPath: 'data:image/png;base64,Zm9v', + content: 'foo', + rawPath: vm.$store.state.entries['foo/foo.png'].rawPath, }); }); diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 925446aa280..eacf1244d55 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -292,7 +292,7 @@ describe('IDE services', () => { it('posts to usage endpoint', () => { const TEST_PROJECT_PATH = 'foo/bar'; - const axiosURL = `${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_PATH}/usage_ping/web_ide_pipelines_count`; + const axiosURL = `${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_PATH}/service_ping/web_ide_pipelines_count`; mock.onPost(axiosURL).reply(200); diff --git a/spec/frontend/ide/stores/modules/clientside/actions_spec.js b/spec/frontend/ide/stores/modules/clientside/actions_spec.js index c167d056039..88d7a630a90 100644 --- a/spec/frontend/ide/stores/modules/clientside/actions_spec.js +++ b/spec/frontend/ide/stores/modules/clientside/actions_spec.js @@ -5,7 +5,7 @@ import * as actions from '~/ide/stores/modules/clientside/actions'; import axios from '~/lib/utils/axios_utils'; const TEST_PROJECT_URL = `${TEST_HOST}/lorem/ipsum`; -const TEST_USAGE_URL = `${TEST_PROJECT_URL}/usage_ping/web_ide_clientside_preview`; +const TEST_USAGE_URL = `${TEST_PROJECT_URL}/service_ping/web_ide_clientside_preview`; describe('IDE store module clientside actions', () => { let rootGetters; diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js index 8f7b8c5e311..79b6b66319e 100644 --- a/spec/frontend/ide/stores/utils_spec.js +++ b/spec/frontend/ide/stores/utils_spec.js @@ -604,7 +604,7 @@ describe('Multi-file store utils', () => { let entries; beforeEach(() => { - const img = { content: '/base64/encoded/image+' }; + const img = { content: 'png-gibberish', rawPath: 'blob:1234' }; mdFile = { path: 'path/to/some/directory/myfile.md' }; entries = { // invalid (or lack of) extensions are also supported as long as there's @@ -637,14 +637,14 @@ describe('Multi-file store utils', () => { ${'* ![img](img.png "title here")'} | ${'png'} | ${'img'} | ${'title here'} `( 'correctly transforms markdown with uncommitted images: $markdownBefore', - ({ markdownBefore, ext, imgAlt, imgTitle }) => { + ({ markdownBefore, imgAlt, imgTitle }) => { mdFile.content = markdownBefore; expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({ content: '* {{gl_md_img_1}}', images: { '{{gl_md_img_1}}': { - src: `data:image/${ext};base64,/base64/encoded/image+`, + src: 'blob:1234', alt: imgAlt, title: imgTitle, }, diff --git a/spec/frontend/import_entities/components/group_dropdown_spec.js b/spec/frontend/import_entities/components/group_dropdown_spec.js new file mode 100644 index 00000000000..f7aa0e889ea --- /dev/null +++ b/spec/frontend/import_entities/components/group_dropdown_spec.js @@ -0,0 +1,44 @@ +import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import GroupDropdown from '~/import_entities/components/group_dropdown.vue'; + +describe('Import entities group dropdown component', () => { + let wrapper; + let namespacesTracker; + + const createComponent = (propsData) => { + namespacesTracker = jest.fn(); + + wrapper = shallowMount(GroupDropdown, { + scopedSlots: { + default: namespacesTracker, + }, + stubs: { GlDropdown }, + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('passes namespaces from props to default slot', () => { + const namespaces = ['ns1', 'ns2']; + createComponent({ namespaces }); + + expect(namespacesTracker).toHaveBeenCalledWith({ namespaces }); + }); + + it('filters namespaces based on user input', async () => { + const namespaces = ['match1', 'some unrelated', 'match2']; + createComponent({ namespaces }); + + namespacesTracker.mockReset(); + wrapper.find(GlSearchBoxByType).vm.$emit('input', 'match'); + + await nextTick(); + + expect(namespacesTracker).toHaveBeenCalledWith({ namespaces: ['match1', 'match2'] }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js index aa6a40cad18..654a8fd00d3 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js @@ -1,8 +1,9 @@ -import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; +import { GlButton, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; import { STATUSES } from '~/import_entities/constants'; import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; import addValidationErrorMutation from '~/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql'; @@ -41,7 +42,7 @@ describe('import table row', () => { }; const findImportButton = () => findByText(GlButton, 'Import'); const findNameInput = () => wrapper.find(GlFormInput); - const findNamespaceDropdown = () => wrapper.find(GlDropdown); + const findNamespaceDropdown = () => wrapper.find(ImportGroupDropdown); const createComponent = (props) => { apolloProvider = createMockApollo([ @@ -65,6 +66,7 @@ describe('import table row', () => { wrapper = shallowMount(ImportTableRow, { apolloProvider, + stubs: { ImportGroupDropdown }, propsData: { availableNamespaces: availableNamespacesFixture, groupPathRegex: /.*/, diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js index d9f4168f1a5..0e748baa313 100644 --- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -11,6 +11,8 @@ import state from '~/import_entities/import_projects/store/state'; describe('ImportProjectsTable', () => { let wrapper; + const USER_NAMESPACE = 'root'; + const findFilterField = () => wrapper .findAllComponents(GlFormInput) @@ -48,7 +50,7 @@ describe('ImportProjectsTable', () => { localVue.use(Vuex); const store = new Vuex.Store({ - state: { ...state(), ...initialState }, + state: { ...state(), defaultTargetNamespace: USER_NAMESPACE, ...initialState }, getters: { ...getters, ...customGetters, diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js index e15389be53a..72640f3d601 100644 --- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js @@ -1,11 +1,11 @@ -import { GlBadge, GlButton } from '@gitlab/ui'; +import { GlBadge, GlButton, GlDropdown } from '@gitlab/ui'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Vuex from 'vuex'; import { STATUSES } from '~/import_entities//constants'; +import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; import ImportStatus from '~/import_entities/components/import_status.vue'; import ProviderRepoTableRow from '~/import_entities/import_projects/components/provider_repo_table_row.vue'; -import Select2Select from '~/vue_shared/components/select2_select.vue'; describe('ProviderRepoTableRow', () => { let wrapper; @@ -16,10 +16,8 @@ describe('ProviderRepoTableRow', () => { newName: 'newName', }; - const availableNamespaces = [ - { text: 'Groups', children: [{ id: 'test', text: 'test' }] }, - { text: 'Users', children: [{ id: 'root', text: 'root' }] }, - ]; + const availableNamespaces = ['test']; + const userNamespace = 'root'; function initStore(initialState) { const store = new Vuex.Store({ @@ -48,7 +46,7 @@ describe('ProviderRepoTableRow', () => { wrapper = shallowMount(ProviderRepoTableRow, { localVue, store, - propsData: { availableNamespaces, ...props }, + propsData: { availableNamespaces, userNamespace, ...props }, }); } @@ -81,9 +79,8 @@ describe('ProviderRepoTableRow', () => { expect(wrapper.find(ImportStatus).props().status).toBe(STATUSES.NONE); }); - it('renders a select2 namespace select', () => { - expect(wrapper.find(Select2Select).exists()).toBe(true); - expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces); + it('renders a group namespace select', () => { + expect(wrapper.find(ImportGroupDropdown).props().namespaces).toBe(availableNamespaces); }); it('renders import button', () => { @@ -133,7 +130,7 @@ describe('ProviderRepoTableRow', () => { }); it('does not renders a namespace select', () => { - expect(wrapper.find(Select2Select).exists()).toBe(false); + expect(wrapper.find(GlDropdown).exists()).toBe(false); }); it('does not render import button', () => { diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap index 4f70f908c4a..1e3c344ce65 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap @@ -39,7 +39,9 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` class="settings-content" > <gl-tabs-stub + queryparamname="tab" theme="indigo" + value="0" > <!----> diff --git a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js index f4342c56f98..1b0253480e0 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js @@ -39,7 +39,6 @@ describe('IncidentsSettingsService', () => { return service.updateSettings({}).then(() => { expect(createFlash).toHaveBeenCalledWith({ message: expect.stringContaining(ERROR_MSG), - type: 'alert', }); }); }); 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 eb5f7e9fe40..2860d3cc37a 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -2,7 +2,6 @@ import { GlFormCheckbox, GlFormInput } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; -import JiraUpgradeCta from '~/integrations/edit/components/jira_upgrade_cta.vue'; import eventHub from '~/integrations/edit/event_hub'; import { createStore } from '~/integrations/edit/store'; @@ -14,6 +13,7 @@ describe('JiraIssuesFields', () => { editProjectPath: '/edit', showJiraIssuesIntegration: true, showJiraVulnerabilitiesIntegration: true, + upgradePlanPath: 'https://gitlab.com', }; const createComponent = ({ isInheriting = false, props, ...options } = {}) => { @@ -37,60 +37,79 @@ describe('JiraIssuesFields', () => { const findEnableCheckboxDisabled = () => findEnableCheckbox().find('[type=checkbox]').attributes('disabled'); const findProjectKey = () => wrapper.findComponent(GlFormInput); - const findJiraUpgradeCta = () => wrapper.findComponent(JiraUpgradeCta); + const findPremiumUpgradeCTA = () => wrapper.findByTestId('premium-upgrade-cta'); + const findUltimateUpgradeCTA = () => wrapper.findByTestId('ultimate-upgrade-cta'); const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities'); const setEnableCheckbox = async (isEnabled = true) => findEnableCheckbox().vm.$emit('input', isEnabled); - describe('jira issues call to action', () => { - it('shows the premium message', () => { - createComponent({ - props: { showJiraIssuesIntegration: false }, - }); - - expect(findJiraUpgradeCta().props()).toMatchObject({ - showPremiumMessage: true, - showUltimateMessage: false, - }); - }); - - it('shows the ultimate message', () => { - createComponent({ - props: { - showJiraIssuesIntegration: true, - showJiraVulnerabilitiesIntegration: false, - }, - }); - - expect(findJiraUpgradeCta().props()).toMatchObject({ - showPremiumMessage: false, - showUltimateMessage: true, - }); - }); - }); - describe('template', () => { - describe('upgrade banner for non-Premium user', () => { - beforeEach(() => { - createComponent({ props: { initialProjectKey: '', showJiraIssuesIntegration: false } }); - }); + describe.each` + showJiraIssuesIntegration | showJiraVulnerabilitiesIntegration + ${false} | ${false} + ${false} | ${true} + ${true} | ${false} + ${true} | ${true} + `( + 'when `showJiraIssuesIntegration` is $jiraIssues and `showJiraVulnerabilitiesIntegration` is $jiraVulnerabilities', + ({ showJiraIssuesIntegration, showJiraVulnerabilitiesIntegration }) => { + beforeEach(() => { + createComponent({ + props: { + showJiraIssuesIntegration, + showJiraVulnerabilitiesIntegration, + }, + }); + }); - it('does not show checkbox and input field', () => { - expect(findEnableCheckbox().exists()).toBe(false); - expect(findProjectKey().exists()).toBe(false); - }); - }); + if (showJiraIssuesIntegration) { + it('renders checkbox and input field', () => { + expect(findEnableCheckbox().exists()).toBe(true); + expect(findEnableCheckboxDisabled()).toBeUndefined(); + expect(findProjectKey().exists()).toBe(true); + }); + + it('does not render the Premium CTA', () => { + expect(findPremiumUpgradeCTA().exists()).toBe(false); + }); + + if (!showJiraVulnerabilitiesIntegration) { + it.each` + scenario | enableJiraIssues + ${'when "Enable Jira issues" is checked, renders Ultimate upgrade CTA'} | ${true} + ${'when "Enable Jira issues" is unchecked, does not render Ultimate upgrade CTA'} | ${false} + `('$scenario', async ({ enableJiraIssues }) => { + if (enableJiraIssues) { + await setEnableCheckbox(); + } + expect(findUltimateUpgradeCTA().exists()).toBe(enableJiraIssues); + }); + } + } else { + it('does not render checkbox and input field', () => { + expect(findEnableCheckbox().exists()).toBe(false); + expect(findProjectKey().exists()).toBe(false); + }); + + it('renders the Premium CTA', () => { + const premiumUpgradeCTA = findPremiumUpgradeCTA(); + + expect(premiumUpgradeCTA.exists()).toBe(true); + expect(premiumUpgradeCTA.props('upgradePlanPath')).toBe(defaultProps.upgradePlanPath); + }); + } + + it('does not render the Ultimate CTA', () => { + expect(findUltimateUpgradeCTA().exists()).toBe(false); + }); + }, + ); describe('Enable Jira issues checkbox', () => { beforeEach(() => { createComponent({ props: { initialProjectKey: '' } }); }); - it('renders enabled checkbox', () => { - expect(findEnableCheckbox().exists()).toBe(true); - expect(findEnableCheckboxDisabled()).toBeUndefined(); - }); - it('renders disabled project_key input', () => { const projectKey = findProjectKey(); @@ -99,10 +118,6 @@ describe('JiraIssuesFields', () => { expect(projectKey.attributes('required')).toBeUndefined(); }); - it('does not show upgrade banner', () => { - expect(findJiraUpgradeCta().exists()).toBe(false); - }); - // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1, // browsers don't include unchecked boxes in form submissions. it('includes issues_enabled as false even if unchecked', () => { 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 eabbea84234..b828b5d8a04 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -1,11 +1,27 @@ -import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { + GlDropdown, + GlDropdownItem, + GlDatepicker, + GlFormGroup, + GlSprintf, + GlLink, + GlModal, +} from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; import { stubComponent } from 'helpers/stub_component'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; +import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; +import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses'; + +let wrapper; +let mock; jest.mock('~/experimentation/experiment_tracking'); @@ -26,10 +42,16 @@ const user3 = { username: 'one_2', avatar_url: '', }; +const user4 = { + id: 'user-defined-token', + name: 'email4@example.com', + username: 'one_4', + avatar_url: '', +}; const sharedGroup = { id: '981' }; const createComponent = (data = {}, props = {}) => { - return shallowMount(InviteMembersModal, { + wrapper = shallowMountExtended(InviteMembersModal, { propsData: { id, name, @@ -51,46 +73,56 @@ const createComponent = (data = {}, props = {}) => { GlDropdown: true, GlDropdownItem: true, GlSprintf, + GlFormGroup: stubComponent(GlFormGroup, { + props: ['state', 'invalidFeedback'], + }), }, }); }; const createInviteMembersToProjectWrapper = () => { - return createComponent({ inviteeType: 'members' }, { isProject: true }); + createComponent({ inviteeType: 'members' }, { isProject: true }); }; const createInviteMembersToGroupWrapper = () => { - return createComponent({ inviteeType: 'members' }, { isProject: false }); + createComponent({ inviteeType: 'members' }, { isProject: false }); }; const createInviteGroupToProjectWrapper = () => { - return createComponent({ inviteeType: 'group' }, { isProject: true }); + createComponent({ inviteeType: 'group' }, { isProject: true }); }; const createInviteGroupToGroupWrapper = () => { - return createComponent({ inviteeType: 'group' }, { isProject: false }); + createComponent({ inviteeType: 'group' }, { isProject: false }); }; -describe('InviteMembersModal', () => { - let wrapper; +beforeEach(() => { + gon.api_version = 'v4'; + mock = new MockAdapter(axios); +}); - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); +afterEach(() => { + wrapper.destroy(); + wrapper = null; + mock.restore(); +}); +describe('InviteMembersModal', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); const findDatepicker = () => wrapper.findComponent(GlDatepicker); const findLink = () => wrapper.findComponent(GlLink); const findIntroText = () => wrapper.find({ ref: 'introText' }).text(); - const findCancelButton = () => wrapper.findComponent({ ref: 'cancelButton' }); - const findInviteButton = () => wrapper.findComponent({ ref: 'inviteButton' }); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findInviteButton = () => wrapper.findByTestId('invite-button'); const clickInviteButton = () => findInviteButton().vm.$emit('click'); + const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); + const membersFormGroupInvalidFeedback = () => findMembersFormGroup().props('invalidFeedback'); + const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect); describe('rendering the modal', () => { beforeEach(() => { - wrapper = createComponent(); + createComponent(); }); it('renders the modal with the correct title', () => { @@ -132,7 +164,7 @@ describe('InviteMembersModal', () => { describe('when inviting to a project', () => { describe('when inviting members', () => { it('includes the correct invitee, type, and formatted name', () => { - wrapper = createInviteMembersToProjectWrapper(); + createInviteMembersToProjectWrapper(); expect(findIntroText()).toBe("You're inviting members to the test name project."); }); @@ -140,7 +172,7 @@ describe('InviteMembersModal', () => { describe('when sharing with a group', () => { it('includes the correct invitee, type, and formatted name', () => { - wrapper = createInviteGroupToProjectWrapper(); + createInviteGroupToProjectWrapper(); expect(findIntroText()).toBe("You're inviting a group to the test name project."); }); @@ -150,7 +182,7 @@ describe('InviteMembersModal', () => { describe('when inviting to a group', () => { describe('when inviting members', () => { it('includes the correct invitee, type, and formatted name', () => { - wrapper = createInviteMembersToGroupWrapper(); + createInviteMembersToGroupWrapper(); expect(findIntroText()).toBe("You're inviting members to the test name group."); }); @@ -158,7 +190,7 @@ describe('InviteMembersModal', () => { describe('when sharing with a group', () => { it('includes the correct invitee, type, and formatted name', () => { - wrapper = createInviteGroupToGroupWrapper(); + createInviteGroupToGroupWrapper(); expect(findIntroText()).toBe("You're inviting a group to the test name group."); }); @@ -167,22 +199,30 @@ describe('InviteMembersModal', () => { }); describe('submitting the invite form', () => { - const apiErrorMessage = 'Member already exists'; + const mockMembersApi = (code, data) => { + mock.onPost(apiPaths.GROUPS_MEMBERS).reply(code, data); + }; + const mockInvitationsApi = (code, data) => { + mock.onPost(apiPaths.GROUPS_INVITATIONS).reply(code, data); + }; + + const expectedEmailRestrictedError = + "email 'email@example.com' does not match the allowed domains: example1.org"; + const expectedSyntaxError = 'email contains an invalid email address'; describe('when inviting an existing user to group by user ID', () => { const postData = { - user_id: '1', + user_id: '1,2', access_level: defaultAccessLevel, expires_at: undefined, invite_source: inviteSource, format: 'json', }; - describe('when invites are sent successfully', () => { + describe('when member is added successfully', () => { beforeEach(() => { - wrapper = createInviteMembersToGroupWrapper(); + createComponent({ newUsersToInvite: [user1, user2] }); - wrapper.setData({ newUsersToInvite: [user1] }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); @@ -190,54 +230,102 @@ describe('InviteMembersModal', () => { clickInviteButton(); }); - it('calls Api addGroupMembersByUserId with the correct params', () => { + it('calls Api addGroupMembersByUserId with the correct params', async () => { + await waitForPromises; + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData); }); - it('displays the successful toastMessage', () => { + it('displays the successful toastMessage', async () => { + await waitForPromises; + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); }); }); - describe('when the invite received an api error message', () => { + describe('when member is not added successfully', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user1] }); + createInviteMembersToGroupWrapper(); - wrapper.vm.$toast = { show: jest.fn() }; - jest - .spyOn(Api, 'addGroupMembersByUserId') - .mockRejectedValue({ response: { data: { message: apiErrorMessage } } }); - jest.spyOn(wrapper.vm, 'showToastMessageError'); + wrapper.setData({ newUsersToInvite: [user1] }); + }); + + it('displays "Member already exists" api message for http status conflict', async () => { + mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); + expect(findMembersFormGroup().props('state')).toBe(false); + expect(findMembersSelect().props('validationState')).toBe(false); }); - it('displays the apiErrorMessage in the toastMessage', async () => { + it('clears the invalid state and message once the list of members to invite is cleared', async () => { + mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS); + + clickInviteButton(); + await waitForPromises(); - expect(wrapper.vm.showToastMessageError).toHaveBeenCalledWith({ - response: { data: { message: apiErrorMessage } }, - }); + expect(membersFormGroupInvalidFeedback()).toBe('Member already exists'); + expect(findMembersFormGroup().props('state')).toBe(false); + expect(findMembersSelect().props('validationState')).toBe(false); + + findMembersSelect().vm.$emit('clear'); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe(''); + expect(findMembersFormGroup().props('state')).not.toBe(false); + expect(findMembersSelect().props('validationState')).not.toBe(false); }); - }); - describe('when any invite failed for any other reason', () => { - beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user1, user2] }); + it('displays the generic error for http server error', async () => { + mockMembersApi(httpStatus.INTERNAL_SERVER_ERROR, 'Request failed with status code 500'); - wrapper.vm.$toast = { show: jest.fn() }; - jest - .spyOn(Api, 'addGroupMembersByUserId') - .mockRejectedValue({ response: { data: { success: false } } }); - jest.spyOn(wrapper.vm, 'showToastMessageError'); + clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); + }); + + it('displays the restricted user api message for response with bad request', async () => { + mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED); clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe(expectedEmailRestrictedError); }); - it('displays the generic error toastMessage', async () => { + it('displays the first part of the error when multiple existing users are restricted by email', async () => { + mockMembersApi(httpStatus.CREATED, membersApiResponse.MULTIPLE_USERS_RESTRICTED); + + clickInviteButton(); + await waitForPromises(); - expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + expect(membersFormGroupInvalidFeedback()).toBe( + "root: User email 'admin@example.com' does not match the allowed domain of example2.com", + ); + expect(findMembersSelect().props('validationState')).toBe(false); + }); + + it('displays an access_level error message received for the existing user', async () => { + mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_ACCESS_LEVEL); + + clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe( + 'should be greater than or equal to Owner inherited membership from group Gitlab Org', + ); + expect(findMembersSelect().props('validationState')).toBe(false); }); }); }); @@ -253,7 +341,7 @@ describe('InviteMembersModal', () => { describe('when invites are sent successfully', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user3] }); + createComponent({ newUsersToInvite: [user3] }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); @@ -271,23 +359,84 @@ describe('InviteMembersModal', () => { }); }); - describe('when any invite failed for any reason', () => { + describe('when invites are not sent successfully', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user1, user2] }); + createInviteMembersToGroupWrapper(); + + wrapper.setData({ newUsersToInvite: [user3] }); + }); + + it('displays the api error for invalid email syntax', async () => { + mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); + + clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); + expect(findMembersSelect().props('validationState')).toBe(false); + }); + it('displays the restricted email error when restricted email is invited', async () => { + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED); + + clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError); + expect(findMembersSelect().props('validationState')).toBe(false); + }); + + it('displays the successful toast message when email has already been invited', async () => { + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); wrapper.vm.$toast = { show: jest.fn() }; - jest - .spyOn(Api, 'addGroupMembersByUserId') - .mockRejectedValue({ response: { data: { success: false } } }); - jest.spyOn(wrapper.vm, 'showToastMessageError'); + jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); + + clickInviteButton(); + + await waitForPromises(); + + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + expect(findMembersSelect().props('validationState')).toBe(null); + }); + + it('displays the first error message when multiple emails return a restricted error message', async () => { + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED); clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError); + expect(findMembersSelect().props('validationState')).toBe(false); + }); + + it('displays the invalid syntax error for bad request', async () => { + mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.ERROR_EMAIL_INVALID); + + clickInviteButton(); + + await waitForPromises(); + + expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); + expect(findMembersSelect().props('validationState')).toBe(false); }); + }); + + describe('when multiple emails are invited at the same time', () => { + it('displays the invalid syntax error if one of the emails is invalid', async () => { + createInviteMembersToGroupWrapper(); + + wrapper.setData({ newUsersToInvite: [user3, user4] }); + mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.ERROR_EMAIL_INVALID); + + clickInviteButton(); - it('displays the generic error toastMessage', async () => { await waitForPromises(); - expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); + expect(findMembersSelect().props('validationState')).toBe(false); }); }); }); @@ -305,7 +454,7 @@ describe('InviteMembersModal', () => { describe('when invites are sent successfully', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user1, user3] }); + createComponent({ newUsersToInvite: [user1, user3] }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); @@ -350,24 +499,20 @@ describe('InviteMembersModal', () => { describe('when any invite failed for any reason', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user1, user3] }); + createInviteMembersToGroupWrapper(); - wrapper.vm.$toast = { show: jest.fn() }; - - jest - .spyOn(Api, 'inviteGroupMembersByEmail') - .mockRejectedValue({ response: { data: { success: false } } }); + wrapper.setData({ newUsersToInvite: [user1, user3] }); - jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); - jest.spyOn(wrapper.vm, 'showToastMessageError'); + mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID); + mockMembersApi(httpStatus.OK, '200 OK'); clickInviteButton(); }); - it('displays the generic error toastMessage', async () => { + it('displays the first error message', async () => { await waitForPromises(); - expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); }); }); }); @@ -382,7 +527,7 @@ describe('InviteMembersModal', () => { }; beforeEach(() => { - wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); + createComponent({ groupToBeSharedWith: sharedGroup }); wrapper.setData({ inviteeType: 'group' }); wrapper.vm.$toast = { show: jest.fn() }; @@ -403,7 +548,7 @@ describe('InviteMembersModal', () => { describe('when sharing the group fails', () => { beforeEach(() => { - wrapper = createComponent({ groupToBeSharedWith: sharedGroup }); + createComponent({ groupToBeSharedWith: sharedGroup }); wrapper.setData({ inviteeType: 'group' }); wrapper.vm.$toast = { show: jest.fn() }; @@ -412,22 +557,20 @@ describe('InviteMembersModal', () => { .spyOn(Api, 'groupShareWithGroup') .mockRejectedValue({ response: { data: { success: false } } }); - jest.spyOn(wrapper.vm, 'showToastMessageError'); - clickInviteButton(); }); - it('displays the generic error toastMessage', async () => { + it('displays the generic error message', async () => { await waitForPromises(); - expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + expect(membersFormGroupInvalidFeedback()).toBe('Something went wrong'); }); }); }); describe('tracking', () => { beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: [user3] }); + createComponent({ newUsersToInvite: [user3] }); wrapper.vm.$toast = { show: jest.fn() }; jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({}); diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index f6e79d3607f..12db7e42464 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -115,6 +115,21 @@ describe('MembersTokenSelect', () => { expect(wrapper.emitted().input[0][0]).toEqual([user1, user2]); }); }); + + describe('when user is removed', () => { + it('emits `clear` event', () => { + findTokenSelector().vm.$emit('token-remove', [user1]); + + expect(wrapper.emitted('clear')).toEqual([[]]); + }); + + it('does not emit `clear` event when there are still tokens selected', () => { + findTokenSelector().vm.$emit('input', [user1, user2]); + findTokenSelector().vm.$emit('token-remove', [user1]); + + expect(wrapper.emitted('clear')).toBeUndefined(); + }); + }); }); describe('when text input is blurred', () => { diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js new file mode 100644 index 00000000000..79b56a33708 --- /dev/null +++ b/spec/frontend/invite_members/mock_data/api_responses.js @@ -0,0 +1,74 @@ +const INVITATIONS_API_EMAIL_INVALID = { + message: { error: 'email contains an invalid email address' }, +}; + +const INVITATIONS_API_ERROR_EMAIL_INVALID = { + error: 'email contains an invalid email address', +}; + +const INVITATIONS_API_EMAIL_RESTRICTED = { + message: { + 'email@example.com': + "Invite email 'email@example.com' does not match the allowed domains: example1.org", + }, + status: 'error', +}; + +const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = { + message: { + 'email@example.com': + "Invite email email 'email@example.com' does not match the allowed domains: example1.org", + 'email4@example.com': + "Invite email email 'email4@example.com' does not match the allowed domains: example1.org", + }, + status: 'error', +}; + +const INVITATIONS_API_EMAIL_TAKEN = { + message: { + 'email@example2.com': 'Invite email has already been taken', + }, + status: 'error', +}; + +const MEMBERS_API_MEMBER_ALREADY_EXISTS = { + message: 'Member already exists', +}; + +const MEMBERS_API_SINGLE_USER_RESTRICTED = { + message: { user: ["email 'email@example.com' does not match the allowed domains: example1.org"] }, +}; + +const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = { + message: { + access_level: [ + 'should be greater than or equal to Owner inherited membership from group Gitlab Org', + ], + }, +}; + +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", + status: 'error', +}; + +export const apiPaths = { + GROUPS_MEMBERS: '/api/v4/groups/1/members', + GROUPS_INVITATIONS: '/api/v4/groups/1/invitations', +}; + +export const membersApiResponse = { + MEMBER_ALREADY_EXISTS: MEMBERS_API_MEMBER_ALREADY_EXISTS, + SINGLE_USER_ACCESS_LEVEL: MEMBERS_API_SINGLE_USER_ACCESS_LEVEL, + SINGLE_USER_RESTRICTED: MEMBERS_API_SINGLE_USER_RESTRICTED, + MULTIPLE_USERS_RESTRICTED: MEMBERS_API_MULTIPLE_USERS_RESTRICTED, +}; + +export const invitationsApiResponse = { + EMAIL_INVALID: INVITATIONS_API_EMAIL_INVALID, + ERROR_EMAIL_INVALID: INVITATIONS_API_ERROR_EMAIL_INVALID, + EMAIL_RESTRICTED: INVITATIONS_API_EMAIL_RESTRICTED, + MULTIPLE_EMAIL_RESTRICTED: INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED, + EMAIL_TAKEN: INVITATIONS_API_EMAIL_TAKEN, +}; diff --git a/spec/frontend/invite_members/utils/response_message_parser_spec.js b/spec/frontend/invite_members/utils/response_message_parser_spec.js new file mode 100644 index 00000000000..3c88b5a2418 --- /dev/null +++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js @@ -0,0 +1,36 @@ +import { + responseMessageFromSuccess, + responseMessageFromError, +} from '~/invite_members/utils/response_message_parser'; + +describe('Response message parser', () => { + const expectedMessage = 'expected display message'; + + describe('parse message from successful response', () => { + const exampleKeyedMsg = { 'email@example.com': expectedMessage }; + const exampleUserMsgMultiple = + ' and username1: id not found and username2: email is restricted'; + + it.each([ + [[{ data: { message: expectedMessage } }]], + [[{ data: { message: expectedMessage + exampleUserMsgMultiple } }]], + [[{ data: { error: expectedMessage } }]], + [[{ data: { message: [expectedMessage] } }]], + [[{ data: { message: exampleKeyedMsg } }]], + ])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => { + expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage); + }); + }); + + describe('message from error response', () => { + it.each([ + [{ response: { data: { error: expectedMessage } } }], + [{ response: { data: { message: { user: [expectedMessage] } } } }], + [{ response: { data: { message: { access_level: [expectedMessage] } } } }], + [{ response: { data: { message: { error: expectedMessage } } } }], + [{ response: { data: { message: expectedMessage } } }], + ])(`returns "${expectedMessage}" from error response: %j`, (errorResponse) => { + expect(responseMessageFromError(errorResponse)).toBe(expectedMessage); + }); + }); +}); diff --git a/spec/frontend/issuable/components/issuable_by_email_spec.js b/spec/frontend/issuable/components/issuable_by_email_spec.js index f11c41fe25d..01abf239e57 100644 --- a/spec/frontend/issuable/components/issuable_by_email_spec.js +++ b/spec/frontend/issuable/components/issuable_by_email_spec.js @@ -154,10 +154,7 @@ describe('IssuableByEmail', () => { await clickResetEmail(); - expect(mockToastShow).toHaveBeenCalledWith( - 'There was an error when reseting email token.', - { type: 'error' }, - ); + expect(mockToastShow).toHaveBeenCalledWith('There was an error when reseting email token.'); expect(findFormInputGroup().props('value')).toBe('user@gitlab.com'); }); }); diff --git a/spec/frontend/issuable_bulk_update_sidebar/components/status_select_spec.js b/spec/frontend/issuable_bulk_update_sidebar/components/status_select_spec.js new file mode 100644 index 00000000000..09dcb963154 --- /dev/null +++ b/spec/frontend/issuable_bulk_update_sidebar/components/status_select_spec.js @@ -0,0 +1,77 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StatusSelect from '~/issuable_bulk_update_sidebar/components/status_select.vue'; +import { ISSUE_STATUS_SELECT_OPTIONS } from '~/issuable_bulk_update_sidebar/constants'; + +describe('StatusSelect', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findHiddenInput = () => wrapper.find('input'); + + function createComponent() { + wrapper = shallowMount(StatusSelect); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with no value selected', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders default text', () => { + expect(findDropdown().props('text')).toBe('Select status'); + }); + + it('renders dropdown items with `is-checked` prop set to `false`', () => { + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(false); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + }); + }); + + describe('when selecting a value', () => { + const selectItemAtIndex = 0; + + beforeEach(async () => { + createComponent(); + await findAllDropdownItems().at(selectItemAtIndex).vm.$emit('click'); + }); + + it('updates value of the hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe( + ISSUE_STATUS_SELECT_OPTIONS[selectItemAtIndex].value, + ); + }); + + it('updates the dropdown text prop', () => { + expect(findDropdown().props('text')).toBe( + ISSUE_STATUS_SELECT_OPTIONS[selectItemAtIndex].text, + ); + }); + + it('sets dropdown item `is-checked` prop to `true`', () => { + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(true); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + }); + + describe('when selecting the value that is already selected', () => { + it('clears dropdown selection', async () => { + await findAllDropdownItems().at(selectItemAtIndex).vm.$emit('click'); + + const dropdownItems = findAllDropdownItems(); + + expect(dropdownItems.at(0).props('isChecked')).toBe(false); + expect(dropdownItems.at(1).props('isChecked')).toBe(false); + expect(findDropdown().props('text')).toBe('Select status'); + }); + }); + }); +}); diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/issuable_create/components/issuable_form_spec.js index a074fddf091..30b116bc35c 100644 --- a/spec/frontend/issuable_create/components/issuable_form_spec.js +++ b/spec/frontend/issuable_create/components/issuable_form_spec.js @@ -23,6 +23,9 @@ const createComponent = ({ <button class="js-issuable-save">Submit issuable</button> `, }, + stubs: { + MarkdownField, + }, }); }; diff --git a/spec/frontend/issuable_show/components/issuable_show_root_spec.js b/spec/frontend/issuable_show/components/issuable_show_root_spec.js index b4c125f4910..7ad409c3a74 100644 --- a/spec/frontend/issuable_show/components/issuable_show_root_spec.js +++ b/spec/frontend/issuable_show/components/issuable_show_root_spec.js @@ -133,14 +133,6 @@ describe('IssuableShowRoot', () => { expect(wrapper.emitted('task-list-update-failure')).toBeTruthy(); }); - it('component emits `sidebar-toggle` event bubbled via issuable-sidebar', () => { - const issuableSidebar = wrapper.find(IssuableSidebar); - - issuableSidebar.vm.$emit('sidebar-toggle', true); - - expect(wrapper.emitted('sidebar-toggle')).toBeTruthy(); - }); - it.each(['keydown-title', 'keydown-description'])( 'component emits `%s` event with event object and issuableMeta params via issuable-body', (eventName) => { diff --git a/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js index 62a0016d67b..c872925cca2 100644 --- a/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js +++ b/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js @@ -1,88 +1,80 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { shallowMount } from '@vue/test-utils'; import Cookies from 'js-cookie'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import IssuableSidebarRoot from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; +import { USER_COLLAPSED_GUTTER_COOKIE } from '~/issuable_sidebar/constants'; -const createComponent = (expanded = true) => - shallowMount(IssuableSidebarRoot, { - propsData: { - expanded, - }, +const MOCK_LAYOUT_PAGE_CLASS = 'layout-page'; + +const createComponent = () => { + setFixtures(`<div class="${MOCK_LAYOUT_PAGE_CLASS}"></div>`); + + return shallowMountExtended(IssuableSidebarRoot, { slots: { 'right-sidebar-items': ` <button class="js-todo">Todo</button> `, }, }); +}; describe('IssuableSidebarRoot', () => { let wrapper; - beforeEach(() => { - wrapper = createComponent(); - }); + const findToggleSidebarButton = () => wrapper.findByTestId('toggle-right-sidebar-button'); + + const assertPageLayoutClasses = ({ isExpanded }) => { + const { classList } = document.querySelector(`.${MOCK_LAYOUT_PAGE_CLASS}`); + if (isExpanded) { + expect(classList).toContain('right-sidebar-expanded'); + expect(classList).not.toContain('right-sidebar-collapsed'); + } else { + expect(classList).toContain('right-sidebar-collapsed'); + expect(classList).not.toContain('right-sidebar-expanded'); + } + }; afterEach(() => { wrapper.destroy(); }); - describe('watch', () => { - describe('isExpanded', () => { - it('emits `sidebar-toggle` event on component', async () => { - wrapper.setData({ - isExpanded: false, - }); - - await wrapper.vm.$nextTick(); - - expect(wrapper.emitted('sidebar-toggle')).toBeTruthy(); - expect(wrapper.emitted('sidebar-toggle')[0]).toEqual([ - { - expanded: false, - }, - ]); - }); - }); - }); + describe('when sidebar is expanded', () => { + beforeEach(() => { + jest.spyOn(Cookies, 'set').mockImplementation(jest.fn()); + jest.spyOn(Cookies, 'get').mockReturnValue(false); + jest.spyOn(bp, 'isDesktop').mockReturnValue(true); - describe('methods', () => { - describe('updatePageContainerClass', () => { - beforeEach(() => { - setFixtures('<div class="layout-page"></div>'); - }); + wrapper = createComponent(); + }); - it.each` - isExpanded | layoutPageClass - ${true} | ${'right-sidebar-expanded'} - ${false} | ${'right-sidebar-collapsed'} - `( - 'set class $layoutPageClass to container element when `isExpanded` prop is $isExpanded', - async ({ isExpanded, layoutPageClass }) => { - wrapper.setData({ - isExpanded, - }); + it('renders component container element with class `right-sidebar-expanded`', () => { + expect(wrapper.classes()).toContain('right-sidebar-expanded'); + }); - await wrapper.vm.$nextTick(); + it('sets layout class to reflect expanded state', () => { + assertPageLayoutClasses({ isExpanded: true }); + }); - wrapper.vm.updatePageContainerClass(); + it('renders sidebar toggle button with text and icon', () => { + const buttonEl = findToggleSidebarButton(); - expect(document.querySelector('.layout-page').classList.contains(layoutPageClass)).toBe( - true, - ); - }, - ); + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.attributes('title')).toBe('Toggle sidebar'); + expect(buttonEl.find('span').text()).toBe('Collapse sidebar'); + expect(wrapper.findByTestId('icon-collapse').isVisible()).toBe(true); }); - describe('handleWindowResize', () => { - beforeEach(async () => { - wrapper.setData({ - userExpanded: true, - }); + describe('when collapsing the sidebar', () => { + it('updates "collapsed_gutter" cookie value and layout classes', async () => { + await findToggleSidebarButton().trigger('click'); - await wrapper.vm.$nextTick(); + expect(Cookies.set).toHaveBeenCalledWith(USER_COLLAPSED_GUTTER_COOKIE, true); + assertPageLayoutClasses({ isExpanded: false }); }); + }); + describe('when window `resize` event is triggered', () => { it.each` breakpoint | isExpandedValue ${'xs'} | ${false} @@ -91,109 +83,49 @@ describe('IssuableSidebarRoot', () => { ${'lg'} | ${true} ${'xl'} | ${true} `( - 'sets `isExpanded` prop to $isExpandedValue only when current screen size is `lg` or `xl`', + 'sets page layout classes correctly when current screen size is `$breakpoint`', async ({ breakpoint, isExpandedValue }) => { jest.spyOn(bp, 'isDesktop').mockReturnValue(breakpoint === 'lg' || breakpoint === 'xl'); - wrapper.vm.handleWindowResize(); + window.dispatchEvent(new Event('resize')); + await wrapper.vm.$nextTick(); - expect(wrapper.vm.isExpanded).toBe(isExpandedValue); + assertPageLayoutClasses({ isExpanded: isExpandedValue }); }, ); - - it('calls `updatePageContainerClass` method', () => { - jest.spyOn(wrapper.vm, 'updatePageContainerClass'); - - wrapper.vm.handleWindowResize(); - - expect(wrapper.vm.updatePageContainerClass).toHaveBeenCalled(); - }); - }); - - describe('handleToggleSidebarClick', () => { - beforeEach(async () => { - jest.spyOn(Cookies, 'set').mockImplementation(jest.fn()); - wrapper.setData({ - isExpanded: true, - }); - - await wrapper.vm.$nextTick(); - }); - - it('flips value of `isExpanded`', () => { - wrapper.vm.handleToggleSidebarClick(); - - expect(wrapper.vm.isExpanded).toBe(false); - expect(wrapper.vm.userExpanded).toBe(false); - }); - - it('updates "collapsed_gutter" cookie value', () => { - wrapper.vm.handleToggleSidebarClick(); - - expect(Cookies.set).toHaveBeenCalledWith('collapsed_gutter', true); - }); - - it('calls `updatePageContainerClass` method', () => { - jest.spyOn(wrapper.vm, 'updatePageContainerClass'); - - wrapper.vm.handleWindowResize(); - - expect(wrapper.vm.updatePageContainerClass).toHaveBeenCalled(); - }); }); }); - describe('template', () => { - describe('sidebar expanded', () => { - beforeEach(async () => { - wrapper.setData({ - isExpanded: true, - }); + describe('when sidebar is collapsed', () => { + beforeEach(() => { + jest.spyOn(Cookies, 'get').mockReturnValue(true); - await wrapper.vm.$nextTick(); - }); - - it('renders component container element with class `right-sidebar-expanded` when `isExpanded` prop is true', () => { - expect(wrapper.classes()).toContain('right-sidebar-expanded'); - }); - - it('renders sidebar toggle button with text and icon', () => { - const buttonEl = wrapper.find('button'); - - expect(buttonEl.exists()).toBe(true); - expect(buttonEl.attributes('title')).toBe('Toggle sidebar'); - expect(buttonEl.find('span').text()).toBe('Collapse sidebar'); - expect(buttonEl.find('[data-testid="icon-collapse"]').isVisible()).toBe(true); - }); + wrapper = createComponent(); }); - describe('sidebar collapsed', () => { - beforeEach(async () => { - wrapper.setData({ - isExpanded: false, - }); - - await wrapper.vm.$nextTick(); - }); + it('renders component container element with class `right-sidebar-collapsed`', () => { + expect(wrapper.classes()).toContain('right-sidebar-collapsed'); + }); - it('renders component container element with class `right-sidebar-collapsed` when `isExpanded` prop is false', () => { - expect(wrapper.classes()).toContain('right-sidebar-collapsed'); - }); + it('sets layout class to reflect collapsed state', () => { + assertPageLayoutClasses({ isExpanded: false }); + }); - it('renders sidebar toggle button with text and icon', () => { - const buttonEl = wrapper.find('button'); + it('renders sidebar toggle button with text and icon', () => { + const buttonEl = findToggleSidebarButton(); - expect(buttonEl.exists()).toBe(true); - expect(buttonEl.attributes('title')).toBe('Toggle sidebar'); - expect(buttonEl.find('[data-testid="icon-expand"]').isVisible()).toBe(true); - }); + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.attributes('title')).toBe('Toggle sidebar'); + expect(wrapper.findByTestId('icon-expand').isVisible()).toBe(true); }); + }); - it('renders sidebar items', () => { - const sidebarItemsEl = wrapper.find('[data-testid="sidebar-items"]'); + it('renders slotted sidebar items', () => { + wrapper = createComponent(); - expect(sidebarItemsEl.exists()).toBe(true); - expect(sidebarItemsEl.find('button.js-todo').exists()).toBe(true); - }); + const sidebarItemsEl = wrapper.findByTestId('sidebar-items'); + + expect(sidebarItemsEl.exists()).toBe(true); + expect(sidebarItemsEl.find('button.js-todo').exists()).toBe(true); }); }); diff --git a/spec/frontend/issuable_spec.js b/spec/frontend/issuable_spec.js index 9c8f1e04609..e0bd7b802c9 100644 --- a/spec/frontend/issuable_spec.js +++ b/spec/frontend/issuable_spec.js @@ -1,5 +1,5 @@ +import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar'; import IssuableIndex from '~/issuable_index'; -import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; describe('Issuable', () => { describe('initBulkUpdate', () => { diff --git a/spec/frontend/issues_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js index a7f3dd81517..86112dad444 100644 --- a/spec/frontend/issues_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js @@ -8,7 +8,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import Issuable from '~/issues_list/components/issuable.vue'; import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue'; import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants'; @@ -104,7 +104,7 @@ describe('Issuables list component', () => { }); it('flashes an error', () => { - expect(flash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); 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 a3ac57ee1bb..846236e1fb5 100644 --- a/spec/frontend/issues_list/components/issues_list_app_spec.js +++ b/spec/frontend/issues_list/components/issues_list_app_spec.js @@ -5,6 +5,7 @@ import { cloneDeep } from 'lodash'; import { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; +import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; @@ -13,15 +14,16 @@ import { filteredTokens, locationSearch, urlParams, + getIssuesCountQueryResponse, } from 'jest/issues_list/mock_data'; import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; 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 { - apiSortParams, CREATED_DESC, DUE_DATE_OVERDUE, PARAM_DUE_DATE, @@ -55,19 +57,18 @@ describe('IssuesListApp component', () => { localVue.use(VueApollo); const defaultProvide = { - autocompleteUsersPath: 'autocomplete/users/path', calendarPath: 'calendar/path', canBulkUpdate: false, emptyStateSvgPath: 'empty-state.svg', exportCsvPath: 'export/csv/path', hasBlockedIssuesFeature: true, hasIssueWeightsFeature: true, + hasIterationsFeature: true, hasProjectIssues: true, - isSignedIn: false, + isSignedIn: true, issuesPath: 'path/to/issues', jiraIntegrationPath: 'jira/integration/path', newIssuePath: 'new/issue/path', - projectLabelsPath: 'project/labels/path', projectPath: 'path/to/project', rssPath: 'rss/path', showNewIssueLink: true, @@ -77,7 +78,7 @@ describe('IssuesListApp component', () => { let defaultQueryResponse = getIssuesQueryResponse; if (IS_EE) { defaultQueryResponse = cloneDeep(getIssuesQueryResponse); - defaultQueryResponse.data.project.issues.nodes[0].blockedByCount = 1; + defaultQueryResponse.data.project.issues.nodes[0].blockingCount = 1; defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null; defaultQueryResponse.data.project.issues.nodes[0].weight = 5; } @@ -93,10 +94,14 @@ describe('IssuesListApp component', () => { const mountComponent = ({ provide = {}, - response = defaultQueryResponse, + issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), + issuesQueryCountResponse = jest.fn().mockResolvedValue(getIssuesCountQueryResponse), mountFn = shallowMount, } = {}) => { - const requestHandlers = [[getIssuesQuery, jest.fn().mockResolvedValue(response)]]; + const requestHandlers = [ + [getIssuesQuery, issuesQueryResponse], + [getIssuesCountQuery, issuesQueryCountResponse], + ]; const apolloProvider = createMockApollo(requestHandlers); return mountFn(IssuesListApp, { @@ -137,8 +142,8 @@ describe('IssuesListApp component', () => { currentTab: IssuableStates.Opened, tabCounts: { opened: 1, - closed: undefined, - all: undefined, + closed: 1, + all: 1, }, issuablesLoading: false, isManualOrdering: false, @@ -148,8 +153,8 @@ describe('IssuesListApp component', () => { hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage, hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage, urlParams: { + sort: urlSortParams[CREATED_DESC], state: IssuableStates.Opened, - ...urlSortParams[CREATED_DESC], }, }); }); @@ -178,7 +183,7 @@ describe('IssuesListApp component', () => { describe('csv import/export component', () => { describe('when user is signed in', () => { - const search = '?search=refactor&state=opened&sort=created_date'; + const search = '?search=refactor&sort=created_date&state=opened'; beforeEach(() => { global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` }); @@ -273,13 +278,17 @@ describe('IssuesListApp component', () => { describe('sort', () => { it.each(Object.keys(urlSortParams))('is set as %s from the url params', (sortKey) => { - global.jsdom.reconfigure({ url: setUrlParams(urlSortParams[sortKey], TEST_HOST) }); + global.jsdom.reconfigure({ + url: setUrlParams({ sort: urlSortParams[sortKey] }, TEST_HOST), + }); wrapper = mountComponent(); expect(findIssuableList().props()).toMatchObject({ initialSortBy: sortKey, - urlParams: urlSortParams[sortKey], + urlParams: { + sort: urlSortParams[sortKey], + }, }); }); }); @@ -542,9 +551,13 @@ describe('IssuesListApp component', () => { }); it('renders all tokens', () => { + const preloadedAuthors = [ + { ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) }, + ]; + expect(findIssuableList().props('searchTokens')).toMatchObject([ - { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] }, - { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_AUTHOR, preloadedAuthors }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors }, { type: TOKEN_TYPE_MILESTONE }, { type: TOKEN_TYPE_LABEL }, { type: TOKEN_TYPE_MY_REACTION }, @@ -557,6 +570,29 @@ describe('IssuesListApp component', () => { }); }); + describe('errors', () => { + describe.each` + error | mountOption | message + ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} + ${'fetching issue counts'} | ${'issuesQueryCountResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} + `('when there is an error $error', ({ mountOption, message }) => { + beforeEach(() => { + wrapper = mountComponent({ + [mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')), + }); + jest.runOnlyPendingTimers(); + }); + + it('shows an error message', () => { + expect(createFlash).toHaveBeenCalledWith({ + captureError: true, + error: new Error('Network error: ERROR'), + message, + }); + }); + }); + }); + describe('events', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { @@ -622,7 +658,7 @@ describe('IssuesListApp component', () => { }; beforeEach(() => { - wrapper = mountComponent({ response }); + wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response) }); jest.runOnlyPendingTimers(); }); @@ -640,7 +676,7 @@ describe('IssuesListApp component', () => { }); describe('when "sort" event is emitted by IssuableList', () => { - it.each(Object.keys(apiSortParams))( + it.each(Object.keys(urlSortParams))( 'updates to the new sort when payload is `%s`', async (sortKey) => { wrapper = mountComponent(); @@ -650,7 +686,9 @@ describe('IssuesListApp component', () => { jest.runOnlyPendingTimers(); await nextTick(); - expect(findIssuableList().props('urlParams')).toMatchObject(urlSortParams[sortKey]); + expect(findIssuableList().props('urlParams')).toMatchObject({ + sort: urlSortParams[sortKey], + }); }, ); }); diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js index 6c669e02070..fd59241fd1d 100644 --- a/spec/frontend/issues_list/mock_data.js +++ b/spec/frontend/issues_list/mock_data.js @@ -7,9 +7,8 @@ export const getIssuesQueryResponse = { data: { project: { issues: { - count: 1, pageInfo: { - hasNextPage: false, + hasNextPage: true, hasPreviousPage: false, startCursor: 'startcursor', endCursor: 'endcursor', @@ -70,6 +69,16 @@ export const getIssuesQueryResponse = { }, }; +export const getIssuesCountQueryResponse = { + data: { + project: { + issues: { + count: 1, + }, + }, + }, +}; + export const locationSearch = [ '?search=find+issues', 'author_username=homer', @@ -86,10 +95,10 @@ export const locationSearch = [ 'not[label_name][]=drama', 'my_reaction_emoji=thumbsup', 'confidential=no', - 'iteration_title=season:+%234', - 'not[iteration_title]=season:+%2320', - 'epic_id=gitlab-org%3A%3A%2612', - 'not[epic_id]=gitlab-org%3A%3A%2634', + 'iteration_id=4', + 'not[iteration_id]=20', + 'epic_id=12', + 'not[epic_id]=34', 'weight=1', 'not[weight]=3', ].join('&'); @@ -118,10 +127,10 @@ export const filteredTokens = [ { type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } }, { type: 'my_reaction_emoji', value: { data: 'thumbsup', operator: OPERATOR_IS } }, { type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } }, - { type: 'iteration', value: { data: 'season: #4', operator: OPERATOR_IS } }, - { type: 'iteration', value: { data: 'season: #20', operator: OPERATOR_IS_NOT } }, - { type: 'epic_id', value: { data: 'gitlab-org::&12', operator: OPERATOR_IS } }, - { type: 'epic_id', value: { data: 'gitlab-org::&34', operator: OPERATOR_IS_NOT } }, + { type: 'iteration', value: { data: '4', operator: OPERATOR_IS } }, + { type: 'iteration', value: { data: '20', operator: OPERATOR_IS_NOT } }, + { type: 'epic_id', value: { data: '12', operator: OPERATOR_IS } }, + { type: 'epic_id', value: { data: '34', operator: OPERATOR_IS_NOT } }, { type: 'weight', value: { data: '1', operator: OPERATOR_IS } }, { type: 'weight', value: { data: '3', operator: OPERATOR_IS_NOT } }, { type: 'filtered-search-term', value: { data: 'find' } }, @@ -138,30 +147,32 @@ export const filteredTokensWithSpecialValues = [ ]; export const apiParams = { - author_username: 'homer', - 'not[author_username]': 'marge', - assignee_username: ['bart', 'lisa'], - 'not[assignee_username]': ['patty', 'selma'], - milestone: 'season 4', - 'not[milestone]': 'season 20', - labels: ['cartoon', 'tv'], - 'not[labels]': ['live action', 'drama'], - my_reaction_emoji: 'thumbsup', + authorUsername: 'homer', + assigneeUsernames: ['bart', 'lisa'], + milestoneTitle: 'season 4', + labelName: ['cartoon', 'tv'], + myReactionEmoji: 'thumbsup', confidential: 'no', - iteration_title: 'season: #4', - 'not[iteration_title]': 'season: #20', - epic_id: '12', - 'not[epic_id]': 'gitlab-org::&34', + iterationId: '4', + epicId: '12', weight: '1', - 'not[weight]': '3', + not: { + authorUsername: 'marge', + assigneeUsernames: ['patty', 'selma'], + milestoneTitle: 'season 20', + labelName: ['live action', 'drama'], + iterationId: '20', + epicId: '34', + weight: '3', + }, }; export const apiParamsWithSpecialValues = { - assignee_id: '123', - assignee_username: 'bart', - my_reaction_emoji: 'None', - iteration_id: 'Current', - epic_id: 'None', + assigneeId: '123', + assigneeUsernames: 'bart', + myReactionEmoji: 'None', + iterationWildcardId: 'CURRENT', + epicId: 'None', weight: 'None', }; @@ -176,10 +187,10 @@ export const urlParams = { 'not[label_name][]': ['live action', 'drama'], my_reaction_emoji: 'thumbsup', confidential: 'no', - iteration_title: 'season: #4', - 'not[iteration_title]': 'season: #20', - epic_id: 'gitlab-org%3A%3A%2612', - 'not[epic_id]': 'gitlab-org::&34', + iteration_id: '4', + 'not[iteration_id]': '20', + epic_id: '12', + 'not[epic_id]': '34', weight: '1', 'not[weight]': '3', }; diff --git a/spec/frontend/issues_list/utils_spec.js b/spec/frontend/issues_list/utils_spec.js index e377c35a0aa..b7863068570 100644 --- a/spec/frontend/issues_list/utils_spec.js +++ b/spec/frontend/issues_list/utils_spec.js @@ -8,10 +8,11 @@ import { urlParams, urlParamsWithSpecialValues, } from 'jest/issues_list/mock_data'; -import { API_PARAM, DUE_DATE_VALUES, URL_PARAM, urlSortParams } from '~/issues_list/constants'; +import { DUE_DATE_VALUES, urlSortParams } from '~/issues_list/constants'; import { - convertToParams, + convertToApiParams, convertToSearchQuery, + convertToUrlParams, getDueDateValue, getFilterTokens, getSortKey, @@ -20,7 +21,7 @@ import { describe('getSortKey', () => { it.each(Object.keys(urlSortParams))('returns %s given the correct inputs', (sortKey) => { - const { sort } = urlSortParams[sortKey]; + const sort = urlSortParams[sortKey]; expect(getSortKey(sort)).toBe(sortKey); }); }); @@ -80,31 +81,23 @@ describe('getFilterTokens', () => { }); }); -describe('convertToParams', () => { +describe('convertToApiParams', () => { it('returns api params given filtered tokens', () => { - expect(convertToParams(filteredTokens, API_PARAM)).toEqual({ - ...apiParams, - epic_id: 'gitlab-org::&12', - }); + expect(convertToApiParams(filteredTokens)).toEqual(apiParams); }); it('returns api params given filtered tokens with special values', () => { - expect(convertToParams(filteredTokensWithSpecialValues, API_PARAM)).toEqual( - apiParamsWithSpecialValues, - ); + expect(convertToApiParams(filteredTokensWithSpecialValues)).toEqual(apiParamsWithSpecialValues); }); +}); +describe('convertToUrlParams', () => { it('returns url params given filtered tokens', () => { - expect(convertToParams(filteredTokens, URL_PARAM)).toEqual({ - ...urlParams, - epic_id: 'gitlab-org::&12', - }); + expect(convertToUrlParams(filteredTokens)).toEqual(urlParams); }); it('returns url params given filtered tokens with special values', () => { - expect(convertToParams(filteredTokensWithSpecialValues, URL_PARAM)).toEqual( - urlParamsWithSpecialValues, - ); + expect(convertToUrlParams(filteredTokensWithSpecialValues)).toEqual(urlParamsWithSpecialValues); }); }); diff --git a/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js new file mode 100644 index 00000000000..ec4cb2739f8 --- /dev/null +++ b/spec/frontend/jira_connect/branches/components/project_dropdown_spec.js @@ -0,0 +1,180 @@ +import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { mount, 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 ProjectDropdown from '~/jira_connect/branches/components/project_dropdown.vue'; +import { PROJECTS_PER_PAGE } from '~/jira_connect/branches/constants'; +import getProjectsQuery from '~/jira_connect/branches/graphql/queries/get_projects.query.graphql'; + +const localVue = createLocalVue(); + +const mockProjects = [ + { + id: 'test', + name: 'test', + nameWithNamespace: 'test', + avatarUrl: 'https://gitlab.com', + path: 'test-path', + fullPath: 'test-path', + repository: { + empty: false, + }, + }, + { + id: 'gitlab', + name: 'GitLab', + nameWithNamespace: 'gitlab-org/gitlab', + avatarUrl: 'https://gitlab.com', + path: 'gitlab', + fullPath: 'gitlab-org/gitlab', + repository: { + empty: false, + }, + }, +]; + +const mockProjectsQueryResponse = { + data: { + projects: { + nodes: mockProjects, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + }, + }, +}; +const mockGetProjectsQuerySuccess = jest.fn().mockResolvedValue(mockProjectsQueryResponse); +const mockGetProjectsQueryFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); +const mockQueryLoading = jest.fn().mockReturnValue(new Promise(() => {})); + +describe('ProjectDropdown', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findDropdownItemByText = (text) => + findAllDropdownItems().wrappers.find((item) => item.text() === text); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + + function createMockApolloProvider({ mockGetProjectsQuery = mockGetProjectsQuerySuccess } = {}) { + localVue.use(VueApollo); + + const mockApollo = createMockApollo([[getProjectsQuery, mockGetProjectsQuery]]); + + return mockApollo; + } + + function createComponent({ mockApollo, props, mountFn = shallowMount } = {}) { + wrapper = mountFn(ProjectDropdown, { + localVue, + apolloProvider: mockApollo || createMockApolloProvider(), + propsData: props, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading projects', () => { + beforeEach(() => { + createComponent({ + mockApollo: createMockApolloProvider({ mockGetProjectsQuery: mockQueryLoading }), + }); + }); + + it('sets dropdown `loading` prop to `true`', () => { + expect(findDropdown().props('loading')).toBe(true); + }); + + it('renders loading icon in dropdown', () => { + expect(findLoadingIcon().isVisible()).toBe(true); + }); + }); + + describe('when projects query succeeds', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + await wrapper.vm.$nextTick(); + }); + + it('sets dropdown `loading` prop to `false`', () => { + expect(findDropdown().props('loading')).toBe(false); + }); + + it('renders dropdown items', () => { + const dropdownItems = findAllDropdownItems(); + expect(dropdownItems.wrappers).toHaveLength(mockProjects.length); + expect(dropdownItems.wrappers.map((item) => item.text())).toEqual( + mockProjects.map((project) => project.nameWithNamespace), + ); + }); + + describe('when selecting a dropdown item', () => { + it('emits `change` event with the selected project name', async () => { + const mockProject = mockProjects[0]; + const itemToSelect = findDropdownItemByText(mockProject.nameWithNamespace); + await itemToSelect.vm.$emit('click'); + + expect(wrapper.emitted('change')[0]).toEqual([mockProject]); + }); + }); + + describe('when `selectedProject` prop is specified', () => { + const mockProject = mockProjects[0]; + + beforeEach(async () => { + wrapper.setProps({ + selectedProject: mockProject, + }); + }); + + it('sets `isChecked` prop of the corresponding dropdown item to `true`', () => { + expect(findDropdownItemByText(mockProject.nameWithNamespace).props('isChecked')).toBe(true); + }); + + it('sets dropdown text to `selectedBranchName` value', () => { + expect(findDropdown().props('text')).toBe(mockProject.nameWithNamespace); + }); + }); + }); + + describe('when projects query fails', () => { + beforeEach(async () => { + createComponent({ + mockApollo: createMockApolloProvider({ mockGetProjectsQuery: mockGetProjectsQueryFailed }), + }); + await waitForPromises(); + }); + + it('emits `error` event', () => { + expect(wrapper.emitted('error')).toBeTruthy(); + }); + }); + + describe('when searching branches', () => { + it('triggers a refetch', async () => { + createComponent({ mountFn: mount }); + await waitForPromises(); + jest.clearAllMocks(); + + const mockSearchTerm = 'gitl'; + await findSearchBox().vm.$emit('input', mockSearchTerm); + + expect(mockGetProjectsQuerySuccess).toHaveBeenCalledWith({ + after: '', + first: PROJECTS_PER_PAGE, + membership: true, + search: mockSearchTerm, + searchNamespaces: true, + sort: 'similarity', + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js new file mode 100644 index 00000000000..9dd11dd6345 --- /dev/null +++ b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js @@ -0,0 +1,192 @@ +import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { mount, 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 SourceBranchDropdown from '~/jira_connect/branches/components/source_branch_dropdown.vue'; +import { BRANCHES_PER_PAGE } from '~/jira_connect/branches/constants'; +import getProjectQuery from '~/jira_connect/branches/graphql/queries/get_project.query.graphql'; + +const localVue = createLocalVue(); + +const mockProject = { + id: 'test', + fullPath: 'test-path', + repository: { + branchNames: ['main', 'f-test', 'release'], + rootRef: 'main', + }, +}; + +const mockProjectQueryResponse = { + data: { + project: mockProject, + }, +}; +const mockGetProjectQuery = jest.fn().mockResolvedValue(mockProjectQueryResponse); +const mockQueryLoading = jest.fn().mockReturnValue(new Promise(() => {})); + +describe('SourceBranchDropdown', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findDropdownItemByText = (text) => + findAllDropdownItems().wrappers.find((item) => item.text() === text); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); + + const assertDropdownItems = () => { + const dropdownItems = findAllDropdownItems(); + expect(dropdownItems.wrappers).toHaveLength(mockProject.repository.branchNames.length); + expect(dropdownItems.wrappers.map((item) => item.text())).toEqual( + mockProject.repository.branchNames, + ); + }; + + function createMockApolloProvider({ getProjectQueryLoading = false } = {}) { + localVue.use(VueApollo); + + const mockApollo = createMockApollo([ + [getProjectQuery, getProjectQueryLoading ? mockQueryLoading : mockGetProjectQuery], + ]); + + return mockApollo; + } + + function createComponent({ mockApollo, props, mountFn = shallowMount } = {}) { + wrapper = mountFn(SourceBranchDropdown, { + localVue, + apolloProvider: mockApollo || createMockApolloProvider(), + propsData: props, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when `selectedProject` prop is not specified', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets dropdown `disabled` prop to `true`', () => { + expect(findDropdown().props('disabled')).toBe(true); + }); + + describe('when `selectedProject` becomes specified', () => { + beforeEach(async () => { + wrapper.setProps({ + selectedProject: mockProject, + }); + + await waitForPromises(); + }); + + it('sets dropdown props correctly', () => { + expect(findDropdown().props()).toMatchObject({ + loading: false, + disabled: false, + text: 'Select a branch', + }); + }); + + it('renders available source branches as dropdown items', () => { + assertDropdownItems(); + }); + }); + }); + + describe('when `selectedProject` prop is specified', () => { + describe('when branches are loading', () => { + it('renders loading icon in dropdown', () => { + createComponent({ + mockApollo: createMockApolloProvider({ getProjectQueryLoading: true }), + props: { selectedProject: mockProject }, + }); + + expect(findLoadingIcon().isVisible()).toBe(true); + }); + }); + + describe('when branches have loaded', () => { + describe('when searching branches', () => { + it('triggers a refetch', async () => { + createComponent({ mountFn: mount, props: { selectedProject: mockProject } }); + await waitForPromises(); + jest.clearAllMocks(); + + const mockSearchTerm = 'mai'; + await findSearchBox().vm.$emit('input', mockSearchTerm); + + expect(mockGetProjectQuery).toHaveBeenCalledWith({ + branchNamesLimit: BRANCHES_PER_PAGE, + branchNamesOffset: 0, + branchNamesSearchPattern: `*${mockSearchTerm}*`, + projectPath: 'test-path', + }); + }); + }); + + describe('template', () => { + beforeEach(async () => { + createComponent({ props: { selectedProject: mockProject } }); + await waitForPromises(); + }); + + it('sets dropdown props correctly', () => { + expect(findDropdown().props()).toMatchObject({ + loading: false, + disabled: false, + text: 'Select a branch', + }); + }); + + it('omits monospace styling from dropdown', () => { + expect(findDropdown().classes()).not.toContain('gl-font-monospace'); + }); + + it('renders available source branches as dropdown items', () => { + assertDropdownItems(); + }); + + it("emits `change` event with the repository's `rootRef` by default", () => { + expect(wrapper.emitted('change')[0]).toEqual([mockProject.repository.rootRef]); + }); + + describe('when selecting a dropdown item', () => { + it('emits `change` event with the selected branch name', async () => { + const mockBranchName = mockProject.repository.branchNames[1]; + const itemToSelect = findDropdownItemByText(mockBranchName); + await itemToSelect.vm.$emit('click'); + + expect(wrapper.emitted('change')[1]).toEqual([mockBranchName]); + }); + }); + + describe('when `selectedBranchName` prop is specified', () => { + const mockBranchName = mockProject.repository.branchNames[2]; + + beforeEach(async () => { + wrapper.setProps({ + selectedBranchName: mockBranchName, + }); + }); + + it('sets `isChecked` prop of the corresponding dropdown item to `true`', () => { + expect(findDropdownItemByText(mockBranchName).props('isChecked')).toBe(true); + }); + + it('sets dropdown text to `selectedBranchName` value', () => { + expect(findDropdown().props('text')).toBe(mockBranchName); + }); + + it('adds monospace styling to dropdown', () => { + expect(findDropdown().classes()).toContain('gl-font-monospace'); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/jira_connect/components/groups_list_spec.js b/spec/frontend/jira_connect/components/groups_list_spec.js index 4b875928a90..d583fb68771 100644 --- a/spec/frontend/jira_connect/components/groups_list_spec.js +++ b/spec/frontend/jira_connect/components/groups_list_spec.js @@ -160,9 +160,13 @@ describe('GroupsList', () => { expect(findGroupsList().classes()).toContain('gl-opacity-5'); }); - it('sets loading prop of ths search box', () => { + it('sets loading prop of the search box', () => { expect(findSearchBox().props('isLoading')).toBe(true); }); + + it('sets value prop of the search box to the search term', () => { + expect(findSearchBox().props('value')).toBe(mockSearchTeam); + }); }); describe('when group search finishes loading', () => { 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 172b6e4831c..f2142ce1fcf 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 @@ -176,7 +176,6 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> </div> - </ul> </div> </td> @@ -304,7 +303,6 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> </div> - </ul> </div> </td> diff --git a/spec/frontend/jobs/components/empty_state_spec.js b/spec/frontend/jobs/components/empty_state_spec.js index c9de110ce06..9738fd14275 100644 --- a/spec/frontend/jobs/components/empty_state_spec.js +++ b/spec/frontend/jobs/components/empty_state_spec.js @@ -9,7 +9,6 @@ describe('Empty State', () => { illustrationSizeClass: 'svg-430', title: 'This job has not started yet', playable: false, - variablesSettingsUrl: '', }; const createWrapper = (props) => { diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index 3fcefde1aba..1f4dd7d6216 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -24,6 +24,7 @@ describe('Job App', () => { let store; let wrapper; let mock; + let origGon; const initSettings = { endpoint: `${TEST_HOST}jobs/123.json`, @@ -37,7 +38,6 @@ describe('Job App', () => { deploymentHelpUrl: 'help/deployment', codeQualityHelpPath: '/help/code_quality', runnerSettingsUrl: 'settings/ci-cd/runners', - variablesSettingsUrl: 'settings/ci-cd/variables', terminalPath: 'jobs/123/terminal', projectPath: 'user-name/project-name', subscriptionsMoreMinutesUrl: 'https://customers.gitlab.com/buy_pipeline_minutes', @@ -86,11 +86,17 @@ describe('Job App', () => { beforeEach(() => { mock = new MockAdapter(axios); store = createStore(); + + origGon = window.gon; + + window.gon = { features: { infinitelyCollapsibleSections: false } }; // NOTE: All of this passes with the feature flag }); afterEach(() => { wrapper.destroy(); mock.restore(); + + window.gon = origGon; }); describe('while loading', () => { diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js index 66f22162c97..4e23a3ba7b8 100644 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js @@ -4,6 +4,7 @@ import { collapsibleSectionClosed, collapsibleSectionOpened } from './mock_data' describe('Job Log Collapsible Section', () => { let wrapper; + let origGon; const traceEndpoint = 'jobs/335'; @@ -18,8 +19,16 @@ describe('Job Log Collapsible Section', () => { }); }; + beforeEach(() => { + origGon = window.gon; + + window.gon = { features: { infinitelyCollapsibleSections: false } }; // NOTE: This also works with true + }); + afterEach(() => { wrapper.destroy(); + + window.gon = origGon; }); describe('with closed section', () => { diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js index 367154e7f82..d184696cd1f 100644 --- a/spec/frontend/jobs/components/log/line_spec.js +++ b/spec/frontend/jobs/components/log/line_spec.js @@ -94,6 +94,16 @@ describe('Job Log Line', () => { expect(findLinkAttributeByIndex(0).href).toBe(queryUrl); }); + it('renders links that have brackets `[]` in their parameters', () => { + const url = `${httpUrl}?label_name[]=frontend`; + + createComponent(mockProps({ text: url })); + + expect(findLine().text()).toBe(url); + expect(findLinks().at(0).text()).toBe(url); + expect(findLinks().at(0).attributes('href')).toBe(url); + }); + it('renders multiple links surrounded by text', () => { createComponent( mockProps({ text: `Well, my HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` }), @@ -125,6 +135,26 @@ describe('Job Log Line', () => { expect(findLinkAttributeByIndex(4).href).toBe(httpsUrl); }); + it('renders multiple links surrounded by brackets', () => { + createComponent(mockProps({ text: `(${httpUrl}) <${httpUrl}> {${httpsUrl}}` })); + expect(findLine().text()).toBe( + '(http://example.com) <http://example.com> {https://example.com}', + ); + + const links = findLinks(); + + expect(links).toHaveLength(3); + + expect(links.at(0).text()).toBe(httpUrl); + expect(links.at(0).attributes('href')).toBe(httpUrl); + + expect(links.at(1).text()).toBe(httpUrl); + expect(links.at(1).attributes('href')).toBe(httpUrl); + + expect(links.at(2).text()).toBe(httpsUrl); + expect(links.at(2).attributes('href')).toBe(httpsUrl); + }); + it('renders text with symbols in it', () => { const text = 'apt-get update < /dev/null > /dev/null'; createComponent(mockProps({ text })); diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index b7aff1f3e3b..99fb6846ce5 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -1,7 +1,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import Log from '~/jobs/components/log/log.vue'; -import { logLinesParser } from '~/jobs/store/utils'; +import { logLinesParserLegacy, logLinesParser } from '~/jobs/store/utils'; import { jobLog } from './mock_data'; describe('Job Log', () => { @@ -9,6 +9,7 @@ describe('Job Log', () => { let actions; let state; let store; + let origGon; const localVue = createLocalVue(); localVue.use(Vuex); @@ -25,8 +26,12 @@ describe('Job Log', () => { toggleCollapsibleLine: () => {}, }; + origGon = window.gon; + + window.gon = { features: { infinitelyCollapsibleSections: false } }; + state = { - trace: logLinesParser(jobLog), + trace: logLinesParserLegacy(jobLog), traceEndpoint: 'jobs/id', }; @@ -40,6 +45,88 @@ describe('Job Log', () => { afterEach(() => { wrapper.destroy(); + + window.gon = origGon; + }); + + const findCollapsibleLine = () => wrapper.find('.collapsible-line'); + + describe('line numbers', () => { + it('renders a line number for each open line', () => { + expect(wrapper.find('#L1').text()).toBe('1'); + expect(wrapper.find('#L2').text()).toBe('2'); + expect(wrapper.find('#L3').text()).toBe('3'); + }); + + it('links to the provided path and correct line number', () => { + expect(wrapper.find('#L1').attributes('href')).toBe(`${state.traceEndpoint}#L1`); + }); + }); + + describe('collapsible sections', () => { + it('renders a clickable header section', () => { + expect(findCollapsibleLine().attributes('role')).toBe('button'); + }); + + it('renders an icon with the open state', () => { + expect(findCollapsibleLine().find('[data-testid="angle-down-icon"]').exists()).toBe(true); + }); + + describe('on click header section', () => { + it('calls toggleCollapsibleLine', () => { + jest.spyOn(wrapper.vm, 'toggleCollapsibleLine'); + + findCollapsibleLine().trigger('click'); + + expect(wrapper.vm.toggleCollapsibleLine).toHaveBeenCalled(); + }); + }); + }); +}); + +describe('Job Log, infinitelyCollapsibleSections feature flag enabled', () => { + let wrapper; + let actions; + let state; + let store; + let origGon; + + const localVue = createLocalVue(); + localVue.use(Vuex); + + const createComponent = () => { + wrapper = mount(Log, { + localVue, + store, + }); + }; + + beforeEach(() => { + actions = { + toggleCollapsibleLine: () => {}, + }; + + origGon = window.gon; + + window.gon = { features: { infinitelyCollapsibleSections: true } }; + + state = { + trace: logLinesParser(jobLog).parsedLines, + traceEndpoint: 'jobs/id', + }; + + store = new Vuex.Store({ + actions, + state, + }); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + + window.gon = origGon; }); const findCollapsibleLine = () => wrapper.find('.collapsible-line'); diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js index eb8c4fe8bc9..76c35703106 100644 --- a/spec/frontend/jobs/components/log/mock_data.js +++ b/spec/frontend/jobs/components/log/mock_data.js @@ -58,6 +58,71 @@ export const utilsMockData = [ }, ]; +export const multipleCollapsibleSectionsMockData = [ + { + offset: 1001, + content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], + }, + { + offset: 1002, + content: [ + { + text: 'Executing "step_script" stage of the job script', + }, + ], + section: 'step-script', + section_header: true, + }, + { + offset: 1003, + content: [{ text: 'sleep 60' }], + section: 'step-script', + }, + { + offset: 1004, + content: [ + { + text: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae', + }, + ], + section: 'step-script', + }, + { + offset: 1005, + content: [{ text: 'executing...' }], + section: 'step-script', + }, + { + offset: 1006, + content: [{ text: '1st collapsible section' }], + section: 'collapsible-1', + section_header: true, + }, + { + offset: 1007, + content: [ + { + text: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae', + }, + ], + section: 'collapsible-1', + }, + { + offset: 1008, + content: [], + section: 'collapsible-1', + section_duration: '01:00', + }, + { + offset: 1009, + content: [], + section: 'step-script', + section_duration: '10:00', + }, +]; + export const originalTrace = [ { offset: 1, diff --git a/spec/frontend/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js index 376a822dde5..7e42ee957d3 100644 --- a/spec/frontend/jobs/components/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/manual_variables_form_spec.js @@ -1,3 +1,4 @@ +import { GlSprintf, GlLink } from '@gitlab/ui'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -18,7 +19,6 @@ describe('Manual Variables Form', () => { method: 'post', button_title: 'Trigger this manual action', }, - variablesSettingsUrl: '/settings', }; const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { @@ -33,15 +33,19 @@ describe('Manual Variables Form', () => { propsData: { ...requiredProps, ...props }, localVue, store, + stubs: { + GlSprintf, + }, }), ); }; const findInputKey = () => wrapper.findComponent({ ref: 'inputKey' }); const findInputValue = () => wrapper.findComponent({ ref: 'inputSecretValue' }); + const findHelpText = () => wrapper.findComponent(GlSprintf); + const findHelpLink = () => wrapper.findComponent(GlLink); const findTriggerBtn = () => wrapper.findByTestId('trigger-manual-job-btn'); - const findHelpText = () => wrapper.findByTestId('form-help-text'); const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); const findCiVariableKey = () => wrapper.findByTestId('ci-variable-key'); const findCiVariableValue = () => wrapper.findByTestId('ci-variable-value'); @@ -62,11 +66,10 @@ describe('Manual Variables Form', () => { }); it('renders help text with provided link', () => { - expect(findHelpText().text()).toBe( - 'Specify variable values to be used in this run. The values specified in CI/CD settings will be used as default', + expect(findHelpText().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe( + '/help/ci/variables/index#add-a-cicd-variable-to-a-project', ); - - expect(wrapper.find('a').attributes('href')).toBe(requiredProps.variablesSettingsUrl); }); describe('when adding a new variable', () => { diff --git a/spec/frontend/jobs/components/sidebar_detail_row_spec.js b/spec/frontend/jobs/components/sidebar_detail_row_spec.js index bae4d6cf837..43f2e022dd8 100644 --- a/spec/frontend/jobs/components/sidebar_detail_row_spec.js +++ b/spec/frontend/jobs/components/sidebar_detail_row_spec.js @@ -7,7 +7,7 @@ describe('Sidebar detail row', () => { const title = 'this is the title'; const value = 'this is the value'; - const helpUrl = '/help/ci/runners/README.html'; + const helpUrl = '/help/ci/runners/index.html'; const findHelpLink = () => wrapper.findComponent(GlLink); diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js index 1c7e45dfb3d..159315330e4 100644 --- a/spec/frontend/jobs/store/mutations_spec.js +++ b/spec/frontend/jobs/store/mutations_spec.js @@ -4,12 +4,21 @@ import state from '~/jobs/store/state'; describe('Jobs Store Mutations', () => { let stateCopy; + let origGon; const html = 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I'; beforeEach(() => { stateCopy = state(); + + origGon = window.gon; + + window.gon = { features: { infinitelyCollapsibleSections: false } }; + }); + + afterEach(() => { + window.gon = origGon; }); describe('SET_JOB_ENDPOINT', () => { @@ -267,3 +276,88 @@ describe('Jobs Store Mutations', () => { }); }); }); + +describe('Job Store mutations, feature flag ON', () => { + let stateCopy; + let origGon; + + const html = + 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I'; + + beforeEach(() => { + stateCopy = state(); + + origGon = window.gon; + + window.gon = { features: { infinitelyCollapsibleSections: true } }; + }); + + afterEach(() => { + window.gon = origGon; + }); + + describe('RECEIVE_TRACE_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, { + append: true, + size: 511846, + complete: true, + lines: [ + { + offset: 1, + content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], + }, + ], + }); + + expect(stateCopy.trace).toEqual([ + { + offset: 1, + content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], + lineNumber: 1, + }, + ]); + }); + }); + + describe('when lines are defined', () => { + it('sets the parsed log ', () => { + mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + append: false, + size: 511846, + complete: true, + lines: [ + { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }] }, + ], + }); + + expect(stateCopy.trace).toEqual([ + { + offset: 0, + content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], + lineNumber: 1, + }, + ]); + }); + }); + + describe('when lines are null', () => { + it('sets the default value', () => { + mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { + append: true, + html, + size: 511846, + complete: false, + lines: null, + }); + + expect(stateCopy.trace).toEqual([]); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index e50d304bb08..35ac2945ab5 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -1,5 +1,6 @@ import { logLinesParser, + logLinesParserLegacy, updateIncrementalTrace, parseHeaderLine, parseLine, @@ -17,6 +18,7 @@ import { headerTraceIncremental, collapsibleTrace, collapsibleTraceIncremental, + multipleCollapsibleSectionsMockData, } from '../components/log/mock_data'; describe('Jobs Store Utils', () => { @@ -175,11 +177,11 @@ describe('Jobs Store Utils', () => { expect(isCollapsibleSection()).toEqual(false); }); }); - describe('logLinesParser', () => { + describe('logLinesParserLegacy', () => { let result; beforeEach(() => { - result = logLinesParser(utilsMockData); + result = logLinesParserLegacy(utilsMockData); }); describe('regular line', () => { @@ -216,6 +218,87 @@ describe('Jobs Store Utils', () => { }); }); + describe('logLinesParser', () => { + let result; + + beforeEach(() => { + result = logLinesParser(utilsMockData); + }); + + describe('regular line', () => { + it('adds a lineNumber property with correct index', () => { + expect(result.parsedLines[0].lineNumber).toEqual(1); + expect(result.parsedLines[1].line.lineNumber).toEqual(2); + }); + }); + + describe('collapsible section', () => { + it('adds a `isClosed` property', () => { + expect(result.parsedLines[1].isClosed).toEqual(false); + }); + + it('adds a `isHeader` property', () => { + expect(result.parsedLines[1].isHeader).toEqual(true); + }); + + it('creates a lines array property with the content of the collapsible section', () => { + expect(result.parsedLines[1].lines.length).toEqual(2); + expect(result.parsedLines[1].lines[0].content).toEqual(utilsMockData[2].content); + expect(result.parsedLines[1].lines[1].content).toEqual(utilsMockData[3].content); + }); + }); + + describe('section duration', () => { + it('adds the section information to the header section', () => { + expect(result.parsedLines[1].line.section_duration).toEqual( + utilsMockData[4].section_duration, + ); + }); + + it('does not add section duration as a line', () => { + expect(result.parsedLines[1].lines.includes(utilsMockData[4])).toEqual(false); + }); + }); + + describe('multiple collapsible sections', () => { + beforeEach(() => { + result = logLinesParser(multipleCollapsibleSectionsMockData); + }); + + it('should contain a section inside another section', () => { + const innerSection = [ + { + isClosed: false, + isHeader: true, + line: { + content: [{ text: '1st collapsible section' }], + lineNumber: 6, + offset: 1006, + section: 'collapsible-1', + section_duration: '01:00', + section_header: true, + }, + lines: [ + { + content: [ + { + text: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam lorem dolor, congue ac condimentum vitae', + }, + ], + lineNumber: 7, + offset: 1007, + section: 'collapsible-1', + }, + ], + }, + ]; + + expect(result.parsedLines[1].lines).toEqual(expect.arrayContaining(innerSection)); + }); + }); + }); + describe('findOffsetAndRemove', () => { describe('when last item is header', () => { const existingLog = [ @@ -391,7 +474,7 @@ describe('Jobs Store Utils', () => { describe('updateIncrementalTrace', () => { describe('without repeated section', () => { it('concats and parses both arrays', () => { - const oldLog = logLinesParser(originalTrace); + const oldLog = logLinesParserLegacy(originalTrace); const result = updateIncrementalTrace(regularIncremental, oldLog); expect(result).toEqual([ @@ -419,7 +502,7 @@ describe('Jobs Store Utils', () => { describe('with regular line repeated offset', () => { it('updates the last line and formats with the incremental part', () => { - const oldLog = logLinesParser(originalTrace); + const oldLog = logLinesParserLegacy(originalTrace); const result = updateIncrementalTrace(regularIncrementalRepeated, oldLog); expect(result).toEqual([ @@ -438,7 +521,7 @@ describe('Jobs Store Utils', () => { describe('with header line repeated', () => { it('updates the header line and formats with the incremental part', () => { - const oldLog = logLinesParser(headerTrace); + const oldLog = logLinesParserLegacy(headerTrace); const result = updateIncrementalTrace(headerTraceIncremental, oldLog); expect(result).toEqual([ @@ -464,7 +547,7 @@ describe('Jobs Store Utils', () => { describe('with collapsible line repeated', () => { it('updates the collapsible line and formats with the incremental part', () => { - const oldLog = logLinesParser(collapsibleTrace); + const oldLog = logLinesParserLegacy(collapsibleTrace); const result = updateIncrementalTrace(collapsibleTraceIncremental, oldLog); expect(result).toEqual([ diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js index a01f86678e9..fa8dbb12a08 100644 --- a/spec/frontend/lib/dompurify_spec.js +++ b/spec/frontend/lib/dompurify_spec.js @@ -30,6 +30,9 @@ const unsafeUrls = [ `https://evil.url/${absoluteGon.sprite_file_icons}`, ]; +const forbiddenDataAttrs = ['data-remote', 'data-url', 'data-type', 'data-method']; +const acceptedDataAttrs = ['data-random', 'data-custom']; + describe('~/lib/dompurify', () => { let originalGon; @@ -95,4 +98,17 @@ describe('~/lib/dompurify', () => { expect(sanitize(htmlXlink)).toBe(expectedSanitized); }); }); + + describe('handles data attributes correctly', () => { + it.each(forbiddenDataAttrs)('removes %s attributes', (attr) => { + const htmlHref = `<a ${attr}="true">hello</a>`; + expect(sanitize(htmlHref)).toBe('<a>hello</a>'); + }); + + it.each(acceptedDataAttrs)('does not remove %s attributes', (attr) => { + const attrWithValue = `${attr}="true"`; + const htmlHref = `<a ${attrWithValue}>hello</a>`; + expect(sanitize(htmlHref)).toBe(`<a ${attrWithValue}>hello</a>`); + }); + }); }); diff --git a/spec/frontend/lib/graphql_spec.js b/spec/frontend/lib/graphql_spec.js new file mode 100644 index 00000000000..a39ce2ffd99 --- /dev/null +++ b/spec/frontend/lib/graphql_spec.js @@ -0,0 +1,54 @@ +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import { stripWhitespaceFromQuery } from '~/lib/graphql'; +import { queryToObject } from '~/lib/utils/url_utility'; + +describe('stripWhitespaceFromQuery', () => { + const operationName = 'getPipelineDetails'; + const variables = `{ + projectPath: 'root/abcd-dag', + iid: '44' + }`; + + const testQuery = getPipelineDetails.loc.source.body; + const defaultPath = '/api/graphql'; + const encodedVariables = encodeURIComponent(variables); + + it('shortens the query argument by replacing multiple spaces and newlines with a single space', () => { + const testString = `${defaultPath}?query=${encodeURIComponent(testQuery)}`; + expect(testString.length > stripWhitespaceFromQuery(testString, defaultPath).length).toBe(true); + }); + + it('does not contract a single space', () => { + const simpleSingleString = `${defaultPath}?query=${encodeURIComponent('fragment Nonsense')}`; + expect(stripWhitespaceFromQuery(simpleSingleString, defaultPath)).toEqual(simpleSingleString); + }); + + it('works with a non-default path', () => { + const newPath = 'another/graphql/path'; + const newPathSingleString = `${newPath}?query=${encodeURIComponent('fragment Nonsense')}`; + expect(stripWhitespaceFromQuery(newPathSingleString, newPath)).toEqual(newPathSingleString); + }); + + it('does not alter other arguments', () => { + const bareParams = `?query=${encodeURIComponent( + testQuery, + )}&operationName=${operationName}&variables=${encodedVariables}`; + const testLongString = `${defaultPath}${bareParams}`; + + const processed = stripWhitespaceFromQuery(testLongString, defaultPath); + const decoded = decodeURIComponent(processed); + const params = queryToObject(decoded); + + expect(params.operationName).toBe(operationName); + expect(params.variables).toBe(variables); + }); + + it('works when there are no query params', () => { + expect(stripWhitespaceFromQuery(defaultPath, defaultPath)).toEqual(defaultPath); + }); + + it('works when the params do not include a query', () => { + const paramsWithoutQuery = `${defaultPath}&variables=${encodedVariables}`; + expect(stripWhitespaceFromQuery(paramsWithoutQuery, defaultPath)).toEqual(paramsWithoutQuery); + }); +}); diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index e03d1ef7295..f5a74ee7f09 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -1,6 +1,56 @@ import * as commonUtils from '~/lib/utils/common_utils'; describe('common_utils', () => { + describe('getPagePath', () => { + const { getPagePath } = commonUtils; + + let originalBody; + + beforeEach(() => { + originalBody = document.body; + document.body = document.createElement('body'); + }); + + afterEach(() => { + document.body = originalBody; + }); + + it('returns an empty path if none is defined', () => { + expect(getPagePath()).toBe(''); + expect(getPagePath(0)).toBe(''); + }); + + describe('returns a path', () => { + const mockSection = 'my_section'; + const mockSubSection = 'my_sub_section'; + const mockPage = 'my_page'; + + it('returns a page', () => { + document.body.dataset.page = mockPage; + + expect(getPagePath()).toBe(mockPage); + expect(getPagePath(0)).toBe(mockPage); + }); + + it('returns a section and page', () => { + document.body.dataset.page = `${mockSection}:${mockPage}`; + + expect(getPagePath()).toBe(mockSection); + expect(getPagePath(0)).toBe(mockSection); + expect(getPagePath(1)).toBe(mockPage); + }); + + it('returns a section and subsection', () => { + document.body.dataset.page = `${mockSection}:${mockSubSection}:${mockPage}`; + + expect(getPagePath()).toBe(mockSection); + expect(getPagePath(0)).toBe(mockSection); + expect(getPagePath(1)).toBe(mockSubSection); + expect(getPagePath(2)).toBe(mockPage); + }); + }); + }); + describe('parseUrl', () => { it('returns an anchor tag with url', () => { expect(commonUtils.parseUrl('/some/absolute/url').pathname).toContain('some/absolute/url'); @@ -26,42 +76,6 @@ describe('common_utils', () => { }); }); - describe('urlParamsToArray', () => { - it('returns empty array for empty querystring', () => { - expect(commonUtils.urlParamsToArray('')).toEqual([]); - }); - - it('should decode params', () => { - expect(commonUtils.urlParamsToArray('?label_name%5B%5D=test')[0]).toBe('label_name[]=test'); - }); - - it('should remove the question mark from the search params', () => { - const paramsArray = commonUtils.urlParamsToArray('?test=thing'); - - expect(paramsArray[0][0]).not.toBe('?'); - }); - }); - - describe('urlParamsToObject', () => { - it('parses path for label with trailing +', () => { - expect(commonUtils.urlParamsToObject('label_name[]=label%2B', {})).toEqual({ - label_name: ['label+'], - }); - }); - - it('parses path for milestone with trailing +', () => { - expect(commonUtils.urlParamsToObject('milestone_title=A%2B', {})).toEqual({ - milestone_title: 'A+', - }); - }); - - it('parses path for search terms with spaces', () => { - expect(commonUtils.urlParamsToObject('search=two+words', {})).toEqual({ - search: 'two words', - }); - }); - }); - describe('handleLocationHash', () => { beforeEach(() => { jest.spyOn(window.document, 'getElementById'); @@ -175,33 +189,6 @@ describe('common_utils', () => { }); }); - describe('parseQueryStringIntoObject', () => { - it('should return object with query parameters', () => { - expect(commonUtils.parseQueryStringIntoObject('scope=all&page=2')).toEqual({ - scope: 'all', - page: '2', - }); - - expect(commonUtils.parseQueryStringIntoObject('scope=all')).toEqual({ scope: 'all' }); - expect(commonUtils.parseQueryStringIntoObject()).toEqual({}); - }); - }); - - describe('objectToQueryString', () => { - it('returns empty string when `param` is undefined, null or empty string', () => { - expect(commonUtils.objectToQueryString()).toBe(''); - expect(commonUtils.objectToQueryString('')).toBe(''); - }); - - it('returns query string with values of `params`', () => { - const singleQueryParams = { foo: true }; - const multipleQueryParams = { foo: true, bar: true }; - - expect(commonUtils.objectToQueryString(singleQueryParams)).toBe('foo=true'); - expect(commonUtils.objectToQueryString(multipleQueryParams)).toBe('foo=true&bar=true'); - }); - }); - describe('buildUrlWithCurrentLocation', () => { it('should build an url with current location and given parameters', () => { expect(commonUtils.buildUrlWithCurrentLocation()).toEqual(window.location.pathname); @@ -310,39 +297,6 @@ describe('common_utils', () => { }); }); - describe('getParameterByName', () => { - beforeEach(() => { - window.history.pushState({}, null, '?scope=all&p=2'); - }); - - afterEach(() => { - window.history.replaceState({}, null, null); - }); - - it('should return valid parameter', () => { - const value = commonUtils.getParameterByName('scope'); - - expect(commonUtils.getParameterByName('p')).toEqual('2'); - expect(value).toBe('all'); - }); - - it('should return invalid parameter', () => { - const value = commonUtils.getParameterByName('fakeParameter'); - - expect(value).toBe(null); - }); - - it('should return valid paramentes if URL is provided', () => { - let value = commonUtils.getParameterByName('foo', 'http://cocteau.twins/?foo=bar'); - - expect(value).toBe('bar'); - - value = commonUtils.getParameterByName('manan', 'http://cocteau.twins/?foo=bar&manan=canchu'); - - expect(value).toBe('canchu'); - }); - }); - describe('normalizedHeaders', () => { it('should upperCase all the header keys to keep them consistent', () => { const apiHeaders = { diff --git a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js new file mode 100644 index 00000000000..2314ec678d3 --- /dev/null +++ b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js @@ -0,0 +1,103 @@ +import { getTimeago, localTimeAgo, timeFor } from '~/lib/utils/datetime/timeago_utility'; +import { s__ } from '~/locale'; +import '~/commons/bootstrap'; + +describe('TimeAgo utils', () => { + let oldGon; + + afterEach(() => { + window.gon = oldGon; + }); + + beforeEach(() => { + oldGon = window.gon; + }); + + describe('getTimeago', () => { + describe('with User Setting timeDisplayRelative: true', () => { + beforeEach(() => { + window.gon = { time_display_relative: true }; + }); + + it.each([ + [new Date().toISOString(), 'just now'], + [new Date().getTime(), 'just now'], + [new Date(), 'just now'], + [null, 'just now'], + ])('formats date `%p` as `%p`', (date, result) => { + expect(getTimeago().format(date)).toEqual(result); + }); + }); + + describe('with User Setting timeDisplayRelative: false', () => { + beforeEach(() => { + window.gon = { time_display_relative: false }; + }); + + it.each([ + [new Date().toISOString(), 'Jul 6, 2020, 12:00 AM'], + [new Date(), 'Jul 6, 2020, 12:00 AM'], + [new Date().getTime(), 'Jul 6, 2020, 12:00 AM'], + // Slightly different behaviour when `null` is passed :see_no_evil` + [null, 'Jan 1, 1970, 12:00 AM'], + ])('formats date `%p` as `%p`', (date, result) => { + expect(getTimeago().format(date)).toEqual(result); + }); + }); + }); + + describe('timeFor', () => { + it('returns localize `past due` when in past', () => { + const date = new Date(); + date.setFullYear(date.getFullYear() - 1); + + expect(timeFor(date)).toBe(s__('Timeago|Past due')); + }); + + it('returns localized remaining time when in the future', () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + + // Add a day to prevent a transient error. If date is even 1 second + // short of a full year, timeFor will return '11 months remaining' + date.setDate(date.getDate() + 1); + + expect(timeFor(date)).toBe(s__('Timeago|1 year remaining')); + }); + }); + + describe('localTimeAgo', () => { + beforeEach(() => { + document.body.innerHTML = + '<time title="some time" datetime="2020-02-18T22:22:32Z">1 hour ago</time>'; + }); + + describe.each` + timeDisplayRelative | text + ${true} | ${'4 months ago'} + ${false} | ${'Feb 18, 2020, 10:22 PM'} + `( + `With User Setting timeDisplayRelative: $timeDisplayRelative`, + ({ timeDisplayRelative, text }) => { + it.each` + updateTooltip | title + ${false} | ${'some time'} + ${true} | ${'Feb 18, 2020 10:22pm UTC'} + `( + `has content: '${text}' and tooltip: '$title' with updateTooltip = $updateTooltip`, + ({ updateTooltip, title }) => { + window.gon = { time_display_relative: timeDisplayRelative }; + + const element = document.querySelector('time'); + localTimeAgo([element], updateTooltip); + + jest.runAllTimers(); + + expect(element.getAttribute('title')).toBe(title); + expect(element.innerText).toBe(text); + }, + ); + }, + ); + }); +}); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index df0ccb19cb7..f6ad41d5478 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -1,30 +1,9 @@ -import $ from 'jquery'; import timezoneMock from 'timezone-mock'; import * as datetimeUtility from '~/lib/utils/datetime_utility'; import { __, s__ } from '~/locale'; import '~/commons/bootstrap'; describe('Date time utils', () => { - describe('timeFor', () => { - it('returns localize `past due` when in past', () => { - const date = new Date(); - date.setFullYear(date.getFullYear() - 1); - - expect(datetimeUtility.timeFor(date)).toBe(s__('Timeago|Past due')); - }); - - it('returns localized remaining time when in the future', () => { - const date = new Date(); - date.setFullYear(date.getFullYear() + 1); - - // Add a day to prevent a transient error. If date is even 1 second - // short of a full year, timeFor will return '11 months remaining' - date.setDate(date.getDate() + 1); - - expect(datetimeUtility.timeFor(date)).toBe(s__('Timeago|1 year remaining')); - }); - }); - describe('get localized day name', () => { it('should return Sunday', () => { const day = datetimeUtility.getDayName(new Date('07/17/2016')); @@ -870,25 +849,6 @@ describe('approximateDuration', () => { }); }); -describe('localTimeAgo', () => { - beforeEach(() => { - document.body.innerHTML = `<time title="some time" datetime="2020-02-18T22:22:32Z">1 hour ago</time>`; - }); - - it.each` - timeagoArg | title - ${false} | ${'some time'} - ${true} | ${'Feb 18, 2020 10:22pm UTC'} - `('converts $seconds seconds to $approximation', ({ timeagoArg, title }) => { - const element = document.querySelector('time'); - datetimeUtility.localTimeAgo($(element), timeagoArg); - - jest.runAllTimers(); - - expect(element.getAttribute('title')).toBe(title); - }); -}); - describe('differenceInSeconds', () => { const startDateTime = new Date('2019-07-17T00:00:00.000Z'); diff --git a/spec/frontend/lib/utils/finite_state_machine_spec.js b/spec/frontend/lib/utils/finite_state_machine_spec.js new file mode 100644 index 00000000000..441dd24c758 --- /dev/null +++ b/spec/frontend/lib/utils/finite_state_machine_spec.js @@ -0,0 +1,293 @@ +import { machine, transition } from '~/lib/utils/finite_state_machine'; + +describe('Finite State Machine', () => { + const STATE_IDLE = 'idle'; + const STATE_LOADING = 'loading'; + const STATE_ERRORED = 'errored'; + + const TRANSITION_START_LOAD = 'START_LOAD'; + const TRANSITION_LOAD_ERROR = 'LOAD_ERROR'; + const TRANSITION_LOAD_SUCCESS = 'LOAD_SUCCESS'; + const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR'; + + const definition = { + initial: STATE_IDLE, + states: { + [STATE_IDLE]: { + on: { + [TRANSITION_START_LOAD]: STATE_LOADING, + }, + }, + [STATE_LOADING]: { + on: { + [TRANSITION_LOAD_ERROR]: STATE_ERRORED, + [TRANSITION_LOAD_SUCCESS]: STATE_IDLE, + }, + }, + [STATE_ERRORED]: { + on: { + [TRANSITION_ACKNOWLEDGE_ERROR]: STATE_IDLE, + [TRANSITION_START_LOAD]: STATE_LOADING, + }, + }, + }, + }; + + describe('machine', () => { + const STATE_IMPOSSIBLE = 'impossible'; + const badDefinition = { + init: definition.initial, + badKeyShouldBeStates: definition.states, + }; + const unstartableDefinition = { + initial: STATE_IMPOSSIBLE, + states: definition.states, + }; + let liveMachine; + + beforeEach(() => { + liveMachine = machine(definition); + }); + + it('throws an error if the machine definition is invalid', () => { + expect(() => machine(badDefinition)).toThrowError( + 'A state machine must have an initial state (`.initial`) and a dictionary of possible states (`.states`)', + ); + }); + + it('throws an error if the initial state is invalid', () => { + expect(() => machine(unstartableDefinition)).toThrowError( + `Cannot initialize the state machine to state '${STATE_IMPOSSIBLE}'. Is that one of the machine's defined states?`, + ); + }); + + it.each` + partOfMachine | equals | description | eqDescription + ${'keys'} | ${['is', 'send', 'value', 'states']} | ${'keys'} | ${'the correct array'} + ${'is'} | ${expect.any(Function)} | ${'`is` property'} | ${'a function'} + ${'send'} | ${expect.any(Function)} | ${'`send` property'} | ${'a function'} + ${'value'} | ${definition.initial} | ${'`value` property'} | ${'the same as the `initial` value of the machine definition'} + ${'states'} | ${definition.states} | ${'`states` property'} | ${'the same as the `states` value of the machine definition'} + `("The machine's $description should be $eqDescription", ({ partOfMachine, equals }) => { + const test = partOfMachine === 'keys' ? Object.keys(liveMachine) : liveMachine[partOfMachine]; + + expect(test).toEqual(equals); + }); + + it.each` + initialState | transitionEvent | expectedState + ${definition.initial} | ${TRANSITION_START_LOAD} | ${STATE_LOADING} + ${STATE_LOADING} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} + ${STATE_ERRORED} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLE} + ${STATE_IDLE} | ${TRANSITION_START_LOAD} | ${STATE_LOADING} + ${STATE_LOADING} | ${TRANSITION_LOAD_SUCCESS} | ${STATE_IDLE} + `( + 'properly steps from $initialState to $expectedState when the event "$transitionEvent" is sent', + ({ initialState, transitionEvent, expectedState }) => { + liveMachine.value = initialState; + + liveMachine.send(transitionEvent); + + expect(liveMachine.is(expectedState)).toBe(true); + expect(liveMachine.value).toBe(expectedState); + }, + ); + + it.each` + initialState | transitionEvent + ${STATE_IDLE} | ${TRANSITION_ACKNOWLEDGE_ERROR} + ${STATE_IDLE} | ${TRANSITION_LOAD_SUCCESS} + ${STATE_IDLE} | ${TRANSITION_LOAD_ERROR} + ${STATE_IDLE} | ${'RANDOM_FOO'} + ${STATE_LOADING} | ${TRANSITION_START_LOAD} + ${STATE_LOADING} | ${TRANSITION_ACKNOWLEDGE_ERROR} + ${STATE_LOADING} | ${'RANDOM_FOO'} + ${STATE_ERRORED} | ${TRANSITION_LOAD_ERROR} + ${STATE_ERRORED} | ${TRANSITION_LOAD_SUCCESS} + ${STATE_ERRORED} | ${'RANDOM_FOO'} + `( + `does not perform any transition if the machine can't move from "$initialState" using the "$transitionEvent" event`, + ({ initialState, transitionEvent }) => { + liveMachine.value = initialState; + + liveMachine.send(transitionEvent); + + expect(liveMachine.is(initialState)).toBe(true); + expect(liveMachine.value).toBe(initialState); + }, + ); + + describe('send', () => { + it.each` + startState | transitionEvent | result + ${STATE_IDLE} | ${TRANSITION_START_LOAD} | ${STATE_LOADING} + ${STATE_LOADING} | ${TRANSITION_LOAD_SUCCESS} | ${STATE_IDLE} + ${STATE_LOADING} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} + ${STATE_ERRORED} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLE} + ${STATE_ERRORED} | ${TRANSITION_START_LOAD} | ${STATE_LOADING} + `( + 'successfully transitions to $result from $startState when the transition $transitionEvent is received', + ({ startState, transitionEvent, result }) => { + liveMachine.value = startState; + + expect(liveMachine.send(transitionEvent)).toEqual(result); + }, + ); + + it.each` + startState | transitionEvent + ${STATE_IDLE} | ${TRANSITION_ACKNOWLEDGE_ERROR} + ${STATE_IDLE} | ${TRANSITION_LOAD_SUCCESS} + ${STATE_IDLE} | ${TRANSITION_LOAD_ERROR} + ${STATE_IDLE} | ${'RANDOM_FOO'} + ${STATE_LOADING} | ${TRANSITION_START_LOAD} + ${STATE_LOADING} | ${TRANSITION_ACKNOWLEDGE_ERROR} + ${STATE_LOADING} | ${'RANDOM_FOO'} + ${STATE_ERRORED} | ${TRANSITION_LOAD_ERROR} + ${STATE_ERRORED} | ${TRANSITION_LOAD_SUCCESS} + ${STATE_ERRORED} | ${'RANDOM_FOO'} + `( + 'remains as $startState if an undefined transition ($transitionEvent) is received', + ({ startState, transitionEvent }) => { + liveMachine.value = startState; + + expect(liveMachine.send(transitionEvent)).toEqual(startState); + }, + ); + + describe('detached', () => { + it.each` + startState | transitionEvent | result + ${STATE_IDLE} | ${TRANSITION_START_LOAD} | ${STATE_LOADING} + ${STATE_LOADING} | ${TRANSITION_LOAD_SUCCESS} | ${STATE_IDLE} + ${STATE_LOADING} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} + ${STATE_ERRORED} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLE} + ${STATE_ERRORED} | ${TRANSITION_START_LOAD} | ${STATE_LOADING} + `( + 'successfully transitions to $result from $startState when the transition $transitionEvent is received outside the context of the machine', + ({ startState, transitionEvent, result }) => { + const liveSend = machine({ + ...definition, + initial: startState, + }).send; + + expect(liveSend(transitionEvent)).toEqual(result); + }, + ); + + it.each` + startState | transitionEvent + ${STATE_IDLE} | ${TRANSITION_ACKNOWLEDGE_ERROR} + ${STATE_IDLE} | ${TRANSITION_LOAD_SUCCESS} + ${STATE_IDLE} | ${TRANSITION_LOAD_ERROR} + ${STATE_IDLE} | ${'RANDOM_FOO'} + ${STATE_LOADING} | ${TRANSITION_START_LOAD} + ${STATE_LOADING} | ${TRANSITION_ACKNOWLEDGE_ERROR} + ${STATE_LOADING} | ${'RANDOM_FOO'} + ${STATE_ERRORED} | ${TRANSITION_LOAD_ERROR} + ${STATE_ERRORED} | ${TRANSITION_LOAD_SUCCESS} + ${STATE_ERRORED} | ${'RANDOM_FOO'} + `( + 'remains as $startState if an undefined transition ($transitionEvent) is received', + ({ startState, transitionEvent }) => { + const liveSend = machine({ + ...definition, + initial: startState, + }).send; + + expect(liveSend(transitionEvent)).toEqual(startState); + }, + ); + }); + }); + + describe('is', () => { + it.each` + bool | test | actual + ${true} | ${STATE_IDLE} | ${STATE_IDLE} + ${false} | ${STATE_LOADING} | ${STATE_IDLE} + ${false} | ${STATE_ERRORED} | ${STATE_IDLE} + ${true} | ${STATE_LOADING} | ${STATE_LOADING} + ${false} | ${STATE_IDLE} | ${STATE_LOADING} + ${false} | ${STATE_ERRORED} | ${STATE_LOADING} + ${true} | ${STATE_ERRORED} | ${STATE_ERRORED} + ${false} | ${STATE_IDLE} | ${STATE_ERRORED} + ${false} | ${STATE_LOADING} | ${STATE_ERRORED} + `( + 'returns "$bool" for "$test" when the current state is "$actual"', + ({ bool, test, actual }) => { + liveMachine = machine({ + ...definition, + initial: actual, + }); + + expect(liveMachine.is(test)).toEqual(bool); + }, + ); + + describe('detached', () => { + it.each` + bool | test | actual + ${true} | ${STATE_IDLE} | ${STATE_IDLE} + ${false} | ${STATE_LOADING} | ${STATE_IDLE} + ${false} | ${STATE_ERRORED} | ${STATE_IDLE} + ${true} | ${STATE_LOADING} | ${STATE_LOADING} + ${false} | ${STATE_IDLE} | ${STATE_LOADING} + ${false} | ${STATE_ERRORED} | ${STATE_LOADING} + ${true} | ${STATE_ERRORED} | ${STATE_ERRORED} + ${false} | ${STATE_IDLE} | ${STATE_ERRORED} + ${false} | ${STATE_LOADING} | ${STATE_ERRORED} + `( + 'returns "$bool" for "$test" when the current state is "$actual"', + ({ bool, test, actual }) => { + const liveIs = machine({ + ...definition, + initial: actual, + }).is; + + expect(liveIs(test)).toEqual(bool); + }, + ); + }); + }); + }); + + describe('transition', () => { + it.each` + startState | transitionEvent | result + ${STATE_IDLE} | ${TRANSITION_START_LOAD} | ${STATE_LOADING} + ${STATE_LOADING} | ${TRANSITION_LOAD_SUCCESS} | ${STATE_IDLE} + ${STATE_LOADING} | ${TRANSITION_LOAD_ERROR} | ${STATE_ERRORED} + ${STATE_ERRORED} | ${TRANSITION_ACKNOWLEDGE_ERROR} | ${STATE_IDLE} + ${STATE_ERRORED} | ${TRANSITION_START_LOAD} | ${STATE_LOADING} + `( + 'successfully transitions to $result from $startState when the transition $transitionEvent is received', + ({ startState, transitionEvent, result }) => { + expect(transition(definition, startState, transitionEvent)).toEqual(result); + }, + ); + + it.each` + startState | transitionEvent + ${STATE_IDLE} | ${TRANSITION_ACKNOWLEDGE_ERROR} + ${STATE_IDLE} | ${TRANSITION_LOAD_SUCCESS} + ${STATE_IDLE} | ${TRANSITION_LOAD_ERROR} + ${STATE_IDLE} | ${'RANDOM_FOO'} + ${STATE_LOADING} | ${TRANSITION_START_LOAD} + ${STATE_LOADING} | ${TRANSITION_ACKNOWLEDGE_ERROR} + ${STATE_LOADING} | ${'RANDOM_FOO'} + ${STATE_ERRORED} | ${TRANSITION_LOAD_ERROR} + ${STATE_ERRORED} | ${TRANSITION_LOAD_SUCCESS} + ${STATE_ERRORED} | ${'RANDOM_FOO'} + `( + 'remains as $startState if an undefined transition ($transitionEvent) is received', + ({ startState, transitionEvent }) => { + expect(transition(definition, startState, transitionEvent)).toEqual(startState); + }, + ); + + it('remains as the provided starting state if it is an unrecognized state', () => { + expect(transition(definition, 'RANDOM_FOO', TRANSITION_START_LOAD)).toEqual('RANDOM_FOO'); + }); + }); +}); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index cad500039c0..beedb9b2eba 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -300,7 +300,7 @@ describe('init markdown', () => { }); }); - describe('Editor Lite', () => { + describe('Source Editor', () => { let editor; beforeEach(() => { diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 31c78681994..66d0faa95e7 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -24,6 +24,16 @@ const setWindowLocation = (value) => { }; describe('URL utility', () => { + let originalLocation; + + beforeAll(() => { + originalLocation = window.location; + }); + + afterAll(() => { + window.location = originalLocation; + }); + describe('webIDEUrl', () => { afterEach(() => { gon.relative_url_root = ''; @@ -319,19 +329,17 @@ describe('URL utility', () => { }); describe('doesHashExistInUrl', () => { - it('should return true when the given string exists in the URL hash', () => { + beforeEach(() => { setWindowLocation({ - href: 'https://gitlab.com/gitlab-org/gitlab-test/issues/1#note_1', + hash: 'https://gitlab.com/gitlab-org/gitlab-test/issues/1#note_1', }); + }); + it('should return true when the given string exists in the URL hash', () => { expect(urlUtils.doesHashExistInUrl('note_')).toBe(true); }); it('should return false when the given string does not exist in the URL hash', () => { - setWindowLocation({ - href: 'https://gitlab.com/gitlab-org/gitlab-test/issues/1#note_1', - }); - expect(urlUtils.doesHashExistInUrl('doesnotexist')).toBe(false); }); }); @@ -651,6 +659,45 @@ describe('URL utility', () => { }); }); + describe('urlParamsToArray', () => { + it('returns empty array for empty querystring', () => { + expect(urlUtils.urlParamsToArray('')).toEqual([]); + }); + + it('should decode params', () => { + expect(urlUtils.urlParamsToArray('?label_name%5B%5D=test')[0]).toBe('label_name[]=test'); + }); + + it('should remove the question mark from the search params', () => { + const paramsArray = urlUtils.urlParamsToArray('?test=thing'); + + expect(paramsArray[0][0]).not.toBe('?'); + }); + }); + + describe('urlParamsToObject', () => { + it('parses path for label with trailing +', () => { + // eslint-disable-next-line import/no-deprecated + expect(urlUtils.urlParamsToObject('label_name[]=label%2B', {})).toEqual({ + label_name: ['label+'], + }); + }); + + it('parses path for milestone with trailing +', () => { + // eslint-disable-next-line import/no-deprecated + expect(urlUtils.urlParamsToObject('milestone_title=A%2B', {})).toEqual({ + milestone_title: 'A+', + }); + }); + + it('parses path for search terms with spaces', () => { + // eslint-disable-next-line import/no-deprecated + expect(urlUtils.urlParamsToObject('search=two+words', {})).toEqual({ + search: 'two words', + }); + }); + }); + describe('queryToObject', () => { it.each` case | query | options | result @@ -673,12 +720,68 @@ describe('URL utility', () => { }); }); + describe('getParameterByName', () => { + const { getParameterByName } = urlUtils; + + it('should return valid parameter', () => { + setWindowLocation({ search: '?scope=all&p=2' }); + + expect(getParameterByName('p')).toEqual('2'); + expect(getParameterByName('scope')).toBe('all'); + }); + + it('should return invalid parameter', () => { + setWindowLocation({ search: '?scope=all&p=2' }); + + expect(getParameterByName('fakeParameter')).toBe(null); + }); + + it('should return a parameter with spaces', () => { + setWindowLocation({ search: '?search=my terms' }); + + expect(getParameterByName('search')).toBe('my terms'); + }); + + it('should return a parameter with encoded spaces', () => { + setWindowLocation({ search: '?search=my%20terms' }); + + expect(getParameterByName('search')).toBe('my terms'); + }); + + it('should return a parameter with plus signs as spaces', () => { + setWindowLocation({ search: '?search=my+terms' }); + + expect(getParameterByName('search')).toBe('my terms'); + }); + + it('should return valid parameters if search is provided', () => { + expect(getParameterByName('foo', 'foo=bar')).toBe('bar'); + expect(getParameterByName('foo', '?foo=bar')).toBe('bar'); + + expect(getParameterByName('manan', 'foo=bar&manan=canchu')).toBe('canchu'); + expect(getParameterByName('manan', '?foo=bar&manan=canchu')).toBe('canchu'); + }); + }); + describe('objectToQuery', () => { it('converts search query object back into a search query', () => { const searchQueryObject = { one: '1', two: '2' }; expect(urlUtils.objectToQuery(searchQueryObject)).toEqual('one=1&two=2'); }); + + it('returns empty string when `params` is undefined, null or empty string', () => { + expect(urlUtils.objectToQuery()).toBe(''); + expect(urlUtils.objectToQuery('')).toBe(''); + }); + + it('returns query string with values of `params`', () => { + const singleQueryParams = { foo: true }; + const multipleQueryParams = { foo: true, bar: true }; + + expect(urlUtils.objectToQuery(singleQueryParams)).toBe('foo=true'); + expect(urlUtils.objectToQuery(multipleQueryParams)).toBe('foo=true&bar=true'); + }); }); describe('cleanLeadingSeparator', () => { diff --git a/spec/frontend/line_highlighter_spec.js b/spec/frontend/line_highlighter_spec.js index b5a0adc9d49..97ae6c0e3b7 100644 --- a/spec/frontend/line_highlighter_spec.js +++ b/spec/frontend/line_highlighter_spec.js @@ -49,6 +49,15 @@ describe('LineHighlighter', () => { } }); + it('highlights a range of lines given in the URL hash using GitHub format', () => { + new LineHighlighter({ hash: '#L5-L25' }); + + expect($(`.${testContext.css}`).length).toBe(21); + for (let line = 5; line <= 25; line += 1) { + expect($(`#LC${line}`)).toHaveClass(testContext.css); + } + }); + it('scrolls to the first highlighted line on initial load', () => { jest.spyOn(utils, 'scrollToElement'); new LineHighlighter({ hash: '#L5-25' }); diff --git a/spec/frontend/locale/index_spec.js b/spec/frontend/locale/index_spec.js index a08be502735..220061fc64a 100644 --- a/spec/frontend/locale/index_spec.js +++ b/spec/frontend/locale/index_spec.js @@ -1,5 +1,5 @@ import { setLanguage } from 'helpers/locale_helper'; -import { createDateTimeFormat, formatNumber, languageCode } from '~/locale'; +import { createDateTimeFormat, formatNumber, languageCode, getPreferredLocales } from '~/locale'; describe('locale', () => { afterEach(() => setLanguage(null)); @@ -18,13 +18,91 @@ describe('locale', () => { }); }); + describe('getPreferredLocales', () => { + beforeEach(() => { + // Need to spy on window.navigator.languages as it is read-only + jest + .spyOn(window.navigator, 'languages', 'get') + .mockReturnValueOnce(['en-GB', 'en-US', 'de-AT']); + }); + + it('filters navigator.languages by GitLab language', () => { + setLanguage('en'); + + expect(getPreferredLocales()).toEqual(['en-GB', 'en-US', 'en']); + }); + + it('filters navigator.languages by GitLab language without locale and sets English Fallback', () => { + setLanguage('de'); + + expect(getPreferredLocales()).toEqual(['de-AT', 'de', 'en']); + }); + + it('filters navigator.languages by GitLab language with locale and sets English Fallback', () => { + setLanguage('de-DE'); + + expect(getPreferredLocales()).toEqual(['de-AT', 'de-DE', 'de', 'en']); + }); + + it('adds GitLab language if navigator.languages does not contain it', () => { + setLanguage('es-ES'); + + expect(getPreferredLocales()).toEqual(['es-ES', 'es', 'en']); + }); + }); + describe('createDateTimeFormat', () => { - beforeEach(() => setLanguage('en')); + const date = new Date(2015, 0, 3, 15, 13, 22); + const formatOptions = { dateStyle: 'long', timeStyle: 'medium' }; it('creates an instance of Intl.DateTimeFormat', () => { - const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' }); + const dateFormat = createDateTimeFormat(formatOptions); + + expect(dateFormat).toBeInstanceOf(Intl.DateTimeFormat); + }); + + it('falls back to `en` and GitLab language is default', () => { + setLanguage(null); + jest.spyOn(window.navigator, 'languages', 'get').mockReturnValueOnce(['de-AT', 'en-GB']); + + const dateFormat = createDateTimeFormat(formatOptions); + expect(dateFormat.format(date)).toBe( + new Intl.DateTimeFormat('en-GB', formatOptions).format(date), + ); + }); + + it('falls back to `en` locale if browser languages are empty', () => { + setLanguage('en'); + jest.spyOn(window.navigator, 'languages', 'get').mockReturnValueOnce([]); + + const dateFormat = createDateTimeFormat(formatOptions); + expect(dateFormat.format(date)).toBe( + new Intl.DateTimeFormat('en', formatOptions).format(date), + ); + }); + + it('prefers `en-GB` if it is the preferred language and GitLab language is `en`', () => { + setLanguage('en'); + jest + .spyOn(window.navigator, 'languages', 'get') + .mockReturnValueOnce(['en-GB', 'en-US', 'en']); + + const dateFormat = createDateTimeFormat(formatOptions); + expect(dateFormat.format(date)).toBe( + new Intl.DateTimeFormat('en-GB', formatOptions).format(date), + ); + }); + + it('prefers `de-AT` if it is GitLab language and not part of the browser languages', () => { + setLanguage('de-AT'); + jest + .spyOn(window.navigator, 'languages', 'get') + .mockReturnValueOnce(['en-GB', 'en-US', 'en']); - expect(dateFormat.format(new Date(2015, 6, 3))).toBe('July 3, 2015'); + const dateFormat = createDateTimeFormat(formatOptions); + expect(dateFormat.format(date)).toBe( + new Intl.DateTimeFormat('de-AT', formatOptions).format(date), + ); }); }); diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js index 9307a3b62fb..46ef1500a20 100644 --- a/spec/frontend/logs/stores/actions_spec.js +++ b/spec/frontend/logs/stores/actions_spec.js @@ -1,6 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { TOKEN_TYPE_POD_NAME } from '~/logs/constants'; @@ -32,7 +31,6 @@ import { mockNextCursor, } from '../mock_data'; -jest.mock('~/flash'); jest.mock('~/lib/utils/datetime_range'); jest.mock('~/logs/utils'); @@ -75,10 +73,6 @@ describe('Logs Store actions', () => { state = logsPageState(); }); - afterEach(() => { - flash.mockClear(); - }); - describe('setInitData', () => { it('should commit environment and pod name mutation', () => testAction( diff --git a/spec/frontend/members/components/app_spec.js b/spec/frontend/members/components/app_spec.js index b9fdf8792fd..9590cd9d8d4 100644 --- a/spec/frontend/members/components/app_spec.js +++ b/spec/frontend/members/components/app_spec.js @@ -5,7 +5,8 @@ import Vuex from 'vuex'; import * as commonUtils from '~/lib/utils/common_utils'; import MembersApp from '~/members/components/app.vue'; import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue'; -import { MEMBER_TYPES } from '~/members/constants'; +import MembersTable from '~/members/components/table/members_table.vue'; +import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES } from '~/members/constants'; import { RECEIVE_MEMBER_ROLE_ERROR, HIDE_ERROR } from '~/members/store/mutation_types'; import mutations from '~/members/store/mutations'; @@ -19,7 +20,7 @@ describe('MembersApp', () => { const createComponent = (state = {}, options = {}) => { store = new Vuex.Store({ modules: { - [MEMBER_TYPES.user]: { + [MEMBER_TYPES.group]: { namespaced: true, state: { showError: true, @@ -34,7 +35,8 @@ describe('MembersApp', () => { wrapper = shallowMount(MembersApp, { localVue, propsData: { - namespace: MEMBER_TYPES.user, + namespace: MEMBER_TYPES.group, + tabQueryParamValue: TAB_QUERY_PARAM_VALUES.group, }, store, ...options, @@ -57,7 +59,7 @@ describe('MembersApp', () => { it('renders and scrolls to error alert', async () => { createComponent({ showError: false, errorMessage: '' }); - store.commit(`${MEMBER_TYPES.user}/${RECEIVE_MEMBER_ROLE_ERROR}`, { + store.commit(`${MEMBER_TYPES.group}/${RECEIVE_MEMBER_ROLE_ERROR}`, { error: new Error('Network Error'), }); @@ -77,7 +79,7 @@ describe('MembersApp', () => { it('does not render and scroll to error alert', async () => { createComponent(); - store.commit(`${MEMBER_TYPES.user}/${HIDE_ERROR}`); + store.commit(`${MEMBER_TYPES.group}/${HIDE_ERROR}`); await nextTick(); @@ -103,4 +105,13 @@ describe('MembersApp', () => { expect(findFilterSortContainer().exists()).toBe(true); }); + + it('renders `MembersTable` component and passes `tabQueryParamValue` prop', () => { + createComponent(); + + const membersTableComponent = wrapper.findComponent(MembersTable); + + expect(membersTableComponent.exists()).toBe(true); + expect(membersTableComponent.props('tabQueryParamValue')).toBe(TAB_QUERY_PARAM_VALUES.group); + }); }); diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js index 5e04e20801a..a3b91cb20bb 100644 --- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js +++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js @@ -216,5 +216,17 @@ describe('MembersFilteredSearchBar', () => { 'https://localhost/?two_factor=enabled&search=foobar&sort=name_asc', ); }); + + it('adds active tab query param', () => { + window.location.search = '?tab=invited'; + + createComponent(); + + findFilteredSearchBar().vm.$emit('onFilter', [ + { type: 'filtered-search-term', value: { data: 'foobar' } }, + ]); + + expect(window.location.href).toBe('https://localhost/?search=foobar&tab=invited'); + }); }); }); diff --git a/spec/frontend/members/components/members_tabs_spec.js b/spec/frontend/members/components/members_tabs_spec.js index 6f1a6d0c223..33d8eebf7eb 100644 --- a/spec/frontend/members/components/members_tabs_spec.js +++ b/spec/frontend/members/components/members_tabs_spec.js @@ -1,9 +1,14 @@ +import { GlTabs } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import MembersApp from '~/members/components/app.vue'; import MembersTabs from '~/members/components/members_tabs.vue'; -import { MEMBER_TYPES } from '~/members/constants'; +import { + MEMBER_TYPES, + TAB_QUERY_PARAM_VALUES, + ACTIVE_TAB_QUERY_PARAM_NAME, +} from '~/members/constants'; import { pagination } from '../mock_data'; describe('MembersTabs', () => { @@ -93,6 +98,18 @@ describe('MembersTabs', () => { wrapper.destroy(); }); + it('renders `GlTabs` with `syncActiveTabWithQueryParams` and `queryParamName` props set', async () => { + await createComponent(); + + const glTabsComponent = wrapper.findComponent(GlTabs); + + expect(glTabsComponent.exists()).toBe(true); + expect(glTabsComponent.props()).toMatchObject({ + syncActiveTabWithQueryParams: true, + queryParamName: ACTIVE_TAB_QUERY_PARAM_NAME, + }); + }); + describe('when tabs have a count', () => { it('renders tabs with count', async () => { await createComponent(); @@ -106,7 +123,7 @@ describe('MembersTabs', () => { expect(findActiveTab().text()).toContain('Members'); }); - it('renders `MembersApp` and passes `namespace` prop', async () => { + it('renders `MembersApp` and passes `namespace` and `tabQueryParamValue` props', async () => { await createComponent(); const membersApps = wrapper.findAllComponents(MembersApp).wrappers; @@ -115,6 +132,10 @@ describe('MembersTabs', () => { expect(membersApps[1].props('namespace')).toBe(MEMBER_TYPES.group); expect(membersApps[2].props('namespace')).toBe(MEMBER_TYPES.invite); expect(membersApps[3].props('namespace')).toBe(MEMBER_TYPES.accessRequest); + + expect(membersApps[1].props('tabQueryParamValue')).toBe(TAB_QUERY_PARAM_VALUES.group); + expect(membersApps[2].props('tabQueryParamValue')).toBe(TAB_QUERY_PARAM_VALUES.invite); + expect(membersApps[3].props('tabQueryParamValue')).toBe(TAB_QUERY_PARAM_VALUES.accessRequest); }); }); @@ -127,56 +148,16 @@ describe('MembersTabs', () => { expect(findTabByText('Invited')).toBeUndefined(); expect(findTabByText('Access requests')).toBeUndefined(); }); - }); - describe('when url param matches `filteredSearchBar.searchParam`', () => { - beforeEach(() => { - window.location.search = '?search_groups=foo+bar'; - }); - - const expectGroupsTabActive = () => { - expect(findActiveTab().text()).toContain('Groups'); - }; - - describe('when tab has a count', () => { - it('sets tab that corresponds to search param as active tab', async () => { - await createComponent(); - - expectGroupsTabActive(); + describe('when url param matches `filteredSearchBar.searchParam`', () => { + beforeEach(() => { + window.location.search = '?search_groups=foo+bar'; }); - }); - - describe('when tab does not have a count', () => { - it('sets tab that corresponds to search param as active tab', async () => { - await createComponent({ totalItems: 0 }); - - expectGroupsTabActive(); - }); - }); - }); - - describe('when url param matches `pagination.paramName`', () => { - beforeEach(() => { - window.location.search = '?invited_page=2'; - }); - - const expectInvitedTabActive = () => { - expect(findActiveTab().text()).toContain('Invited'); - }; - - describe('when tab has a count', () => { - it('sets tab that corresponds to pagination param as active tab', async () => { - await createComponent(); - - expectInvitedTabActive(); - }); - }); - describe('when tab does not have a count', () => { - it('sets tab that corresponds to pagination param as active tab', async () => { + it('shows tab that corresponds to search param', async () => { await createComponent({ totalItems: 0 }); - expectInvitedTabActive(); + expect(findTabByText('Groups')).not.toBeUndefined(); }); }); }); diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 5308d7651a3..3a17d78bd17 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -15,7 +15,7 @@ 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 } from '~/members/constants'; +import { MEMBER_TYPES, TAB_QUERY_PARAM_VALUES } from '~/members/constants'; import * as initUserPopovers from '~/user_popovers'; import { member as memberMock, @@ -34,7 +34,7 @@ describe('MembersTable', () => { const createStore = (state = {}) => { return new Vuex.Store({ modules: { - [MEMBER_TYPES.user]: { + [MEMBER_TYPES.invite]: { namespaced: true, state: { members: [], @@ -54,11 +54,14 @@ describe('MembersTable', () => { const createComponent = (state, provide = {}) => { wrapper = mount(MembersTable, { localVue, + propsData: { + tabQueryParamValue: TAB_QUERY_PARAM_VALUES.invite, + }, store: createStore(state), provide: { sourceId: 1, currentUserId: 1, - namespace: MEMBER_TYPES.user, + namespace: MEMBER_TYPES.invite, ...provide, }, stubs: [ @@ -74,7 +77,7 @@ describe('MembersTable', () => { }); }; - const url = 'https://localhost/foo-bar/-/project_members'; + const url = 'https://localhost/foo-bar/-/project_members?tab=invited'; const getByText = (text, options) => createWrapper(getByTextHelper(wrapper.element, text, options)); @@ -92,7 +95,7 @@ describe('MembersTable', () => { const expectCorrectLinkToPage2 = () => { expect(findPagination().findByText('2', { selector: 'a' }).attributes('href')).toBe( - `${url}?page=2`, + `${url}&invited_members_page=2`, ); }; @@ -271,7 +274,7 @@ describe('MembersTable', () => { currentPage: 1, perPage: 5, totalItems: 10, - paramName: 'page', + paramName: 'invited_members_page', }, }); @@ -279,14 +282,14 @@ describe('MembersTable', () => { }); it('removes any url params defined as `null` in the `params` attribute', () => { - window.location = new URL(`${url}?search_groups=foo`); + window.location = new URL(`${url}&search_groups=foo`); createComponent({ pagination: { currentPage: 1, perPage: 5, totalItems: 10, - paramName: 'page', + paramName: 'invited_members_page', params: { search_groups: null }, }, }); diff --git a/spec/frontend/milestones/milestone_utils_spec.js b/spec/frontend/milestones/milestone_utils_spec.js new file mode 100644 index 00000000000..f863f31e5a9 --- /dev/null +++ b/spec/frontend/milestones/milestone_utils_spec.js @@ -0,0 +1,47 @@ +import { useFakeDate } from 'helpers/fake_date'; +import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; + +describe('sortMilestonesByDueDate', () => { + useFakeDate(2021, 6, 22); + const mockMilestones = [ + { + id: 2, + }, + { + id: 1, + dueDate: '2021-01-01', + }, + { + id: 4, + dueDate: '2021-02-01', + expired: true, + }, + { + id: 3, + dueDate: `2021-08-01`, + }, + ]; + + describe('sorts milestones', () => { + it('expired milestones are kept at the bottom of the list', () => { + const sortedMilestones = [...mockMilestones].sort(sortMilestonesByDueDate); + + expect(sortedMilestones[2].id).toBe(mockMilestones[1].id); // milestone with id `1` is expired + expect(sortedMilestones[3].id).toBe(mockMilestones[2].id); // milestone with id `4` is expired + }); + + it('milestones with closest due date are kept at the top of the list', () => { + const sortedMilestones = [...mockMilestones].sort(sortMilestonesByDueDate); + + // milestone with id `3` & 2021-08-01 is closest to current date i.e. 2021-07-22 + expect(sortedMilestones[0].id).toBe(mockMilestones[3].id); + }); + + it('milestones with no due date are kept between milestones with closest due date and expired milestones', () => { + const sortedMilestones = [...mockMilestones].sort(sortMilestonesByDueDate); + + // milestone with id `2` has no due date + expect(sortedMilestones[1].id).toBe(mockMilestones[0].id); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 754ddd96c9b..ea6e4f4a5ed 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -51,6 +51,8 @@ describe('Time series component', () => { }, stubs: { GlPopover: true, + GlLineChart, + GlAreaChart, }, attachTo: document.body, }); @@ -202,7 +204,7 @@ describe('Time series component', () => { describe('when series is of line type', () => { beforeEach(() => { - createWrapper(); + createWrapper({}, mount); wrapper.vm.formatTooltipText(mockLineSeriesData()); return wrapper.vm.$nextTick(); }); diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js index 6e98ca28071..dbb9fd5f603 100644 --- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js +++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -43,6 +43,9 @@ describe('Actions menu', () => { wrapper = shallowMount(ActionsMenu, { propsData: { ...dashboardActionsMenuProps, ...props }, store, + stubs: { + GlModal, + }, ...options, }); }; @@ -82,7 +85,7 @@ describe('Actions menu', () => { it('modal for custom metrics form is rendered', () => { expect(findAddMetricModal().exists()).toBe(true); - expect(findAddMetricModal().attributes().modalid).toBe('addMetric'); + expect(findAddMetricModal().props('modalId')).toBe('addMetric'); }); it('add metric modal submit button exists', () => { diff --git a/spec/frontend/nav/components/top_nav_menu_item_spec.js b/spec/frontend/nav/components/top_nav_menu_item_spec.js index fd2b4d3b056..71154e18915 100644 --- a/spec/frontend/nav/components/top_nav_menu_item_spec.js +++ b/spec/frontend/nav/components/top_nav_menu_item_spec.js @@ -73,7 +73,7 @@ describe('~/nav/components/top_nav_menu_item.vue', () => { expect(findButtonIcons()).toEqual([ { name: TEST_MENU_ITEM.icon, - classes: ['gl-mr-2!'], + classes: ['gl-mr-3!'], }, { name: 'chevron-right', diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 537622b7918..bb79b43205b 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -7,7 +7,7 @@ import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import CommentForm from '~/notes/components/comment_form.vue'; import * as constants from '~/notes/constants'; @@ -464,9 +464,9 @@ describe('issue_comment_form component', () => { await wrapper.vm.$nextTick; await wrapper.vm.$nextTick; - expect(flash).toHaveBeenCalledWith( - `Something went wrong while closing the ${type}. Please try again later.`, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: `Something went wrong while closing the ${type}. Please try again later.`, + }); }); }); @@ -500,9 +500,9 @@ describe('issue_comment_form component', () => { await wrapper.vm.$nextTick; await wrapper.vm.$nextTick; - expect(flash).toHaveBeenCalledWith( - `Something went wrong while reopening the ${type}. Please try again later.`, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: `Something went wrong while reopening the ${type}. Please try again later.`, + }); }); }); diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index cd24b9afbdf..59ac75f00e6 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -1,5 +1,5 @@ import { getByRole } from '@testing-library/dom'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import '~/behaviors/markdown/render_gfm'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import NoteableNote from '~/notes/components/noteable_note.vue'; @@ -23,8 +23,8 @@ describe('DiscussionNotes', () => { let wrapper; const getList = () => getByRole(wrapper.element, 'list'); - const createComponent = (props) => { - wrapper = shallowMount(DiscussionNotes, { + const createComponent = (props, mountingMethod = shallowMount) => { + wrapper = mountingMethod(DiscussionNotes, { store, propsData: { discussion: discussionMock, @@ -33,7 +33,11 @@ describe('DiscussionNotes', () => { ...props, }, scopedSlots: { - footer: '<p slot-scope="{ showReplies }">showReplies:{{showReplies}}</p>', + footer: ` + <template #default="{ showReplies }"> + <p>showReplies:{{ showReplies }}</p>, + </template> + `, }, slots: { 'avatar-badge': '<span class="avatar-badge-slot-content" />', @@ -112,7 +116,7 @@ describe('DiscussionNotes', () => { }); it('passes down avatar-badge slot content', () => { - createComponent(); + createComponent({}, mount); expect(wrapper.find('.avatar-badge-slot-content').exists()).toBe(true); }); }); diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index 7444c441e06..f217dfd2e48 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -1,5 +1,4 @@ import { mount } from '@vue/test-utils'; -import { escape } from 'lodash'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -263,7 +262,9 @@ describe('issue_note', () => { await waitForPromises(); expect(alertSpy).not.toHaveBeenCalled(); - expect(wrapper.vm.note.note_html).toBe(escape(noteBody)); + expect(wrapper.vm.note.note_html).toBe( + '<p><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"></p>\n', + ); }); }); @@ -291,7 +292,7 @@ describe('issue_note', () => { await wrapper.vm.$nextTick(); let noteBodyProps = noteBody.props(); - expect(noteBodyProps.note.note_html).toBe(updatedText); + expect(noteBodyProps.note.note_html).toBe(`<p>${updatedText}</p>\n`); noteBody.vm.$emit('cancelForm'); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 7eef2017dfb..2ff65d3f47e 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -2,7 +2,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import Api from '~/api'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import * as notesConstants from '~/notes/constants'; @@ -33,10 +33,7 @@ jest.mock('~/flash', () => { }; }); - return { - createFlash: flash, - deprecatedCreateFlash: flash, - }; + return flash; }); describe('Actions Notes Store', () => { @@ -348,13 +345,13 @@ describe('Actions Notes Store', () => { await startPolling(); expect(axiosMock.history.get).toHaveLength(1); - expect(Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); await advanceXMoreIntervals(1); expect(axiosMock.history.get).toHaveLength(2); - expect(Flash).toHaveBeenCalled(); - expect(Flash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledTimes(1); }); it('resets the failure counter on success', async () => { @@ -375,14 +372,14 @@ describe('Actions Notes Store', () => { await advanceXMoreIntervals(1); // Failure #2 // That was the first failure AFTER a success, so we should NOT see the error displayed - expect(Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); // Now we'll allow another failure await advanceXMoreIntervals(1); // Failure #3 // Since this is the second failure in a row, the error should happen - expect(Flash).toHaveBeenCalled(); - expect(Flash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledTimes(1); }); it('hides the error display if it exists on success', async () => { @@ -393,8 +390,8 @@ describe('Actions Notes Store', () => { await advanceXMoreIntervals(2); // After two errors, the error should be displayed - expect(Flash).toHaveBeenCalled(); - expect(Flash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledTimes(1); axiosMock.reset(); successMock(); @@ -906,7 +903,7 @@ describe('Actions Notes Store', () => { .then(() => done.fail('Expected error to be thrown!')) .catch((err) => { expect(err).toBe(error); - expect(Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); }) .then(done) .catch(done.fail); @@ -928,11 +925,10 @@ describe('Actions Notes Store', () => { ) .then((resp) => { expect(resp.hasFlash).toBe(true); - expect(Flash).toHaveBeenCalledWith( - 'Your comment could not be submitted because something went wrong', - 'alert', - flashContainer, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Your comment could not be submitted because something went wrong', + parent: flashContainer, + }); }) .catch(() => done.fail('Expected success response!')) .then(done) @@ -954,7 +950,7 @@ describe('Actions Notes Store', () => { ) .then((data) => { expect(data).toBe(res); - expect(Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); }) .then(done) .catch(done.fail); @@ -997,7 +993,7 @@ describe('Actions Notes Store', () => { ['resolveDiscussion', { discussionId }], ['restartPolling'], ]); - expect(Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); }); }); @@ -1012,7 +1008,10 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); - expect(Flash).toHaveBeenCalledWith(TEST_ERROR_MESSAGE, 'alert', flashContainer); + expect(createFlash).toHaveBeenCalledWith({ + message: TEST_ERROR_MESSAGE, + parent: flashContainer, + }); }); }); @@ -1027,11 +1026,10 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); - expect(Flash).toHaveBeenCalledWith( - 'Something went wrong while applying the suggestion. Please try again.', - 'alert', - flashContainer, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Something went wrong while applying the suggestion. Please try again.', + parent: flashContainer, + }); }); }); @@ -1039,7 +1037,7 @@ describe('Actions Notes Store', () => { dispatch.mockReturnValue(Promise.reject()); testSubmitSuggestion(done, () => { - expect(Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); }); }); }); @@ -1083,7 +1081,7 @@ describe('Actions Notes Store', () => { ['restartPolling'], ]); - expect(Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); }); }); @@ -1101,7 +1099,10 @@ describe('Actions Notes Store', () => { ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); - expect(Flash).toHaveBeenCalledWith(TEST_ERROR_MESSAGE, 'alert', flashContainer); + expect(createFlash).toHaveBeenCalledWith({ + message: TEST_ERROR_MESSAGE, + parent: flashContainer, + }); }); }); @@ -1119,11 +1120,11 @@ describe('Actions Notes Store', () => { ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); - expect(Flash).toHaveBeenCalledWith( - 'Something went wrong while applying the batch of suggestions. Please try again.', - 'alert', - flashContainer, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: + 'Something went wrong while applying the batch of suggestions. Please try again.', + parent: flashContainer, + }); }); }); @@ -1139,7 +1140,7 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(Flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); }); }); }); @@ -1283,7 +1284,7 @@ describe('Actions Notes Store', () => { ) .then(() => done.fail('Expected error to be thrown')) .catch(() => { - expect(Flash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); done(); }); }); diff --git a/spec/frontend/notifications/components/custom_notifications_modal_spec.js b/spec/frontend/notifications/components/custom_notifications_modal_spec.js index 5e4114d91f5..0782ec7cdd5 100644 --- a/spec/frontend/notifications/components/custom_notifications_modal_spec.js +++ b/spec/frontend/notifications/components/custom_notifications_modal_spec.js @@ -177,11 +177,8 @@ describe('CustomNotificationsModal', () => { await waitForPromises(); - expect( - mockToastShow, - ).toHaveBeenCalledWith( + expect(mockToastShow).toHaveBeenCalledWith( 'An error occurred while loading the notification settings. Please try again.', - { type: 'error' }, ); }); }); @@ -255,11 +252,8 @@ describe('CustomNotificationsModal', () => { await waitForPromises(); - expect( - mockToastShow, - ).toHaveBeenCalledWith( + expect(mockToastShow).toHaveBeenCalledWith( 'An error occurred while updating the notification settings. Please try again.', - { type: 'error' }, ); }); }); diff --git a/spec/frontend/notifications/components/notifications_dropdown_spec.js b/spec/frontend/notifications/components/notifications_dropdown_spec.js index e90bd68d067..e12251ce6d9 100644 --- a/spec/frontend/notifications/components/notifications_dropdown_spec.js +++ b/spec/frontend/notifications/components/notifications_dropdown_spec.js @@ -242,11 +242,8 @@ describe('NotificationsDropdown', () => { await clickDropdownItemAt(1); expect(wrapper.vm.selectedNotificationLevel).toBe('global'); - expect( - mockToastShow, - ).toHaveBeenCalledWith( + expect(mockToastShow).toHaveBeenCalledWith( 'An error occurred while updating the notification settings. Please try again.', - { type: 'error' }, ); }); diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js index 5eecfd395e2..258c6eae692 100644 --- a/spec/frontend/operation_settings/components/metrics_settings_spec.js +++ b/spec/frontend/operation_settings/components/metrics_settings_spec.js @@ -205,7 +205,6 @@ describe('operation settings external dashboard component', () => { .then(() => expect(createFlash).toHaveBeenCalledWith({ message: `There was an error saving your changes. ${message}`, - type: 'alert', }), ); }); diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap index 07aba62fef6..dbebdeeb452 100644 --- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -21,6 +21,7 @@ exports[`packages_list_app renders 1`] = ` <img alt="" class="gl-max-w-full" + role="img" src="helpSvg" /> </div> diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js index 463e4a4febb..a1076b729f8 100644 --- a/spec/frontend/packages/shared/utils_spec.js +++ b/spec/frontend/packages/shared/utils_spec.js @@ -40,6 +40,8 @@ describe('Packages shared utils', () => { ${'pypi'} | ${'PyPI'} ${'rubygems'} | ${'RubyGems'} ${'composer'} | ${'Composer'} + ${'debian'} | ${'Debian'} + ${'helm'} | ${'Helm'} ${'foo'} | ${null} `(`package type`, ({ packageType, expectedResult }) => { it(`${packageType} should show as ${expectedResult}`, () => { diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js new file mode 100644 index 00000000000..97444ec108f --- /dev/null +++ b/spec/frontend/packages_and_registries/package_registry/components/details/app_spec.js @@ -0,0 +1,35 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue'; + +describe('PackagesApp', () => { + let wrapper; + + function createComponent() { + wrapper = shallowMount(PackagesApp, { + provide: { + titleComponent: 'titleComponent', + projectName: 'projectName', + canDelete: 'canDelete', + svgPath: 'svgPath', + npmPath: 'npmPath', + npmHelpPath: 'npmHelpPath', + projectListUrl: 'projectListUrl', + groupListUrl: 'groupListUrl', + }, + }); + } + + const emptyState = () => wrapper.findComponent(GlEmptyState); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders an empty state component', () => { + createComponent(); + + expect(emptyState().exists()).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 14ee3f3e3b8..f2877a1f2a5 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 @@ -137,7 +137,7 @@ describe('Group Settings App', () => { href: PACKAGES_DOCS_PATH, target: '_blank', }); - expect(findLink().text()).toBe('More Information'); + expect(findLink().text()).toBe('Learn more.'); }); it('calls the graphql API with the proper variables', () => { @@ -244,9 +244,7 @@ describe('Group Settings App', () => { await waitForPromises(); - expect(show).toHaveBeenCalledWith(SUCCESS_UPDATING_SETTINGS, { - type: 'success', - }); + expect(show).toHaveBeenCalledWith(SUCCESS_UPDATING_SETTINGS); }); it('has an optimistic response', async () => { 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 7062773b46b..cf554717127 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 @@ -33,6 +33,10 @@ Array [ exports[`Utils formOptionsGenerator returns an object containing keepN 1`] = ` Array [ Object { + "key": null, + "label": "", + }, + Object { "default": false, "key": "ONE_TAG", "label": "1 tag per image name", @@ -74,6 +78,10 @@ Array [ exports[`Utils formOptionsGenerator returns an object containing olderThan 1`] = ` Array [ Object { + "key": null, + "label": "", + }, + Object { "default": false, "key": "SEVEN_DAYS", "label": "7 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 7a52b4a5d0f..1009db46401 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 @@ -22,7 +22,7 @@ exports[`Settings Form Enable matches snapshot 1`] = ` exports[`Settings Form Keep N matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="keep-n-dropdown" - formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" + formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" label="Keep the most recent:" name="keep-n" value="TEN_TAGS" @@ -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]" + formoptions="[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/packages_and_registries/settings/project/settings/components/settings_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js index 7e5383d7ff1..3a71af94d5a 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/settings_form_spec.js @@ -132,9 +132,9 @@ describe('Settings Form', () => { model | finder | fieldName | type | defaultValue ${'enabled'} | ${findEnableToggle} | ${'Enable'} | ${'toggle'} | ${false} ${'cadence'} | ${findCadenceDropdown} | ${'Cadence'} | ${'dropdown'} | ${'EVERY_DAY'} - ${'keepN'} | ${findKeepNDropdown} | ${'Keep N'} | ${'dropdown'} | ${'TEN_TAGS'} + ${'keepN'} | ${findKeepNDropdown} | ${'Keep N'} | ${'dropdown'} | ${''} ${'nameRegexKeep'} | ${findKeepRegexInput} | ${'Keep Regex'} | ${'textarea'} | ${''} - ${'olderThan'} | ${findOlderThanDropdown} | ${'OlderThan'} | ${'dropdown'} | ${'NINETY_DAYS'} + ${'olderThan'} | ${findOlderThanDropdown} | ${'OlderThan'} | ${'dropdown'} | ${''} ${'nameRegex'} | ${findRemoveRegexInput} | ${'Remove regex'} | ${'textarea'} | ${''} `('$fieldName', ({ model, finder, type, defaultValue }) => { it('matches snapshot', () => { @@ -293,10 +293,10 @@ describe('Settings Form', () => { input: { cadence: 'EVERY_DAY', enabled: true, - keepN: 'TEN_TAGS', + keepN: null, nameRegex: 'asdasdssssdfdf', nameRegexKeep: 'sss', - olderThan: 'NINETY_DAYS', + olderThan: null, projectPath: 'path', }, }); @@ -321,9 +321,7 @@ describe('Settings Form', () => { await waitForPromises(); await wrapper.vm.$nextTick(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { - type: 'success', - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE); }); describe('when submit fails', () => { @@ -339,9 +337,7 @@ describe('Settings Form', () => { await waitForPromises(); await wrapper.vm.$nextTick(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo', { - type: 'error', - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo'); }); }); @@ -355,9 +351,7 @@ describe('Settings Form', () => { await waitForPromises(); await wrapper.vm.$nextTick(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { - type: 'error', - }); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE); }); it('parses the error messages', async () => { diff --git a/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js index 4c81671cd46..ed126d87ae3 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/utils_spec.js @@ -12,6 +12,7 @@ describe('Utils', () => { olderThanTranslationGenerator, ); expect(result).toEqual([ + { key: null, label: '' }, { variable: 1, label: '1 day' }, { variable: 2, label: '2 days' }, ]); diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js index 95679a51c6d..ff352303143 100644 --- a/spec/frontend/pager_spec.js +++ b/spec/frontend/pager_spec.js @@ -6,6 +6,7 @@ import { removeParams } from '~/lib/utils/url_utility'; import Pager from '~/pager'; jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), removeParams: jest.fn().mockName('removeParams'), })); diff --git a/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js new file mode 100644 index 00000000000..858c7b76ac8 --- /dev/null +++ b/spec/frontend/pages/admin/application_settings/metrics_and_profiling/usage_statistics_spec.js @@ -0,0 +1,57 @@ +import initSetHelperText, { + HELPER_TEXT_SERVICE_PING_DISABLED, + HELPER_TEXT_SERVICE_PING_ENABLED, +} from '~/pages/admin/application_settings/metrics_and_profiling/usage_statistics'; + +describe('UsageStatistics', () => { + const FIXTURE = 'application_settings/usage.html'; + let usagePingCheckBox; + let usagePingFeaturesCheckBox; + let usagePingFeaturesLabel; + let usagePingFeaturesHelperText; + + beforeEach(() => { + loadFixtures(FIXTURE); + initSetHelperText(); + usagePingCheckBox = document.getElementById('application_setting_usage_ping_enabled'); + usagePingFeaturesCheckBox = document.getElementById( + 'application_setting_usage_ping_features_enabled', + ); + usagePingFeaturesLabel = document.getElementById('service_ping_features_label'); + usagePingFeaturesHelperText = document.getElementById('service_ping_features_helper_text'); + }); + + const expectEnabledUsagePingFeaturesCheckBox = () => { + expect(usagePingFeaturesCheckBox.classList.contains('gl-cursor-not-allowed')).toBe(false); + expect(usagePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_ENABLED); + }; + + const expectDisabledUsagePingFeaturesCheckBox = () => { + expect(usagePingFeaturesLabel.classList.contains('gl-cursor-not-allowed')).toBe(true); + expect(usagePingFeaturesHelperText.textContent).toEqual(HELPER_TEXT_SERVICE_PING_DISABLED); + }; + + describe('Registration Features checkbox', () => { + it('is disabled when Usage Ping checkbox is unchecked', () => { + expect(usagePingCheckBox.checked).toBe(false); + expectDisabledUsagePingFeaturesCheckBox(); + }); + + it('is enabled when Usage Ping checkbox is checked', () => { + usagePingCheckBox.click(); + expect(usagePingCheckBox.checked).toBe(true); + expectEnabledUsagePingFeaturesCheckBox(); + }); + + it('is switched to disabled when Usage Ping checkbox is unchecked ', () => { + usagePingCheckBox.click(); + usagePingFeaturesCheckBox.click(); + expectEnabledUsagePingFeaturesCheckBox(); + + usagePingCheckBox.click(); + expect(usagePingCheckBox.checked).toBe(false); + expect(usagePingFeaturesCheckBox.checked).toBe(false); + expectDisabledUsagePingFeaturesCheckBox(); + }); + }); +}); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index c80ccfa8256..dd617b1ffc2 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -29,10 +29,12 @@ describe('ForkForm component', () => { const MOCK_NAMESPACES_RESPONSE = [ { name: 'one', + full_name: 'one-group/one', id: 1, }, { name: 'two', + full_name: 'two-group/two', id: 2, }, ]; @@ -155,7 +157,7 @@ describe('ForkForm component', () => { describe('forks namespaces', () => { beforeEach(() => { mockGetRequest({ namespaces: MOCK_NAMESPACES_RESPONSE }); - createComponent(); + createFullComponent(); }); it('make GET request from endpoint', async () => { @@ -178,8 +180,23 @@ describe('ForkForm component', () => { const optionsArray = findForkUrlInput().findAll('option'); expect(optionsArray).toHaveLength(MOCK_NAMESPACES_RESPONSE.length + 1); - expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].name); - expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].name); + expect(optionsArray.at(1).text()).toBe(MOCK_NAMESPACES_RESPONSE[0].full_name); + expect(optionsArray.at(2).text()).toBe(MOCK_NAMESPACES_RESPONSE[1].full_name); + }); + + it('set namespaces in alphabetical order', async () => { + const namespace = { + name: 'three', + full_name: 'aaa/three', + id: 3, + }; + mockGetRequest({ + namespaces: [...MOCK_NAMESPACES_RESPONSE, namespace], + }); + createComponent(); + await axios.waitForAll(); + + expect(wrapper.vm.namespaces).toEqual([namespace, ...MOCK_NAMESPACES_RESPONSE]); }); }); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js index b5425fa6f2e..490dafed4ae 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_item_spec.js @@ -34,10 +34,10 @@ describe('Fork groups list item component', () => { }); }; - it('renders pending removal badge if applicable', () => { + it('renders pending deletion badge if applicable', () => { createWrapper({ group: { ...DEFAULT_GROUP_DATA, marked_for_deletion: true } }); - expect(wrapper.find(GlBadge).text()).toBe('pending removal'); + expect(wrapper.find(GlBadge).text()).toBe('pending deletion'); }); it('renders go to fork button if has forked project', () => { diff --git a/spec/frontend/pages/projects/new/components/app_spec.js b/spec/frontend/pages/projects/new/components/app_spec.js index b604e636243..ab8c6d529a8 100644 --- a/spec/frontend/pages/projects/new/components/app_spec.js +++ b/spec/frontend/pages/projects/new/components/app_spec.js @@ -1,13 +1,10 @@ import { shallowMount } from '@vue/test-utils'; -import { assignGitlabExperiment } from 'helpers/experimentation_helper'; import App from '~/pages/projects/new/components/app.vue'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; describe('Experimental new project creation app', () => { let wrapper; - const findNewNamespacePage = () => wrapper.findComponent(NewNamespacePage); - const createComponent = (propsData) => { wrapper = shallowMount(App, { propsData }); }; @@ -16,36 +13,6 @@ describe('Experimental new project creation app', () => { wrapper.destroy(); }); - describe('new_repo experiment', () => { - it('passes new_repo experiment', () => { - createComponent(); - - expect(findNewNamespacePage().props().experiment).toBe('new_repo'); - }); - - describe('when in the candidate variant', () => { - assignGitlabExperiment('new_repo', 'candidate'); - - it('has "repository" in the panel title', () => { - createComponent(); - - expect(findNewNamespacePage().props().panels[0].title).toBe( - 'Create blank project/repository', - ); - }); - }); - - describe('when in the control variant', () => { - assignGitlabExperiment('new_repo', 'control'); - - it('has "project" in the panel title', () => { - createComponent(); - - expect(findNewNamespacePage().props().panels[0].title).toBe('Create blank project'); - }); - }); - }); - it('passes custom new project guideline text to underlying component', () => { const DEMO_GUIDELINES = 'Demo guidelines'; const guidelineSelector = '#new-project-guideline'; diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index 878721666ff..4c253f0610b 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -94,6 +94,8 @@ describe('Settings Panel', () => { const findPackageSettings = () => wrapper.find({ ref: 'package-settings' }); const findPackagesEnabledInput = () => wrapper.find('[name="project[packages_enabled]"]'); const findPagesSettings = () => wrapper.find({ ref: 'pages-settings' }); + const findPagesAccessLevels = () => + wrapper.find('[name="project[project_feature_attributes][pages_access_level]"]'); const findEmailSettings = () => wrapper.find({ ref: 'email-settings' }); const findShowDefaultAwardEmojis = () => wrapper.find('input[name="project[project_setting_attributes][show_default_award_emojis]"]'); @@ -479,6 +481,29 @@ describe('Settings Panel', () => { describe('Pages', () => { it.each` + visibilityLevel | pagesAccessControlForced | output + ${visibilityOptions.PRIVATE} | ${true} | ${[[visibilityOptions.INTERNAL, 'Only Project Members'], [visibilityOptions.PUBLIC, 'Everyone With Access']]} + ${visibilityOptions.PRIVATE} | ${false} | ${[[visibilityOptions.INTERNAL, 'Only Project Members'], [visibilityOptions.PUBLIC, 'Everyone With Access'], [30, 'Everyone']]} + ${visibilityOptions.INTERNAL} | ${true} | ${[[visibilityOptions.INTERNAL, 'Only Project Members'], [visibilityOptions.PUBLIC, 'Everyone With Access']]} + ${visibilityOptions.INTERNAL} | ${false} | ${[[visibilityOptions.INTERNAL, 'Only Project Members'], [visibilityOptions.PUBLIC, 'Everyone With Access'], [30, 'Everyone']]} + ${visibilityOptions.PUBLIC} | ${true} | ${[[visibilityOptions.INTERNAL, 'Only Project Members'], [visibilityOptions.PUBLIC, 'Everyone With Access']]} + ${visibilityOptions.PUBLIC} | ${false} | ${[[visibilityOptions.INTERNAL, 'Only Project Members'], [visibilityOptions.PUBLIC, 'Everyone With Access'], [30, 'Everyone']]} + `( + 'renders correct options when pagesAccessControlForced is $pagesAccessControlForced and visibilityLevel is $visibilityLevel', + async ({ visibilityLevel, pagesAccessControlForced, output }) => { + wrapper = mountComponent({ + pagesAvailable: true, + pagesAccessControlEnabled: true, + pagesAccessControlForced, + }); + + await findProjectVisibilityLevelInput().trigger('change', visibilityLevel); + + expect(findPagesAccessLevels().props('options')).toStrictEqual(output); + }, + ); + + it.each` pagesAvailable | pagesAccessControlEnabled | visibility ${true} | ${true} | ${'show'} ${true} | ${false} | ${'hide'} diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js index 403142d7ff7..1e51ddf909a 100644 --- a/spec/frontend/persistent_user_callout_spec.js +++ b/spec/frontend/persistent_user_callout_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import PersistentUserCallout from '~/persistent_user_callout'; @@ -96,9 +96,9 @@ describe('PersistentUserCallout', () => { return waitForPromises().then(() => { expect(persistentUserCallout.container.remove).not.toHaveBeenCalled(); - expect(Flash).toHaveBeenCalledWith( - 'An error occurred while dismissing the alert. Refresh the page and try again.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: 'An error occurred while dismissing the alert. Refresh the page and try again.', + }); }); }); }); @@ -203,9 +203,10 @@ describe('PersistentUserCallout', () => { return waitForPromises().then(() => { expect(window.location.assign).not.toHaveBeenCalled(); - expect(Flash).toHaveBeenCalledWith( - 'An error occurred while acknowledging the notification. Refresh the page and try again.', - ); + expect(createFlash).toHaveBeenCalledWith({ + message: + 'An error occurred while acknowledging the notification. Refresh the page and try again.', + }); }); }); }); diff --git a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js index fb191fccb0d..7dd8a77d055 100644 --- a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js @@ -8,7 +8,7 @@ import { mockLintResponse, mockCiConfigPath } from '../../mock_data'; describe('Text editor component', () => { let wrapper; - const MockEditorLite = { + const MockSourceEditor = { template: '<div/>', props: ['value', 'fileName', 'editorOptions'], mounted() { @@ -26,13 +26,13 @@ describe('Text editor component', () => { ciConfigPath: mockCiConfigPath, }, stubs: { - EditorLite: MockEditorLite, + SourceEditor: MockSourceEditor, }, }); }; const findIcon = () => wrapper.findComponent(GlIcon); - const findEditor = () => wrapper.findComponent(MockEditorLite); + const findEditor = () => wrapper.findComponent(MockSourceEditor); afterEach(() => { wrapper.destroy(); diff --git a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js new file mode 100644 index 00000000000..3ee53d4a055 --- /dev/null +++ b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -0,0 +1,53 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue'; +import { + pipelineEditorTrackingOptions, + TEMPLATE_REPOSITORY_URL, +} from '~/pipeline_editor/constants'; + +describe('CI Editor Header', () => { + let wrapper; + let trackingSpy = null; + + const createComponent = () => { + wrapper = shallowMount(CiEditorHeader, {}); + }; + + const findLinkBtn = () => wrapper.findComponent(GlButton); + + afterEach(() => { + wrapper.destroy(); + unmockTracking(); + }); + + describe('link button', () => { + beforeEach(() => { + createComponent(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }); + + it('finds the browse template button', () => { + expect(findLinkBtn().exists()).toBe(true); + }); + + it('contains the link to the template repo', () => { + expect(findLinkBtn().attributes('href')).toBe(TEMPLATE_REPOSITORY_URL); + }); + + it('has the external-link icon', () => { + expect(findLinkBtn().props('icon')).toBe('external-link'); + }); + + it('tracks the click on the browse button', async () => { + const { label, actions } = pipelineEditorTrackingOptions; + + await findLinkBtn().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, actions.browse_templates, { + label, + }); + }); + }); +}); 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 6f9245e39aa..c6c7f593cc5 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { EDITOR_READY_EVENT } from '~/editor/constants'; -import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; import { mockCiConfigPath, @@ -19,7 +19,7 @@ describe('Pipeline Editor | Text editor component', () => { let mockUse; let mockRegisterCiSchema; - const MockEditorLite = { + const MockSourceEditor = { template: '<div/>', props: ['value', 'fileName'], mounted() { @@ -55,15 +55,15 @@ describe('Pipeline Editor | Text editor component', () => { [EDITOR_READY_EVENT]: editorReadyListener, }, stubs: { - EditorLite: MockEditorLite, + SourceEditor: MockSourceEditor, }, }); }; - const findEditor = () => wrapper.findComponent(MockEditorLite); + const findEditor = () => wrapper.findComponent(MockSourceEditor); beforeEach(() => { - EditorLiteExtension.deferRerender = jest.fn(); + SourceEditorExtension.deferRerender = jest.fn(); }); afterEach(() => { diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js index e731ad8695e..85b51d08f88 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -207,7 +207,8 @@ describe('Pipeline editor branch switcher', () => { it('updates session history when selecting a different branch', async () => { const branch = findDropdownItems().at(1); - await branch.vm.$emit('click'); + branch.vm.$emit('click'); + await waitForPromises(); expect(window.history.pushState).toHaveBeenCalled(); expect(window.history.pushState.mock.calls[0][2]).toContain(`?branch_name=${branch.text()}`); @@ -215,7 +216,8 @@ describe('Pipeline editor branch switcher', () => { it('does not update session history when selecting current branch', async () => { const branch = findDropdownItems().at(0); - await branch.vm.$emit('click'); + branch.vm.$emit('click'); + await waitForPromises(); expect(branch.text()).toBe(mockDefaultBranch); expect(window.history.pushState).not.toHaveBeenCalled(); @@ -227,7 +229,8 @@ describe('Pipeline editor branch switcher', () => { expect(branch.text()).not.toBe(mockDefaultBranch); expect(wrapper.emitted('refetchContent')).toBeUndefined(); - await branch.vm.$emit('click'); + branch.vm.$emit('click'); + await waitForPromises(); expect(wrapper.emitted('refetchContent')).toBeDefined(); expect(wrapper.emitted('refetchContent')).toHaveLength(1); @@ -239,10 +242,20 @@ describe('Pipeline editor branch switcher', () => { expect(branch.text()).toBe(mockDefaultBranch); expect(wrapper.emitted('refetchContent')).toBeUndefined(); - await branch.vm.$emit('click'); + branch.vm.$emit('click'); + await waitForPromises(); expect(wrapper.emitted('refetchContent')).toBeUndefined(); }); + + it('emits the updateCommitSha event when selecting a different branch', async () => { + expect(wrapper.emitted('updateCommitSha')).toBeUndefined(); + + const branch = findDropdownItems().at(1); + branch.vm.$emit('click'); + + expect(wrapper.emitted('updateCommitSha')).toHaveLength(1); + }); }); describe('when searching', () => { diff --git a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js index 8def83d578b..3becf82ed6e 100644 --- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js @@ -6,7 +6,7 @@ import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; const mockContent1 = 'MOCK CONTENT 1'; const mockContent2 = 'MOCK CONTENT 2'; -const MockEditorLite = { +const MockSourceEditor = { template: '<div>EDITOR</div>', }; @@ -48,12 +48,12 @@ describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { wrapper = mount(EditorTab, { propsData: props, slots: { - default: MockEditorLite, + default: MockSourceEditor, }, }); }; - const findSlotComponent = () => wrapper.findComponent(MockEditorLite); + const findSlotComponent = () => wrapper.findComponent(MockSourceEditor); const findAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => { diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js index d39c0d80296..76ae96c623a 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js @@ -1,15 +1,8 @@ import MockAdapter from 'axios-mock-adapter'; -import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { resolvers } from '~/pipeline_editor/graphql/resolvers'; -import { - mockCiConfigPath, - mockCiYml, - mockDefaultBranch, - mockLintResponse, - mockProjectFullPath, -} from '../mock_data'; +import { mockLintResponse } from '../mock_data'; jest.mock('~/api', () => { return { @@ -18,36 +11,6 @@ jest.mock('~/api', () => { }); describe('~/pipeline_editor/graphql/resolvers', () => { - describe('Query', () => { - describe('blobContent', () => { - beforeEach(() => { - Api.getRawFile.mockResolvedValue({ - data: mockCiYml, - }); - }); - - afterEach(() => { - Api.getRawFile.mockReset(); - }); - - it('resolves lint data with type names', async () => { - const result = resolvers.Query.blobContent(null, { - projectPath: mockProjectFullPath, - path: mockCiConfigPath, - ref: mockDefaultBranch, - }); - - expect(Api.getRawFile).toHaveBeenCalledWith(mockProjectFullPath, mockCiConfigPath, { - ref: mockDefaultBranch, - }); - - // eslint-disable-next-line no-underscore-dangle - expect(result.__typename).toBe('BlobContent'); - await expect(result.rawData).resolves.toBe(mockCiYml); - }); - }); - }); - describe('Mutation', () => { describe('lintCI', () => { let mock; diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index cadcdf6ae2e..4d4a8c21d78 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -35,6 +35,23 @@ job_build: - echo "build" needs: ["job_test_2"] `; +export const mockBlobContentQueryResponse = { + data: { + project: { repository: { blobs: { nodes: [{ rawBlob: mockCiYml }] } } }, + }, +}; + +export const mockBlobContentQueryResponseNoCiFile = { + data: { + project: { repository: { blobs: { nodes: [] } } }, + }, +}; + +export const mockBlobContentQueryResponseEmptyCiFile = { + data: { + project: { repository: { blobs: { nodes: [{ rawBlob: '' }] } } }, + }, +}; const mockJobFields = { beforeScript: [], @@ -139,6 +156,35 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => { }; }; +export const mockNewCommitShaResults = { + data: { + project: { + pipelines: { + nodes: [ + { + id: 'gid://gitlab/Ci::Pipeline/1', + sha: 'd0d56d363d8a3f67a8ab9fc00207d468f30032ca', + path: `/${mockProjectFullPath}/-/pipelines/488`, + commitPath: `/${mockProjectFullPath}/-/commit/d0d56d363d8a3f67a8ab9fc00207d468f30032ca`, + }, + { + id: 'gid://gitlab/Ci::Pipeline/2', + sha: 'fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa', + path: `/${mockProjectFullPath}/-/pipelines/487`, + commitPath: `/${mockProjectFullPath}/-/commit/fcab2ece40b26f428dfa3aa288b12c3c5bdb06aa`, + }, + { + id: 'gid://gitlab/Ci::Pipeline/3', + sha: '6c16b17c7f94a438ae19a96c285bb49e3c632cf4', + path: `/${mockProjectFullPath}/-/pipelines/433`, + commitPath: `/${mockProjectFullPath}/-/commit/6c16b17c7f94a438ae19a96c285bb49e3c632cf4`, + }, + ], + }, + }, + }, +}; + export const mockProjectBranches = { data: { project: { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index c88fe159c0d..b0d1a69ee56 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -3,7 +3,6 @@ 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 httpStatusCodes from '~/lib/utils/http_status'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; @@ -11,21 +10,30 @@ import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tab import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; import { COMMIT_SUCCESS, COMMIT_FAILURE } from '~/pipeline_editor/constants'; +import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.graphql'; import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.graphql'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; +import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql'; +import getLatestCommitShaQuery from '~/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql'; import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; import { mockCiConfigPath, mockCiConfigQueryResponse, + mockBlobContentQueryResponse, + mockBlobContentQueryResponseEmptyCiFile, + mockBlobContentQueryResponseNoCiFile, mockCiYml, + mockCommitSha, mockDefaultBranch, mockProjectFullPath, + mockNewCommitShaResults, } from './mock_data'; const localVue = createLocalVue(); localVue.use(VueApollo); -const MockEditorLite = { +const MockSourceEditor = { template: '<div/>', }; @@ -44,6 +52,10 @@ describe('Pipeline editor app component', () => { let mockApollo; let mockBlobContentData; let mockCiConfigData; + let mockGetTemplate; + let mockUpdateCommitSha; + let mockLatestCommitShaQuery; + let mockPipelineQuery; const createComponent = ({ blobLoading = false, options = {}, provide = {} } = {}) => { wrapper = shallowMount(PipelineEditorApp, { @@ -55,7 +67,7 @@ describe('Pipeline editor app component', () => { PipelineEditorHome, PipelineEditorTabs, PipelineEditorMessages, - EditorLite: MockEditorLite, + SourceEditor: MockSourceEditor, PipelineEditorEmptyState, }, mocks: { @@ -75,16 +87,23 @@ describe('Pipeline editor app component', () => { }; const createComponentWithApollo = async ({ props = {}, provide = {} } = {}) => { - const handlers = [[getCiConfigData, mockCiConfigData]]; + const handlers = [ + [getBlobContent, mockBlobContentData], + [getCiConfigData, mockCiConfigData], + [getTemplate, mockGetTemplate], + [getLatestCommitShaQuery, mockLatestCommitShaQuery], + [getPipelineQuery, mockPipelineQuery], + ]; + const resolvers = { Query: { - blobContent() { - return { - __typename: 'BlobContent', - rawData: mockBlobContentData(), - }; + commitSha() { + return mockCommitSha; }, }, + Mutation: { + updateCommitSha: mockUpdateCommitSha, + }, }; mockApollo = createMockApollo(handlers, resolvers); @@ -116,6 +135,10 @@ describe('Pipeline editor app component', () => { beforeEach(() => { mockBlobContentData = jest.fn(); mockCiConfigData = jest.fn(); + mockGetTemplate = jest.fn(); + mockUpdateCommitSha = jest.fn(); + mockLatestCommitShaQuery = jest.fn(); + mockPipelineQuery = jest.fn(); }); afterEach(() => { @@ -133,7 +156,7 @@ describe('Pipeline editor app component', () => { describe('when queries are called', () => { beforeEach(() => { - mockBlobContentData.mockResolvedValue(mockCiYml); + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); mockCiConfigData.mockResolvedValue(mockCiConfigQueryResponse); }); @@ -154,39 +177,19 @@ describe('Pipeline editor app component', () => { expect(mockCiConfigData).toHaveBeenCalledWith({ content: mockCiYml, projectPath: mockProjectFullPath, + sha: mockCommitSha, }); }); }); describe('when no CI config file exists', () => { - describe('in a project without a repository', () => { - it('shows an empty state and does not show editor home component', async () => { - mockBlobContentData.mockRejectedValueOnce({ - response: { - status: httpStatusCodes.BAD_REQUEST, - }, - }); - await createComponentWithApollo(); - - expect(findEmptyState().exists()).toBe(true); - expect(findAlert().exists()).toBe(false); - expect(findEditorHome().exists()).toBe(false); - }); - }); - - describe('in a project with a repository', () => { - it('shows an empty state and does not show editor home component', async () => { - mockBlobContentData.mockRejectedValueOnce({ - response: { - status: httpStatusCodes.NOT_FOUND, - }, - }); - await createComponentWithApollo(); + it('shows an empty state and does not show editor home component', async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); + await createComponentWithApollo(); - expect(findEmptyState().exists()).toBe(true); - expect(findAlert().exists()).toBe(false); - expect(findEditorHome().exists()).toBe(false); - }); + expect(findEmptyState().exists()).toBe(true); + expect(findAlert().exists()).toBe(false); + expect(findEditorHome().exists()).toBe(false); }); describe('because of a fetching error', () => { @@ -204,13 +207,28 @@ 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 () => { - mockBlobContentData.mockRejectedValueOnce({ - response: { - status: httpStatusCodes.NOT_FOUND, - }, - }); + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); await createComponentWithApollo({ provide: { @@ -315,21 +333,83 @@ describe('Pipeline editor app component', () => { }); it('hides start screen when refetch fetches CI file', async () => { - mockBlobContentData.mockRejectedValue({ - response: { - status: httpStatusCodes.NOT_FOUND, - }, - }); + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponseNoCiFile); await createComponentWithApollo(); expect(findEmptyState().exists()).toBe(true); expect(findEditorHome().exists()).toBe(false); - mockBlobContentData.mockResolvedValue(mockCiYml); + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); await wrapper.vm.$apollo.queries.initialCiFileContent.refetch(); expect(findEmptyState().exists()).toBe(false); expect(findEditorHome().exists()).toBe(true); }); }); + + describe('when a template parameter is present in the URL', () => { + const { location } = window; + + beforeEach(() => { + delete window.location; + window.location = new URL('https://localhost?template=Android'); + }); + + afterEach(() => { + window.location = location; + }); + + it('renders the given template', async () => { + await createComponentWithApollo(); + + expect(mockGetTemplate).toHaveBeenCalledWith({ + projectPath: mockProjectFullPath, + templateName: 'Android', + }); + + expect(findEmptyState().exists()).toBe(false); + expect(findTextEditor().exists()).toBe(true); + }); + }); + + describe('when updating commit sha', () => { + const newCommitSha = mockNewCommitShaResults.data.project.pipelines.nodes[0].sha; + + beforeEach(async () => { + mockUpdateCommitSha.mockResolvedValue(newCommitSha); + mockLatestCommitShaQuery.mockResolvedValue(mockNewCommitShaResults); + await createComponentWithApollo(); + }); + + it('fetches updated commit sha for the new branch', async () => { + expect(mockLatestCommitShaQuery).not.toHaveBeenCalled(); + + wrapper + .findComponent(PipelineEditorHome) + .vm.$emit('updateCommitSha', { newBranch: 'new-branch' }); + await waitForPromises(); + + expect(mockLatestCommitShaQuery).toHaveBeenCalledWith({ + projectPath: mockProjectFullPath, + ref: 'new-branch', + }); + }); + + it('updates commit sha with the newly fetched commit sha', async () => { + expect(mockUpdateCommitSha).not.toHaveBeenCalled(); + + wrapper + .findComponent(PipelineEditorHome) + .vm.$emit('updateCommitSha', { newBranch: 'new-branch' }); + await waitForPromises(); + + expect(mockUpdateCommitSha).toHaveBeenCalled(); + expect(mockUpdateCommitSha).toHaveBeenCalledWith( + expect.any(Object), + { commitSha: mockNewCommitShaResults.data.project.pipelines.nodes[0].sha }, + expect.any(Object), + expect.any(Object), + ); + }); + }); }); diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js index 912bc7a104a..1af3065477d 100644 --- a/spec/frontend/pipelines/empty_state_spec.js +++ b/spec/frontend/pipelines/empty_state_spec.js @@ -1,14 +1,21 @@ +import '~/commons'; import { mount } from '@vue/test-utils'; import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; describe('Pipelines Empty State', () => { let wrapper; const findIllustration = () => wrapper.find('img'); const findButton = () => wrapper.find('a'); + const pipelinesCiTemplates = () => wrapper.findComponent(PipelinesCiTemplates); const createWrapper = (props = {}) => { wrapper = mount(EmptyState, { + provide: { + pipelineEditorPath: '', + suggestedCiTemplates: [], + }, propsData: { emptyStateSvgPath: 'foo.svg', canSetCi: true, @@ -27,27 +34,8 @@ describe('Pipelines Empty State', () => { wrapper = null; }); - it('should render empty state SVG', () => { - expect(findIllustration().attributes('src')).toBe('foo.svg'); - }); - - it('should render empty state header', () => { - expect(wrapper.text()).toContain('Build with confidence'); - }); - - it('should render empty state information', () => { - expect(wrapper.text()).toContain( - 'GitLab CI/CD can automatically build, test, and deploy your code. Let GitLab take care of time', - 'consuming tasks, so you can spend more time creating', - ); - }); - - it('should render button with help path', () => { - expect(findButton().attributes('href')).toBe('/help/ci/quick_start/index.md'); - }); - - it('should render button text', () => { - expect(findButton().text()).toBe('Get started with CI/CD'); + it('should render the CI/CD templates', () => { + expect(pipelinesCiTemplates()).toExist(); }); }); diff --git a/spec/frontend/pipelines/graph/mock_data.js b/spec/frontend/pipelines/graph/mock_data.js index 28fe3b67e7b..3812483766d 100644 --- a/spec/frontend/pipelines/graph/mock_data.js +++ b/spec/frontend/pipelines/graph/mock_data.js @@ -12,6 +12,10 @@ export const mockPipelineResponse = { usesNeeds: true, downstream: null, upstream: null, + userPermissions: { + __typename: 'PipelinePermissions', + updatePipeline: true, + }, stages: { __typename: 'CiStageConnection', nodes: [ @@ -573,6 +577,10 @@ export const wrappedPipelineReturn = { iid: '38', complete: true, usesNeeds: true, + userPermissions: { + __typename: 'PipelinePermissions', + updatePipeline: true, + }, downstream: { __typename: 'PipelineConnection', nodes: [], diff --git a/spec/frontend/pipelines/graph/stage_column_component_spec.js b/spec/frontend/pipelines/graph/stage_column_component_spec.js index f9f6c96a1a6..99e8ea9d0a4 100644 --- a/spec/frontend/pipelines/graph/stage_column_component_spec.js +++ b/spec/frontend/pipelines/graph/stage_column_component_spec.js @@ -31,6 +31,9 @@ const defaultProps = { name: 'Fish', groups: mockGroups, pipelineId: 159, + userPermissions: { + updatePipeline: true, + }, }; describe('stage column component', () => { @@ -53,7 +56,6 @@ describe('stage column component', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); describe('when mounted', () => { @@ -152,36 +154,52 @@ describe('stage column component', () => { }); describe('with action', () => { - beforeEach(() => { + const defaults = { + groups: [ + { + id: 4259, + name: '<img src=x onerror=alert(document.domain)>', + status: { + icon: 'status_success', + label: 'success', + tooltip: '<img src=x onerror=alert(document.domain)>', + }, + jobs: [mockJob], + }, + ], + title: 'test', + hasTriggeredBy: false, + action: { + icon: 'play', + title: 'Play all', + path: 'action', + }, + }; + + it('renders action button if permissions are permitted', () => { createComponent({ method: mount, props: { - groups: [ - { - id: 4259, - name: '<img src=x onerror=alert(document.domain)>', - status: { - icon: 'status_success', - label: 'success', - tooltip: '<img src=x onerror=alert(document.domain)>', - }, - jobs: [mockJob], - }, - ], - title: 'test', - hasTriggeredBy: false, - action: { - icon: 'play', - title: 'Play all', - path: 'action', - }, + ...defaults, }, }); - }); - it('renders action button', () => { expect(findActionComponent().exists()).toBe(true); }); + + it('does not render action button if permissions are not permitted', () => { + createComponent({ + method: mount, + props: { + ...defaults, + userPermissions: { + updatePipeline: false, + }, + }, + }); + + expect(findActionComponent().exists()).toBe(false); + }); }); describe('without action', () => { diff --git a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap index 16c28791514..82206e907ff 100644 --- a/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap +++ b/spec/frontend/pipelines/graph_shared/__snapshots__/links_inner_spec.js.snap @@ -2,29 +2,29 @@ exports[`Links Inner component with a large number of needs matches snapshot and has expected path 1`] = ` "<div class=\\"gl-display-flex gl-relative\\" totalgroups=\\"10\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> - <path d=\\"M202,118L42,118C72,118,72,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> - <path d=\\"M202,118L52,118C82,118,82,148,112,148\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> - <path d=\\"M222,138L62,138C92,138,92,158,122,158\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> - <path d=\\"M212,128L72,128C102,128,102,168,132,168\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> - <path d=\\"M232,148L82,148C112,148,112,178,142,178\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M202,118C52,118,52,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M202,118C62,118,62,148,112,148\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M222,138C72,138,72,158,122,158\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M212,128C82,128,82,168,132,168\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M232,148C92,148,92,178,142,178\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> </svg> </div>" `; exports[`Links Inner component with a parallel need matches snapshot and has expected path 1`] = ` "<div class=\\"gl-display-flex gl-relative\\" totalgroups=\\"10\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> - <path d=\\"M192,108L22,108C52,108,52,118,82,118\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M192,108C32,108,32,118,82,118\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> </svg> </div>" `; exports[`Links Inner component with one need matches snapshot and has expected path 1`] = ` "<div class=\\"gl-display-flex gl-relative\\" totalgroups=\\"10\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> - <path d=\\"M202,118L42,118C72,118,72,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M202,118C52,118,52,138,102,138\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> </svg> </div>" `; exports[`Links Inner component with same stage needs matches snapshot and has expected path 1`] = ` "<div class=\\"gl-display-flex gl-relative\\" totalgroups=\\"10\\"><svg id=\\"link-svg\\" viewBox=\\"0,0,1019,445\\" width=\\"1019px\\" height=\\"445px\\" class=\\"gl-absolute gl-pointer-events-none\\"> - <path d=\\"M192,108L22,108C52,108,52,118,82,118\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> - <path d=\\"M202,118L32,118C62,118,62,128,92,128\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M192,108C32,108,32,118,82,118\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> + <path d=\\"M202,118C42,118,42,128,92,128\\" stroke-width=\\"2\\" class=\\"gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease gl-stroke-gray-200\\"></path> </svg> </div>" `; diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index 7bac7036f46..1b89e322d31 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -6,7 +6,7 @@ import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; +import StageName from '~/pipelines/components/pipeline_graph/stage_name.vue'; import { pipelineData, singleStageData } from './mock_data'; describe('pipeline graph component', () => { @@ -35,11 +35,9 @@ describe('pipeline graph component', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findAllJobPills = () => wrapper.findAll(JobPill); - const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]'); - const findAllStagePills = () => wrapper.findAllComponents(StagePill); + const findAllStageNames = () => wrapper.findAllComponents(StageName); const findLinksLayer = () => wrapper.findComponent(LinksLayer); const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]'); - const findStageBackgroundElementAt = (index) => findAllStageBackgroundElements().at(index); afterEach(() => { wrapper.destroy(); @@ -67,10 +65,10 @@ describe('pipeline graph component', () => { wrapper = createComponent({ pipelineData: singleStageData }); }); - it('renders the right number of stage pills', () => { + it('renders the right number of stage titles', () => { const expectedStagesLength = singleStageData.stages.length; - expect(findAllStagePills()).toHaveLength(expectedStagesLength); + expect(findAllStageNames()).toHaveLength(expectedStagesLength); }); it('renders the right number of job pills', () => { @@ -81,20 +79,6 @@ describe('pipeline graph component', () => { expect(findAllJobPills()).toHaveLength(expectedJobsLength); }); - - describe('rounds corner', () => { - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${true} - ${'gl-rounded-top-left-6'} | ${true} - ${'gl-rounded-top-right-6'} | ${true} - ${'gl-rounded-bottom-right-6'} | ${true} - `('$cssClass should be $expectedState on the only element', ({ cssClass, expectedState }) => { - const classes = findStageBackgroundElementAt(0).classes(); - - expect(classes.includes(cssClass)).toBe(expectedState); - }); - }); }); describe('with multiple stages and jobs', () => { @@ -102,10 +86,10 @@ describe('pipeline graph component', () => { wrapper = createComponent(); }); - it('renders the right number of stage pills', () => { + it('renders the right number of stage titles', () => { const expectedStagesLength = pipelineData.stages.length; - expect(findAllStagePills()).toHaveLength(expectedStagesLength); + expect(findAllStageNames()).toHaveLength(expectedStagesLength); }); it('renders the right number of job pills', () => { @@ -116,34 +100,5 @@ describe('pipeline graph component', () => { expect(findAllJobPills()).toHaveLength(expectedJobsLength); }); - - describe('rounds corner', () => { - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${true} - ${'gl-rounded-top-left-6'} | ${true} - ${'gl-rounded-top-right-6'} | ${false} - ${'gl-rounded-bottom-right-6'} | ${false} - `( - '$cssClass should be $expectedState on the first element', - ({ cssClass, expectedState }) => { - const classes = findStageBackgroundElementAt(0).classes(); - - expect(classes.includes(cssClass)).toBe(expectedState); - }, - ); - - it.each` - cssClass | expectedState - ${'gl-rounded-bottom-left-6'} | ${false} - ${'gl-rounded-top-left-6'} | ${false} - ${'gl-rounded-top-right-6'} | ${true} - ${'gl-rounded-bottom-right-6'} | ${true} - `('$cssClass should be $expectedState on the last element', ({ cssClass, expectedState }) => { - const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes(); - - expect(classes.includes(cssClass)).toBe(expectedState); - }); - }); }); }); diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/pipelines_ci_templates_spec.js index 0c37bf2d84a..db66b675fb9 100644 --- a/spec/frontend/pipelines/pipelines_ci_templates_spec.js +++ b/spec/frontend/pipelines/pipelines_ci_templates_spec.js @@ -1,30 +1,25 @@ +import '~/commons'; import { shallowMount } from '@vue/test-utils'; -import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { mockTracking } from 'helpers/tracking_helper'; import PipelinesCiTemplate from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; -const addCiYmlPath = "/-/new/main?commit_message='Add%20.gitlab-ci.yml'"; +const pipelineEditorPath = '/-/ci/editor'; const suggestedCiTemplates = [ { name: 'Android', logo: '/assets/illustrations/logos/android.svg' }, { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' }, { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' }, ]; -jest.mock('~/experimentation/experiment_tracking'); - describe('Pipelines CI Templates', () => { let wrapper; - - const GlEmoji = { template: '<img/>' }; + let trackingSpy; const createWrapper = () => { return shallowMount(PipelinesCiTemplate, { provide: { - addCiYmlPath, + pipelineEditorPath, suggestedCiTemplates, }, - stubs: { - GlEmoji, - }, }); }; @@ -44,9 +39,9 @@ describe('Pipelines CI Templates', () => { wrapper = createWrapper(); }); - it('links to the hello world template', () => { + it('links to the getting started template', () => { expect(findTestTemplateLinks().at(0).attributes('href')).toBe( - addCiYmlPath.concat('&template=Hello-World'), + pipelineEditorPath.concat('?template=Getting-Started'), ); }); }); @@ -68,7 +63,7 @@ describe('Pipelines CI Templates', () => { it('links to the correct template', () => { expect(findTemplateLinks().at(0).attributes('href')).toBe( - addCiYmlPath.concat('&template=Android'), + pipelineEditorPath.concat('?template=Android'), ); }); @@ -88,24 +83,25 @@ describe('Pipelines CI Templates', () => { describe('tracking', () => { beforeEach(() => { wrapper = createWrapper(); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); it('sends an event when template is clicked', () => { findTemplateLinks().at(0).vm.$emit('click'); - expect(ExperimentTracking).toHaveBeenCalledWith('pipeline_empty_state_templates', { + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { label: 'Android', }); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('template_clicked'); }); - it('sends an event when Hello-World template is clicked', () => { + it('sends an event when Getting-Started template is clicked', () => { findTestTemplateLinks().at(0).vm.$emit('click'); - expect(ExperimentTracking).toHaveBeenCalledWith('pipeline_empty_state_templates', { - label: 'Hello-World', + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', { + label: 'Getting-Started', }); - expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('template_clicked'); }); }); }); diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 874ecbccf82..2166961cedd 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -12,6 +12,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; +import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue'; import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import { RAW_TEXT_WARNING } from '~/pipelines/constants'; import Store from '~/pipelines/stores/pipelines_store'; @@ -82,6 +83,10 @@ describe('Pipelines', () => { const createComponent = (props = defaultProps) => { wrapper = extendedWrapper( mount(PipelinesComponent, { + provide: { + pipelineEditorPath: '', + suggestedCiTemplates: [], + }, propsData: { store: new Store(), projectId: mockProjectId, @@ -551,52 +556,74 @@ describe('Pipelines', () => { await waitForPromises(); }); - it('renders empty state', () => { - expect(findEmptyState().text()).toContain('Build with confidence'); - expect(findEmptyState().text()).toContain( - 'GitLab CI/CD can automatically build, test, and deploy your code.', - ); - - expect(findEmptyState().find(GlButton).text()).toBe('Get started with CI/CD'); - expect(findEmptyState().find(GlButton).attributes('href')).toBe( - '/help/ci/quick_start/index.md', - ); + it('renders the CI/CD templates', () => { + expect(wrapper.find(PipelinesCiTemplates)).toExist(); }); describe('when the code_quality_walkthrough experiment is active', () => { beforeAll(() => { getExperimentData.mockImplementation((name) => name === 'code_quality_walkthrough'); - getExperimentVariant.mockReturnValue('candidate'); }); - it('renders another CTA button', () => { - expect(findEmptyState().findComponent(GlButton).text()).toBe('Add a code quality job'); - expect(findEmptyState().findComponent(GlButton).attributes('href')).toBe( - paths.codeQualityPagePath, - ); + describe('the control state', () => { + beforeAll(() => { + getExperimentVariant.mockReturnValue('control'); + }); + + it('renders the CI/CD templates', () => { + expect(wrapper.find(PipelinesCiTemplates)).toExist(); + }); + }); + + describe('the candidate state', () => { + beforeAll(() => { + getExperimentVariant.mockReturnValue('candidate'); + }); + + it('renders another CTA button', () => { + expect(findEmptyState().findComponent(GlButton).text()).toBe('Add a code quality job'); + expect(findEmptyState().findComponent(GlButton).attributes('href')).toBe( + paths.codeQualityPagePath, + ); + }); }); }); describe('when the ci_runner_templates experiment is active', () => { beforeAll(() => { getExperimentData.mockImplementation((name) => name === 'ci_runner_templates'); - getExperimentVariant.mockReturnValue('candidate'); }); - it('renders two buttons', () => { - expect(findEmptyState().findAllComponents(GlButton).length).toBe(2); - expect(findEmptyState().findAllComponents(GlButton).at(0).text()).toBe( - 'Install GitLab Runners', - ); - expect(findEmptyState().findAllComponents(GlButton).at(0).attributes('href')).toBe( - paths.ciRunnerSettingsPath, - ); - expect(findEmptyState().findAllComponents(GlButton).at(1).text()).toBe( - 'Learn about Runners', - ); - expect(findEmptyState().findAllComponents(GlButton).at(1).attributes('href')).toBe( - '/help/ci/quick_start/index.md', - ); + describe('the control state', () => { + beforeAll(() => { + getExperimentVariant.mockReturnValue('control'); + }); + + it('renders the CI/CD templates', () => { + expect(wrapper.find(PipelinesCiTemplates)).toExist(); + }); + }); + + describe('the candidate state', () => { + beforeAll(() => { + getExperimentVariant.mockReturnValue('candidate'); + }); + + it('renders two buttons', () => { + expect(findEmptyState().findAllComponents(GlButton).length).toBe(2); + expect(findEmptyState().findAllComponents(GlButton).at(0).text()).toBe( + 'Install GitLab Runners', + ); + expect(findEmptyState().findAllComponents(GlButton).at(0).attributes('href')).toBe( + paths.ciRunnerSettingsPath, + ); + expect(findEmptyState().findAllComponents(GlButton).at(1).text()).toBe( + 'Learn about Runners', + ); + expect(findEmptyState().findAllComponents(GlButton).at(1).attributes('href')).toBe( + '/help/ci/quick_start/index.md', + ); + }); }); }); diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js index 9e6f5594d26..f1172a73d36 100644 --- a/spec/frontend/profile/preferences/components/profile_preferences_spec.js +++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js @@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createFlash from '~/flash'; import IntegrationView from '~/profile/preferences/components/integration_view.vue'; import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue'; import { i18n } from '~/profile/preferences/constants'; @@ -15,6 +16,7 @@ import { lightModeThemeId2, } from '../mock_data'; +jest.mock('~/flash'); const expectedUrl = '/foo'; describe('ProfilePreferences component', () => { @@ -54,10 +56,6 @@ describe('ProfilePreferences component', () => { return wrapper.findComponent(GlButton); } - function findFlashError() { - return document.querySelector('.flash-container .flash-text'); - } - function createThemeInput(themeId = lightModeThemeId1) { const input = document.createElement('input'); input.setAttribute('name', 'user[theme_id]'); @@ -82,10 +80,6 @@ describe('ProfilePreferences component', () => { document.body.classList.add('content-wrapper'); } - beforeEach(() => { - setFixtures('<div class="flash-container"></div>'); - }); - afterEach(() => { wrapper.destroy(); wrapper = null; @@ -152,7 +146,7 @@ describe('ProfilePreferences component', () => { const successEvent = new CustomEvent('ajax:success'); form.dispatchEvent(successEvent); - expect(findFlashError().innerText.trim()).toEqual(i18n.defaultSuccess); + expect(createFlash).toHaveBeenCalledWith({ message: i18n.defaultSuccess, type: 'notice' }); }); it('displays the custom success message', () => { @@ -160,14 +154,14 @@ describe('ProfilePreferences component', () => { const successEvent = new CustomEvent('ajax:success', { detail: [{ message }] }); form.dispatchEvent(successEvent); - expect(findFlashError().innerText.trim()).toEqual(message); + expect(createFlash).toHaveBeenCalledWith({ message, type: 'notice' }); }); it('displays the default error message', () => { const errorEvent = new CustomEvent('ajax:error'); form.dispatchEvent(errorEvent); - expect(findFlashError().innerText.trim()).toEqual(i18n.defaultError); + expect(createFlash).toHaveBeenCalledWith({ message: i18n.defaultError, type: 'alert' }); }); it('displays the custom error message', () => { @@ -175,7 +169,7 @@ describe('ProfilePreferences component', () => { const errorEvent = new CustomEvent('ajax:error', { detail: [{ message }] }); form.dispatchEvent(errorEvent); - expect(findFlashError().innerText.trim()).toEqual(message); + expect(createFlash).toHaveBeenCalledWith({ message, type: 'alert' }); }); }); diff --git a/spec/frontend/projects/commit/components/branches_dropdown_spec.js b/spec/frontend/projects/commit/components/branches_dropdown_spec.js index ab84c3768d0..30556cdeae1 100644 --- a/spec/frontend/projects/commit/components/branches_dropdown_spec.js +++ b/spec/frontend/projects/commit/components/branches_dropdown_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import BranchesDropdown from '~/projects/commit/components/branches_dropdown.vue'; Vue.use(Vuex); @@ -82,7 +83,7 @@ describe('BranchesDropdown', () => { expect(findSearchBoxByType().exists()).toBe(true); expect(findSearchBoxByType().vm.$attrs).toMatchObject({ placeholder: 'Search branches', - debounce: 250, + debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS, }); }); }); diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap index f0d72124379..c255fcce321 100644 --- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -57,10 +57,6 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` /> </gl-alert-stub> - <p> - This action cannot be undone. You will lose this project's repository and all related resources, including issues, merge requests, etc. - </p> - <p class="gl-mb-1" > diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap index c37f6415898..fc51825f15b 100644 --- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap +++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/ci_cd_analytics_area_chart_spec.js.snap @@ -21,11 +21,7 @@ exports[`CiCdAnalyticsAreaChart matches the snapshot 1`] = ` option="[object Object]" thresholds="" width="0" - > - <template /> - - <template /> - </glareachart-stub> + /> </div> </div> `; diff --git a/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js b/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js index 2d6efe7ae83..0c5bbe2a115 100644 --- a/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js +++ b/spec/frontend/projects/settings/components/shared_runners_toggle_spec.js @@ -20,6 +20,7 @@ describe('projects/settings/components/shared_runners', () => { isDisabledAndUnoverridable: false, isLoading: false, updatePath: TEST_UPDATE_PATH, + isCreditCardValidationRequired: false, ...props, }, }); diff --git a/spec/frontend/projects/terraform_notification/terraform_notification_spec.js b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js new file mode 100644 index 00000000000..be34b207c4b --- /dev/null +++ b/spec/frontend/projects/terraform_notification/terraform_notification_spec.js @@ -0,0 +1,62 @@ +import { GlBanner } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; +import TerraformNotification from '~/projects/terraform_notification/components/terraform_notification.vue'; + +jest.mock('~/lib/utils/common_utils'); + +const bannerDissmisedKey = 'terraform_notification_dismissed_for_project_1'; + +describe('TerraformNotificationBanner', () => { + let wrapper; + + const propsData = { + projectId: 1, + }; + const findBanner = () => wrapper.findComponent(GlBanner); + + beforeEach(() => { + wrapper = shallowMount(TerraformNotification, { + propsData, + stubs: { GlBanner }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + parseBoolean.mockReturnValue(false); + }); + + describe('when the dismiss cookie is set', () => { + beforeEach(() => { + parseBoolean.mockReturnValue(true); + wrapper = shallowMount(TerraformNotification, { + propsData, + }); + }); + + it('should not render the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); + + describe('when the dismiss cookie is not set', () => { + it('should render the banner', () => { + expect(findBanner().exists()).toBe(true); + }); + }); + + describe('when close button is clicked', () => { + beforeEach(async () => { + await findBanner().vm.$emit('close'); + }); + + it('should set the cookie with the bannerDissmisedKey', () => { + expect(setCookie).toHaveBeenCalledWith(bannerDissmisedKey, true); + }); + + it('should remove the banner', () => { + expect(findBanner().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js new file mode 100644 index 00000000000..c89bb874a7f --- /dev/null +++ b/spec/frontend/registry/explorer/components/list_page/cleanup_status_spec.js @@ -0,0 +1,87 @@ +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import CleanupStatus from '~/registry/explorer/components/list_page/cleanup_status.vue'; +import { + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CLEANUP_STATUS_SCHEDULED, + CLEANUP_STATUS_ONGOING, + CLEANUP_STATUS_UNFINISHED, + UNFINISHED_STATUS, + UNSCHEDULED_STATUS, + SCHEDULED_STATUS, + ONGOING_STATUS, +} from '~/registry/explorer/constants'; + +describe('cleanup_status', () => { + let wrapper; + + const findMainIcon = () => wrapper.findByTestId('main-icon'); + const findExtraInfoIcon = () => wrapper.findByTestId('extra-info'); + + const mountComponent = (propsData = { status: SCHEDULED_STATUS }) => { + wrapper = shallowMountExtended(CleanupStatus, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + status | visible | text + ${UNFINISHED_STATUS} | ${true} | ${CLEANUP_STATUS_UNFINISHED} + ${SCHEDULED_STATUS} | ${true} | ${CLEANUP_STATUS_SCHEDULED} + ${ONGOING_STATUS} | ${true} | ${CLEANUP_STATUS_ONGOING} + ${UNSCHEDULED_STATUS} | ${false} | ${''} + `( + 'when the status is $status is $visible that the component is mounted and has the correct text', + ({ status, visible, text }) => { + mountComponent({ status }); + + expect(findMainIcon().exists()).toBe(visible); + expect(wrapper.text()).toBe(text); + }, + ); + + describe('main icon', () => { + it('exists', () => { + mountComponent(); + + expect(findMainIcon().exists()).toBe(true); + }); + + it(`has the orange class when the status is ${UNFINISHED_STATUS}`, () => { + mountComponent({ status: UNFINISHED_STATUS }); + + expect(findMainIcon().classes('gl-text-orange-500')).toBe(true); + }); + }); + + describe('extra info icon', () => { + it.each` + status | visible + ${UNFINISHED_STATUS} | ${true} + ${SCHEDULED_STATUS} | ${false} + ${ONGOING_STATUS} | ${false} + `( + 'when the status is $status is $visible that the extra icon is visible', + ({ status, visible }) => { + mountComponent({ status }); + + expect(findExtraInfoIcon().exists()).toBe(visible); + }, + ); + + it(`has a tooltip`, () => { + mountComponent({ status: UNFINISHED_STATUS }); + + const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(ASYNC_DELETE_IMAGE_ERROR_MESSAGE); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index 323d7b177e7..db0f869ab52 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -3,15 +3,14 @@ import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import DeleteButton from '~/registry/explorer/components/delete_button.vue'; +import CleanupStatus from '~/registry/explorer/components/list_page/cleanup_status.vue'; import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; import { ROW_SCHEDULED_FOR_DELETION, LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, - CLEANUP_TIMED_OUT_ERROR_MESSAGE, IMAGE_DELETE_SCHEDULED_STATUS, - IMAGE_FAILED_DELETED_STATUS, + SCHEDULED_STATUS, ROOT_IMAGE_TEXT, } from '~/registry/explorer/constants'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -27,7 +26,7 @@ describe('Image List Row', () => { const findTagsCount = () => wrapper.find('[data-testid="tags-count"]'); const findDeleteBtn = () => wrapper.findComponent(DeleteButton); const findClipboardButton = () => wrapper.findComponent(ClipboardButton); - const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]'); + const findCleanupStatus = () => wrapper.findComponent(CleanupStatus); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findListItemComponent = () => wrapper.findComponent(ListItem); @@ -106,23 +105,22 @@ describe('Image List Row', () => { expect(button.props('title')).toBe(item.location); }); - describe('warning icon', () => { + describe('cleanup status component', () => { it.each` - status | expirationPolicyStartedAt | shown | title - ${IMAGE_FAILED_DELETED_STATUS} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE} - ${''} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE} - ${''} | ${false} | ${false} | ${''} + expirationPolicyCleanupStatus | shown + ${null} | ${false} + ${SCHEDULED_STATUS} | ${true} `( - 'when status is $status and expirationPolicyStartedAt is $expirationPolicyStartedAt', - ({ expirationPolicyStartedAt, status, shown, title }) => { - mountComponent({ item: { ...item, status, expirationPolicyStartedAt } }); + 'when expirationPolicyCleanupStatus is $expirationPolicyCleanupStatus it is $shown that the component exists', + ({ expirationPolicyCleanupStatus, shown }) => { + mountComponent({ item: { ...item, expirationPolicyCleanupStatus } }); - const icon = findWarningIcon(); - expect(icon.exists()).toBe(shown); + expect(findCleanupStatus().exists()).toBe(shown); if (shown) { - const tooltip = getBinding(icon.element, 'gl-tooltip'); - expect(tooltip.value.title).toBe(title); + expect(findCleanupStatus().props()).toMatchObject({ + status: expirationPolicyCleanupStatus, + }); } }, ); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index fe258dcd4e8..27246cf2364 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -9,6 +9,7 @@ export const imagesListResponse = [ canDelete: true, createdAt: '2020-11-03T13:29:21Z', expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', }, { __typename: 'ContainerRepository', @@ -20,6 +21,7 @@ export const imagesListResponse = [ canDelete: true, createdAt: '2020-09-21T06:57:43Z', expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', }, ]; diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index e0a1343c39c..b2580d47549 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -5,6 +5,7 @@ Object { "data": Array [ Object { "_links": Object { + "__typename": "ReleaseLinks", "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.2&scope=all&state=closed", "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.2&scope=all&state=closed", "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.2/edit", @@ -19,24 +20,29 @@ Object { "links": Array [], "sources": Array [ Object { + "__typename": "ReleaseSource", "format": "zip", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.zip", }, Object { + "__typename": "ReleaseSource", "format": "tar.gz", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.gz", }, Object { + "__typename": "ReleaseSource", "format": "tar.bz2", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar.bz2", }, Object { + "__typename": "ReleaseSource", "format": "tar", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.2/releases-project-v1.2.tar", }, ], }, "author": Object { + "__typename": "UserCore", "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", "username": "administrator", "webUrl": "http://localhost/administrator", @@ -57,6 +63,7 @@ Object { }, Object { "_links": Object { + "__typename": "ReleaseLinks", "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed", "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=closed", "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit", @@ -70,6 +77,7 @@ Object { "count": 8, "links": Array [ Object { + "__typename": "ReleaseAssetLink", "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-3", "external": true, "id": "gid://gitlab/Releases::Link/13", @@ -78,6 +86,7 @@ Object { "url": "https://example.com/image", }, Object { + "__typename": "ReleaseAssetLink", "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-2", "external": true, "id": "gid://gitlab/Releases::Link/12", @@ -86,6 +95,7 @@ Object { "url": "https://example.com/package", }, Object { + "__typename": "ReleaseAssetLink", "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-1", "external": false, "id": "gid://gitlab/Releases::Link/11", @@ -94,6 +104,7 @@ Object { "url": "http://localhost/releases-namespace/releases-project/runbook", }, Object { + "__typename": "ReleaseAssetLink", "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/linux-amd64", "external": true, "id": "gid://gitlab/Releases::Link/10", @@ -104,24 +115,29 @@ Object { ], "sources": Array [ Object { + "__typename": "ReleaseSource", "format": "zip", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.zip", }, Object { + "__typename": "ReleaseSource", "format": "tar.gz", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.gz", }, Object { + "__typename": "ReleaseSource", "format": "tar.bz2", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.bz2", }, Object { + "__typename": "ReleaseSource", "format": "tar", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar", }, ], }, "author": Object { + "__typename": "UserCore", "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", "username": "administrator", "webUrl": "http://localhost/administrator", @@ -134,6 +150,7 @@ Object { "descriptionHtml": "<p data-sourcepos=\\"1:1-1:33\\" dir=\\"auto\\">Best. Release. <strong>Ever.</strong> <gl-emoji title=\\"rocket\\" data-name=\\"rocket\\" data-unicode-version=\\"6.0\\">🚀</gl-emoji></p>", "evidences": Array [ Object { + "__typename": "ReleaseEvidence", "collectedAt": "2018-12-03T00:00:00Z", "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json", "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d", @@ -141,6 +158,7 @@ Object { ], "milestones": Array [ Object { + "__typename": "Milestone", "description": "The 12.3 milestone", "id": "gid://gitlab/Milestone/123", "issueStats": Object { @@ -153,6 +171,7 @@ Object { "webUrl": "/releases-namespace/releases-project/-/milestones/1", }, Object { + "__typename": "Milestone", "description": "The 12.4 milestone", "id": "gid://gitlab/Milestone/124", "issueStats": Object { @@ -173,6 +192,7 @@ Object { }, ], "paginationInfo": Object { + "__typename": "PageInfo", "endCursor": "eyJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyIsImlkIjoiMSJ9", "hasNextPage": false, "hasPreviousPage": false, @@ -192,24 +212,28 @@ Object { "count": undefined, "links": Array [ Object { + "directAssetPath": "/binaries/awesome-app-3", "id": "gid://gitlab/Releases::Link/13", "linkType": "image", "name": "Image", "url": "https://example.com/image", }, Object { + "directAssetPath": "/binaries/awesome-app-2", "id": "gid://gitlab/Releases::Link/12", "linkType": "package", "name": "Package", "url": "https://example.com/package", }, Object { + "directAssetPath": "/binaries/awesome-app-1", "id": "gid://gitlab/Releases::Link/11", "linkType": "runbook", "name": "Runbook", "url": "http://localhost/releases-namespace/releases-project/runbook", }, Object { + "directAssetPath": "/binaries/linux-amd64", "id": "gid://gitlab/Releases::Link/10", "linkType": "other", "name": "linux-amd64 binaries", @@ -247,6 +271,7 @@ exports[`releases/util.js convertOneReleaseGraphQLResponse matches snapshot 1`] Object { "data": Object { "_links": Object { + "__typename": "ReleaseLinks", "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed", "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=closed", "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit", @@ -260,6 +285,7 @@ Object { "count": 8, "links": Array [ Object { + "__typename": "ReleaseAssetLink", "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-3", "external": true, "id": "gid://gitlab/Releases::Link/13", @@ -268,6 +294,7 @@ Object { "url": "https://example.com/image", }, Object { + "__typename": "ReleaseAssetLink", "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-2", "external": true, "id": "gid://gitlab/Releases::Link/12", @@ -276,6 +303,7 @@ Object { "url": "https://example.com/package", }, Object { + "__typename": "ReleaseAssetLink", "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-1", "external": false, "id": "gid://gitlab/Releases::Link/11", @@ -284,6 +312,7 @@ Object { "url": "http://localhost/releases-namespace/releases-project/runbook", }, Object { + "__typename": "ReleaseAssetLink", "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/linux-amd64", "external": true, "id": "gid://gitlab/Releases::Link/10", @@ -294,24 +323,29 @@ Object { ], "sources": Array [ Object { + "__typename": "ReleaseSource", "format": "zip", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.zip", }, Object { + "__typename": "ReleaseSource", "format": "tar.gz", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.gz", }, Object { + "__typename": "ReleaseSource", "format": "tar.bz2", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.bz2", }, Object { + "__typename": "ReleaseSource", "format": "tar", "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar", }, ], }, "author": Object { + "__typename": "UserCore", "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", "username": "administrator", "webUrl": "http://localhost/administrator", @@ -324,6 +358,7 @@ Object { "descriptionHtml": "<p data-sourcepos=\\"1:1-1:33\\" dir=\\"auto\\">Best. Release. <strong>Ever.</strong> <gl-emoji title=\\"rocket\\" data-name=\\"rocket\\" data-unicode-version=\\"6.0\\">🚀</gl-emoji></p>", "evidences": Array [ Object { + "__typename": "ReleaseEvidence", "collectedAt": "2018-12-03T00:00:00Z", "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json", "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d", @@ -331,6 +366,7 @@ Object { ], "milestones": Array [ Object { + "__typename": "Milestone", "description": "The 12.3 milestone", "id": "gid://gitlab/Milestone/123", "issueStats": Object { @@ -343,6 +379,7 @@ Object { "webUrl": "/releases-namespace/releases-project/-/milestones/1", }, Object { + "__typename": "Milestone", "description": "The 12.4 milestone", "id": "gid://gitlab/Milestone/124", "issueStats": Object { 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 002d8939058..096d319c82f 100644 --- a/spec/frontend/releases/components/app_index_apollo_client_spec.js +++ b/spec/frontend/releases/components/app_index_apollo_client_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; 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'; import createFlash from '~/flash'; import { historyPushState } from '~/lib/utils/common_utils'; import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue'; @@ -12,7 +13,6 @@ import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; -import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; Vue.use(VueApollo); @@ -21,10 +21,14 @@ jest.mock('~/flash'); let mockQueryParams; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), + historyPushState: jest.fn(), +})); + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), getParameterByName: jest .fn() .mockImplementation((parameterName) => mockQueryParams[parameterName]), - historyPushState: jest.fn(), })); describe('app_index_apollo_client.vue', () => { diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 3a28020c284..43e88650ae3 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -2,14 +2,14 @@ import { shallowMount } from '@vue/test-utils'; import { merge } from 'lodash'; import Vue from 'vue'; import Vuex from 'vuex'; -import { getParameterByName } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import AppIndex from '~/releases/components/app_index.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; import ReleasesSort from '~/releases/components/releases_sort.vue'; -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), getParameterByName: jest.fn(), })); diff --git a/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap b/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap index c932379a253..111757e2d30 100644 --- a/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap +++ b/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap @@ -14,6 +14,7 @@ Object { exports[`Grouped Issues List with data renders a report item with the correct props 1`] = ` Object { "component": "TestIssueBody", + "iconComponent": "IssueStatusIcon", "isNew": false, "issue": Object { "name": "foo", 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 d29048d640c..0f7c2559e8b 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 @@ -114,7 +114,7 @@ describe('Grouped test reports app', () => { setReports(newFailedTestReports); }); - it('tracks usage ping metric when enabled', () => { + it('tracks service ping metric when enabled', () => { mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: true } }); findExpandButton().trigger('click'); @@ -132,7 +132,7 @@ describe('Grouped test reports app', () => { expect(Api.trackRedisHllUserEvent).toHaveBeenCalledTimes(1); }); - it('does not track usage ping metric when disabled', () => { + it('does not track service ping metric when disabled', () => { mountComponent({ glFeatures: { usageDataITestingSummaryWidgetTotal: false } }); findExpandButton().trigger('click'); diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js new file mode 100644 index 00000000000..a449fd6f06c --- /dev/null +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -0,0 +1,117 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; +import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; + +const DEFAULT_PROPS = { + name: 'some name', + path: 'some/path', + canPushCode: true, + replacePath: 'some/replace/path', + deletePath: 'some/delete/path', + emptyRepo: false, +}; + +const DEFAULT_INJECT = { + targetBranch: 'master', + originalBranch: 'master', +}; + +describe('BlobButtonGroup component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(BlobButtonGroup, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: { + ...DEFAULT_INJECT, + }, + directives: { + GlModal: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal); + const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); + const findReplaceButton = () => wrapper.findAll(GlButton).at(0); + + it('renders component', () => { + createComponent(); + + const { name, path } = DEFAULT_PROPS; + + expect(wrapper.props()).toMatchObject({ + name, + path, + }); + }); + + describe('buttons', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders both the replace and delete button', () => { + expect(wrapper.findAll(GlButton)).toHaveLength(2); + }); + + it('renders the buttons in the correct order', () => { + expect(wrapper.findAll(GlButton).at(0).text()).toBe('Replace'); + expect(wrapper.findAll(GlButton).at(1).text()).toBe('Delete'); + }); + + it('triggers the UploadBlobModal from the replace button', () => { + const { value } = getBinding(findReplaceButton().element, 'gl-modal'); + const modalId = findUploadBlobModal().props('modalId'); + + expect(modalId).toEqual(value); + }); + }); + + it('renders UploadBlobModal', () => { + createComponent(); + + const { targetBranch, originalBranch } = DEFAULT_INJECT; + const { name, path, canPushCode, replacePath } = DEFAULT_PROPS; + const title = `Replace ${name}`; + + expect(findUploadBlobModal().props()).toMatchObject({ + modalTitle: title, + commitMessage: title, + targetBranch, + originalBranch, + canPushCode, + path, + replacePath, + primaryBtnText: 'Replace file', + }); + }); + + it('renders DeleteBlobModel', () => { + createComponent(); + + const { targetBranch, originalBranch } = DEFAULT_INJECT; + const { name, canPushCode, deletePath, emptyRepo } = DEFAULT_PROPS; + const title = `Delete ${name}`; + + expect(findDeleteBlobModal().props()).toMatchObject({ + modalTitle: title, + commitMessage: title, + targetBranch, + originalBranch, + canPushCode, + deletePath, + emptyRepo, + }); + }); +}); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index 495039b4ccb..a83d0a607f2 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -1,11 +1,23 @@ import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import BlobContent from '~/blob/components/blob_content.vue'; 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 BlobHeaderEdit from '~/repository/components/blob_header_edit.vue'; -import BlobReplace from '~/repository/components/blob_replace.vue'; +import BlobEdit from '~/repository/components/blob_edit.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'; + +jest.mock('~/repository/components/blob_viewers'); let wrapper; const simpleMockData = { @@ -17,6 +29,7 @@ const simpleMockData = { fileType: 'text', tooLarge: false, path: 'some_file.js', + webPath: 'some_file.js', editBlobPath: 'some_file.js/edit', ideEditPath: 'some_file.js/ide/edit', storedExternally: false, @@ -27,7 +40,6 @@ const simpleMockData = { canLock: true, isLocked: false, lockLink: 'some_file.js/lock', - canModifyBlob: true, forkPath: 'some_file.js/fork', simpleViewer: { fileType: 'text', @@ -47,6 +59,51 @@ const richMockData = { }, }; +const projectMockData = { + userPermissions: { + pushCode: true, + }, + repository: { + empty: false, + }, +}; + +const localVue = createLocalVue(); +const mockAxios = new MockAdapter(axios); + +const createComponentWithApollo = (mockData = {}) => { + localVue.use(VueApollo); + + const defaultPushCode = projectMockData.userPermissions.pushCode; + const defaultEmptyRepo = projectMockData.repository.empty; + const { blobs, emptyRepo = defaultEmptyRepo, canPushCode = defaultPushCode } = mockData; + + const mockResolver = jest.fn().mockResolvedValue({ + data: { + project: { + userPermissions: { pushCode: canPushCode }, + repository: { + empty: emptyRepo, + blobs: { + nodes: [blobs], + }, + }, + }, + }, + }); + + const fakeApollo = createMockApollo([[blobInfoQuery, mockResolver]]); + + wrapper = shallowMount(BlobContentViewer, { + localVue, + apolloProvider: fakeApollo, + propsData: { + path: 'some_file.js', + projectPath: 'some/path', + }, + }); +}; + const createFactory = (mountFn) => ( { props = {}, mockData = {}, stubs = {} } = {}, loading = false, @@ -78,9 +135,9 @@ const fullFactory = createFactory(mount); describe('Blob content viewer component', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findBlobHeader = () => wrapper.findComponent(BlobHeader); - const findBlobHeaderEdit = () => wrapper.findComponent(BlobHeaderEdit); + const findBlobEdit = () => wrapper.findComponent(BlobEdit); const findBlobContent = () => wrapper.findComponent(BlobContent); - const findBlobReplace = () => wrapper.findComponent(BlobReplace); + const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); afterEach(() => { wrapper.destroy(); @@ -163,6 +220,67 @@ 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 }); + await waitForPromises(); + + expect(mockAxios.history.get).toHaveLength(1); + }); + }); + + describe('Blob viewer', () => { + afterEach(() => { + loadViewer.mockRestore(); + viewerProps.mockRestore(); + }); + + it('does not render a BlobContent component if a Blob viewer is available', () => { + loadViewer.mockReturnValueOnce(() => true); + factory({ mockData: { blobInfo: richMockData } }); + + expect(findBlobContent().exists()).toBe(false); + }); + + it.each` + viewer | loadViewerReturnValue | viewerPropsReturnValue + ${'empty'} | ${EmptyViewer} | ${{}} + ${'download'} | ${DownloadViewer} | ${{ filePath: '/some/file/path', fileName: 'test.js', fileSize: 100 }} + ${'text'} | ${TextViewer} | ${{ content: 'test', fileName: 'test.js', readOnly: true }} + `( + 'renders viewer component for $viewer files', + async ({ viewer, loadViewerReturnValue, viewerPropsReturnValue }) => { + loadViewer.mockReturnValue(loadViewerReturnValue); + viewerProps.mockReturnValue(viewerPropsReturnValue); + + factory({ + mockData: { + blobInfo: { + ...simpleMockData, + fileType: null, + simpleViewer: { + ...simpleMockData.simpleViewer, + fileType: viewer, + }, + }, + }, + }); + + await nextTick(); + + expect(loadViewer).toHaveBeenCalledWith(viewer); + expect(wrapper.findComponent(loadViewerReturnValue).exists()).toBe(true); + }, + ); + }); + describe('BlobHeader action slot', () => { const { ideEditPath, editBlobPath } = simpleMockData; @@ -177,7 +295,7 @@ describe('Blob content viewer component', () => { await nextTick(); - expect(findBlobHeaderEdit().props()).toMatchObject({ + expect(findBlobEdit().props()).toMatchObject({ editPath: editBlobPath, webIdePath: ideEditPath, }); @@ -194,31 +312,56 @@ describe('Blob content viewer component', () => { await nextTick(); - expect(findBlobHeaderEdit().props()).toMatchObject({ + expect(findBlobEdit().props()).toMatchObject({ editPath: editBlobPath, webIdePath: ideEditPath, }); }); - describe('BlobReplace', () => { - const { name, path } = simpleMockData; + it('does not render BlobHeaderEdit button when viewing a binary file', async () => { + fullFactory({ + mockData: { blobInfo: richMockData, isBinary: true }, + stubs: { + BlobContent: true, + BlobReplace: true, + }, + }); + + await nextTick(); + + expect(findBlobEdit().exists()).toBe(false); + }); + + describe('BlobButtonGroup', () => { + const { name, path, replacePath, webPath } = simpleMockData; + const { + userPermissions: { pushCode }, + repository: { empty }, + } = projectMockData; it('renders component', async () => { window.gon.current_user_id = 1; fullFactory({ - mockData: { blobInfo: simpleMockData }, + mockData: { + blobInfo: simpleMockData, + project: { userPermissions: { pushCode }, repository: { empty } }, + }, stubs: { BlobContent: true, - BlobReplace: true, + BlobButtonGroup: true, }, }); await nextTick(); - expect(findBlobReplace().props()).toMatchObject({ + expect(findBlobButtonGroup().props()).toMatchObject({ name, path, + replacePath, + deletePath: webPath, + canPushCode: pushCode, + emptyRepo: empty, }); }); @@ -235,7 +378,7 @@ describe('Blob content viewer component', () => { await nextTick(); - expect(findBlobReplace().exists()).toBe(false); + expect(findBlobButtonGroup().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/repository/components/blob_header_edit_spec.js b/spec/frontend/repository/components/blob_edit_spec.js index c0eb7c523c4..e6e69cd8549 100644 --- a/spec/frontend/repository/components/blob_header_edit_spec.js +++ b/spec/frontend/repository/components/blob_edit_spec.js @@ -1,6 +1,6 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import BlobHeaderEdit from '~/repository/components/blob_header_edit.vue'; +import BlobEdit from '~/repository/components/blob_edit.vue'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; const DEFAULT_PROPS = { @@ -8,11 +8,11 @@ const DEFAULT_PROPS = { webIdePath: 'some_file.js/ide/edit', }; -describe('BlobHeaderEdit component', () => { +describe('BlobEdit component', () => { let wrapper; const createComponent = (consolidatedEditButton = false, props = {}) => { - wrapper = shallowMount(BlobHeaderEdit, { + wrapper = shallowMount(BlobEdit, { propsData: { ...DEFAULT_PROPS, ...props, diff --git a/spec/frontend/repository/components/blob_replace_spec.js b/spec/frontend/repository/components/blob_replace_spec.js deleted file mode 100644 index 4a6f147da22..00000000000 --- a/spec/frontend/repository/components/blob_replace_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import BlobReplace from '~/repository/components/blob_replace.vue'; -import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; - -const DEFAULT_PROPS = { - name: 'some name', - path: 'some/path', - canPushCode: true, - replacePath: 'some/replace/path', -}; - -const DEFAULT_INJECT = { - targetBranch: 'master', - originalBranch: 'master', -}; - -describe('BlobReplace component', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(BlobReplace, { - propsData: { - ...DEFAULT_PROPS, - ...props, - }, - provide: { - ...DEFAULT_INJECT, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); - - it('renders component', () => { - createComponent(); - - const { name, path } = DEFAULT_PROPS; - - expect(wrapper.props()).toMatchObject({ - name, - path, - }); - }); - - it('renders UploadBlobModal', () => { - createComponent(); - - const { targetBranch, originalBranch } = DEFAULT_INJECT; - const { name, path, canPushCode, replacePath } = DEFAULT_PROPS; - const title = `Replace ${name}`; - - expect(findUploadBlobModal().props()).toMatchObject({ - modalTitle: title, - commitMessage: title, - targetBranch, - originalBranch, - canPushCode, - path, - replacePath, - primaryBtnText: 'Replace file', - }); - }); -}); diff --git a/spec/frontend/repository/components/blob_viewers/__snapshots__/empty_viewer_spec.js.snap b/spec/frontend/repository/components/blob_viewers/__snapshots__/empty_viewer_spec.js.snap new file mode 100644 index 00000000000..e702ea5fd00 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/__snapshots__/empty_viewer_spec.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Empty Viewer matches the snapshot 1`] = ` +<div + class="nothing-here-block" +> + Empty file +</div> +`; diff --git a/spec/frontend/repository/components/blob_viewers/download_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/download_viewer_spec.js new file mode 100644 index 00000000000..c71b2b3c55c --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/download_viewer_spec.js @@ -0,0 +1,70 @@ +import { GlLink, GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import DownloadViewer from '~/repository/components/blob_viewers/download_viewer.vue'; + +describe('Text Viewer', () => { + let wrapper; + + const DEFAULT_PROPS = { + fileName: 'file_name.js', + filePath: '/some/file/path', + fileSize: 2269674, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(DownloadViewer, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + }); + }; + + it('renders component', () => { + createComponent(); + + const { fileName, filePath, fileSize } = DEFAULT_PROPS; + expect(wrapper.props()).toMatchObject({ + fileName, + filePath, + fileSize, + }); + }); + + it('renders download human readable file size text', () => { + createComponent(); + + const downloadText = `Download (${numberToHumanSize(DEFAULT_PROPS.fileSize)})`; + expect(wrapper.text()).toBe(downloadText); + }); + + it('renders download text', () => { + createComponent({ + fileSize: 0, + }); + + expect(wrapper.text()).toBe('Download'); + }); + + it('renders download link', () => { + createComponent(); + const { filePath, fileName } = DEFAULT_PROPS; + + expect(wrapper.findComponent(GlLink).attributes()).toMatchObject({ + rel: 'nofollow', + target: '_blank', + href: filePath, + download: fileName, + }); + }); + + it('renders download icon', () => { + createComponent(); + + expect(wrapper.findComponent(GlIcon).props()).toMatchObject({ + name: 'download', + size: 16, + }); + }); +}); diff --git a/spec/frontend/repository/components/blob_viewers/empty_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/empty_viewer_spec.js new file mode 100644 index 00000000000..e65f20ea0af --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/empty_viewer_spec.js @@ -0,0 +1,14 @@ +import { shallowMount } from '@vue/test-utils'; +import EmptyViewer from '~/repository/components/blob_viewers/empty_viewer.vue'; + +describe('Empty Viewer', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(EmptyViewer); + }); + + it('matches the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js b/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js new file mode 100644 index 00000000000..88c5bee6564 --- /dev/null +++ b/spec/frontend/repository/components/blob_viewers/text_viewer_spec.js @@ -0,0 +1,30 @@ +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import TextViewer from '~/repository/components/blob_viewers/text_viewer.vue'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; + +describe('Text Viewer', () => { + let wrapper; + const propsData = { + content: 'Some content', + fileName: 'file_name.js', + readOnly: true, + }; + + const createComponent = () => { + wrapper = shallowMount(TextViewer, { propsData }); + }; + + const findEditor = () => wrapper.findComponent(SourceEditor); + + it('renders a Source Editor component', async () => { + createComponent(); + + await waitForPromises(); + + expect(findEditor().exists()).toBe(true); + expect(findEditor().props('value')).toBe(propsData.content); + expect(findEditor().props('fileName')).toBe(propsData.fileName); + expect(findEditor().props('editorOptions')).toEqual({ readOnly: propsData.readOnly }); + }); +}); diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js new file mode 100644 index 00000000000..a74e3e6d325 --- /dev/null +++ b/spec/frontend/repository/components/delete_blob_modal_spec.js @@ -0,0 +1,130 @@ +import { GlFormTextarea, GlModal, GlFormInput, GlToggle } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const initialProps = { + modalId: 'Delete-blob', + modalTitle: 'Delete File', + deletePath: 'some/path', + commitMessage: 'Delete File', + targetBranch: 'some-target-branch', + originalBranch: 'main', + canPushCode: true, + emptyRepo: false, +}; + +describe('DeleteBlobModal', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(DeleteBlobModal, { + propsData: { + ...initialProps, + ...props, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + const findForm = () => wrapper.findComponent({ ref: 'form' }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders Modal component', () => { + createComponent(); + + const { modalTitle: title } = initialProps; + + expect(findModal().props()).toMatchObject({ + title, + size: 'md', + actionPrimary: { + text: 'Delete file', + }, + actionCancel: { + text: 'Cancel', + }, + }); + }); + + describe('form', () => { + it('gets passed the path for action attribute', () => { + createComponent(); + expect(findForm().attributes('action')).toBe(initialProps.deletePath); + }); + + it('submits the form', async () => { + createComponent(); + + const submitSpy = jest.spyOn(findForm().element, 'submit'); + findModal().vm.$emit('primary', { preventDefault: () => {} }); + await nextTick(); + + expect(submitSpy).toHaveBeenCalled(); + submitSpy.mockRestore(); + }); + + it.each` + component | defaultValue | canPushCode | targetBranch | originalBranch | exist + ${GlFormTextarea} | ${initialProps.commitMessage} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${GlFormInput} | ${initialProps.targetBranch} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${GlFormInput} | ${undefined} | ${false} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${false} + ${GlToggle} | ${'true'} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${GlToggle} | ${undefined} | ${true} | ${'same-branch'} | ${'same-branch'} | ${false} + `( + 'has the correct form fields ', + ({ component, defaultValue, canPushCode, targetBranch, originalBranch, exist }) => { + createComponent({ + canPushCode, + targetBranch, + originalBranch, + }); + const formField = wrapper.findComponent(component); + + if (!exist) { + expect(formField.exists()).toBe(false); + return; + } + + expect(formField.exists()).toBe(true); + expect(formField.attributes('value')).toBe(defaultValue); + }, + ); + + it.each` + input | value | emptyRepo | canPushCode | exist + ${'authenticity_token'} | ${'mock-csrf-token'} | ${false} | ${true} | ${true} + ${'authenticity_token'} | ${'mock-csrf-token'} | ${true} | ${false} | ${true} + ${'_method'} | ${'delete'} | ${false} | ${true} | ${true} + ${'_method'} | ${'delete'} | ${true} | ${false} | ${true} + ${'original_branch'} | ${initialProps.originalBranch} | ${false} | ${true} | ${true} + ${'original_branch'} | ${undefined} | ${true} | ${true} | ${false} + ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${true} + ${'create_merge_request'} | ${'1'} | ${false} | ${true} | ${true} + ${'create_merge_request'} | ${undefined} | ${true} | ${false} | ${false} + `( + 'passes $input as a hidden input with the correct value', + ({ input, value, emptyRepo, canPushCode, exist }) => { + createComponent({ + emptyRepo, + canPushCode, + }); + + const inputMethod = findForm().find(`input[name="${input}"]`); + + if (!exist) { + expect(inputMethod.exists()).toBe(false); + return; + } + + expect(inputMethod.attributes('type')).toBe('hidden'); + expect(inputMethod.attributes('value')).toBe(value); + }, + ); + }); +}); 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 ac60fc4917d..6f461f4c69b 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -11,6 +11,7 @@ exports[`Repository table row component renders a symlink table row 1`] = ` class="tree-item-link str-truncated" data-qa-selector="file_name_link" href="https://test.com" + title="test" > <file-icon-stub class="mr-1 position-relative text-secondary" @@ -64,6 +65,7 @@ exports[`Repository table row component renders table row 1`] = ` class="tree-item-link str-truncated" data-qa-selector="file_name_link" href="https://test.com" + title="test" > <file-icon-stub class="mr-1 position-relative text-secondary" @@ -117,6 +119,7 @@ exports[`Repository table row component renders table row for path with special class="tree-item-link str-truncated" data-qa-selector="file_name_link" href="https://test.com" + title="test" > <file-icon-stub class="mr-1 position-relative text-secondary" diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index d397bc185e2..1d1ec58100f 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import filesQuery from 'shared_queries/repository/files.query.graphql'; 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 { TREE_INITIAL_FETCH_COUNT } from '~/repository/constants'; let vm; let $apollo; @@ -19,10 +19,17 @@ function factory(path, data = () => ({})) { mocks: { $apollo, }, + provide: { + glFeatures: { + increasePageSizeExponentially: true, + }, + }, }); } describe('Repository table component', () => { + const findFileTable = () => vm.find(FileTable); + afterEach(() => { vm.destroy(); }); @@ -85,14 +92,12 @@ describe('Repository table component', () => { describe('FileTable showMore', () => { describe('when is present', () => { - const fileTable = () => vm.find(FileTable); - beforeEach(async () => { factory('/'); }); it('is changes hasShowMore to false when "showMore" event is emitted', async () => { - fileTable().vm.$emit('showMore'); + findFileTable().vm.$emit('showMore'); await vm.vm.$nextTick(); @@ -100,7 +105,7 @@ describe('Repository table component', () => { }); it('changes clickedShowMore when "showMore" event is emitted', async () => { - fileTable().vm.$emit('showMore'); + findFileTable().vm.$emit('showMore'); await vm.vm.$nextTick(); @@ -110,7 +115,7 @@ describe('Repository table component', () => { it('triggers fetchFiles when "showMore" event is emitted', () => { jest.spyOn(vm.vm, 'fetchFiles'); - fileTable().vm.$emit('showMore'); + findFileTable().vm.$emit('showMore'); expect(vm.vm.fetchFiles).toHaveBeenCalled(); }); @@ -126,10 +131,52 @@ describe('Repository table component', () => { expect(vm.vm.hasShowMore).toBe(false); }); - it('has limit of 1000 files on initial load', () => { + it.each` + totalBlobs | pagesLoaded | limitReached + ${900} | ${1} | ${false} + ${1000} | ${1} | ${true} + ${1002} | ${1} | ${true} + ${1002} | ${2} | ${false} + ${1900} | ${2} | ${false} + ${2000} | ${2} | ${true} + `('has limit of 1000 entries per page', async ({ totalBlobs, pagesLoaded, limitReached }) => { factory('/'); - expect(TREE_INITIAL_FETCH_COUNT * vm.vm.pageSize).toBe(1000); + const blobs = new Array(totalBlobs).fill('fakeBlob'); + vm.setData({ entries: { blobs }, pagesLoaded }); + + await vm.vm.$nextTick(); + + expect(findFileTable().props('hasMore')).toBe(limitReached); + }); + + it.each` + fetchCounter | pageSize + ${0} | ${10} + ${2} | ${30} + ${4} | ${50} + ${6} | ${70} + ${8} | ${90} + ${10} | ${100} + ${20} | ${100} + ${100} | ${100} + ${200} | ${100} + `('exponentially increases page size, to a maximum of 100', ({ fetchCounter, pageSize }) => { + factory('/'); + vm.setData({ fetchCounter }); + + vm.vm.fetchFiles(); + + expect($apollo.query).toHaveBeenCalledWith({ + query: filesQuery, + variables: { + pageSize, + nextPageCursor: '', + path: '/', + projectPath: '', + ref: '', + }, + }); }); }); }); diff --git a/spec/frontend/repository/components/upload_blob_modal_spec.js b/spec/frontend/repository/components/upload_blob_modal_spec.js index d93b1d7e5f1..08a6583b60c 100644 --- a/spec/frontend/repository/components/upload_blob_modal_spec.js +++ b/spec/frontend/repository/components/upload_blob_modal_spec.js @@ -190,7 +190,9 @@ describe('UploadBlobModal', () => { }); it('creates a flash error', () => { - expect(createFlash).toHaveBeenCalledWith('Error uploading file. Please try again.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'Error uploading file. Please try again.', + }); }); afterEach(() => { diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index 8cabf902a4f..5186c9a8992 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -1,6 +1,10 @@ import MockAdapter from 'axios-mock-adapter'; +import { createMockClient } from 'helpers/mock_apollo_helper'; import axios from '~/lib/utils/axios_utils'; import { resolveCommit, fetchLogsTree } from '~/repository/log_tree'; +import commitsQuery from '~/repository/queries/commits.query.graphql'; +import projectPathQuery from '~/repository/queries/project_path.query.graphql'; +import refQuery from '~/repository/queries/ref.query.graphql'; const mockData = [ { @@ -10,6 +14,7 @@ const mockData = [ committed_date: '2019-01-01', }, commit_path: `https://test.com`, + commit_title_html: 'commit title', file_name: 'index.js', type: 'blob', }, @@ -50,19 +55,15 @@ describe('fetchLogsTree', () => { global.gon = { relative_url_root: '' }; - client = { - readQuery: () => ({ - projectPath: 'gitlab-org/gitlab-foss', - escapedRef: 'main', - commits: [], - }), - writeQuery: jest.fn(), - }; - resolver = { entry: { name: 'index.js', type: 'blob' }, resolve: jest.fn(), }; + + client = createMockClient(); + client.writeQuery({ query: projectPathQuery, data: { projectPath: 'gitlab-org/gitlab-foss' } }); + client.writeQuery({ query: refQuery, data: { ref: 'main', escapedRef: 'main' } }); + client.writeQuery({ query: commitsQuery, data: { commits: [] } }); }); afterEach(() => { @@ -125,25 +126,19 @@ describe('fetchLogsTree', () => { it('writes query to client', async () => { await fetchLogsTree(client, '', '0', resolver); - expect(client.writeQuery).toHaveBeenCalledWith({ - query: expect.anything(), - data: { - projectPath: 'gitlab-org/gitlab-foss', - escapedRef: 'main', - commits: [ - expect.objectContaining({ - __typename: 'LogTreeCommit', - commitPath: 'https://test.com', - committedDate: '2019-01-01', - fileName: 'index.js', - filePath: '/index.js', - message: 'testing message', - sha: '123', - titleHtml: undefined, - type: 'blob', - }), - ], - }, + expect(client.readQuery({ query: commitsQuery })).toEqual({ + commits: [ + expect.objectContaining({ + commitPath: 'https://test.com', + committedDate: '2019-01-01', + fileName: 'index.js', + filePath: '/index.js', + message: 'testing message', + sha: '123', + titleHtml: 'commit title', + type: 'blob', + }), + ], }); }); }); diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js index 8699e1cf420..d1f861669a0 100644 --- a/spec/frontend/right_sidebar_spec.js +++ b/spec/frontend/right_sidebar_spec.js @@ -66,22 +66,6 @@ describe('RightSidebar', () => { assertSidebarState('collapsed'); }); - it('should broadcast todo:toggle event when add todo clicked', (done) => { - const todos = getJSONFixture('todos/todos.json'); - mock.onPost(/(.*)\/todos$/).reply(200, todos); - - const todoToggleSpy = jest.fn(); - $(document).on('todo:toggle', todoToggleSpy); - - $('.issuable-sidebar-header .js-issuable-todo').click(); - - setImmediate(() => { - expect(todoToggleSpy.mock.calls.length).toEqual(1); - - done(); - }); - }); - it('should not hide collapsed icons', () => { [].forEach.call(document.querySelectorAll('.sidebar-collapsed-icon'), (el) => { expect(el.querySelector('.fa, svg').classList.contains('hidden')).toBeFalsy(); 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 12651a82a0c..95f7c38cafc 100644 --- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js +++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js @@ -1,18 +1,30 @@ -import { shallowMount } from '@vue/test-utils'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; 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 deleteRunnerMutation from '~/runner/graphql/delete_runner.mutation.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'; -const mockId = '1'; +const mockRunner = runnerData.data.runner; const getRunnersQueryName = getRunnersQuery.definitions[0].name.value; +const localVue = createLocalVue(); +localVue.use(VueApollo); + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); + describe('RunnerTypeCell', () => { let wrapper; - let mutate; + const runnerDeleteMutationHandler = jest.fn(); + const runnerUpdateMutationHandler = jest.fn(); const findEditBtn = () => wrapper.findByTestId('edit-runner'); const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); @@ -23,26 +35,43 @@ describe('RunnerTypeCell', () => { shallowMount(RunnerActionCell, { propsData: { runner: { - id: `gid://gitlab/Ci::Runner/${mockId}`, + id: mockRunner.id, active, }, }, - mocks: { - $apollo: { - mutate, - }, - }, + localVue, + apolloProvider: createMockApollo([ + [runnerDeleteMutation, runnerDeleteMutationHandler], + [runnerUpdateMutation, runnerUpdateMutationHandler], + ]), ...options, }), ); }; beforeEach(() => { - mutate = jest.fn(); + runnerDeleteMutationHandler.mockResolvedValue({ + data: { + runnerDelete: { + errors: [], + }, + }, + }); + + runnerUpdateMutationHandler.mockResolvedValue({ + data: { + runnerUpdate: { + runner: runnerData.data.runner, + errors: [], + }, + }, + }); }); afterEach(() => { - mutate.mockReset(); + runnerDeleteMutationHandler.mockReset(); + runnerUpdateMutationHandler.mockReset(); + wrapper.destroy(); }); @@ -58,17 +87,6 @@ describe('RunnerTypeCell', () => { ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true} `('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => { beforeEach(() => { - mutate.mockResolvedValue({ - data: { - runnerUpdate: { - runner: { - id: `gid://gitlab/Ci::Runner/1`, - __typename: 'CiRunner', - }, - }, - }, - }); - createComponent({ active: isActive }); }); @@ -93,46 +111,93 @@ describe('RunnerTypeCell', () => { }); describe(`When clicking on the ${icon} button`, () => { - beforeEach(async () => { + it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => { + expect(runnerUpdateMutationHandler).toHaveBeenCalledTimes(0); + await findToggleActiveBtn().vm.$emit('click'); - await waitForPromises(); - }); - it(`The apollo mutation to set active to ${newActiveValue} is called`, () => { - expect(mutate).toHaveBeenCalledTimes(1); - expect(mutate).toHaveBeenCalledWith({ - mutation: runnerUpdateMutation, - variables: { - input: { - id: `gid://gitlab/Ci::Runner/${mockId}`, - active: newActiveValue, - }, + expect(runnerUpdateMutationHandler).toHaveBeenCalledTimes(1); + expect(runnerUpdateMutationHandler).toHaveBeenCalledWith({ + input: { + id: mockRunner.id, + active: newActiveValue, }, }); }); - it('The button does not have a loading state', () => { + it('The button does not have a loading state after the mutation occurs', async () => { + await findToggleActiveBtn().vm.$emit('click'); + + expect(findToggleActiveBtn().props('loading')).toBe(true); + + await waitForPromises(); + expect(findToggleActiveBtn().props('loading')).toBe(false); }); }); - }); - describe('When the user clicks a runner', () => { - beforeEach(() => { - createComponent(); + describe('When update fails', () => { + describe('On a network error', () => { + const mockErrorMsg = 'Update error!'; + + beforeEach(async () => { + runnerUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + await findToggleActiveBtn().vm.$emit('click'); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`Network error: ${mockErrorMsg}`), + component: 'RunnerActionsCell', + }); + }); - mutate.mockResolvedValue({ - data: { - runnerDelete: { - runner: { - id: `gid://gitlab/Ci::Runner/1`, - __typename: 'CiRunner', + it('error is shown to the user', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); + }); + + describe('On a validation error', () => { + const mockErrorMsg = 'Runner not found!'; + const mockErrorMsg2 = 'User not allowed!'; + + beforeEach(async () => { + runnerUpdateMutationHandler.mockResolvedValue({ + data: { + runnerUpdate: { + runner: runnerData.data.runner, + errors: [mockErrorMsg, mockErrorMsg2], + }, }, - }, - }, + }); + + await findToggleActiveBtn().vm.$emit('click'); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerActionsCell', + }); + }); + + it('error is shown to the user', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); }); + }); + }); + describe('When the user clicks a runner', () => { + beforeEach(() => { jest.spyOn(window, 'confirm'); + + createComponent(); + }); + + afterEach(() => { + window.confirm.mockRestore(); }); describe('When the user confirms deletion', () => { @@ -141,18 +206,28 @@ describe('RunnerTypeCell', () => { await findDeleteBtn().vm.$emit('click'); }); - it('The user sees a confirmation alert', async () => { + it('The user sees a confirmation alert', () => { expect(window.confirm).toHaveBeenCalledTimes(1); expect(window.confirm).toHaveBeenCalledWith(expect.any(String)); }); it('The delete mutation is called correctly', () => { - expect(mutate).toHaveBeenCalledTimes(1); - expect(mutate).toHaveBeenCalledWith({ - mutation: deleteRunnerMutation, + expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1); + expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({ + input: { id: mockRunner.id }, + }); + }); + + it('When delete mutation is called, current runners are refetched', async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + + await findDeleteBtn().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: runnerDeleteMutation, variables: { input: { - id: `gid://gitlab/Ci::Runner/${mockId}`, + id: mockRunner.id, }, }, awaitRefetchQueries: true, @@ -176,6 +251,57 @@ describe('RunnerTypeCell', () => { expect(findDeleteBtn().attributes('title')).toBe(''); }); + + describe('When delete fails', () => { + describe('On a network error', () => { + const mockErrorMsg = 'Delete error!'; + + beforeEach(async () => { + runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); + + await findDeleteBtn().vm.$emit('click'); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`Network error: ${mockErrorMsg}`), + component: 'RunnerActionsCell', + }); + }); + + it('error is shown to the user', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); + }); + + describe('On a validation error', () => { + const mockErrorMsg = 'Runner not found!'; + const mockErrorMsg2 = 'User not allowed!'; + + beforeEach(async () => { + runnerDeleteMutationHandler.mockResolvedValue({ + data: { + runnerDelete: { + errors: [mockErrorMsg, mockErrorMsg2], + }, + }, + }); + + await findDeleteBtn().vm.$emit('click'); + }); + + it('error is reported to sentry', () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerActionsCell', + }); + }); + + it('error is shown to the user', () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); + }); + }); }); describe('When the user does not confirm deletion', () => { @@ -189,7 +315,7 @@ describe('RunnerTypeCell', () => { }); it('The delete mutation is not called', () => { - expect(mutate).toHaveBeenCalledTimes(0); + expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(0); }); it('The delete button does not have a loading state', () => { diff --git a/spec/frontend/runner/components/helpers/masked_value_spec.js b/spec/frontend/runner/components/helpers/masked_value_spec.js new file mode 100644 index 00000000000..f87315057ec --- /dev/null +++ b/spec/frontend/runner/components/helpers/masked_value_spec.js @@ -0,0 +1,51 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import MaskedValue from '~/runner/components/helpers/masked_value.vue'; + +const mockSecret = '01234567890'; +const mockMasked = '***********'; + +describe('MaskedValue', () => { + let wrapper; + + const findButton = () => wrapper.findComponent(GlButton); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(MaskedValue, { + propsData: { + value: mockSecret, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays masked value by default', () => { + expect(wrapper.text()).toBe(mockMasked); + }); + + describe('When the icon is clicked', () => { + beforeEach(() => { + findButton().vm.$emit('click'); + }); + + it('Displays the actual value', () => { + expect(wrapper.text()).toBe(mockSecret); + expect(wrapper.text()).not.toBe(mockMasked); + }); + + it('When user clicks again, displays masked value', async () => { + await findButton().vm.$emit('click'); + + expect(wrapper.text()).toBe(mockMasked); + expect(wrapper.text()).not.toBe(mockSecret); + }); + }); +}); diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index 61a8f821b30..85cf7ea92df 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -2,8 +2,10 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; -import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE } from '~/runner/constants'; +import TagToken from '~/runner/components/search_tokens/tag_token.vue'; +import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG } from '~/runner/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; describe('RunnerList', () => { let wrapper; @@ -11,6 +13,7 @@ describe('RunnerList', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); + const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); const mockDefaultSort = 'CREATED_DESC'; const mockOtherSort = 'CONTACTED_DESC'; @@ -18,18 +21,20 @@ describe('RunnerList', () => { { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, ]; + const mockActiveRunnersCount = 2; const createComponent = ({ props = {}, options = {} } = {}) => { wrapper = extendedWrapper( shallowMount(RunnerFilteredSearchBar, { propsData: { + namespace: 'runners', value: { filters: [], sort: mockDefaultSort, }, + activeRunnersCount: mockActiveRunnersCount, ...props, }, - attrs: { namespace: 'runners' }, stubs: { FilteredSearch, GlFilteredSearch, @@ -53,6 +58,18 @@ describe('RunnerList', () => { expect(findFilteredSearch().props('namespace')).toBe('runners'); }); + it('Displays an active runner count', () => { + expect(findActiveRunnersMessage().text()).toBe( + `Runners currently online: ${mockActiveRunnersCount}`, + ); + }); + + it('Displays a large active runner count', () => { + createComponent({ props: { activeRunnersCount: 2000 } }); + + expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000'); + }); + it('sets sorting options', () => { const SORT_OPTIONS_COUNT = 2; @@ -65,12 +82,18 @@ describe('RunnerList', () => { expect(findFilteredSearch().props('tokens')).toEqual([ expect.objectContaining({ type: PARAM_KEY_STATUS, + token: BaseToken, options: expect.any(Array), }), expect.objectContaining({ type: PARAM_KEY_RUNNER_TYPE, + token: BaseToken, options: expect.any(Array), }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + token: TagToken, + }), ]); }); diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index d88d7b3fbee..5fff3581e39 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -1,5 +1,6 @@ import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; +import { cloneDeep } from 'lodash'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerList from '~/runner/components/runner_list.vue'; @@ -11,7 +12,6 @@ const mockActiveRunnersCount = mockRunners.length; describe('RunnerList', () => { let wrapper; - const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findTable = () => wrapper.findComponent(GlTable); const findHeaders = () => wrapper.findAll('th'); @@ -39,18 +39,6 @@ describe('RunnerList', () => { wrapper.destroy(); }); - it('Displays active runner count', () => { - expect(findActiveRunnersMessage().text()).toBe( - `Runners currently online: ${mockActiveRunnersCount}`, - ); - }); - - it('Displays a large active runner count', () => { - createComponent({ props: { activeRunnersCount: 2000 } }); - - expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000'); - }); - it('Displays headers', () => { const headerLabels = findHeaders().wrappers.map((w) => w.text()); @@ -85,12 +73,11 @@ describe('RunnerList', () => { ); expect(findCell({ fieldKey: 'name' }).text()).toContain(description); - // Other fields: some cells are empty in the first iteration - // See https://gitlab.com/gitlab-org/gitlab/-/issues/329658#pending-features + // Other fields expect(findCell({ fieldKey: 'version' }).text()).toBe(version); expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress); - expect(findCell({ fieldKey: 'projectCount' }).text()).toBe(''); - expect(findCell({ fieldKey: 'jobCount' }).text()).toBe(''); + expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('1'); + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0'); expect(findCell({ fieldKey: 'tagList' }).text()).toBe(''); expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); @@ -101,6 +88,54 @@ describe('RunnerList', () => { expect(actions.findByTestId('toggle-active-runner').exists()).toBe(true); }); + describe('Table data formatting', () => { + let mockRunnersCopy; + + beforeEach(() => { + mockRunnersCopy = cloneDeep(mockRunners); + }); + + it('Formats null project counts', () => { + mockRunnersCopy[0].projectCount = null; + + createComponent({ props: { runners: mockRunnersCopy } }, mount); + + expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('n/a'); + }); + + it('Formats 0 project counts', () => { + mockRunnersCopy[0].projectCount = 0; + + createComponent({ props: { runners: mockRunnersCopy } }, mount); + + expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('0'); + }); + + it('Formats big project counts', () => { + mockRunnersCopy[0].projectCount = 1000; + + createComponent({ props: { runners: mockRunnersCopy } }, mount); + + expect(findCell({ fieldKey: 'projectCount' }).text()).toBe('1,000'); + }); + + it('Formats job counts', () => { + mockRunnersCopy[0].jobCount = 1000; + + createComponent({ props: { runners: mockRunnersCopy } }, mount); + + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000'); + }); + + it('Formats big job counts with a plus symbol', () => { + mockRunnersCopy[0].jobCount = 1001; + + createComponent({ props: { runners: mockRunnersCopy } }, mount); + + expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('1,000+'); + }); + }); + it('Links to the runner page', () => { const { id } = mockRunners[0]; diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js index add595d784e..effef0e7ebf 100644 --- a/spec/frontend/runner/components/runner_manual_setup_help_spec.js +++ b/spec/frontend/runner/components/runner_manual_setup_help_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import MaskedValue from '~/runner/components/helpers/masked_value.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; @@ -37,6 +38,7 @@ describe('RunnerManualSetupHelp', () => { ...props, }, stubs: { + MaskedValue, GlSprintf, }, }), @@ -93,7 +95,11 @@ describe('RunnerManualSetupHelp', () => { expect(findRunnerInstructions().exists()).toBe(true); }); - it('Displays the registration token', () => { + it('Displays the registration token', async () => { + findRegistrationToken().find('[data-testid="toggle-masked"]').vm.$emit('click'); + + await nextTick(); + expect(findRegistrationToken().text()).toBe(mockRegistrationToken); expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken); }); @@ -105,6 +111,7 @@ describe('RunnerManualSetupHelp', () => { it('Replaces the runner reset button', async () => { const mockNewRegistrationToken = 'NEW_MOCK_REGISTRATION_TOKEN'; + findRegistrationToken().find('[data-testid="toggle-masked"]').vm.$emit('click'); findRunnerRegistrationTokenReset().vm.$emit('tokenReset', mockNewRegistrationToken); await nextTick(); diff --git a/spec/frontend/runner/components/runner_registration_token_reset_spec.js b/spec/frontend/runner/components/runner_registration_token_reset_spec.js index fa5751b380f..6dc207e369c 100644 --- a/spec/frontend/runner/components/runner_registration_token_reset_spec.js +++ b/spec/frontend/runner/components/runner_registration_token_reset_spec.js @@ -7,8 +7,10 @@ import createFlash, { FLASH_TYPES } from '~/flash'; import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue'; import { INSTANCE_TYPE } from '~/runner/constants'; import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql'; +import { captureException } from '~/runner/sentry_utils'; jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); const localVue = createLocalVue(); localVue.use(VueApollo); @@ -111,25 +113,32 @@ describe('RunnerRegistrationTokenReset', () => { describe('On error', () => { it('On network error, error message is shown', async () => { - runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce( - new Error('Something went wrong'), - ); + const mockErrorMsg = 'Token reset failed!'; + + runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); window.confirm.mockReturnValueOnce(true); await findButton().vm.$emit('click'); await waitForPromises(); expect(createFlash).toHaveBeenLastCalledWith({ - message: 'Network error: Something went wrong', + message: `Network error: ${mockErrorMsg}`, + }); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`Network error: ${mockErrorMsg}`), + component: 'RunnerRegistrationTokenReset', }); }); it('On validation error, error message is shown', async () => { + const mockErrorMsg = 'User not allowed!'; + const mockErrorMsg2 = 'Type is not valid!'; + runnersRegistrationTokenResetMutationHandler.mockResolvedValue({ data: { runnersRegistrationTokenReset: { token: null, - errors: ['Token reset failed'], + errors: [mockErrorMsg, mockErrorMsg2], }, }, }); @@ -139,7 +148,11 @@ describe('RunnerRegistrationTokenReset', () => { await waitForPromises(); expect(createFlash).toHaveBeenLastCalledWith({ - message: 'Token reset failed', + message: `${mockErrorMsg} ${mockErrorMsg2}`, + }); + expect(captureException).toHaveBeenCalledWith({ + error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`), + component: 'RunnerRegistrationTokenReset', }); }); }); diff --git a/spec/frontend/runner/components/runner_tag_spec.js b/spec/frontend/runner/components/runner_tag_spec.js new file mode 100644 index 00000000000..dda318f8153 --- /dev/null +++ b/spec/frontend/runner/components/runner_tag_spec.js @@ -0,0 +1,45 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerTag from '~/runner/components/runner_tag.vue'; + +describe('RunnerTag', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(RunnerTag, { + propsData: { + tag: 'tag1', + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('Displays tag text', () => { + expect(wrapper.text()).toBe('tag1'); + }); + + it('Displays tags with correct style', () => { + expect(findBadge().props()).toMatchObject({ + size: 'md', + variant: 'info', + }); + }); + + it('Displays tags with small size', () => { + createComponent({ + props: { size: 'sm' }, + }); + + expect(findBadge().props('size')).toBe('sm'); + }); +}); diff --git a/spec/frontend/runner/components/runner_tags_spec.js b/spec/frontend/runner/components/runner_tags_spec.js index 7bb3f65e4ba..b6487ade0d6 100644 --- a/spec/frontend/runner/components/runner_tags_spec.js +++ b/spec/frontend/runner/components/runner_tags_spec.js @@ -1,5 +1,5 @@ import { GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import RunnerTags from '~/runner/components/runner_tags.vue'; describe('RunnerTags', () => { @@ -9,7 +9,7 @@ describe('RunnerTags', () => { const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i); const createComponent = ({ props = {} } = {}) => { - wrapper = shallowMount(RunnerTags, { + wrapper = mount(RunnerTags, { propsData: { tagList: ['tag1', 'tag2'], ...props, @@ -45,14 +45,6 @@ describe('RunnerTags', () => { expect(findBadge().props('size')).toBe('sm'); }); - it('Displays tags with a variant', () => { - createComponent({ - props: { variant: 'warning' }, - }); - - expect(findBadge().props('variant')).toBe('warning'); - }); - it('Is empty when there are no tags', () => { createComponent({ props: { tagList: null }, diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js index 6333ed7118a..15029d7a911 100644 --- a/spec/frontend/runner/components/runner_update_form_spec.js +++ b/spec/frontend/runner/components/runner_update_form_spec.js @@ -15,9 +15,11 @@ import { ACCESS_LEVEL_NOT_PROTECTED, } from '~/runner/constants'; import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql'; +import { captureException } from '~/runner/sentry_utils'; import { runnerData } from '../mock_data'; jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); const mockRunner = runnerData.data.runner; @@ -205,13 +207,11 @@ describe('RunnerUpdateForm', () => { }); it.each` - value | submitted - ${''} | ${{ tagList: [] }} - ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} - ${'with spaces'} | ${{ tagList: ['with spaces'] }} - ${',,,,, commas'} | ${{ tagList: ['commas'] }} - ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }} - ${' trimmed , trimmed2 '} | ${{ tagList: ['trimmed', 'trimmed2'] }} + value | submitted + ${''} | ${{ tagList: [] }} + ${'tag1, tag2'} | ${{ tagList: ['tag1', 'tag2'] }} + ${'with spaces'} | ${{ tagList: ['with spaces'] }} + ${'more ,,,,, commas'} | ${{ tagList: ['more', 'commas'] }} `('Field updates runner\'s tags for "$value"', async ({ value, submitted }) => { const runner = { ...mockRunner, tagList: ['tag1'] }; createComponent({ props: { runner } }); @@ -232,22 +232,30 @@ describe('RunnerUpdateForm', () => { }); it('On network error, error message is shown', async () => { - runnerUpdateHandler.mockRejectedValue(new Error('Something went wrong')); + const mockErrorMsg = 'Update error!'; + + runnerUpdateHandler.mockRejectedValue(new Error(mockErrorMsg)); await submitFormAndWait(); expect(createFlash).toHaveBeenLastCalledWith({ - message: 'Network error: Something went wrong', + message: `Network error: ${mockErrorMsg}`, + }); + expect(captureException).toHaveBeenCalledWith({ + component: 'RunnerUpdateForm', + error: new Error(`Network error: ${mockErrorMsg}`), }); expect(findSubmitDisabledAttr()).toBeUndefined(); }); - it('On validation error, error message is shown', async () => { + it('On validation error, error message is shown and it is not sent to sentry', async () => { + const mockErrorMsg = 'Invalid value!'; + runnerUpdateHandler.mockResolvedValue({ data: { runnerUpdate: { runner: mockRunner, - errors: ['A value is invalid'], + errors: [mockErrorMsg], }, }, }); @@ -255,8 +263,9 @@ describe('RunnerUpdateForm', () => { await submitFormAndWait(); expect(createFlash).toHaveBeenLastCalledWith({ - message: 'A value is invalid', + message: mockErrorMsg, }); + expect(captureException).not.toHaveBeenCalled(); expect(findSubmitDisabledAttr()).toBeUndefined(); }); }); diff --git a/spec/frontend/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/runner/components/search_tokens/tag_token_spec.js new file mode 100644 index 00000000000..52b87542243 --- /dev/null +++ b/spec/frontend/runner/components/search_tokens/tag_token_spec.js @@ -0,0 +1,188 @@ +import { GlFilteredSearchSuggestion, GlLoadingIcon, GlToken } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; + +import TagToken, { TAG_SUGGESTIONS_PATH } from '~/runner/components/search_tokens/tag_token.vue'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { getRecentlyUsedSuggestions } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; + +jest.mock('~/flash'); + +jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ + ...jest.requireActual('~/vue_shared/components/filtered_search_bar/filtered_search_utils'), + getRecentlyUsedSuggestions: jest.fn(), +})); + +const mockStorageKey = 'stored-recent-tags'; + +const mockTags = [ + { id: 1, name: 'linux' }, + { id: 2, name: 'windows' }, + { id: 3, name: 'mac' }, +]; + +const mockTagsFiltered = [mockTags[0]]; + +const mockSearchTerm = mockTags[0].name; + +const GlFilteredSearchTokenStub = { + template: `<div> + <slot name="view-token"></slot> + <slot name="suggestions"></slot> + </div>`, +}; + +const mockTagTokenConfig = { + icon: 'tag', + title: 'Tags', + type: 'tag', + token: TagToken, + recentTokenValuesStorageKey: mockStorageKey, + operators: OPERATOR_IS_ONLY, +}; + +describe('TagToken', () => { + let mock; + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(TagToken, { + propsData: { + config: mockTagTokenConfig, + value: { data: '' }, + active: false, + ...props, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + filteredSearchSuggestionListInstance: { + register: jest.fn(), + unregister: jest.fn(), + }, + }, + stubs: { + GlFilteredSearchToken: GlFilteredSearchTokenStub, + }, + }); + }; + + const findGlFilteredSearchSuggestions = () => + wrapper.findAllComponents(GlFilteredSearchSuggestion); + const findGlFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchTokenStub); + const findToken = () => wrapper.findComponent(GlToken); + const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + beforeEach(async () => { + mock = new MockAdapter(axios); + + mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(200, mockTags); + mock + .onGet(TAG_SUGGESTIONS_PATH, { params: { search: mockSearchTerm } }) + .reply(200, mockTagsFiltered); + + getRecentlyUsedSuggestions.mockReturnValue([]); + + createComponent(); + await waitForPromises(); + }); + + afterEach(() => { + getRecentlyUsedSuggestions.mockReset(); + wrapper.destroy(); + }); + + describe('when the tags token is displayed', () => { + it('requests tags suggestions', () => { + expect(mock.history.get[0].params).toEqual({ search: '' }); + }); + + it('displays tags suggestions', () => { + mockTags.forEach(({ name }, i) => { + expect(findGlFilteredSearchSuggestions().at(i).text()).toBe(name); + }); + }); + }); + + describe('when suggestions are stored', () => { + const storedSuggestions = [{ id: 4, value: 'docker', text: 'docker' }]; + + beforeEach(async () => { + getRecentlyUsedSuggestions.mockReturnValue(storedSuggestions); + + createComponent(); + await waitForPromises(); + }); + + it('suggestions are loaded from a correct key', () => { + expect(getRecentlyUsedSuggestions).toHaveBeenCalledWith(mockStorageKey); + }); + + it('displays stored tags suggestions', () => { + expect(findGlFilteredSearchSuggestions()).toHaveLength( + mockTags.length + storedSuggestions.length, + ); + + expect(findGlFilteredSearchSuggestions().at(0).text()).toBe(storedSuggestions[0].text); + }); + }); + + describe('when the users filters suggestions', () => { + beforeEach(async () => { + findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm }); + + jest.runAllTimers(); + }); + + it('requests filtered tags suggestions', async () => { + await waitForPromises(); + + expect(mock.history.get[1].params).toEqual({ search: mockSearchTerm }); + }); + + it('shows the loading icon', async () => { + await nextTick(); + + expect(findGlLoadingIcon().exists()).toBe(true); + }); + + it('displays filtered tags suggestions', async () => { + await waitForPromises(); + + expect(findGlFilteredSearchSuggestions()).toHaveLength(mockTagsFiltered.length); + + expect(findGlFilteredSearchSuggestions().at(0).text()).toBe(mockTagsFiltered[0].name); + }); + }); + + describe('when suggestions cannot be loaded', () => { + beforeEach(async () => { + mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(500); + + createComponent(); + await waitForPromises(); + }); + + it('error is shown', async () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ message: expect.any(String) }); + }); + }); + + describe('when the user selects a value', () => { + beforeEach(async () => { + createComponent({ value: { data: mockTags[0].name } }); + findGlFilteredSearchToken().vm.$emit('select'); + + await waitForPromises(); + }); + + it('selected tag is displayed', async () => { + expect(findToken().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/runner/runner_detail/runner_details_app_spec.js b/spec/frontend/runner/runner_detail/runner_details_app_spec.js index d0bd701458d..1a1428e8cb1 100644 --- a/spec/frontend/runner/runner_detail/runner_details_app_spec.js +++ b/spec/frontend/runner/runner_detail/runner_details_app_spec.js @@ -2,14 +2,19 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RunnerTypeBadge from '~/runner/components/runner_type_badge.vue'; import getRunnerQuery from '~/runner/graphql/get_runner.query.graphql'; import RunnerDetailsApp from '~/runner/runner_details/runner_details_app.vue'; +import { captureException } from '~/runner/sentry_utils'; import { runnerData } from '../mock_data'; +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); + const mockRunnerGraphqlId = runnerData.data.runner.id; const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; @@ -23,11 +28,9 @@ describe('RunnerDetailsApp', () => { const findRunnerTypeBadge = () => wrapper.findComponent(RunnerTypeBadge); const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { - const handlers = [[getRunnerQuery, mockRunnerQuery]]; - wrapper = mountFn(RunnerDetailsApp, { localVue, - apolloProvider: createMockApollo(handlers), + apolloProvider: createMockApollo([[getRunnerQuery, mockRunnerQuery]]), propsData: { runnerId: mockRunnerId, ...props, @@ -63,4 +66,22 @@ describe('RunnerDetailsApp', () => { expect(findRunnerTypeBadge().text()).toBe('shared'); }); + + describe('When there is an error', () => { + beforeEach(async () => { + mockRunnerQuery = jest.fn().mockRejectedValueOnce(new Error('Error!')); + await createComponentWithApollo(); + }); + + it('error is reported to sentry', async () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Network error: Error!'), + component: 'RunnerDetailsApp', + }); + }); + + it('error is shown to the user', async () => { + expect(createFlash).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/runner/runner_detail/runner_update_form_utils_spec.js b/spec/frontend/runner/runner_detail/runner_update_form_utils_spec.js new file mode 100644 index 00000000000..510b4e604ac --- /dev/null +++ b/spec/frontend/runner/runner_detail/runner_update_form_utils_spec.js @@ -0,0 +1,96 @@ +import { ACCESS_LEVEL_NOT_PROTECTED } from '~/runner/constants'; +import { + modelToUpdateMutationVariables, + runnerToModel, +} from '~/runner/runner_details/runner_update_form_utils'; + +const mockId = 'gid://gitlab/Ci::Runner/1'; +const mockDescription = 'Runner Desc.'; + +const mockRunner = { + id: mockId, + description: mockDescription, + maximumTimeout: 100, + accessLevel: ACCESS_LEVEL_NOT_PROTECTED, + active: true, + locked: true, + runUntagged: true, + tagList: ['tag-1', 'tag-2'], +}; + +const mockModel = { + ...mockRunner, + tagList: 'tag-1, tag-2', +}; + +describe('~/runner/runner_details/runner_update_form_utils', () => { + describe('runnerToModel', () => { + it('collects all model data', () => { + expect(runnerToModel(mockRunner)).toEqual(mockModel); + }); + + it('does not collect other data', () => { + const model = runnerToModel({ + ...mockRunner, + unrelated: 'unrelatedValue', + }); + + expect(model.unrelated).toEqual(undefined); + }); + + it('tag list defaults to an empty string', () => { + const model = runnerToModel({ + ...mockRunner, + tagList: undefined, + }); + + expect(model.tagList).toEqual(''); + }); + }); + + describe('modelToUpdateMutationVariables', () => { + it('collects all model data', () => { + expect(modelToUpdateMutationVariables(mockModel)).toEqual({ + input: { + ...mockRunner, + }, + }); + }); + + it('collects a nullable timeout from the model', () => { + const variables = modelToUpdateMutationVariables({ + ...mockModel, + maximumTimeout: '', + }); + + expect(variables).toEqual({ + input: { + ...mockRunner, + maximumTimeout: null, + }, + }); + }); + + it.each` + tagList | tagListInput + ${''} | ${[]} + ${'tag1, tag2'} | ${['tag1', 'tag2']} + ${'with spaces'} | ${['with spaces']} + ${',,,,, commas'} | ${['commas']} + ${'more ,,,,, commas'} | ${['more', 'commas']} + ${' trimmed , trimmed2 '} | ${['trimmed', 'trimmed2']} + `('collect tags separated by commas for "$value"', ({ tagList, tagListInput }) => { + const variables = modelToUpdateMutationVariables({ + ...mockModel, + tagList, + }); + + expect(variables).toEqual({ + input: { + ...mockRunner, + tagList: tagListInput, + }, + }); + }); + }); +}); diff --git a/spec/frontend/runner/runner_list/runner_list_app_spec.js b/spec/frontend/runner/runner_list/runner_list_app_spec.js index dd913df7143..54b7d1f1bdb 100644 --- a/spec/frontend/runner/runner_list/runner_list_app_spec.js +++ b/spec/frontend/runner/runner_list/runner_list_app_spec.js @@ -1,9 +1,9 @@ -import * as Sentry from '@sentry/browser'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; @@ -23,13 +23,15 @@ import { } from '~/runner/constants'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import RunnerListApp from '~/runner/runner_list/runner_list_app.vue'; +import { captureException } from '~/runner/sentry_utils'; import { runnersData, runnersDataPaginated } from '../mock_data'; const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN'; const mockActiveRunnersCount = 2; -jest.mock('@sentry/browser'); +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), updateHistory: jest.fn(), @@ -64,7 +66,7 @@ describe('RunnerListApp', () => { }; const setQuery = (query) => { - window.location.href = `${TEST_HOST}/admin/runners/${query}`; + window.location.href = `${TEST_HOST}/admin/runners?${query}`; window.location.search = query; }; @@ -80,11 +82,6 @@ describe('RunnerListApp', () => { beforeEach(async () => { setQuery(''); - Sentry.withScope.mockImplementation((fn) => { - const scope = { setTag: jest.fn() }; - fn(scope); - }); - mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); createComponentWithApollo(); await waitForPromises(); @@ -119,7 +116,7 @@ describe('RunnerListApp', () => { describe('when a filter is preselected', () => { beforeEach(async () => { - window.location.search = `?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`; + setQuery(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); createComponentWithApollo(); await waitForPromises(); @@ -130,6 +127,7 @@ describe('RunnerListApp', () => { filters: [ { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, + { type: 'tag', value: { data: 'tag1', operator: '=' } }, ], sort: 'CREATED_DESC', pagination: { page: 1 }, @@ -140,6 +138,7 @@ describe('RunnerListApp', () => { expect(mockRunnersQuery).toHaveBeenLastCalledWith({ status: STATUS_ACTIVE, type: INSTANCE_TYPE, + tagList: ['tag1'], sort: DEFAULT_SORT, first: RUNNER_PAGE_SIZE, }); @@ -157,7 +156,7 @@ describe('RunnerListApp', () => { it('updates the browser url', () => { expect(updateHistory).toHaveBeenLastCalledWith({ title: expect.any(String), - url: 'http://test.host/admin/runners/?status[]=ACTIVE&sort=CREATED_ASC', + url: 'http://test.host/admin/runners?status[]=ACTIVE&sort=CREATED_ASC', }); }); @@ -189,15 +188,21 @@ describe('RunnerListApp', () => { describe('when runners query fails', () => { beforeEach(async () => { - mockRunnersQuery = jest.fn().mockRejectedValue(new Error()); + mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); createComponentWithApollo(); await waitForPromises(); }); it('error is reported to sentry', async () => { - expect(Sentry.withScope).toHaveBeenCalled(); - expect(Sentry.captureException).toHaveBeenCalled(); + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Network error: Error!'), + component: 'RunnerListApp', + }); + }); + + it('error is shown to the user', async () => { + expect(createFlash).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/runner/runner_list/runner_search_utils_spec.js b/spec/frontend/runner/runner_list/runner_search_utils_spec.js index a1f33e9c880..e7969676549 100644 --- a/spec/frontend/runner/runner_list/runner_search_utils_spec.js +++ b/spec/frontend/runner/runner_list/runner_search_utils_spec.js @@ -99,6 +99,37 @@ describe('search_params.js', () => { }, }, { + name: 'a tag', + urlQuery: '?tag[]=tag-1', + search: { + filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + tagList: ['tag-1'], + first: 20, + sort: 'CREATED_DESC', + }, + }, + { + name: 'two tags', + urlQuery: '?tag[]=tag-1&tag[]=tag-2', + search: { + filters: [ + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: 'tag', value: { data: 'tag-2', operator: '=' } }, + ], + pagination: { page: 1 }, + sort: 'CREATED_DESC', + }, + graphqlVariables: { + tagList: ['tag-1', 'tag-2'], + first: 20, + sort: 'CREATED_DESC', + }, + }, + { name: 'the next page', urlQuery: '?page=2&after=AFTER_CURSOR', search: { filters: [], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC' }, @@ -115,14 +146,15 @@ describe('search_params.js', () => { graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE }, }, { - name: - 'the next page filtered by multiple status, a single instance type and a non default sort', + name: 'the next page filtered by a status, an instance type, tags and a non default sort', urlQuery: - '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', + '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', search: { filters: [ { type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }, + { type: 'tag', value: { data: 'tag-1', operator: '=' } }, + { type: 'tag', value: { data: 'tag-2', operator: '=' } }, ], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_ASC', @@ -130,6 +162,7 @@ describe('search_params.js', () => { graphqlVariables: { status: 'ACTIVE', type: 'INSTANCE_TYPE', + tagList: ['tag-1', 'tag-2'], sort: 'CREATED_ASC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE, diff --git a/spec/frontend/runner/sentry_utils_spec.js b/spec/frontend/runner/sentry_utils_spec.js new file mode 100644 index 00000000000..b61eb63961e --- /dev/null +++ b/spec/frontend/runner/sentry_utils_spec.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; +import { captureException } from '~/runner/sentry_utils'; + +jest.mock('@sentry/browser'); + +describe('~/runner/sentry_utils', () => { + let mockSetTag; + + beforeEach(async () => { + mockSetTag = jest.fn(); + + Sentry.withScope.mockImplementation((fn) => { + const scope = { setTag: mockSetTag }; + fn(scope); + }); + }); + + describe('captureException', () => { + const mockError = new Error('Something went wrong!'); + + it('error is reported to sentry', () => { + captureException({ error: mockError }); + + expect(Sentry.withScope).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + }); + + it('error is reported to sentry with a component name', () => { + const mockComponentName = 'MyComponent'; + + captureException({ error: mockError, component: mockComponentName }); + + expect(Sentry.withScope).toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + + expect(mockSetTag).toHaveBeenCalledWith('vue_component', mockComponentName); + }); + }); +}); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index fbe01f372b0..24ce45e8a09 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -1,3 +1,6 @@ +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; +import * as types from '~/search/store/mutation_types'; + export const MOCK_QUERY = { scope: 'issues', state: 'all', @@ -6,45 +9,45 @@ export const MOCK_QUERY = { }; export const MOCK_GROUP = { + id: 1, name: 'test group', full_name: 'full name / test group', - id: 1, }; export const MOCK_GROUPS = [ { + id: 1, avatar_url: null, name: 'test group', full_name: 'full name / test group', - id: 1, }, { + id: 2, avatar_url: 'https://avatar.com', name: 'test group 2', full_name: 'full name / test group 2', - id: 2, }, ]; export const MOCK_PROJECT = { + id: 1, name: 'test project', namespace: MOCK_GROUP, nameWithNamespace: 'test group / test project', - id: 1, }; export const MOCK_PROJECTS = [ { + id: 1, name: 'test project', namespace: MOCK_GROUP, name_with_namespace: 'test group / test project', - id: 1, }, { + id: 2, name: 'test project 2', namespace: MOCK_GROUP, name_with_namespace: 'test group / test project 2', - id: 2, }, ]; @@ -63,3 +66,41 @@ export const MOCK_SORT_OPTIONS = [ }, }, ]; + +export const MOCK_LS_KEY = 'mock-ls-key'; + +export const MOCK_INFLATED_DATA = [ + { id: 1, name: 'test 1' }, + { id: 2, name: 'test 2' }, +]; + +export const FRESH_STORED_DATA = [ + { id: 1, name: 'test 1', frequency: 1 }, + { id: 2, name: 'test 2', frequency: 2 }, +]; + +export const STALE_STORED_DATA = [ + { id: 1, name: 'blah 1', frequency: 1 }, + { id: 2, name: 'blah 2', frequency: 2 }, +]; + +export const MOCK_FRESH_DATA_RES = { name: 'fresh' }; + +export const PROMISE_ALL_EXPECTED_MUTATIONS = { + initGroups: { + type: types.LOAD_FREQUENT_ITEMS, + payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA }, + }, + resGroups: { + type: types.LOAD_FREQUENT_ITEMS, + payload: { key: GROUPS_LOCAL_STORAGE_KEY, data: [MOCK_FRESH_DATA_RES, MOCK_FRESH_DATA_RES] }, + }, + initProjects: { + type: types.LOAD_FREQUENT_ITEMS, + payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: FRESH_STORED_DATA }, + }, + resProjects: { + type: types.LOAD_FREQUENT_ITEMS, + payload: { key: PROJECTS_LOCAL_STORAGE_KEY, data: [MOCK_FRESH_DATA_RES, MOCK_FRESH_DATA_RES] }, + }, +}; diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js index 634661c5843..3755f8ffae7 100644 --- a/spec/frontend/search/store/actions_spec.js +++ b/spec/frontend/search/store/actions_spec.js @@ -5,9 +5,20 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; import * as actions from '~/search/store/actions'; +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; import * as types from '~/search/store/mutation_types'; import createState from '~/search/store/state'; -import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECT, MOCK_PROJECTS } from '../mock_data'; +import * as storeUtils from '~/search/store/utils'; +import { + MOCK_QUERY, + MOCK_GROUPS, + MOCK_PROJECT, + MOCK_PROJECTS, + MOCK_GROUP, + FRESH_STORED_DATA, + MOCK_FRESH_DATA_RES, + PROMISE_ALL_EXPECTED_MUTATIONS, +} from '../mock_data'; jest.mock('~/flash'); jest.mock('~/lib/utils/url_utility', () => ({ @@ -56,6 +67,46 @@ describe('Global Search Store Actions', () => { }); }); + describe.each` + action | axiosMock | type | expectedMutations | flashCallCount | lsKey + ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initGroups, PROMISE_ALL_EXPECTED_MUTATIONS.resGroups]} | ${0} | ${GROUPS_LOCAL_STORAGE_KEY} + ${actions.loadFrequentGroups} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initGroups]} | ${1} | ${GROUPS_LOCAL_STORAGE_KEY} + ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 200 }} | ${'success'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initProjects, PROMISE_ALL_EXPECTED_MUTATIONS.resProjects]} | ${0} | ${PROJECTS_LOCAL_STORAGE_KEY} + ${actions.loadFrequentProjects} | ${{ method: 'onGet', code: 500 }} | ${'error'} | ${[PROMISE_ALL_EXPECTED_MUTATIONS.initProjects]} | ${1} | ${PROJECTS_LOCAL_STORAGE_KEY} + `( + 'Promise.all calls', + ({ action, axiosMock, type, expectedMutations, flashCallCount, lsKey }) => { + describe(action.name, () => { + describe(`on ${type}`, () => { + beforeEach(() => { + storeUtils.loadDataFromLS = jest.fn().mockReturnValue(FRESH_STORED_DATA); + mock[axiosMock.method]().reply(axiosMock.code, MOCK_FRESH_DATA_RES); + }); + + it(`should dispatch the correct mutations`, () => { + return testAction({ action, state, expectedMutations }).then(() => { + expect(storeUtils.loadDataFromLS).toHaveBeenCalledWith(lsKey); + flashCallback(flashCallCount); + }); + }); + }); + }); + }, + ); + + describe('getGroupsData', () => { + const mockCommit = () => {}; + beforeEach(() => { + jest.spyOn(Api, 'groups').mockResolvedValue(MOCK_GROUPS); + }); + + it('calls Api.groups with order_by set to similarity', () => { + actions.fetchGroups({ commit: mockCommit }, 'test'); + + expect(Api.groups).toHaveBeenCalledWith('test', { order_by: 'similarity' }); + }); + }); + describe('getProjectsData', () => { const mockCommit = () => {}; beforeEach(() => { @@ -64,10 +115,19 @@ describe('Global Search Store Actions', () => { }); describe('when groupId is set', () => { - it('calls Api.groupProjects', () => { + it('calls Api.groupProjects with expected parameters', () => { actions.fetchProjects({ commit: mockCommit, state }); - expect(Api.groupProjects).toHaveBeenCalled(); + expect(Api.groupProjects).toHaveBeenCalledWith( + state.query.group_id, + state.query.search, + { + order_by: 'similarity', + include_subgroups: true, + with_shared: false, + }, + expect.any(Function), + ); expect(Api.projects).not.toHaveBeenCalled(); }); }); @@ -121,4 +181,44 @@ describe('Global Search Store Actions', () => { }); }); }); + + describe('setFrequentGroup', () => { + beforeEach(() => { + storeUtils.setFrequentItemToLS = jest.fn(); + }); + + it(`calls setFrequentItemToLS with ${GROUPS_LOCAL_STORAGE_KEY} and item data`, async () => { + await testAction({ + action: actions.setFrequentGroup, + payload: MOCK_GROUP, + state, + }); + + expect(storeUtils.setFrequentItemToLS).toHaveBeenCalledWith( + GROUPS_LOCAL_STORAGE_KEY, + state.frequentItems, + MOCK_GROUP, + ); + }); + }); + + describe('setFrequentProject', () => { + beforeEach(() => { + storeUtils.setFrequentItemToLS = jest.fn(); + }); + + it(`calls setFrequentItemToLS with ${PROJECTS_LOCAL_STORAGE_KEY} and item data`, async () => { + await testAction({ + action: actions.setFrequentProject, + payload: MOCK_PROJECT, + state, + }); + + expect(storeUtils.setFrequentItemToLS).toHaveBeenCalledWith( + PROJECTS_LOCAL_STORAGE_KEY, + state.frequentItems, + MOCK_PROJECT, + ); + }); + }); }); diff --git a/spec/frontend/search/store/getters_spec.js b/spec/frontend/search/store/getters_spec.js new file mode 100644 index 00000000000..081e6a986eb --- /dev/null +++ b/spec/frontend/search/store/getters_spec.js @@ -0,0 +1,32 @@ +import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; +import * as getters from '~/search/store/getters'; +import createState from '~/search/store/state'; +import { MOCK_QUERY, MOCK_GROUPS, MOCK_PROJECTS } from '../mock_data'; + +describe('Global Search Store Getters', () => { + let state; + + beforeEach(() => { + state = createState({ query: MOCK_QUERY }); + }); + + describe('frequentGroups', () => { + beforeEach(() => { + state.frequentItems[GROUPS_LOCAL_STORAGE_KEY] = MOCK_GROUPS; + }); + + it('returns the correct data', () => { + expect(getters.frequentGroups(state)).toStrictEqual(MOCK_GROUPS); + }); + }); + + describe('frequentProjects', () => { + beforeEach(() => { + state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY] = MOCK_PROJECTS; + }); + + it('returns the correct data', () => { + expect(getters.frequentProjects(state)).toStrictEqual(MOCK_PROJECTS); + }); + }); +}); diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js index df94ba40ff2..a60718a972d 100644 --- a/spec/frontend/search/store/mutations_spec.js +++ b/spec/frontend/search/store/mutations_spec.js @@ -71,4 +71,13 @@ describe('Global Search Store Mutations', () => { expect(state.query[payload.key]).toBe(payload.value); }); }); + + describe('LOAD_FREQUENT_ITEMS', () => { + it('sets frequentItems[key] to data', () => { + const payload = { key: 'test-key', data: [1, 2, 3] }; + mutations[types.LOAD_FREQUENT_ITEMS](state, payload); + + expect(state.frequentItems[payload.key]).toStrictEqual(payload.data); + }); + }); }); diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js new file mode 100644 index 00000000000..5055fa2cc3d --- /dev/null +++ b/spec/frontend/search/store/utils_spec.js @@ -0,0 +1,197 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { MAX_FREQUENCY } from '~/search/store/constants'; +import { loadDataFromLS, setFrequentItemToLS, mergeById } from '~/search/store/utils'; +import { + MOCK_LS_KEY, + MOCK_GROUPS, + MOCK_INFLATED_DATA, + FRESH_STORED_DATA, + STALE_STORED_DATA, +} from '../mock_data'; + +const PREV_TIME = new Date().getTime() - 1; +const CURRENT_TIME = new Date().getTime(); + +useLocalStorageSpy(); +jest.mock('~/lib/utils/accessor', () => ({ + isLocalStorageAccessSafe: jest.fn().mockReturnValue(true), +})); + +describe('Global Search Store Utils', () => { + afterEach(() => { + localStorage.clear(); + }); + + describe('loadDataFromLS', () => { + let res; + + describe('with valid data', () => { + beforeEach(() => { + localStorage.setItem(MOCK_LS_KEY, JSON.stringify(MOCK_GROUPS)); + res = loadDataFromLS(MOCK_LS_KEY); + }); + + it('returns parsed array', () => { + expect(res).toStrictEqual(MOCK_GROUPS); + }); + }); + + describe('with invalid data', () => { + beforeEach(() => { + localStorage.setItem(MOCK_LS_KEY, '[}'); + res = loadDataFromLS(MOCK_LS_KEY); + }); + + it('wipes local storage and returns an empty array', () => { + expect(localStorage.removeItem).toHaveBeenCalledWith(MOCK_LS_KEY); + expect(res).toStrictEqual([]); + }); + }); + }); + + describe('setFrequentItemToLS', () => { + const frequentItems = {}; + + describe('with existing data', () => { + describe(`when frequency is less than ${MAX_FREQUENCY}`, () => { + beforeEach(() => { + frequentItems[MOCK_LS_KEY] = [{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: PREV_TIME }]; + setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); + }); + + it('adds 1 to the frequency, tracks lastUsed, and calls localStorage.setItem', () => { + expect(localStorage.setItem).toHaveBeenCalledWith( + MOCK_LS_KEY, + JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 2, lastUsed: CURRENT_TIME }]), + ); + }); + }); + + describe(`when frequency is equal to ${MAX_FREQUENCY}`, () => { + beforeEach(() => { + frequentItems[MOCK_LS_KEY] = [ + { ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: PREV_TIME }, + ]; + setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); + }); + + it(`does not further increase frequency past ${MAX_FREQUENCY}, tracks lastUsed, and calls localStorage.setItem`, () => { + expect(localStorage.setItem).toHaveBeenCalledWith( + MOCK_LS_KEY, + JSON.stringify([ + { ...MOCK_GROUPS[0], frequency: MAX_FREQUENCY, lastUsed: CURRENT_TIME }, + ]), + ); + }); + }); + }); + + describe('with no existing data', () => { + beforeEach(() => { + frequentItems[MOCK_LS_KEY] = []; + setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); + }); + + it('adds a new entry with frequency 1, tracks lastUsed, and calls localStorage.setItem', () => { + expect(localStorage.setItem).toHaveBeenCalledWith( + MOCK_LS_KEY, + JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]), + ); + }); + }); + + describe('with multiple entries', () => { + beforeEach(() => { + frequentItems[MOCK_LS_KEY] = [ + { id: 1, frequency: 2, lastUsed: PREV_TIME }, + { id: 2, frequency: 1, lastUsed: PREV_TIME }, + { id: 3, frequency: 1, lastUsed: PREV_TIME }, + ]; + setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 3 }); + }); + + it('sorts the array by most frequent and lastUsed', () => { + expect(localStorage.setItem).toHaveBeenCalledWith( + MOCK_LS_KEY, + JSON.stringify([ + { id: 3, frequency: 2, lastUsed: CURRENT_TIME }, + { id: 1, frequency: 2, lastUsed: PREV_TIME }, + { id: 2, frequency: 1, lastUsed: PREV_TIME }, + ]), + ); + }); + }); + + describe('with max entries', () => { + beforeEach(() => { + frequentItems[MOCK_LS_KEY] = [ + { id: 1, frequency: 5, lastUsed: PREV_TIME }, + { id: 2, frequency: 4, lastUsed: PREV_TIME }, + { id: 3, frequency: 3, lastUsed: PREV_TIME }, + { id: 4, frequency: 2, lastUsed: PREV_TIME }, + { id: 5, frequency: 1, lastUsed: PREV_TIME }, + ]; + setFrequentItemToLS(MOCK_LS_KEY, frequentItems, { id: 6 }); + }); + + it('removes the last item in the array', () => { + expect(localStorage.setItem).toHaveBeenCalledWith( + MOCK_LS_KEY, + JSON.stringify([ + { id: 1, frequency: 5, lastUsed: PREV_TIME }, + { id: 2, frequency: 4, lastUsed: PREV_TIME }, + { id: 3, frequency: 3, lastUsed: PREV_TIME }, + { id: 4, frequency: 2, lastUsed: PREV_TIME }, + { id: 6, frequency: 1, lastUsed: CURRENT_TIME }, + ]), + ); + }); + }); + + describe('with null data loaded in', () => { + beforeEach(() => { + frequentItems[MOCK_LS_KEY] = null; + setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_GROUPS[0]); + }); + + it('wipes local storage', () => { + expect(localStorage.removeItem).toHaveBeenCalledWith(MOCK_LS_KEY); + }); + }); + + describe('with additional data', () => { + beforeEach(() => { + const MOCK_ADDITIONAL_DATA_GROUP = { ...MOCK_GROUPS[0], extraData: 'test' }; + frequentItems[MOCK_LS_KEY] = []; + setFrequentItemToLS(MOCK_LS_KEY, frequentItems, MOCK_ADDITIONAL_DATA_GROUP); + }); + + it('parses out extra data for LS', () => { + expect(localStorage.setItem).toHaveBeenCalledWith( + MOCK_LS_KEY, + JSON.stringify([{ ...MOCK_GROUPS[0], frequency: 1, lastUsed: CURRENT_TIME }]), + ); + }); + }); + }); + + describe.each` + description | inflatedData | storedData | response + ${'identical'} | ${MOCK_INFLATED_DATA} | ${FRESH_STORED_DATA} | ${FRESH_STORED_DATA} + ${'stale'} | ${MOCK_INFLATED_DATA} | ${STALE_STORED_DATA} | ${FRESH_STORED_DATA} + ${'empty'} | ${MOCK_INFLATED_DATA} | ${[]} | ${MOCK_INFLATED_DATA} + ${'null'} | ${MOCK_INFLATED_DATA} | ${null} | ${MOCK_INFLATED_DATA} + `('mergeById', ({ description, inflatedData, storedData, response }) => { + describe(`with ${description} storedData`, () => { + let res; + + beforeEach(() => { + res = mergeById(inflatedData, storedData); + }); + + it('prioritizes inflatedData and preserves frequency count', () => { + expect(response).toStrictEqual(res); + }); + }); + }); +}); diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js index 15b46f9c058..fbd7ad6bb57 100644 --- a/spec/frontend/search/topbar/components/group_filter_spec.js +++ b/spec/frontend/search/topbar/components/group_filter_spec.js @@ -1,13 +1,14 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { GROUPS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; import GroupFilter from '~/search/topbar/components/group_filter.vue'; import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), @@ -19,6 +20,8 @@ describe('GroupFilter', () => { const actionSpies = { fetchGroups: jest.fn(), + setFrequentGroup: jest.fn(), + loadFrequentGroups: jest.fn(), }; const defaultProps = { @@ -32,10 +35,12 @@ describe('GroupFilter', () => { ...initialState, }, actions: actionSpies, + getters: { + frequentGroups: () => [], + }, }); wrapper = shallowMount(GroupFilter, { - localVue, store, propsData: { ...defaultProps, @@ -62,12 +67,14 @@ describe('GroupFilter', () => { }); describe('events', () => { + beforeEach(() => { + createComponent(); + }); + describe('when @search is emitted', () => { const search = 'test'; beforeEach(() => { - createComponent(); - findSearchableDropdown().vm.$emit('search', search); }); @@ -77,14 +84,31 @@ describe('GroupFilter', () => { }); }); - describe('when @change is emitted', () => { + describe('when @change is emitted with Any', () => { beforeEach(() => { - createComponent(); + findSearchableDropdown().vm.$emit('change', ANY_OPTION); + }); + + it('calls setUrlParams with group null, project id null, and then calls visitUrl', () => { + expect(setUrlParams).toHaveBeenCalledWith({ + [GROUP_DATA.queryParam]: null, + [PROJECT_DATA.queryParam]: null, + }); + + expect(visitUrl).toHaveBeenCalled(); + }); + + it('does not call setFrequentGroup', () => { + expect(actionSpies.setFrequentGroup).not.toHaveBeenCalled(); + }); + }); + describe('when @change is emitted with a group', () => { + beforeEach(() => { findSearchableDropdown().vm.$emit('change', MOCK_GROUP); }); - it('calls calls setUrlParams with group id, project id null, and visitUrl', () => { + it('calls setUrlParams with group id, project id null, and then calls visitUrl', () => { expect(setUrlParams).toHaveBeenCalledWith({ [GROUP_DATA.queryParam]: MOCK_GROUP.id, [PROJECT_DATA.queryParam]: null, @@ -92,6 +116,20 @@ describe('GroupFilter', () => { expect(visitUrl).toHaveBeenCalled(); }); + + it(`calls setFrequentGroup with the group and ${GROUPS_LOCAL_STORAGE_KEY}`, () => { + expect(actionSpies.setFrequentGroup).toHaveBeenCalledWith(expect.any(Object), MOCK_GROUP); + }); + }); + + describe('when @first-open is emitted', () => { + beforeEach(() => { + findSearchableDropdown().vm.$emit('first-open'); + }); + + it('calls loadFrequentGroups', () => { + expect(actionSpies.loadFrequentGroups).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/search/topbar/components/project_filter_spec.js b/spec/frontend/search/topbar/components/project_filter_spec.js index 3bd0769b34a..63b0f882ca4 100644 --- a/spec/frontend/search/topbar/components/project_filter_spec.js +++ b/spec/frontend/search/topbar/components/project_filter_spec.js @@ -1,13 +1,14 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import Vuex from 'vuex'; import { MOCK_PROJECT, MOCK_QUERY } from 'jest/search/mock_data'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { PROJECTS_LOCAL_STORAGE_KEY } from '~/search/store/constants'; import ProjectFilter from '~/search/topbar/components/project_filter.vue'; import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants'; -const localVue = createLocalVue(); -localVue.use(Vuex); +Vue.use(Vuex); jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), @@ -19,6 +20,8 @@ describe('ProjectFilter', () => { const actionSpies = { fetchProjects: jest.fn(), + setFrequentProject: jest.fn(), + loadFrequentProjects: jest.fn(), }; const defaultProps = { @@ -32,10 +35,12 @@ describe('ProjectFilter', () => { ...initialState, }, actions: actionSpies, + getters: { + frequentProjects: () => [], + }, }); wrapper = shallowMount(ProjectFilter, { - localVue, store, propsData: { ...defaultProps, @@ -84,12 +89,16 @@ describe('ProjectFilter', () => { findSearchableDropdown().vm.$emit('change', ANY_OPTION); }); - it('calls setUrlParams with project id, not group id, then calls visitUrl', () => { + it('calls setUrlParams with null, no group id, then calls visitUrl', () => { expect(setUrlParams).toHaveBeenCalledWith({ - [PROJECT_DATA.queryParam]: ANY_OPTION.id, + [PROJECT_DATA.queryParam]: null, }); expect(visitUrl).toHaveBeenCalled(); }); + + it('does not call setFrequentProject', () => { + expect(actionSpies.setFrequentProject).not.toHaveBeenCalled(); + }); }); describe('with a Project', () => { @@ -104,6 +113,23 @@ describe('ProjectFilter', () => { }); expect(visitUrl).toHaveBeenCalled(); }); + + it(`calls setFrequentProject with the group and ${PROJECTS_LOCAL_STORAGE_KEY}`, () => { + expect(actionSpies.setFrequentProject).toHaveBeenCalledWith( + expect.any(Object), + MOCK_PROJECT, + ); + }); + }); + }); + + describe('when @first-open is emitted', () => { + beforeEach(() => { + findSearchableDropdown().vm.$emit('first-open'); + }); + + it('calls loadFrequentProjects', () => { + expect(actionSpies.loadFrequentProjects).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js index 10d779f0f90..b21cf5c6b79 100644 --- a/spec/frontend/search/topbar/components/searchable_dropdown_spec.js +++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js @@ -2,9 +2,9 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from import { shallowMount, mount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data'; import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue'; -import SearchableDropdownItem from '~/search/topbar/components/searchable_dropdown_item.vue'; import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants'; Vue.use(Vuex); @@ -29,13 +29,15 @@ describe('Global Search Searchable Dropdown', () => { }, }); - wrapper = mountFn(SearchableDropdown, { - store, - propsData: { - ...defaultProps, - ...props, - }, - }); + wrapper = extendedWrapper( + mountFn(SearchableDropdown, { + store, + propsData: { + ...defaultProps, + ...props, + }, + }), + ); }; afterEach(() => { @@ -45,10 +47,11 @@ describe('Global Search Searchable Dropdown', () => { const findGlDropdown = () => wrapper.findComponent(GlDropdown); const findGlDropdownSearch = () => findGlDropdown().findComponent(GlSearchBoxByType); const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text'); - const findSearchableDropdownItems = () => - findGlDropdown().findAllComponents(SearchableDropdownItem); + const findSearchableDropdownItems = () => wrapper.findAllByTestId('searchable-items'); + const findFrequentDropdownItems = () => wrapper.findAllByTestId('frequent-items'); const findAnyDropdownItem = () => findGlDropdown().findComponent(GlDropdownItem); - const findFirstGroupDropdownItem = () => findSearchableDropdownItems().at(0); + const findFirstSearchableDropdownItem = () => findSearchableDropdownItems().at(0); + const findFirstFrequentDropdownItem = () => findFrequentDropdownItems().at(0); const findLoader = () => wrapper.findComponent(GlSkeletonLoader); describe('template', () => { @@ -82,7 +85,7 @@ describe('Global Search Searchable Dropdown', () => { }); }); - describe('findDropdownItems', () => { + describe('Searchable Dropdown Items', () => { describe('when loading is false', () => { beforeEach(() => { createComponent({}, { items: MOCK_GROUPS }); @@ -96,7 +99,7 @@ describe('Global Search Searchable Dropdown', () => { expect(findAnyDropdownItem().exists()).toBe(true); }); - it('renders SearchableDropdownItem for each item', () => { + it('renders searchable dropdown item for each item', () => { expect(findSearchableDropdownItems()).toHaveLength(MOCK_GROUPS.length); }); }); @@ -114,12 +117,31 @@ describe('Global Search Searchable Dropdown', () => { expect(findAnyDropdownItem().exists()).toBe(true); }); - it('does not render SearchableDropdownItem', () => { + it('does not render searchable dropdown items', () => { expect(findSearchableDropdownItems()).toHaveLength(0); }); }); }); + describe.each` + searchText | frequentItems | length + ${''} | ${[]} | ${0} + ${''} | ${MOCK_GROUPS} | ${MOCK_GROUPS.length} + ${'test'} | ${[]} | ${0} + ${'test'} | ${MOCK_GROUPS} | ${0} + `('Frequent Dropdown Items', ({ searchText, frequentItems, length }) => { + describe(`when search is ${searchText} and frequentItems length is ${frequentItems.length}`, () => { + beforeEach(() => { + createComponent({}, { frequentItems }); + wrapper.setData({ searchText }); + }); + + it(`should${length ? '' : ' not'} render frequent dropdown items`, () => { + expect(findFrequentDropdownItems()).toHaveLength(length); + }); + }); + }); + describe('Dropdown Text', () => { describe('when selectedItem is any', () => { beforeEach(() => { @@ -145,7 +167,7 @@ describe('Global Search Searchable Dropdown', () => { describe('actions', () => { beforeEach(() => { - createComponent({}, { items: MOCK_GROUPS }); + createComponent({}, { items: MOCK_GROUPS, frequentItems: MOCK_GROUPS }); }); it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => { @@ -154,10 +176,41 @@ describe('Global Search Searchable Dropdown', () => { expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]); }); - it('on SearchableDropdownItem @change, the wrapper $emits change with the item', () => { - findFirstGroupDropdownItem().vm.$emit('change', MOCK_GROUPS[0]); + it('on searchable item @change, the wrapper $emits change with the item', () => { + findFirstSearchableDropdownItem().vm.$emit('change', MOCK_GROUPS[0]); + + expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); + }); + + it('on frequent item @change, the wrapper $emits change with the item', () => { + findFirstFrequentDropdownItem().vm.$emit('change', MOCK_GROUPS[0]); expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]); }); + + describe('opening the dropdown', () => { + describe('for the first time', () => { + beforeEach(() => { + findGlDropdown().vm.$emit('show'); + }); + + it('$emits @search and @first-open', () => { + expect(wrapper.emitted('search')[0]).toStrictEqual([wrapper.vm.searchText]); + expect(wrapper.emitted('first-open')[0]).toStrictEqual([]); + }); + }); + + describe('not for the first time', () => { + beforeEach(() => { + wrapper.setData({ hasBeenOpened: true }); + findGlDropdown().vm.$emit('show'); + }); + + it('$emits @search and not @first-open', () => { + expect(wrapper.emitted('search')[0]).toStrictEqual([wrapper.vm.searchText]); + expect(wrapper.emitted('first-open')).toBeUndefined(); + }); + }); + }); }); }); diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index 5aca07d59e4..c643cf6557d 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign */ import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; @@ -22,31 +21,33 @@ describe('Search autocomplete dropdown', () => { const groupName = 'Gitlab Org'; const removeBodyAttributes = () => { - const $body = $('body'); + const { body } = document; - $body.removeAttr('data-page'); - $body.removeAttr('data-project'); - $body.removeAttr('data-group'); + delete body.dataset.page; + delete body.dataset.project; + delete body.dataset.group; }; // Add required attributes to body before starting the test. // section would be dashboard|group|project - const addBodyAttributes = (section) => { - if (section == null) { - section = 'dashboard'; - } - - const $body = $('body'); + const addBodyAttributes = (section = 'dashboard') => { removeBodyAttributes(); + + const { body } = document; switch (section) { case 'dashboard': - return $body.attr('data-page', 'root:index'); + body.dataset.page = 'root:index'; + break; case 'group': - $body.attr('data-page', 'groups:show'); - return $body.data('group', 'gitlab-org'); + body.dataset.page = 'groups:show'; + body.dataset.group = 'gitlab-org'; + break; case 'project': - $body.attr('data-page', 'projects:show'); - return $body.data('project', 'gitlab-ce'); + body.dataset.page = 'projects:show'; + body.dataset.project = 'gitlab-ce'; + break; + default: + break; } }; @@ -56,34 +57,31 @@ describe('Search autocomplete dropdown', () => { // Mock `gl` object in window for dashboard specific page. App code will need it. const mockDashboardOptions = () => { - window.gl || (window.gl = {}); - return (window.gl.dashboardOptions = { + window.gl.dashboardOptions = { issuesPath: dashboardIssuesPath, mrPath: dashboardMRsPath, - }); + }; }; // Mock `gl` object in window for project specific page. App code will need it. const mockProjectOptions = () => { - window.gl || (window.gl = {}); - return (window.gl.projectOptions = { + window.gl.projectOptions = { 'gitlab-ce': { issuesPath: projectIssuesPath, mrPath: projectMRsPath, projectName, }, - }); + }; }; const mockGroupOptions = () => { - window.gl || (window.gl = {}); - return (window.gl.groupOptions = { + window.gl.groupOptions = { 'gitlab-org': { issuesPath: groupIssuesPath, mrPath: groupMRsPath, projectName: groupName, }, - }); + }; }; const assertLinks = (list, issuesPath, mrsPath) => { @@ -113,7 +111,7 @@ describe('Search autocomplete dropdown', () => { window.gon.current_username = userName; window.gl = window.gl || (window.gl = {}); - return (widget = initSearchAutocomplete({ autocompletePath })); + widget = initSearchAutocomplete({ autocompletePath }); }); afterEach(() => { diff --git a/spec/frontend/search_autocomplete_utils_spec.js b/spec/frontend/search_autocomplete_utils_spec.js new file mode 100644 index 00000000000..4fdec717e93 --- /dev/null +++ b/spec/frontend/search_autocomplete_utils_spec.js @@ -0,0 +1,114 @@ +import { + isInGroupsPage, + isInProjectPage, + getGroupSlug, + getProjectSlug, +} from '~/search_autocomplete_utils'; + +describe('search_autocomplete_utils', () => { + let originalBody; + + beforeEach(() => { + originalBody = document.body; + document.body = document.createElement('body'); + }); + + afterEach(() => { + document.body = originalBody; + }); + + describe('isInGroupsPage', () => { + it.each` + page | result + ${'groups:index'} | ${true} + ${'groups:show'} | ${true} + ${'projects:show'} | ${false} + `(`returns $result in for page $page`, ({ page, result }) => { + document.body.dataset.page = page; + + expect(isInGroupsPage()).toBe(result); + }); + }); + + describe('isInProjectPage', () => { + it.each` + page | result + ${'projects:index'} | ${true} + ${'projects:show'} | ${true} + ${'groups:show'} | ${false} + `(`returns $result in for page $page`, ({ page, result }) => { + document.body.dataset.page = page; + + expect(isInProjectPage()).toBe(result); + }); + }); + + describe('getProjectSlug', () => { + it('returns null when no project is present or on project page', () => { + expect(getProjectSlug()).toBe(null); + }); + + it('returns null when not on project page', () => { + document.body.dataset.project = 'gitlab'; + + expect(getProjectSlug()).toBe(null); + }); + + it('returns null when project is missing', () => { + document.body.dataset.page = 'projects'; + + expect(getProjectSlug()).toBe(undefined); + }); + + it('returns project', () => { + document.body.dataset.page = 'projects'; + document.body.dataset.project = 'gitlab'; + + expect(getProjectSlug()).toBe('gitlab'); + }); + + it('returns project in edit page', () => { + document.body.dataset.page = 'projects:edit'; + document.body.dataset.project = 'gitlab'; + + expect(getProjectSlug()).toBe('gitlab'); + }); + }); + + describe('getGroupSlug', () => { + it('returns null when no group is present or on group page', () => { + expect(getGroupSlug()).toBe(null); + }); + + it('returns null when not on group page', () => { + document.body.dataset.group = 'gitlab-org'; + + expect(getGroupSlug()).toBe(null); + }); + + it('returns null when group is missing on groups page', () => { + document.body.dataset.page = 'groups'; + + expect(getGroupSlug()).toBe(undefined); + }); + + it('returns null when group is missing on project page', () => { + document.body.dataset.page = 'project'; + + expect(getGroupSlug()).toBe(null); + }); + + it.each` + page + ${'groups'} + ${'groups:edit'} + ${'projects'} + ${'projects:edit'} + `(`returns group in page $page`, ({ page }) => { + document.body.dataset.page = page; + document.body.dataset.group = 'gitlab-org'; + + expect(getGroupSlug()).toBe('gitlab-org'); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/auto_dev_ops_alert_spec.js b/spec/frontend/security_configuration/components/auto_dev_ops_alert_spec.js new file mode 100644 index 00000000000..467ae35408c --- /dev/null +++ b/spec/frontend/security_configuration/components/auto_dev_ops_alert_spec.js @@ -0,0 +1,55 @@ +import { GlAlert } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; + +const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; +const autoDevopsPath = '/enableAutoDevopsPath'; + +describe('AutoDevopsAlert component', () => { + let wrapper; + + const createComponent = () => { + wrapper = mount(AutoDevopsAlert, { + provide: { + autoDevopsHelpPagePath, + autoDevopsPath, + }, + }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains correct body text', () => { + expect(wrapper.text()).toContain('Quickly enable all'); + }); + + it('renders the link correctly', () => { + const link = wrapper.find('a'); + + expect(link.attributes('href')).toBe(autoDevopsHelpPagePath); + expect(link.text()).toBe('Auto DevOps'); + }); + + it('bubbles up dismiss events from the GlAlert', () => { + expect(wrapper.emitted('dismiss')).toBe(undefined); + + findAlert().vm.$emit('dismiss'); + + expect(wrapper.emitted('dismiss')).toEqual([[]]); + }); + + it('has a button pointing to autoDevopsPath', () => { + expect(findAlert().props()).toMatchObject({ + primaryButtonText: 'Enable Auto DevOps', + primaryButtonLink: autoDevopsPath, + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index c69e135012e..3658dbb5ef2 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; +import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { makeFeature } from './utils'; describe('FeatureCard component', () => { @@ -126,21 +127,23 @@ describe('FeatureCard component', () => { describe('actions', () => { describe.each` - context | available | configured | configurationPath | canEnableByMergeRequest | action - ${'unavailable'} | ${false} | ${false} | ${null} | ${false} | ${null} - ${'available'} | ${true} | ${false} | ${null} | ${false} | ${'guide'} - ${'configured'} | ${true} | ${true} | ${null} | ${false} | ${'guide'} - ${'available, can enable by MR'} | ${true} | ${false} | ${null} | ${true} | ${'create-mr'} - ${'configured, can enable by MR'} | ${true} | ${true} | ${null} | ${true} | ${'guide'} - ${'available with config path'} | ${true} | ${false} | ${'foo'} | ${false} | ${'enable'} - ${'available with config path, can enable by MR'} | ${true} | ${false} | ${'foo'} | ${true} | ${'enable'} - ${'configured with config path'} | ${true} | ${true} | ${'foo'} | ${false} | ${'configure'} - ${'configured with config path, can enable by MR'} | ${true} | ${true} | ${'foo'} | ${true} | ${'configure'} + context | type | available | configured | configurationPath | canEnableByMergeRequest | action + ${'unavailable'} | ${REPORT_TYPE_SAST} | ${false} | ${false} | ${null} | ${false} | ${null} + ${'available'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${false} | ${'guide'} + ${'configured'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${null} | ${false} | ${'guide'} + ${'available, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${null} | ${true} | ${'create-mr'} + ${'available, can enable by MR, unknown type'} | ${'foo'} | ${true} | ${false} | ${null} | ${true} | ${'guide'} + ${'configured, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${null} | ${true} | ${'guide'} + ${'available with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'foo'} | ${false} | ${'enable'} + ${'available with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${'foo'} | ${true} | ${'enable'} + ${'configured with config path'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'foo'} | ${false} | ${'configure'} + ${'configured with config path, can enable by MR'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${'foo'} | ${true} | ${'configure'} `( 'given $context feature', - ({ available, configured, configurationPath, canEnableByMergeRequest, action }) => { + ({ type, available, configured, configurationPath, canEnableByMergeRequest, action }) => { beforeEach(() => { feature = makeFeature({ + type, available, configured, configurationPath, diff --git a/spec/frontend/security_configuration/components/redesigned_app_spec.js b/spec/frontend/security_configuration/components/redesigned_app_spec.js index 7e27a3e1108..119a25a77c1 100644 --- a/spec/frontend/security_configuration/components/redesigned_app_spec.js +++ b/spec/frontend/security_configuration/components/redesigned_app_spec.js @@ -2,6 +2,7 @@ import { GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; import { SAST_NAME, SAST_SHORT_NAME, @@ -13,6 +14,7 @@ import { LICENSE_COMPLIANCE_HELP_PATH, } from '~/security_configuration/components/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; + import RedesignedSecurityConfigurationApp, { i18n, } from '~/security_configuration/components/redesigned_app.vue'; @@ -23,6 +25,9 @@ import { } from '~/vue_shared/security_reports/constants'; const upgradePath = '/upgrade'; +const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; +const autoDevopsPath = '/autoDevopsPath'; +const gitlabCiHistoryPath = 'test/historyPath'; describe('redesigned App component', () => { let wrapper; @@ -36,6 +41,8 @@ describe('redesigned App component', () => { propsData, provide: { upgradePath, + autoDevopsHelpPagePath, + autoDevopsPath, }, stubs: { UserCalloutDismisser: makeMockUserCalloutDismisser({ @@ -52,9 +59,30 @@ describe('redesigned App component', () => { const findTabs = () => wrapper.findAllComponents(GlTab); const findByTestId = (id) => wrapper.findByTestId(id); const findFeatureCards = () => wrapper.findAllComponents(FeatureCard); - const findComplianceViewHistoryLink = () => findByTestId('compliance-view-history-link'); - const findSecurityViewHistoryLink = () => findByTestId('security-view-history-link'); + const findLink = ({ href, text, container = wrapper }) => { + const selector = `a[href="${href}"]`; + const link = container.find(selector); + + if (link.exists() && link.text() === text) { + return link; + } + + return wrapper.find(`${selector} does not exist`); + }; + const findSecurityViewHistoryLink = () => + findLink({ + href: gitlabCiHistoryPath, + text: i18n.configurationHistory, + container: findByTestId('security-testing-tab'), + }); + const findComplianceViewHistoryLink = () => + findLink({ + href: gitlabCiHistoryPath, + text: i18n.configurationHistory, + container: findByTestId('compliance-testing-tab'), + }); const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner); + const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert); const securityFeaturesMock = [ { @@ -119,6 +147,10 @@ describe('redesigned App component', () => { expect(cards.at(1).props()).toEqual({ feature: complianceFeaturesMock[0] }); }); + it('renders a basic description', () => { + expect(wrapper.text()).toContain(i18n.description); + }); + it('should not show latest pipeline link when latestPipelinePath is not defined', () => { expect(findByTestId('latest-pipeline-info').exists()).toBe(false); }); @@ -129,6 +161,44 @@ describe('redesigned App component', () => { }); }); + describe('autoDevOpsAlert', () => { + describe('given the right props', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + autoDevopsEnabled: false, + gitlabCiPresent: false, + canEnableAutoDevops: true, + }); + }); + + it('should show AutoDevopsAlert', () => { + expect(findAutoDevopsAlert().exists()).toBe(true); + }); + + it('calls the dismiss callback when closing the AutoDevopsAlert', () => { + expect(userCalloutDismissSpy).not.toHaveBeenCalled(); + + findAutoDevopsAlert().vm.$emit('dismiss'); + + expect(userCalloutDismissSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('given the wrong props', () => { + beforeEach(() => { + createComponent({ + augmentedSecurityFeatures: securityFeaturesMock, + augmentedComplianceFeatures: complianceFeaturesMock, + }); + }); + it('should not show AutoDevopsAlert', () => { + expect(findAutoDevopsAlert().exists()).toBe(false); + }); + }); + }); + describe('upgrade banner', () => { const makeAvailable = (available) => (feature) => ({ ...feature, available }); @@ -193,9 +263,8 @@ describe('redesigned App component', () => { it('should show latest pipeline info on the security tab with correct link when latestPipelinePath is defined', () => { const latestPipelineInfoSecurity = findByTestId('latest-pipeline-info-security'); - expect(latestPipelineInfoSecurity.exists()).toBe(true); expect(latestPipelineInfoSecurity.text()).toMatchInterpolatedText( - i18n.securityTestingDescription, + i18n.latestPipelineDescription, ); expect(latestPipelineInfoSecurity.find('a').attributes('href')).toBe('test/path'); }); @@ -203,9 +272,8 @@ describe('redesigned App component', () => { it('should show latest pipeline info on the compliance tab with correct link when latestPipelinePath is defined', () => { const latestPipelineInfoCompliance = findByTestId('latest-pipeline-info-compliance'); - expect(latestPipelineInfoCompliance.exists()).toBe(true); expect(latestPipelineInfoCompliance.text()).toMatchInterpolatedText( - i18n.securityTestingDescription, + i18n.latestPipelineDescription, ); expect(latestPipelineInfoCompliance.find('a').attributes('href')).toBe('test/path'); }); @@ -217,7 +285,7 @@ describe('redesigned App component', () => { augmentedSecurityFeatures: securityFeaturesMock, augmentedComplianceFeatures: complianceFeaturesMock, gitlabCiPresent: true, - gitlabCiHistoryPath: 'test/historyPath', + gitlabCiHistoryPath, }); }); diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js index 6ad167cadda..eaed4532baa 100644 --- a/spec/frontend/security_configuration/utils_spec.js +++ b/spec/frontend/security_configuration/utils_spec.js @@ -35,7 +35,15 @@ const mockValidCustomFeature = [ { name: 'SAST', type: 'SAST', - customfield: 'customvalue', + customField: 'customvalue', + }, +]; + +const mockValidCustomFeatureSnakeCase = [ + { + name: 'SAST', + type: 'SAST', + custom_field: 'customvalue', }, ]; @@ -79,3 +87,15 @@ describe('returns an object with augmentedSecurityFeatures and augmentedComplian ).toEqual(expectedOutputCustomFeature); }); }); + +describe('returns an object with camelcased keys', () => { + it('given a customfeature in snakecase', () => { + expect( + augmentFeatures( + mockSecurityFeatures, + mockComplianceFeatures, + mockValidCustomFeatureSnakeCase, + ), + ).toEqual(expectedOutputCustomFeature); + }); +}); diff --git a/spec/frontend/sentry/index_spec.js b/spec/frontend/sentry/index_spec.js index 13b9b9e909c..d1f098112e8 100644 --- a/spec/frontend/sentry/index_spec.js +++ b/spec/frontend/sentry/index_spec.js @@ -7,6 +7,8 @@ describe('SentryConfig options', () => { const gitlabUrl = 'gitlabUrl'; const environment = 'test'; const revision = 'revision'; + const featureCategory = 'my_feature_category'; + let indexReturnValue; beforeEach(() => { @@ -16,6 +18,7 @@ describe('SentryConfig options', () => { current_user_id: currentUserId, gitlab_url: gitlabUrl, revision, + feature_category: featureCategory, }; process.env.HEAD_COMMIT_SHA = revision; @@ -34,6 +37,7 @@ describe('SentryConfig options', () => { release: revision, tags: { revision, + feature_category: featureCategory, }, }); }); diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js index 1f5097ef2a8..9f67b681b8d 100644 --- a/spec/frontend/sentry/sentry_config_spec.js +++ b/spec/frontend/sentry/sentry_config_spec.js @@ -72,11 +72,13 @@ describe('SentryConfig', () => { release: 'revision', tags: { revision: 'revision', + feature_category: 'my_feature_category', }, }; beforeEach(() => { jest.spyOn(Sentry, 'init').mockImplementation(); + jest.spyOn(Sentry, 'setTags').mockImplementation(); sentryConfig.options = options; sentryConfig.IGNORE_ERRORS = 'ignore_errors'; @@ -89,7 +91,6 @@ describe('SentryConfig', () => { expect(Sentry.init).toHaveBeenCalledWith({ dsn: options.dsn, release: options.release, - tags: options.tags, sampleRate: 0.95, whitelistUrls: options.whitelistUrls, environment: 'test', @@ -98,6 +99,10 @@ describe('SentryConfig', () => { }); }); + it('should call Sentry.setTags', () => { + expect(Sentry.setTags).toHaveBeenCalledWith(options.tags); + }); + it('should set environment from options', () => { sentryConfig.options.environment = 'development'; @@ -106,7 +111,6 @@ describe('SentryConfig', () => { expect(Sentry.init).toHaveBeenCalledWith({ dsn: options.dsn, release: options.release, - tags: options.tags, sampleRate: 0.95, whitelistUrls: options.whitelistUrls, environment: 'development', diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap index 36f6746b754..53bef449c2f 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -3,7 +3,7 @@ exports[`EmptyStateComponent should render content 1`] = ` "<section class=\\"row empty-state text-center\\"> <div class=\\"col-12\\"> - <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" class=\\"gl-max-w-full\\"></div> + <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full\\"></div> </div> <div class=\\"col-12\\"> <div class=\\"text-content gl-mx-auto gl-my-0 gl-p-5\\"> diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js index b49e6255923..2d5a3653631 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_spec.js @@ -1,7 +1,6 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import AssigneeAvatar from '~/sidebar/components/assignees/assignee_avatar.vue'; import CollapsedAssignee from '~/sidebar/components/assignees/collapsed_assignee.vue'; -import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import userDataMock from '../../user_data_mock'; const TEST_USER = userDataMock(); @@ -17,11 +16,8 @@ describe('CollapsedAssignee assignee component', () => { ...props, }; - wrapper = shallowMount(CollapsedAssignee, { + wrapper = mount(CollapsedAssignee, { propsData, - stubs: { - UserNameWithStatus, - }, }); } diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index 0e052abffeb..8504684d23a 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -176,7 +176,7 @@ describe('Sidebar assignees widget', () => { ).toBe(true); }); - it('emits an event with assignees list on successful mutation', async () => { + it('emits an event with assignees list and issuable id on successful mutation', async () => { createComponent(); await waitForPromises(); @@ -193,18 +193,21 @@ describe('Sidebar assignees widget', () => { expect(wrapper.emitted('assignees-updated')).toEqual([ [ - [ - { - __typename: 'User', - avatarUrl: - 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - id: 'gid://gitlab/User/1', - name: 'Administrator', - username: 'root', - webUrl: '/root', - status: null, - }, - ], + { + assignees: [ + { + __typename: 'User', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + ], + id: 1, + }, ], ]); }); @@ -285,6 +288,21 @@ describe('Sidebar assignees widget', () => { expect(updateIssueAssigneesMutationSuccess).not.toHaveBeenCalled(); expect(findUserSelect().isVisible()).toBe(true); }); + + it('calls the mutation old issuable id if `iid` prop was changed', async () => { + findUserSelect().vm.$emit('input', [{ username: 'francina.skiles' }]); + wrapper.setProps({ + iid: '2', + }); + await nextTick(); + findEditableItem().vm.$emit('close'); + + expect(updateIssueAssigneesMutationSuccess).toHaveBeenCalledWith({ + assigneeUsernames: ['francina.skiles'], + fullPath: '/mygroup/myProject', + iid: '1', + }); + }); }); it('shows an error if update assignees mutation is rejected', async () => { diff --git a/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js b/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js index 9483c6624c5..4dbf3d426bb 100644 --- a/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js +++ b/spec/frontend/sidebar/components/assignees/user_name_with_status_spec.js @@ -1,25 +1,21 @@ -import { GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; -const name = 'Goku'; +const name = 'Administrator'; const containerClasses = 'gl-cool-class gl-over-9000'; describe('UserNameWithStatus', () => { let wrapper; function createComponent(props = {}) { - return shallowMount(UserNameWithStatus, { + wrapper = mount(UserNameWithStatus, { propsData: { name, containerClasses, ...props }, - stubs: { - GlSprintf, - }, }); } beforeEach(() => { - wrapper = createComponent(); + createComponent(); }); afterEach(() => { @@ -41,11 +37,39 @@ describe('UserNameWithStatus', () => { describe(`with availability="${AVAILABILITY_STATUS.BUSY}"`, () => { beforeEach(() => { - wrapper = createComponent({ availability: AVAILABILITY_STATUS.BUSY }); + createComponent({ availability: AVAILABILITY_STATUS.BUSY }); }); it('will render "Busy"', () => { - expect(wrapper.html()).toContain('Goku (Busy)'); + expect(wrapper.text()).toContain('(Busy)'); + }); + }); + + describe('when user has pronouns set', () => { + const pronouns = 'they/them'; + + beforeEach(() => { + createComponent({ pronouns }); + }); + + it("renders user's name with pronouns", () => { + expect(wrapper.text()).toMatchInterpolatedText(`${name} (${pronouns})`); + }); + }); + + describe('when user does not have pronouns set', () => { + describe.each` + pronouns + ${undefined} + ${null} + ${''} + ${' '} + `('when `pronouns` prop is $pronouns', ({ pronouns }) => { + it("renders only the user's name", () => { + createComponent({ pronouns }); + + expect(wrapper.text()).toMatchInterpolatedText(name); + }); }); }); }); diff --git a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js index 8d58854b013..f5e5ab4a984 100644 --- a/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js +++ b/spec/frontend/sidebar/components/sidebar_dropdown_widget_spec.js @@ -451,8 +451,9 @@ describe('SidebarDropdownWidget', () => { expect(projectMilestonesSpy).toHaveBeenNthCalledWith(1, { fullPath: mockIssue.projectPath, - title: '', + sort: null, state: 'active', + title: '', }); }); @@ -477,8 +478,9 @@ describe('SidebarDropdownWidget', () => { expect(projectMilestonesSpy).toHaveBeenNthCalledWith(2, { fullPath: mockIssue.projectPath, - title: mockSearchTerm, + sort: null, state: 'active', + title: mockSearchTerm, }); }); }); diff --git a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js new file mode 100644 index 00000000000..23f1753c4bf --- /dev/null +++ b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js @@ -0,0 +1,126 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; +import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql'; +import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import { todosResponse, noTodosResponse } from '../../mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); + +describe('Sidebar Todo Widget', () => { + let wrapper; + let fakeApollo; + + const findTodoButton = () => wrapper.findComponent(TodoButton); + + const createComponent = ({ + todosQueryHandler = jest.fn().mockResolvedValue(noTodosResponse), + } = {}) => { + fakeApollo = createMockApollo([[epicTodoQuery, todosQueryHandler]]); + + wrapper = shallowMount(SidebarTodoWidget, { + apolloProvider: fakeApollo, + provide: { + canUpdate: true, + isClassicSidebar: true, + }, + propsData: { + fullPath: 'group', + issuableIid: '1', + issuableId: 'gid://gitlab/Epic/4', + issuableType: 'epic', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + fakeApollo = null; + }); + + describe('when user does not have a todo for the issuable', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('passes false isTodo prop to Todo button component', () => { + expect(findTodoButton().props('isTodo')).toBe(false); + }); + + it('emits `todoUpdated` event with a `false` payload', () => { + expect(wrapper.emitted('todoUpdated')).toEqual([[false]]); + }); + }); + + describe('when user has a todo for the issuable', () => { + beforeEach(() => { + createComponent({ + todosQueryHandler: jest.fn().mockResolvedValue(todosResponse), + }); + return waitForPromises(); + }); + + it('passes true isTodo prop to Todo button component', () => { + expect(findTodoButton().props('isTodo')).toBe(true); + }); + + it('emits `todoUpdated` event with a `true` payload', () => { + expect(wrapper.emitted('todoUpdated')).toEqual([[true]]); + }); + }); + + it('displays a flash message when query is rejected', async () => { + createComponent({ + todosQueryHandler: jest.fn().mockRejectedValue('Houston, we have a problem'), + }); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + + describe('collapsed', () => { + const event = { stopPropagation: jest.fn(), preventDefault: jest.fn() }; + + beforeEach(() => { + createComponent({ + todosQueryHandler: jest.fn().mockResolvedValue(noTodosResponse), + }); + return waitForPromises(); + }); + + it('shows add todo icon', () => { + expect(wrapper.find(GlIcon).exists()).toBe(true); + + expect(wrapper.find(GlIcon).props('name')).toBe('todo-add'); + }); + + it('sets default tooltip title', () => { + expect(wrapper.find(GlIcon).attributes('title')).toBe('Add a to do'); + }); + + it('when user has a to do', async () => { + createComponent({ + todosQueryHandler: jest.fn().mockResolvedValue(todosResponse), + }); + + await waitForPromises(); + expect(wrapper.find(GlIcon).props('name')).toBe('todo-done'); + expect(wrapper.find(GlIcon).attributes('title')).toBe('Mark as done'); + }); + + it('emits `todoUpdated` event on click on icon', async () => { + wrapper.find(GlIcon).vm.$emit('click', event); + + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('todoUpdated')).toEqual([[false]]); + }); + }); +}); diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js index 49283ea99cf..1673425947e 100644 --- a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { createStore as createMrStore } from '~/mr_notes/stores'; import createStore from '~/notes/stores'; import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue'; @@ -130,7 +130,7 @@ describe('EditFormButtons', () => { }); it('does not flash an error message', () => { - expect(flash).not.toHaveBeenCalled(); + expect(createFlash).not.toHaveBeenCalled(); }); }); @@ -165,9 +165,9 @@ describe('EditFormButtons', () => { }); it('calls flash with the correct message', () => { - expect(flash).toHaveBeenCalledWith( - `Something went wrong trying to change the locked state of this ${issuableDisplayName}`, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: `Something went wrong trying to change the locked state of this ${issuableDisplayName}`, + }); }); }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index d6287b93fb9..9fab24d7518 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -530,6 +530,7 @@ export const mockMilestone1 = { title: 'Foobar Milestone', webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/1', state: 'active', + expired: false, }; export const mockMilestone2 = { @@ -538,6 +539,7 @@ export const mockMilestone2 = { title: 'Awesome Milestone', webUrl: 'http://gdk.test:3000/groups/gitlab-org/-/milestones/2', state: 'active', + expired: false, }; export const mockProjectMilestonesResponse = { @@ -571,6 +573,7 @@ export const mockMilestoneMutationResponse = { id: 'gid://gitlab/Milestone/2', title: 'Awesome Milestone', state: 'active', + expired: false, __typename: 'Milestone', }, __typename: 'Issue', @@ -609,4 +612,38 @@ export const issuableTimeTrackingResponse = { }, }; +export const todosResponse = { + data: { + workspace: { + __typename: 'Group', + issuable: { + __typename: 'Epic', + id: 'gid://gitlab/Epic/4', + currentUserTodos: { + nodes: [ + { + id: 'gid://gitlab/Todo/433', + }, + ], + }, + }, + }, + }, +}; + +export const noTodosResponse = { + data: { + workspace: { + __typename: 'Group', + issuable: { + __typename: 'Epic', + id: 'gid://gitlab/Epic/4', + currentUserTodos: { + nodes: [], + }, + }, + }, + }, +}; + export default mockData; diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js index 6a7758ace40..d9972ae75c3 100644 --- a/spec/frontend/sidebar/sidebar_move_issue_spec.js +++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue'; import SidebarService from '~/sidebar/services/sidebar_service'; @@ -7,6 +8,8 @@ import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; import Mock from './mock_data'; +jest.mock('~/flash'); + describe('SidebarMoveIssue', () => { let mock; const test = {}; @@ -99,7 +102,6 @@ describe('SidebarMoveIssue', () => { }); it('should remove loading state from confirm button on failure', (done) => { - jest.spyOn(window, 'Flash').mockImplementation(() => {}); jest.spyOn(test.mediator, 'moveIssue').mockReturnValue(Promise.reject()); test.mediator.setMoveToProjectId(7); @@ -108,7 +110,7 @@ describe('SidebarMoveIssue', () => { expect(test.mediator.moveIssue).toHaveBeenCalled(); // Wait for the move issue request to fail setImmediate(() => { - expect(window.Flash).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); expect(test.$confirmButton.prop('disabled')).toBeFalsy(); expect(test.$confirmButton.hasClass('is-loading')).toBe(false); done(); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap index b0c253bca65..e12255fe825 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap @@ -13,7 +13,7 @@ exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = ` value="foo/bar/test.md" /> - <editor-lite-stub + <source-editor-stub editoroptions="[object Object]" fileglobalid="blob_local_7" filename="foo/bar/test.md" diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index efdb52cfcd9..4e88ab9504e 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -7,8 +7,7 @@ import { useFakeDate } from 'helpers/fake_date'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; -import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import createFlash from '~/flash'; import * as urlUtils from '~/lib/utils/url_utility'; import SnippetEditApp from '~/snippets/components/edit.vue'; import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; @@ -29,7 +28,6 @@ jest.mock('~/flash'); const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js']; const TEST_API_ERROR = new Error('TEST_API_ERROR'); -const TEST_CAPTCHA_ERROR = new UnsolvedCaptchaError(); const TEST_MUTATION_ERROR = 'Test mutation error'; const TEST_ACTIONS = { NO_CONTENT: merge({}, testEntries.created.diff, { content: '' }), @@ -319,14 +317,16 @@ describe('Snippet Edit app', () => { }); expect(urlUtils.redirectTo).not.toHaveBeenCalled(); - expect(Flash).toHaveBeenCalledWith(expectMessage); + expect(createFlash).toHaveBeenCalledWith({ + message: expectMessage, + }); }, ); - describe.each([TEST_API_ERROR, TEST_CAPTCHA_ERROR])('with apollo network error', (error) => { + describe('with apollo network error', () => { beforeEach(async () => { jest.spyOn(console, 'error').mockImplementation(); - mutateSpy.mockRejectedValue(error); + mutateSpy.mockRejectedValue(TEST_API_ERROR); await createComponentAndSubmit(); }); @@ -337,9 +337,9 @@ describe('Snippet Edit app', () => { it('should flash', () => { // Apollo automatically wraps the resolver's error in a NetworkError - expect(Flash).toHaveBeenCalledWith( - `Can't update snippet: Network error: ${error.message}`, - ); + expect(createFlash).toHaveBeenCalledWith({ + message: `Can't update snippet: Network error: ${TEST_API_ERROR.message}`, + }); }); it('should console error', () => { @@ -348,7 +348,7 @@ describe('Snippet Edit app', () => { // eslint-disable-next-line no-console expect(console.error).toHaveBeenCalledWith( '[gitlab] unexpected error while updating snippet', - expect.objectContaining({ message: `Network error: ${error.message}` }), + expect.objectContaining({ message: `Network error: ${TEST_API_ERROR.message}` }), ); }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index 4b3b21c5507..7ea27864519 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -8,7 +8,7 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; -import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; jest.mock('~/flash'); @@ -48,7 +48,7 @@ describe('Snippet Blob Edit component', () => { const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findHeader = () => wrapper.find(BlobHeaderEdit); - const findContent = () => wrapper.find(EditorLite); + const findContent = () => wrapper.find(SourceEditor); const getLastUpdatedArgs = () => { const event = wrapper.emitted()['blob-updated']; diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js index d9bceb76a37..757611166d7 100644 --- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js +++ b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js @@ -8,8 +8,8 @@ import { SUBMIT_CHANGES_MERGE_REQUEST_ERROR, TRACKING_ACTION_CREATE_COMMIT, TRACKING_ACTION_CREATE_MERGE_REQUEST, - USAGE_PING_TRACKING_ACTION_CREATE_COMMIT, - USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, + SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT, + SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE, DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION, } from '~/static_site_editor/constants'; @@ -237,7 +237,7 @@ describe('submitContentChanges', () => { }); }); - describe('sends the correct Usage Ping tracking event', () => { + describe('sends the correct Service Ping tracking event', () => { beforeEach(() => { jest.spyOn(Api, 'trackRedisCounterEvent').mockResolvedValue({ data: '' }); }); @@ -245,7 +245,7 @@ describe('submitContentChanges', () => { it('for commiting changes', () => { return submitContentChanges(buildPayload()).then(() => { expect(Api.trackRedisCounterEvent).toHaveBeenCalledWith( - USAGE_PING_TRACKING_ACTION_CREATE_COMMIT, + SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT, ); }); }); @@ -253,7 +253,7 @@ describe('submitContentChanges', () => { it('for creating a merge request', () => { return submitContentChanges(buildPayload()).then(() => { expect(Api.trackRedisCounterEvent).toHaveBeenCalledWith( - USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, + SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST, ); }); }); diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js index 882b7b55b3e..c622f86072d 100644 --- a/spec/frontend/terraform/components/terraform_list_spec.js +++ b/spec/frontend/terraform/components/terraform_list_spec.js @@ -47,6 +47,9 @@ describe('TerraformList', () => { localVue, apolloProvider, propsData, + stubs: { + GlTab, + }, }); }; diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js new file mode 100644 index 00000000000..14d7b00cb6d --- /dev/null +++ b/spec/frontend/token_access/mock_data.js @@ -0,0 +1,84 @@ +export const enabledJobTokenScope = { + data: { + project: { + ciCdSettings: { + jobTokenScopeEnabled: true, + __typename: 'ProjectCiCdSetting', + }, + __typename: 'Project', + }, + }, +}; + +export const disabledJobTokenScope = { + data: { + project: { + ciCdSettings: { + jobTokenScopeEnabled: false, + __typename: 'ProjectCiCdSetting', + }, + __typename: 'Project', + }, + }, +}; + +export const updateJobTokenScope = { + data: { + ciCdSettingsUpdate: { + ciCdSettings: { + jobTokenScopeEnabled: true, + __typename: 'ProjectCiCdSetting', + }, + errors: [], + __typename: 'CiCdSettingsUpdatePayload', + }, + }, +}; + +export const projectsWithScope = { + data: { + project: { + __typename: 'Project', + ciJobTokenScope: { + __typename: 'CiJobTokenScopeType', + projects: { + __typename: 'ProjectConnection', + nodes: [ + { + fullPath: 'root/332268-test', + name: 'root/332268-test', + }, + ], + }, + }, + }, + }, +}; + +export const addProjectSuccess = { + data: { + ciJobTokenScopeAddProject: { + errors: [], + __typename: 'CiJobTokenScopeAddProjectPayload', + }, + }, +}; + +export const removeProjectSuccess = { + data: { + ciJobTokenScopeRemoveProject: { + errors: [], + __typename: 'CiJobTokenScopeRemoveProjectPayload', + }, + }, +}; + +export const mockProjects = [ + { + name: 'merge-train-stuff', + fullPath: 'root/merge-train-stuff', + isLocked: false, + __typename: 'Project', + }, + { name: 'ci-project', fullPath: 'root/ci-project', isLocked: true, __typename: 'Project' }, +]; diff --git a/spec/frontend/token_access/token_access_spec.js b/spec/frontend/token_access/token_access_spec.js new file mode 100644 index 00000000000..c7323eb19fe --- /dev/null +++ b/spec/frontend/token_access/token_access_spec.js @@ -0,0 +1,218 @@ +import { GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import TokenAccess from '~/token_access/components/token_access.vue'; +import addProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql'; +import removeProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql'; +import updateCIJobTokenScopeMutation from '~/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql'; +import getCIJobTokenScopeQuery from '~/token_access/graphql/queries/get_ci_job_token_scope.query.graphql'; +import getProjectsWithCIJobTokenScopeQuery from '~/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql'; +import { + enabledJobTokenScope, + disabledJobTokenScope, + updateJobTokenScope, + projectsWithScope, + addProjectSuccess, + removeProjectSuccess, +} from './mock_data'; + +const projectPath = 'root/my-repo'; +const error = new Error('Error'); +const localVue = createLocalVue(); + +localVue.use(VueApollo); + +jest.mock('~/flash'); + +describe('TokenAccess component', () => { + let wrapper; + + const enabledJobTokenScopeHandler = jest.fn().mockResolvedValue(enabledJobTokenScope); + const disabledJobTokenScopeHandler = jest.fn().mockResolvedValue(disabledJobTokenScope); + const updateJobTokenScopeHandler = jest.fn().mockResolvedValue(updateJobTokenScope); + const getProjectsWithScope = jest.fn().mockResolvedValue(projectsWithScope); + const addProjectSuccessHandler = jest.fn().mockResolvedValue(addProjectSuccess); + const addProjectFailureHandler = jest.fn().mockRejectedValue(error); + const removeProjectSuccessHandler = jest.fn().mockResolvedValue(removeProjectSuccess); + const removeProjectFailureHandler = jest.fn().mockRejectedValue(error); + + const findToggle = () => wrapper.findComponent(GlToggle); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAddProjectBtn = () => wrapper.find('[data-testid="add-project-button"]'); + const findRemoveProjectBtn = () => wrapper.find('[data-testid="remove-project-button"]'); + const findTokenSection = () => wrapper.find('[data-testid="token-section"]'); + + const createMockApolloProvider = (requestHandlers) => { + return createMockApollo(requestHandlers); + }; + + const createComponent = (requestHandlers, mountFn = shallowMount) => { + wrapper = mountFn(TokenAccess, { + localVue, + provide: { + fullPath: projectPath, + }, + apolloProvider: createMockApolloProvider(requestHandlers), + data() { + return { + targetProjectPath: 'root/test', + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('loading state', () => { + it('shows loading state while waiting on query to resolve', async () => { + createComponent([ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + ]); + + expect(findLoadingIcon().exists()).toBe(true); + + await waitForPromises(); + + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('toggle', () => { + it('the toggle should be enabled and the token section should show', async () => { + createComponent([ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + ]); + + await waitForPromises(); + + expect(findToggle().props('value')).toBe(true); + expect(findTokenSection().exists()).toBe(true); + }); + + it('the toggle should be disabled and the token section should not show', async () => { + createComponent([ + [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + ]); + + await waitForPromises(); + + expect(findToggle().props('value')).toBe(false); + expect(findTokenSection().exists()).toBe(false); + }); + + it('switching the toggle calls the mutation and fetches the projects again', async () => { + createComponent([ + [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], + [updateCIJobTokenScopeMutation, updateJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + ]); + + await waitForPromises(); + + expect(getProjectsWithScope).toHaveBeenCalledTimes(1); + + findToggle().vm.$emit('change', true); + + await waitForPromises(); + + expect(updateJobTokenScopeHandler).toHaveBeenCalledWith({ + input: { fullPath: projectPath, jobTokenScopeEnabled: true }, + }); + expect(getProjectsWithScope).toHaveBeenCalledTimes(2); + }); + }); + + describe('add project', () => { + it('calls add project mutation', async () => { + createComponent( + [ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [addProjectCIJobTokenScopeMutation, addProjectSuccessHandler], + ], + mount, + ); + + await waitForPromises(); + + findAddProjectBtn().trigger('click'); + + expect(addProjectSuccessHandler).toHaveBeenCalledWith({ + input: { + projectPath, + targetProjectPath: 'root/test', + }, + }); + }); + + it('add project handles error correctly', async () => { + createComponent( + [ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [addProjectCIJobTokenScopeMutation, addProjectFailureHandler], + ], + mount, + ); + + await waitForPromises(); + + findAddProjectBtn().trigger('click'); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('remove project', () => { + it('calls remove project mutation', async () => { + createComponent( + [ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [removeProjectCIJobTokenScopeMutation, removeProjectSuccessHandler], + ], + mount, + ); + + await waitForPromises(); + + findRemoveProjectBtn().trigger('click'); + + expect(removeProjectSuccessHandler).toHaveBeenCalledWith({ + input: { + projectPath, + targetProjectPath: 'root/332268-test', + }, + }); + }); + + it('remove project handles error correctly', async () => { + createComponent( + [ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [removeProjectCIJobTokenScopeMutation, removeProjectFailureHandler], + ], + mount, + ); + + await waitForPromises(); + + findRemoveProjectBtn().trigger('click'); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/token_access/token_projects_table_spec.js b/spec/frontend/token_access/token_projects_table_spec.js new file mode 100644 index 00000000000..3bda0d0b530 --- /dev/null +++ b/spec/frontend/token_access/token_projects_table_spec.js @@ -0,0 +1,51 @@ +import { GlTable, GlButton } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import TokenProjectsTable from '~/token_access/components/token_projects_table.vue'; +import { mockProjects } from './mock_data'; + +describe('Token projects table', () => { + let wrapper; + + const createComponent = () => { + wrapper = mount(TokenProjectsTable, { + provide: { + fullPath: 'root/ci-project', + }, + propsData: { + projects: mockProjects, + }, + }); + }; + + const findTable = () => wrapper.findComponent(GlTable); + const findAllTableRows = () => wrapper.findAll('[data-testid="projects-token-table-row"]'); + const findDeleteProjectBtn = () => wrapper.findComponent(GlButton); + const findAllDeleteProjectBtn = () => wrapper.findAllComponents(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('displays the correct amount of table rows', () => { + expect(findAllTableRows()).toHaveLength(mockProjects.length); + }); + + it('delete project button emits event with correct project to delete', async () => { + await findDeleteProjectBtn().trigger('click'); + + expect(wrapper.emitted('removeProject')).toEqual([[mockProjects[0].fullPath]]); + }); + + it('does not show the remove icon if the project is locked', () => { + // currently two mock projects with one being a locked project + expect(findAllDeleteProjectBtn()).toHaveLength(1); + }); +}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index d8dae2b2dc0..13498cfb823 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -197,6 +197,52 @@ describe('Tracking', () => { expectedError, ); }); + + it('does not add empty form whitelist rules', () => { + Tracking.enableFormTracking({ fields: { allow: ['input-class1'] } }); + + expect(snowplowSpy).toHaveBeenCalledWith( + 'enableFormTracking', + { fields: { whitelist: ['input-class1'] } }, + [], + ); + }); + + describe('when `document.readyState` does not equal `complete`', () => { + const originalReadyState = document.readyState; + const setReadyState = (value) => { + Object.defineProperty(document, 'readyState', { + value, + configurable: true, + }); + }; + const fireReadyStateChangeEvent = () => { + document.dispatchEvent(new Event('readystatechange')); + }; + + beforeEach(() => { + setReadyState('interactive'); + }); + + afterEach(() => { + setReadyState(originalReadyState); + }); + + it('does not call `window.snowplow` until `readystatechange` is fired and `document.readyState` equals `complete`', () => { + Tracking.enableFormTracking({ fields: { allow: ['input-class1'] } }); + + expect(snowplowSpy).not.toHaveBeenCalled(); + + fireReadyStateChangeEvent(); + + expect(snowplowSpy).not.toHaveBeenCalled(); + + setReadyState('complete'); + fireReadyStateChangeEvent(); + + expect(snowplowSpy).toHaveBeenCalled(); + }); + }); }); describe('.flushPendingEvents', () => { diff --git a/spec/frontend/vue_alerts_spec.js b/spec/frontend/vue_alerts_spec.js index 05b73415544..30be606292f 100644 --- a/spec/frontend/vue_alerts_spec.js +++ b/spec/frontend/vue_alerts_spec.js @@ -28,8 +28,8 @@ describe('VueAlerts', () => { alerts .map( (x) => ` - <div class="js-vue-alert" - data-dismissible="${x.dismissible}" + <div class="js-vue-alert" + data-dismissible="${x.dismissible}" data-title="${x.title}" data-primary-button-text="${x.primaryButtonText}" data-primary-button-link="${x.primaryButtonLink}" diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index 115f21d8b35..f44f0b98207 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Header from '~/vue_merge_request_widget/components/mr_widget_header.vue'; @@ -26,6 +26,15 @@ describe('MRWidgetHeader', () => { expect(downloadPlainDiffEl.attributes('href')).toBe('/mr/plainDiffPath'); }; + const commonMrProps = { + divergedCommitsCount: 1, + sourceBranch: 'mr-widget-refactor', + sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', + targetBranch: 'main', + targetBranchPath: '/foo/bar/main', + statusPath: 'abc', + }; + describe('computed', () => { describe('shouldShowCommitsBehindText', () => { it('return true when there are divergedCommitsCount', () => { @@ -59,36 +68,28 @@ describe('MRWidgetHeader', () => { describe('commitsBehindText', () => { it('returns singular when there is one commit', () => { - createComponent({ - mr: { - divergedCommitsCount: 1, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'main', - targetBranchPath: '/foo/bar/main', - statusPath: 'abc', + wrapper = mount(Header, { + propsData: { + mr: commonMrProps, }, }); - expect(wrapper.vm.commitsBehindText).toBe( - 'The source branch is <a href="/foo/bar/main">1 commit behind</a> the target branch', + expect(wrapper.find('.diverged-commits-count').element.innerHTML).toBe( + 'The source branch is <a href="/foo/bar/main" class="gl-link">1 commit behind</a> the target branch', ); }); it('returns plural when there is more than one commit', () => { - createComponent({ - mr: { - divergedCommitsCount: 2, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">Link</a>', - targetBranch: 'main', - targetBranchPath: '/foo/bar/main', - statusPath: 'abc', + wrapper = mount(Header, { + propsData: { + mr: { + ...commonMrProps, + divergedCommitsCount: 2, + }, }, }); - - expect(wrapper.vm.commitsBehindText).toBe( - 'The source branch is <a href="/foo/bar/main">2 commits behind</a> the target branch', + expect(wrapper.find('.diverged-commits-count').element.innerHTML).toBe( + 'The source branch is <a href="/foo/bar/main" class="gl-link">2 commits behind</a> the target branch', ); }); }); @@ -273,19 +274,18 @@ describe('MRWidgetHeader', () => { describe('with diverged commits', () => { beforeEach(() => { - createComponent({ - mr: { - divergedCommitsCount: 12, - sourceBranch: 'mr-widget-refactor', - sourceBranchLink: '<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>', - sourceBranchRemoved: false, - targetBranchPath: 'foo/bar/commits-path', - targetBranchTreePath: 'foo/bar/tree/path', - targetBranch: 'main', - isOpen: true, - emailPatchesPath: '/mr/email-patches', - plainDiffPath: '/mr/plainDiffPath', - statusPath: 'abc', + wrapper = mount(Header, { + propsData: { + mr: { + ...commonMrProps, + divergedCommitsCount: 12, + sourceBranchRemoved: false, + targetBranchPath: 'foo/bar/commits-path', + targetBranchTreePath: 'foo/bar/tree/path', + isOpen: true, + emailPatchesPath: '/mr/email-patches', + plainDiffPath: '/mr/plainDiffPath', + }, }, }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js index 5081e1e5906..d3221cc2fc7 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_rebase_spec.js @@ -70,9 +70,9 @@ describe('Merge request widget rebase component', () => { const text = findRebaseMessageElText(); - expect(text).toContain('Fast-forward merge is not possible.'); + expect(text).toContain('Merge blocked'); expect(text.replace(/\s\s+/g, ' ')).toContain( - 'Rebase the source branch onto the target branch.', + 'the source branch must be rebased onto the target branch', ); }); @@ -111,12 +111,10 @@ describe('Merge request widget rebase component', () => { const text = findRebaseMessageElText(); - expect(text).toContain('Fast-forward merge is not possible.'); - expect(text).toContain('Rebase the source branch onto'); - expect(text).toContain('foo'); - expect(text.replace(/\s\s+/g, ' ')).toContain( - 'to allow this merge request to be merged.', + expect(text).toContain( + 'Merge blocked: the source branch must be rebased onto the target branch.', ); + expect(text).toContain('the source branch must be rebased'); }); it('should render the correct target branch name', () => { @@ -136,7 +134,7 @@ describe('Merge request widget rebase component', () => { const elem = findRebaseMessageEl(); expect(elem.text()).toContain( - `Fast-forward merge is not possible. Rebase the source branch onto ${targetBranch} to allow this merge request to be merged.`, + `Merge blocked: the source branch must be rebased onto the target branch.`, ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js index 5d09af50420..8214cedc4a1 100644 --- a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js @@ -63,7 +63,7 @@ describe('Commits edit component', () => { beforeEach(() => { createComponent({ header: `<div class="test-header">${testCommitMessage}</div>`, - checkbox: `<label slot="checkbox" class="test-checkbox">${testLabel}</label >`, + checkbox: `<label class="test-checkbox">${testLabel}</label >`, }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index fee78d3af94..e1bce7f0474 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -199,7 +199,7 @@ describe('MRWidgetConflicts', () => { }); expect(removeBreakLine(wrapper.text()).trim()).toContain( - 'Fast-forward merge is not possible. To merge this request, first rebase locally.', + 'Merge blocked: fast-forward merge is not possible. To merge this request, first rebase locally.', ); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 6bb87893c31..9c3a6d581e8 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -217,7 +217,6 @@ describe('MRWidgetMerged', () => { vm.mr.sourceBranchRemoved = false; Vue.nextTick(() => { - expect(vm.$el.innerText).toContain('You can delete the source branch now'); expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); done(); }); @@ -229,7 +228,6 @@ describe('MRWidgetMerged', () => { Vue.nextTick(() => { expect(vm.$el.innerText).toContain('The source branch is being deleted'); - expect(vm.$el.innerText).not.toContain('You can delete the source branch now'); expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); done(); }); 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 2d00cd8e8d4..cd77d442cbf 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 @@ -70,6 +70,9 @@ const createComponent = (customConfig = {}, mergeRequestWidgetGraphql = false) = mergeRequestWidgetGraphql, }, }, + stubs: { + CommitEdit, + }, }); }; diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index 8e36a9225d6..e6f1e15d718 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -273,9 +273,9 @@ export default { 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', mr_troubleshooting_docs_path: 'help', ci_troubleshooting_docs_path: 'help2', - merge_request_pipelines_docs_path: '/help/ci/merge_request_pipelines/index.md', + merge_request_pipelines_docs_path: '/help/ci/pipelines/merge_request_pipelines.md', merge_train_when_pipeline_succeeds_docs_path: - '/help/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/#startadd-to-merge-train-when-pipeline-succeeds', + '/help/ci/pipelines/merge_trains.md#startadd-to-merge-train-when-pipeline-succeeds', squash: true, visual_review_app_available: true, merge_trains_enabled: true, diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 3f91591f5cd..c14cf0db370 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -7,7 +7,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" - title="Ada, Leonardo, and Marie" + title="Ada, Leonardo, and Marie reacted with :thumbsup:" type="button" > <!----> @@ -37,7 +37,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" - title="You, Ada, and Marie" + title="You, Ada, and Marie reacted with :thumbsdown:" type="button" > <!----> @@ -67,7 +67,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" - title="Ada and Jane" + title="Ada and Jane reacted with :smile:" type="button" > <!----> @@ -97,7 +97,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" - title="You, Ada, Jane, and Leonardo" + title="You, Ada, Jane, and Leonardo reacted with :ok_hand:" type="button" > <!----> @@ -127,7 +127,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" - title="You" + title="You reacted with :cactus:" type="button" > <!----> @@ -157,7 +157,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" - title="Marie" + title="Marie reacted with :a:" type="button" > <!----> @@ -187,7 +187,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` <button class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" - title="You" + title="You reacted with :b:" type="button" > <!----> diff --git a/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap index 26785855369..7ce155f6a5d 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/source_editor_spec.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Editor Lite component rendering matches the snapshot 1`] = ` +exports[`Source Editor component rendering matches the snapshot 1`] = ` <div data-editor-loading="" - id="editor-lite-snippet_777" + id="source-editor-snippet_777" > <pre class="editor-loading-content" diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 55f9eedc169..95e9760c181 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -98,43 +98,43 @@ describe('vue_shared/components/awards_list', () => { classes: REACTION_CONTROL_CLASSES, count: 3, html: matchingEmojiTag(EMOJI_THUMBSUP), - title: 'Ada, Leonardo, and Marie', + title: `Ada, Leonardo, and Marie reacted with :${EMOJI_THUMBSUP}:`, }, { classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 3, html: matchingEmojiTag(EMOJI_THUMBSDOWN), - title: 'You, Ada, and Marie', + title: `You, Ada, and Marie reacted with :${EMOJI_THUMBSDOWN}:`, }, { classes: REACTION_CONTROL_CLASSES, count: 2, html: matchingEmojiTag(EMOJI_SMILE), - title: 'Ada and Jane', + title: `Ada and Jane reacted with :${EMOJI_SMILE}:`, }, { classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 4, html: matchingEmojiTag(EMOJI_OK), - title: 'You, Ada, Jane, and Leonardo', + title: `You, Ada, Jane, and Leonardo reacted with :${EMOJI_OK}:`, }, { classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 1, html: matchingEmojiTag(EMOJI_CACTUS), - title: 'You', + title: `You reacted with :${EMOJI_CACTUS}:`, }, { classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_A), - title: 'Marie', + title: `Marie reacted with :${EMOJI_A}:`, }, { classes: [...REACTION_CONTROL_CLASSES, 'selected'], count: 1, html: matchingEmojiTag(EMOJI_B), - title: 'You', + title: `You reacted with :${EMOJI_B}:`, }, ]); }); @@ -246,13 +246,13 @@ describe('vue_shared/components/awards_list', () => { classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_100), - title: 'Marie', + title: `Marie reacted with :${EMOJI_100}:`, }, { classes: REACTION_CONTROL_CLASSES, count: 1, html: matchingEmojiTag(EMOJI_SMILE), - title: 'Marie', + title: `Marie reacted with :${EMOJI_SMILE}:`, }, ]); }); diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js index f592db935ec..d14f3e5559f 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js @@ -10,9 +10,10 @@ describe('Blob Rich Viewer component', () => { const content = '<h1 id="markdown">Foo Bar</h1>'; const defaultType = 'markdown'; - function createComponent(type = defaultType) { + function createComponent(type = defaultType, richViewer) { wrapper = shallowMount(RichViewer, { propsData: { + richViewer, content, type, }, @@ -31,6 +32,12 @@ describe('Blob Rich Viewer component', () => { expect(wrapper.html()).toContain(content); }); + it('renders the richViewer if one is present', () => { + const richViewer = '<div class="js-pdf-viewer"></div>'; + createComponent('pdf', richViewer); + expect(wrapper.html()).toContain(richViewer); + }); + it('queries for advanced viewer', () => { expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType); }); 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 46d4edad891..c6c351a7f3f 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 @@ -2,7 +2,7 @@ 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 EditorLite from '~/vue_shared/components/editor_lite.vue'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; describe('Blob Simple Viewer component', () => { let wrapper; @@ -96,7 +96,7 @@ describe('Blob Simple Viewer component', () => { }); describe('Vue refactoring to use Source Editor', () => { - const findEditorLite = () => wrapper.find(EditorLite); + const findSourceEditor = () => wrapper.find(SourceEditor); it.each` doesRender | condition | isRawContent | isRefactorFlagEnabled @@ -105,19 +105,19 @@ describe('Blob Simple Viewer component', () => { ${'Does not'} | ${'both, the FF and rawContent are not specified'} | ${false} | ${false} ${'Does'} | ${'both, the FF and rawContent are specified'} | ${true} | ${true} `( - '$doesRender render Editor Lite component in readonly mode when $condition', + '$doesRender render Source Editor component in readonly mode when $condition', async ({ isRawContent, isRefactorFlagEnabled } = {}) => { createComponent('raw content', isRawContent, isRefactorFlagEnabled); await waitForPromises(); if (isRawContent && isRefactorFlagEnabled) { - expect(findEditorLite().exists()).toBe(true); + expect(findSourceEditor().exists()).toBe(true); - expect(findEditorLite().props('value')).toBe('raw content'); - expect(findEditorLite().props('fileName')).toBe('test.js'); - expect(findEditorLite().props('editorOptions')).toEqual({ readOnly: true }); + expect(findSourceEditor().props('value')).toBe('raw content'); + expect(findSourceEditor().props('fileName')).toBe('test.js'); + expect(findSourceEditor().props('editorOptions')).toEqual({ readOnly: true }); } else { - expect(findEditorLite().exists()).toBe(false); + expect(findSourceEditor().exists()).toBe(false); } }, ); diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js index eacc41ccdad..8deb466b33c 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/image_diff_viewer_spec.js @@ -109,9 +109,11 @@ describe('ImageDiffViewer', () => { components: { imageDiffViewer, }, - data: { - ...allProps, - diffMode: 'renamed', + data() { + return { + ...allProps, + diffMode: 'renamed', + }; }, ...compileToFunctions(` <image-diff-viewer @@ -121,7 +123,9 @@ describe('ImageDiffViewer', () => { :new-size="newSize" :old-size="oldSize" > - <span slot="image-overlay" class="overlay">test</span> + <template #image-overlay> + <span class="overlay">test</span> + </template> </image-diff-viewer> `), }).$mount(); diff --git a/spec/frontend/vue_shared/components/dismissible_alert_spec.js b/spec/frontend/vue_shared/components/dismissible_alert_spec.js index cfa6d1064e5..fcd004d35a7 100644 --- a/spec/frontend/vue_shared/components/dismissible_alert_spec.js +++ b/spec/frontend/vue_shared/components/dismissible_alert_spec.js @@ -5,18 +5,12 @@ import DismissibleAlert from '~/vue_shared/components/dismissible_alert.vue'; const TEST_HTML = 'Hello World! <strong>Foo</strong>'; describe('vue_shared/components/dismissible_alert', () => { - const testAlertProps = { - primaryButtonText: 'Lorem ipsum', - primaryButtonLink: '/lorem/ipsum', - }; - let wrapper; const createComponent = (props = {}) => { wrapper = shallowMount(DismissibleAlert, { propsData: { html: TEST_HTML, - ...testAlertProps, ...props, }, }); @@ -28,16 +22,13 @@ describe('vue_shared/components/dismissible_alert', () => { const findAlert = () => wrapper.find(GlAlert); - describe('with default', () => { + describe('default', () => { beforeEach(() => { createComponent(); }); it('shows alert', () => { - const alert = findAlert(); - - expect(alert.exists()).toBe(true); - expect(alert.props()).toEqual(expect.objectContaining(testAlertProps)); + expect(findAlert().exists()).toBe(true); }); it('shows given HTML', () => { @@ -54,4 +45,32 @@ describe('vue_shared/components/dismissible_alert', () => { }); }); }); + + describe('with additional props', () => { + const testAlertProps = { + dismissible: true, + title: 'Mock Title', + primaryButtonText: 'Lorem ipsum', + primaryButtonLink: '/lorem/ipsum', + variant: 'warning', + }; + + beforeEach(() => { + createComponent(testAlertProps); + }); + + it('passes other props', () => { + expect(findAlert().props()).toEqual(expect.objectContaining(testAlertProps)); + }); + }); + + describe('with unsafe HTML', () => { + beforeEach(() => { + createComponent({ html: '<a onclick="alert("XSS")">Link</a>' }); + }); + + it('removes unsafe HTML', () => { + expect(findAlert().html()).toContain('<a>Link</a>'); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js index d757b7fac72..181fc4017a3 100644 --- a/spec/frontend/vue_shared/components/file_finder/index_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js @@ -154,6 +154,16 @@ describe('File finder item spec', () => { }); }); + describe('DOM Performance', () => { + it('renders less DOM nodes if not visible by utilizing v-if', async () => { + vm.visible = false; + + await waitForPromises(); + + expect(vm.$el).toBeInstanceOf(Comment); + }); + }); + describe('watches', () => { describe('searchText', () => { it('resets focusedIndex when updated', (done) => { @@ -169,7 +179,7 @@ describe('File finder item spec', () => { }); describe('visible', () => { - it('returns searchText when false', (done) => { + it('resets searchText when changed to false', (done) => { vm.searchText = 'test'; vm.visible = true; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index 93cddff8421..1b97011bf7f 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -11,7 +11,7 @@ import { processFilters, filterToQueryObject, urlQueryToFilter, - getRecentlyUsedTokenValues, + getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -328,32 +328,32 @@ describe('urlQueryToFilter', () => { ); }); -describe('getRecentlyUsedTokenValues', () => { +describe('getRecentlyUsedSuggestions', () => { useLocalStorageSpy(); beforeEach(() => { localStorage.removeItem(mockStorageKey); }); - it('returns array containing recently used token values from provided recentTokenValuesStorageKey', () => { + it('returns array containing recently used token values from provided recentSuggestionsStorageKey', () => { setLocalStorageAvailability(true); const mockExpectedArray = [{ foo: 'bar' }]; localStorage.setItem(mockStorageKey, JSON.stringify(mockExpectedArray)); - expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual(mockExpectedArray); + expect(getRecentlyUsedSuggestions(mockStorageKey)).toEqual(mockExpectedArray); }); - it('returns empty array when provided recentTokenValuesStorageKey does not have anything in localStorage', () => { + it('returns empty array when provided recentSuggestionsStorageKey does not have anything in localStorage', () => { setLocalStorageAvailability(true); - expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual([]); + expect(getRecentlyUsedSuggestions(mockStorageKey)).toEqual([]); }); it('returns empty array when when access to localStorage is not available', () => { setLocalStorageAvailability(false); - expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual([]); + expect(getRecentlyUsedSuggestions(mockStorageKey)).toEqual([]); }); }); @@ -366,7 +366,7 @@ describe('setTokenValueToRecentlyUsed', () => { localStorage.removeItem(mockStorageKey); }); - it('adds provided tokenValue to localStorage for recentTokenValuesStorageKey', () => { + it('adds provided tokenValue to localStorage for recentSuggestionsStorageKey', () => { setLocalStorageAvailability(true); setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1); 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 951b050495c..74f579e77ed 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 @@ -94,7 +94,7 @@ describe('AuthorToken', () => { it('calls `config.fetchAuthors` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchAuthors'); - getBaseToken().vm.$emit('fetch-token-values', mockAuthors[0].username); + getBaseToken().vm.$emit('fetch-suggestions', mockAuthors[0].username); expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( mockAuthorToken.fetchPath, @@ -105,17 +105,17 @@ describe('AuthorToken', () => { it('sets response to `authors` when request is succesful', () => { jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); - getBaseToken().vm.$emit('fetch-token-values', 'root'); + getBaseToken().vm.$emit('fetch-suggestions', 'root'); return waitForPromises().then(() => { - expect(getBaseToken().props('tokenValues')).toEqual(mockAuthors); + expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); }); }); it('calls `createFlash` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); - getBaseToken().vm.$emit('fetch-token-values', 'root'); + getBaseToken().vm.$emit('fetch-suggestions', 'root'); return waitForPromises().then(() => { expect(createFlash).toHaveBeenCalledWith({ @@ -127,17 +127,17 @@ describe('AuthorToken', () => { it('sets `loading` to false when request completes', async () => { jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); - getBaseToken().vm.$emit('fetch-token-values', 'root'); + getBaseToken().vm.$emit('fetch-suggestions', 'root'); await waitForPromises(); - expect(getBaseToken().props('tokensListLoading')).toBe(false); + expect(getBaseToken().props('suggestionsLoading')).toBe(false); }); }); }); describe('template', () => { - const activateTokenValuesList = async () => { + const activateSuggestionsList = async () => { const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); const suggestionsSegment = tokenSegments.at(2); suggestionsSegment.vm.$emit('activate'); @@ -154,7 +154,7 @@ describe('AuthorToken', () => { expect(baseTokenEl.exists()).toBe(true); expect(baseTokenEl.props()).toMatchObject({ - tokenValues: mockAuthors, + suggestions: mockAuthors, fnActiveTokenValue: wrapper.vm.getActiveAuthor, }); }); @@ -221,7 +221,7 @@ describe('AuthorToken', () => { stubs: { Portal: true }, }); - await activateTokenValuesList(); + await activateSuggestionsList(); const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); @@ -252,7 +252,7 @@ describe('AuthorToken', () => { stubs: { Portal: true }, }); - await activateTokenValuesList(); + await activateSuggestionsList(); const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); 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 89c5cedc9b8..cd6ffd679d0 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 @@ -7,7 +7,7 @@ import { import { DEFAULT_LABELS } from '~/vue_shared/components/filtered_search_bar/constants'; import { - getRecentlyUsedTokenValues, + getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -49,10 +49,10 @@ const mockProps = { config: mockLabelToken, value: { data: '' }, active: false, - tokenValues: [], - tokensListLoading: false, - defaultTokenValues: DEFAULT_LABELS, - recentTokenValuesStorageKey: mockStorageKey, + suggestions: [], + suggestionsLoading: false, + defaultSuggestions: DEFAULT_LABELS, + recentSuggestionsStorageKey: mockStorageKey, fnCurrentTokenValue: jest.fn(), }; @@ -83,7 +83,7 @@ describe('BaseToken', () => { props: { ...mockProps, value: { data: `"${mockRegularLabel.title}"` }, - tokenValues: mockLabels, + suggestions: mockLabels, }, }); }); @@ -93,8 +93,8 @@ describe('BaseToken', () => { }); describe('data', () => { - it('calls `getRecentlyUsedTokenValues` to populate `recentTokenValues` when `recentTokenValuesStorageKey` is defined', () => { - expect(getRecentlyUsedTokenValues).toHaveBeenCalledWith(mockStorageKey); + it('calls `getRecentlyUsedSuggestions` to populate `recentSuggestions` when `recentSuggestionsStorageKey` is defined', () => { + expect(getRecentlyUsedSuggestions).toHaveBeenCalledWith(mockStorageKey); }); }); @@ -147,15 +147,15 @@ describe('BaseToken', () => { wrapperWithTokenActive.destroy(); }); - it('emits `fetch-token-values` event on the component when value of this prop is changed to false and `tokenValues` array is empty', async () => { + it('emits `fetch-suggestions` event on the component when value of this prop is changed to false and `suggestions` array is empty', async () => { wrapperWithTokenActive.setProps({ active: false, }); await wrapperWithTokenActive.vm.$nextTick(); - expect(wrapperWithTokenActive.emitted('fetch-token-values')).toBeTruthy(); - expect(wrapperWithTokenActive.emitted('fetch-token-values')).toEqual([ + expect(wrapperWithTokenActive.emitted('fetch-suggestions')).toBeTruthy(); + expect(wrapperWithTokenActive.emitted('fetch-suggestions')).toEqual([ [`"${mockRegularLabel.title}"`], ]); }); @@ -164,7 +164,7 @@ describe('BaseToken', () => { describe('methods', () => { describe('handleTokenValueSelected', () => { - it('calls `setTokenValueToRecentlyUsed` when `recentTokenValuesStorageKey` is defined', () => { + it('calls `setTokenValueToRecentlyUsed` when `recentSuggestionsStorageKey` is defined', () => { const mockTokenValue = { id: 1, title: 'Foo', @@ -175,14 +175,14 @@ describe('BaseToken', () => { expect(setTokenValueToRecentlyUsed).toHaveBeenCalledWith(mockStorageKey, mockTokenValue); }); - it('does not add token from preloadedTokenValues', async () => { + it('does not add token from preloadedSuggestions', async () => { const mockTokenValue = { id: 1, title: 'Foo', }; wrapper.setProps({ - preloadedTokenValues: [mockTokenValue], + preloadedSuggestions: [mockTokenValue], }); await wrapper.vm.$nextTick(); @@ -228,7 +228,7 @@ describe('BaseToken', () => { wrapperWithNoStubs.destroy(); }); - it('emits `fetch-token-values` event on component after a delay when component emits `input` event', async () => { + 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' }); @@ -236,8 +236,8 @@ describe('BaseToken', () => { jest.runAllTimers(); - expect(wrapperWithNoStubs.emitted('fetch-token-values')).toBeTruthy(); - expect(wrapperWithNoStubs.emitted('fetch-token-values')[2]).toEqual(['foo']); + 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/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js index ca5dc984ae0..bd654c5a9cb 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 @@ -7,7 +7,7 @@ import { mockIterationToken } from '../mock_data'; jest.mock('~/flash'); describe('IterationToken', () => { - const title = 'gitlab-org: #1'; + const id = 123; let wrapper; const createComponent = ({ config = mockIterationToken, value = { data: '' } } = {}) => @@ -28,14 +28,14 @@ describe('IterationToken', () => { }); it('renders iteration value', async () => { - wrapper = createComponent({ value: { data: title } }); + wrapper = createComponent({ value: { data: id } }); await wrapper.vm.$nextTick(); const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); expect(tokenSegments).toHaveLength(3); // `Iteration` `=` `gitlab-org: #1` - expect(tokenSegments.at(2).text()).toBe(title); + expect(tokenSegments.at(2).text()).toBe(id.toString()); }); it('fetches initial values', () => { @@ -43,10 +43,10 @@ describe('IterationToken', () => { wrapper = createComponent({ config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy }, - value: { data: title }, + value: { data: id }, }); - expect(fetchIterationsSpy).toHaveBeenCalledWith(title); + expect(fetchIterationsSpy).toHaveBeenCalledWith(id); }); it('fetches iterations on user input', () => { 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 cc40ff96b65..ec9458f64d2 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 @@ -159,7 +159,7 @@ describe('LabelToken', () => { expect(baseTokenEl.exists()).toBe(true); expect(baseTokenEl.props()).toMatchObject({ - tokenValues: mockLabels, + suggestions: mockLabels, fnActiveTokenValue: wrapper.vm.getActiveLabel, }); }); 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 9f550ac9afc..74ceb03bb96 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 @@ -9,6 +9,7 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; +import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -21,6 +22,7 @@ import { } from '../mock_data'; jest.mock('~/flash'); +jest.mock('~/milestones/milestone_utils'); const defaultStubs = { Portal: true, @@ -112,6 +114,7 @@ describe('MilestoneToken', () => { return waitForPromises().then(() => { expect(wrapper.vm.milestones).toEqual(mockMilestones); + expect(sortMilestonesByDueDate).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap index f3ce03796f9..5e956d66b6a 100644 --- a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap @@ -55,6 +55,8 @@ exports[`Issue placeholder note component matches snapshot 1`] = ` <p> Foo </p> + + </div> </div> </div> diff --git a/spec/frontend/vue_shared/components/paginated_list_spec.js b/spec/frontend/vue_shared/components/paginated_list_spec.js index c0ee49f194f..9f819cc4e94 100644 --- a/spec/frontend/vue_shared/components/paginated_list_spec.js +++ b/spec/frontend/vue_shared/components/paginated_list_spec.js @@ -7,9 +7,11 @@ describe('Pagination links component', () => { let glPaginatedList; const template = ` - <div class="slot" slot-scope="{ listItem }"> - <span class="item">Item Name: {{listItem.id}}</span> - </div> + <template #default="{ listItem }"> + <div class="slot"> + <span class="item">Item Name: {{ listItem.id }}</span> + </div> + </template> `; const props = { diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js index 0daadeebc20..84dad2374cb 100644 --- a/spec/frontend/vue_shared/components/project_avatar/default_spec.js +++ b/spec/frontend/vue_shared/components/project_avatar/default_spec.js @@ -3,7 +3,7 @@ import mountComponent from 'helpers/vue_mount_component_helper'; import { projectData } from 'jest/ide/mock_data'; import { TEST_HOST } from 'spec/test_constants'; import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; -import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue'; +import ProjectAvatarDefault from '~/vue_shared/components/deprecated_project_avatar/default.vue'; describe('ProjectAvatarDefault component', () => { const Component = Vue.extend(ProjectAvatarDefault); diff --git a/spec/frontend/vue_shared/components/project_avatar_spec.js b/spec/frontend/vue_shared/components/project_avatar_spec.js new file mode 100644 index 00000000000..d55f3127a74 --- /dev/null +++ b/spec/frontend/vue_shared/components/project_avatar_spec.js @@ -0,0 +1,67 @@ +import { GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; + +const defaultProps = { + projectName: 'GitLab', +}; + +describe('ProjectAvatar', () => { + let wrapper; + + const findGlAvatar = () => wrapper.findComponent(GlAvatar); + + const createComponent = ({ props, attrs } = {}) => { + wrapper = shallowMount(ProjectAvatar, { propsData: { ...defaultProps, ...props }, attrs }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders GlAvatar with correct props', () => { + createComponent(); + + const avatar = findGlAvatar(); + expect(avatar.exists()).toBe(true); + expect(avatar.props()).toMatchObject({ + alt: defaultProps.projectName, + entityName: defaultProps.projectName, + size: 32, + src: '', + }); + }); + + describe('with `size` prop', () => { + it('renders GlAvatar with specified `size` prop', () => { + const mockSize = 48; + createComponent({ props: { size: mockSize } }); + + const avatar = findGlAvatar(); + expect(avatar.props('size')).toBe(mockSize); + }); + }); + + describe('with `projectAvatarUrl` prop', () => { + it('renders GlAvatar with specified `src` prop', () => { + const mockProjectAvatarUrl = 'https://gitlab.com'; + createComponent({ props: { projectAvatarUrl: mockProjectAvatarUrl } }); + + const avatar = findGlAvatar(); + expect(avatar.props('src')).toBe(mockProjectAvatarUrl); + }); + }); + + describe.each` + alt + ${''} + ${'custom-alt'} + `('when `alt` prop is "$alt"', ({ alt }) => { + it('renders GlAvatar with specified `alt` attribute', () => { + createComponent({ props: { alt } }); + + const avatar = findGlAvatar(); + expect(avatar.props('alt')).toBe(alt); + }); + }); +}); 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 649eb2643f1..ab028ea52b7 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,5 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; 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'; const localVue = createLocalVue(); @@ -53,7 +54,7 @@ describe('ProjectListItem component', () => { it(`renders the project avatar`, () => { wrapper = shallowMount(Component, options); - expect(wrapper.find('.js-project-avatar').exists()).toBe(true); + expect(wrapper.findComponent(ProjectAvatar).exists()).toBe(true); }); it(`renders a simple namespace name with a trailing slash`, () => { diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap index add0c36a120..cdfe311acd9 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/resizable_chart_container_spec.js.snap @@ -2,20 +2,22 @@ exports[`Resizable Chart Container renders the component 1`] = ` <div> - <div - class="slot" - > - <span - class="width" + <template> + <div + class="slot" > - 0 - </span> - - <span - class="height" - > - 0 - </span> - </div> + <span + class="width" + > + 0 + </span> + + <span + class="height" + > + 0 + </span> + </div> + </template> </div> `; diff --git a/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js b/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js index 1fce3c5d0b0..40f0c0f29f2 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js +++ b/spec/frontend/vue_shared/components/resizable_chart/resizable_chart_container_spec.js @@ -16,10 +16,12 @@ describe('Resizable Chart Container', () => { wrapper = mount(ResizableChartContainer, { scopedSlots: { default: ` - <div class="slot" slot-scope="{ width, height }"> - <span class="width">{{width}}</span> - <span class="height">{{height}}</span> - </div> + <template #default="{ width, height }"> + <div class="slot"> + <span class="width">{{width}}</span> + <span class="height">{{height}}</span> + </div> + </template> `, }, }); diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js index d58c87d66cb..395c74dcba6 100644 --- a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js +++ b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { - expectedDownloadDropdownProps, + expectedDownloadDropdownPropsWithTitle, securityReportMergeRequestDownloadPathsQueryResponse, } from 'jest/vue_shared/security_reports/mock_data'; import createFlash from '~/flash'; @@ -80,7 +80,7 @@ describe('Merge request artifact Download', () => { }); it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithTitle); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js b/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js index b99b1a66b79..3980033862e 100644 --- a/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; @@ -14,6 +14,9 @@ describe('SidebarCopyableField', () => { const createComponent = (propsData = defaultProps) => { wrapper = shallowMount(CopyableField, { propsData, + stubs: { + GlSprintf, + }, }); }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 60903933505..06ea88c09a0 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -54,7 +54,6 @@ describe('DropdownContentsLabelsView', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]'); @@ -381,6 +380,15 @@ describe('DropdownContentsLabelsView', () => { expect(findDropdownFooter().exists()).toBe(false); }); + it('does not render footer list items when `allowLabelCreate` is false and `labelsManagePath` is null', () => { + createComponent({ + ...mockConfig, + allowLabelCreate: false, + labelsManagePath: null, + }); + expect(findDropdownFooter().exists()).toBe(false); + }); + it('renders footer list items when `state.variant` is "embedded"', () => { expect(findDropdownFooter().exists()).toBe(true); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index 3f11095cb04..46ade5d5857 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -1,11 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions'; import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; +jest.mock('~/flash'); + describe('LabelsSelect Actions', () => { let state; const mockInitialState = { @@ -91,10 +94,6 @@ describe('LabelsSelect Actions', () => { }); describe('receiveLabelsFailure', () => { - beforeEach(() => { - setFixtures('<div class="flash-container"></div>'); - }); - it('sets value `state.labelsFetchInProgress` to `false`', (done) => { testAction( actions.receiveLabelsFailure, @@ -109,9 +108,7 @@ describe('LabelsSelect Actions', () => { it('shows flash error', () => { actions.receiveLabelsFailure({ commit: () => {} }); - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - 'Error fetching labels.', - ); + expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); }); }); @@ -186,10 +183,6 @@ describe('LabelsSelect Actions', () => { }); describe('receiveCreateLabelFailure', () => { - beforeEach(() => { - setFixtures('<div class="flash-container"></div>'); - }); - it('sets value `state.labelCreateInProgress` to `false`', (done) => { testAction( actions.receiveCreateLabelFailure, @@ -204,9 +197,7 @@ describe('LabelsSelect Actions', () => { it('shows flash error', () => { actions.receiveCreateLabelFailure({ commit: () => {} }); - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - 'Error creating label.', - ); + expect(createFlash).toHaveBeenCalledWith({ message: 'Error creating label.' }); }); }); 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 ab266ac8aed..1d2a9c34599 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 @@ -153,7 +153,16 @@ describe('LabelsSelect Mutations', () => { }); describe(`${types.UPDATE_SELECTED_LABELS}`, () => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + let labels; + + beforeEach(() => { + labels = [ + { id: 1, title: 'scoped::test', set: true }, + { id: 2, set: false, title: 'scoped::one' }, + { id: 3, title: '' }, + { id: 4, title: '' }, + ]; + }); it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { const updatedLabelIds = [2]; @@ -169,5 +178,23 @@ describe('LabelsSelect Mutations', () => { } }); }); + + describe('when label is scoped', () => { + it('unsets the currently selected scoped label and sets the current label', () => { + const state = { + labels, + }; + mutations[types.UPDATE_SELECTED_LABELS](state, { + labels: [{ id: 2, title: 'scoped::one' }], + }); + + expect(state.labels).toEqual([ + { id: 1, title: 'scoped::test', set: false }, + { id: 2, set: true, title: 'scoped::one', touched: true }, + { id: 3, title: '' }, + { id: 4, title: '' }, + ]); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js index 59f3268c000..b3ffee2d020 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js @@ -1,88 +1,97 @@ import { GlLabel } from '@gitlab/ui'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; +import { shallowMount } from '@vue/test-utils'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store'; - -import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data'; - -const localVue = createLocalVue(); -localVue.use(Vuex); +import { mockRegularLabel, mockScopedLabel } from './mock_data'; describe('DropdownValue', () => { let wrapper; - const createComponent = (initialState = {}, slots = {}) => { - const store = new Vuex.Store(labelsSelectModule()); - - store.dispatch('setInitialState', { ...mockConfig, ...initialState }); + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findRegularLabel = () => findAllLabels().at(0); + const findScopedLabel = () => findAllLabels().at(1); + const findWrapper = () => wrapper.find('[data-testid="value-wrapper"]'); + const findEmptyPlaceholder = () => wrapper.find('[data-testid="empty-placeholder"]'); + const createComponent = (props = {}, slots = {}) => { wrapper = shallowMount(DropdownValue, { - localVue, - store, slots, + propsData: { + selectedLabels: [mockRegularLabel, mockScopedLabel], + allowLabelRemove: true, + allowScopedLabels: true, + labelsFilterBasePath: '/gitlab-org/my-project/issues', + labelsFilterParam: 'label_name', + ...props, + }, }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); - describe('methods', () => { - describe('labelFilterUrl', () => { - it('returns a label filter URL based on provided label param', () => { - createComponent(); - - expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe( - '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', - ); - }); + describe('when there are no labels', () => { + beforeEach(() => { + createComponent( + { + selectedLabels: [], + }, + { + default: 'None', + }, + ); }); - describe('scopedLabel', () => { - beforeEach(() => { - createComponent(); - }); + it('does not apply `has-labels` class to the wrapping container', () => { + expect(findWrapper().classes()).not.toContain('has-labels'); + }); - it('returns `true` when provided label param is a scoped label', () => { - expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true); - }); + it('renders an empty placeholder', () => { + expect(findEmptyPlaceholder().exists()).toBe(true); + expect(findEmptyPlaceholder().text()).toBe('None'); + }); - it('returns `false` when provided label param is a regular label', () => { - expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false); - }); + it('does not render any labels', () => { + expect(findAllLabels().length).toBe(0); }); }); - describe('template', () => { - it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => { + describe('when there are labels', () => { + beforeEach(() => { createComponent(); + }); - expect(wrapper.attributes('class')).toContain('has-labels'); + it('applies `has-labels` class to the wrapping container', () => { + expect(findWrapper().classes()).toContain('has-labels'); }); - it('renders element containing `None` when `selectedLabels` is empty', () => { - createComponent( - { - selectedLabels: [], - }, - { - default: 'None', - }, - ); - const noneEl = wrapper.find('span.text-secondary'); + it('does not render an empty placeholder', () => { + expect(findEmptyPlaceholder().exists()).toBe(false); + }); - expect(noneEl.exists()).toBe(true); - expect(noneEl.text()).toBe('None'); + it('renders a list of two labels', () => { + expect(findAllLabels().length).toBe(2); }); - it('renders labels when `selectedLabels` is not empty', () => { - createComponent(); + it('passes correct props to the regular label', () => { + expect(findRegularLabel().props('target')).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', + ); + expect(findRegularLabel().props('scoped')).toBe(false); + }); + + it('passes correct props to the scoped label', () => { + expect(findScopedLabel().props('target')).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%3A%3ABar', + ); + expect(findScopedLabel().props('scoped')).toBe(true); + }); - expect(wrapper.findAll(GlLabel).length).toBe(2); + it('emits `onLabelRemove` event with the correct ID', () => { + findRegularLabel().vm.$emit('close'); + expect(wrapper.emitted('onLabelRemove')).toEqual([[mockRegularLabel.id]]); }); }); }); 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 ee1346c362f..66971446f47 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 @@ -34,6 +34,10 @@ describe('LabelsSelectRoot', () => { stubs: { 'dropdown-contents': DropdownContents, }, + provide: { + iid: '1', + projectPath: 'test', + }, }); }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js index 7ef4b769b6b..27de7de2411 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js @@ -1,11 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions'; import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types'; import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state'; +jest.mock('~/flash'); + describe('LabelsSelect Actions', () => { let state; const mockInitialState = { @@ -91,10 +94,6 @@ describe('LabelsSelect Actions', () => { }); describe('receiveLabelsFailure', () => { - beforeEach(() => { - setFixtures('<div class="flash-container"></div>'); - }); - it('sets value `state.labelsFetchInProgress` to `false`', (done) => { testAction( actions.receiveLabelsFailure, @@ -109,9 +108,7 @@ describe('LabelsSelect Actions', () => { it('shows flash error', () => { actions.receiveLabelsFailure({ commit: () => {} }); - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - 'Error fetching labels.', - ); + expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js index acb275b5d90..9e965cb33e8 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js @@ -120,7 +120,16 @@ describe('LabelsSelect Mutations', () => { }); describe(`${types.UPDATE_SELECTED_LABELS}`, () => { - const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + let labels; + + beforeEach(() => { + labels = [ + { id: 1, title: 'scoped::test', set: true }, + { id: 2, set: false, title: 'scoped::one' }, + { id: 3, title: '' }, + { id: 4, title: '' }, + ]; + }); it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { const updatedLabelIds = [2]; @@ -136,5 +145,23 @@ describe('LabelsSelect Mutations', () => { } }); }); + + describe('when label is scoped', () => { + it('unsets the currently selected scoped label and sets the current label', () => { + const state = { + labels, + }; + mutations[types.UPDATE_SELECTED_LABELS](state, { + labels: [{ id: 2, title: 'scoped::one' }], + }); + + expect(state.labels).toEqual([ + { id: 1, title: 'scoped::test', set: false }, + { id: 2, set: true, title: 'scoped::one', touched: true }, + { id: 3, title: '' }, + { id: 4, title: '' }, + ]); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/sidebar/todo_button_spec.js index 8043bb7785b..de3e1ccfb03 100644 --- a/spec/frontend/vue_shared/components/todo_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/todo_button_spec.js @@ -1,9 +1,10 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; -import TodoButton from '~/vue_shared/components/todo_button.vue'; +import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; describe('Todo Button', () => { let wrapper; + let dispatchEventSpy; const createComponent = (props = {}, mountFn = shallowMount) => { wrapper = mountFn(TodoButton, { @@ -13,8 +14,17 @@ describe('Todo Button', () => { }); }; + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + jest.spyOn(document, 'querySelector').mockReturnValue({ + innerText: 2, + }); + }); + afterEach(() => { wrapper.destroy(); + dispatchEventSpy = null; + jest.clearAllMocks(); }); it('renders GlButton', () => { @@ -30,6 +40,16 @@ describe('Todo Button', () => { expect(wrapper.emitted().click).toBeTruthy(); }); + it('calls dispatchDocumentEvent to update global To-Do counter correctly', () => { + createComponent({}, mount); + wrapper.find(GlButton).trigger('click'); + const dispatchedEvent = dispatchEventSpy.mock.calls[0][0]; + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + expect(dispatchedEvent.detail).toEqual({ count: 1 }); + expect(dispatchedEvent.type).toBe('todo:toggle'); + }); + it.each` label | isTodo ${'Mark as done'} | ${true} diff --git a/spec/frontend/vue_shared/components/editor_lite_spec.js b/spec/frontend/vue_shared/components/source_editor_spec.js index badd5aed0e3..dca4d60e23c 100644 --- a/spec/frontend/vue_shared/components/editor_lite_spec.js +++ b/spec/frontend/vue_shared/components/source_editor_spec.js @@ -1,12 +1,12 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { EDITOR_READY_EVENT } from '~/editor/constants'; -import Editor from '~/editor/editor_lite'; -import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import Editor from '~/editor/source_editor'; +import SourceEditor from '~/vue_shared/components/source_editor.vue'; -jest.mock('~/editor/editor_lite'); +jest.mock('~/editor/source_editor'); -describe('Editor Lite component', () => { +describe('Source Editor component', () => { let wrapper; let mockInstance; @@ -30,7 +30,7 @@ describe('Editor Lite component', () => { }; }); function createComponent(props = {}) { - wrapper = shallowMount(EditorLite, { + wrapper = shallowMount(SourceEditor, { propsData: { value, fileName, @@ -73,10 +73,10 @@ describe('Editor Lite component', () => { createComponent({ value: undefined }); expect(spy).not.toHaveBeenCalled(); - expect(wrapper.find('[id^="editor-lite-"]').exists()).toBe(true); + expect(wrapper.find('[id^="source-editor-"]').exists()).toBe(true); }); - it('initialises Editor Lite instance', () => { + it('initialises Source Editor instance', () => { const el = wrapper.find({ ref: 'editor' }).element; expect(createInstanceMock).toHaveBeenCalledWith({ el, @@ -111,7 +111,7 @@ describe('Editor Lite component', () => { expect(wrapper.emitted().input).toEqual([[value]]); }); - it('emits EDITOR_READY_EVENT event when the Editor Lite is ready', async () => { + it('emits EDITOR_READY_EVENT event when the Source Editor is ready', async () => { const el = wrapper.find({ ref: 'editor' }).element; expect(wrapper.emitted()[EDITOR_READY_EVENT]).toBeUndefined(); 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 87fe8619f28..538e67ef354 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 @@ -1,5 +1,5 @@ -import { GlSkeletonLoader, GlSprintf, GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlSkeletonLoader, GlIcon } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { AVAILABILITY_STATUS } from '~/set_status_modal/utils'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; @@ -13,6 +13,7 @@ const DEFAULT_PROPS = { bio: null, workInformation: null, status: null, + pronouns: 'they/them', loaded: true, }, }; @@ -30,23 +31,18 @@ describe('User Popover Component', () => { wrapper.destroy(); }); - const findByTestId = (testid) => wrapper.find(`[data-testid="${testid}"]`); const findUserStatus = () => wrapper.find('.js-user-status'); const findTarget = () => document.querySelector('.js-user-link'); const findUserName = () => wrapper.find(UserNameWithStatus); - const findSecurityBotDocsLink = () => findByTestId('user-popover-bot-docs-link'); + const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link'); const createWrapper = (props = {}, options = {}) => { - wrapper = shallowMount(UserPopover, { + wrapper = mountExtended(UserPopover, { propsData: { ...DEFAULT_PROPS, target: findTarget(), ...props, }, - stubs: { - GlSprintf, - UserNameWithStatus, - }, ...options, }); }; @@ -232,6 +228,12 @@ describe('User Popover Component', () => { expect(wrapper.text()).not.toContain('(Busy)'); }); + + it('passes `pronouns` prop to `UserNameWithStatus` component', () => { + createWrapper(); + + expect(findUserName().props('pronouns')).toBe('they/them'); + }); }); describe('bot user', () => { diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index 0fabc6525ea..b777ac0a0a4 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -275,48 +275,4 @@ describe('User select dropdown', () => { expect(findEmptySearchResults().exists()).toBe(true); }); }); - - // TODO Remove this test after the following issue is resolved in the backend - // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 - describe('temporary error suppression', () => { - beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(); - }); - - const nullError = { message: 'Cannot return null for non-nullable field GroupMember.user' }; - - it.each` - mockErrors - ${[nullError]} - ${[nullError, nullError]} - `('does not emit errors', async ({ mockErrors }) => { - createComponent({ - searchQueryHandler: jest.fn().mockResolvedValue({ - errors: mockErrors, - }), - }); - await waitForSearch(); - - expect(wrapper.emitted()).toEqual({}); - // eslint-disable-next-line no-console - expect(console.error).toHaveBeenCalled(); - }); - - it.each` - mockErrors - ${[{ message: 'serious error' }]} - ${[nullError, { message: 'serious error' }]} - `('emits error when non-null related errors are included', async ({ mockErrors }) => { - createComponent({ - searchQueryHandler: jest.fn().mockResolvedValue({ - errors: mockErrors, - }), - }); - await waitForSearch(); - - expect(wrapper.emitted('error')).toEqual([[]]); - // eslint-disable-next-line no-console - expect(console.error).not.toHaveBeenCalled(); - }); - }); }); 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 5a6c91bda9f..0fd4d0dab87 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -15,8 +15,8 @@ const ACTION_EDIT = { tooltip: '', attrs: { 'data-qa-selector': 'edit_button', - 'data-track-event': 'click_edit', - 'data-track-label': 'Edit', + 'data-track-action': 'click_consolidated_edit', + 'data-track-label': 'edit', }, }; const ACTION_EDIT_CONFIRM_FORK = { @@ -32,8 +32,8 @@ const ACTION_WEB_IDE = { text: 'Web IDE', attrs: { 'data-qa-selector': 'web_ide_button', - 'data-track-event': 'click_edit_ide', - 'data-track-label': 'Web IDE', + 'data-track-action': 'click_consolidated_edit_ide', + 'data-track-label': 'web_ide', }, }; const ACTION_WEB_IDE_CONFIRM_FORK = { diff --git a/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js index 602213fca83..2d51f6dbeeb 100644 --- a/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js +++ b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js @@ -1,12 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { mockTracking } from 'helpers/tracking_helper'; -import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; -import { getExperimentData } from '~/experimentation/utils'; import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue'; -jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() })); - describe('Welcome page', () => { let wrapper; let trackingSpy; @@ -28,7 +24,6 @@ describe('Welcome page', () => { beforeEach(() => { trackingSpy = mockTracking('_category_', document, jest.spyOn); trackingSpy.mockImplementation(() => {}); - getExperimentData.mockReturnValue(undefined); }); afterEach(() => { @@ -38,7 +33,7 @@ describe('Welcome page', () => { }); it('tracks link clicks', async () => { - createComponent({ propsData: { experiment: 'foo', panels: [{ name: 'test', href: '#' }] } }); + createComponent({ propsData: { panels: [{ name: 'test', href: '#' }] } }); const link = wrapper.find('a'); link.trigger('click'); await nextTick(); @@ -47,25 +42,6 @@ describe('Welcome page', () => { }); }); - it('adds experiment data if in experiment', async () => { - const mockExperimentData = 'data'; - getExperimentData.mockReturnValue(mockExperimentData); - - createComponent({ propsData: { experiment: 'foo', panels: [{ name: 'test', href: '#' }] } }); - const link = wrapper.find('a'); - link.trigger('click'); - await nextTick(); - return wrapper.vm.$nextTick().then(() => { - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { - label: 'test', - context: { - data: mockExperimentData, - schema: TRACKING_CONTEXT_SCHEMA, - }, - }); - }); - }); - it('renders footer slot if provided', () => { const DUMMY = 'Test message'; createComponent({ diff --git a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js index 30937921900..6115dc6e61b 100644 --- a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js +++ b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js @@ -37,13 +37,6 @@ describe('Experimental new project creation app', () => { window.location.hash = ''; }); - it('passes experiment to welcome component if provided', () => { - const EXPERIMENT = 'foo'; - createComponent({ propsData: { experiment: EXPERIMENT } }); - - expect(findWelcomePage().props().experiment).toBe(EXPERIMENT); - }); - describe('with empty hash', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/vue_shared/oncall_schedules_list_spec.js b/spec/frontend/vue_shared/oncall_schedules_list_spec.js index 5c30809c09b..f83a5187b8b 100644 --- a/spec/frontend/vue_shared/oncall_schedules_list_spec.js +++ b/spec/frontend/vue_shared/oncall_schedules_list_spec.js @@ -18,7 +18,7 @@ const mockSchedules = [ }, ]; -const userName = 'User 1'; +const userName = "O'User"; describe('On-call schedules list', () => { let wrapper; diff --git a/spec/frontend/vue_shared/plugins/global_toast_spec.js b/spec/frontend/vue_shared/plugins/global_toast_spec.js index 89f43a5e556..322586a772c 100644 --- a/spec/frontend/vue_shared/plugins/global_toast_spec.js +++ b/spec/frontend/vue_shared/plugins/global_toast_spec.js @@ -1,11 +1,10 @@ -import Vue from 'vue'; -import toast from '~/vue_shared/plugins/global_toast'; +import toast, { instance } from '~/vue_shared/plugins/global_toast'; describe('Global toast', () => { let spyFunc; beforeEach(() => { - spyFunc = jest.spyOn(Vue.prototype.$toast, 'show').mockImplementation(() => {}); + spyFunc = jest.spyOn(instance.$toast, 'show').mockImplementation(() => {}); }); afterEach(() => { @@ -18,7 +17,7 @@ describe('Global toast', () => { toast(arg1, arg2); - expect(Vue.prototype.$toast.show).toHaveBeenCalledTimes(1); - expect(Vue.prototype.$toast.show).toHaveBeenCalledWith(arg1, arg2); + expect(instance.$toast.show).toHaveBeenCalledTimes(1); + expect(instance.$toast.show).toHaveBeenCalledWith(arg1, arg2); }); }); diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js index 517eee6a729..facbd51168c 100644 --- a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js +++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js @@ -9,6 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { humanize } from '~/lib/utils/text_utility'; import { redirectTo } from '~/lib/utils/url_utility'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; +import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { buildConfigureSecurityFeatureMockFactory } from './apollo_mocks'; jest.mock('~/lib/utils/url_utility'); @@ -169,6 +170,29 @@ describe('ManageViaMr component', () => { }, ); + describe('canRender static method', () => { + it.each` + context | type | available | configured | canEnableByMergeRequest | expectedValue + ${'an unconfigured feature'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${true} | ${true} + ${'a configured feature'} | ${REPORT_TYPE_SAST} | ${true} | ${true} | ${true} | ${false} + ${'an unavailable feature'} | ${REPORT_TYPE_SAST} | ${false} | ${false} | ${true} | ${false} + ${'a feature which cannot be enabled via MR'} | ${REPORT_TYPE_SAST} | ${true} | ${false} | ${false} | ${false} + ${'an unknown feature'} | ${'foo'} | ${true} | ${false} | ${true} | ${false} + `( + 'given $context returns $expectedValue', + ({ type, available, configured, canEnableByMergeRequest, expectedValue }) => { + expect( + ManageViaMr.canRender({ + type, + available, + configured, + canEnableByMergeRequest, + }), + ).toBe(expectedValue); + }, + ); + }); + describe('button props', () => { it('passes the variant and category props to the GlButton', () => { const variant = 'danger'; diff --git a/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js index 9138d2d3f4c..4b75da0b126 100644 --- a/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js +++ b/spec/frontend/vue_shared/security_reports/components/security_report_download_dropdown_spec.js @@ -40,14 +40,13 @@ describe('SecurityReportDownloadDropdown component', () => { expect(findDropdown().props('loading')).toBe(false); }); - it('renders a dropdown items for each artifact', () => { + it('renders a dropdown item for each artifact', () => { artifacts.forEach((artifact, i) => { const item = findDropdownItems().at(i); expect(item.text()).toContain(artifact.name); - expect(item.attributes()).toMatchObject({ - href: artifact.path, - download: expect.any(String), - }); + + expect(item.element.getAttribute('href')).toBe(artifact.path); + expect(item.element.getAttribute('download')).toBeDefined(); }); }); }); @@ -61,4 +60,32 @@ describe('SecurityReportDownloadDropdown component', () => { expect(findDropdown().props('loading')).toBe(true); }); }); + + describe('given title props', () => { + beforeEach(() => { + createComponent({ artifacts: [], loading: true, title: 'test title' }); + }); + + it('should render title', () => { + expect(findDropdown().attributes('title')).toBe('test title'); + }); + + it('should not render text', () => { + expect(findDropdown().text().trim()).toBe(''); + }); + }); + + describe('given text props', () => { + beforeEach(() => { + createComponent({ artifacts: [], loading: true, text: 'test text' }); + }); + + it('should not render title', () => { + expect(findDropdown().props().title).not.toBeDefined(); + }); + + it('should render text', () => { + expect(findDropdown().props().text).toContain('test text'); + }); + }); }); diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js index bd9ce3b7314..06631710509 100644 --- a/spec/frontend/vue_shared/security_reports/mock_data.js +++ b/spec/frontend/vue_shared/security_reports/mock_data.js @@ -581,9 +581,18 @@ export const secretDetectionArtifacts = [ }, ]; -export const expectedDownloadDropdownProps = { +export const expectedDownloadDropdownPropsWithTitle = { loading: false, artifacts: [...secretDetectionArtifacts, ...sastArtifacts], + text: '', + title: 'Download results', +}; + +export const expectedDownloadDropdownPropsWithText = { + loading: false, + artifacts: [...secretDetectionArtifacts, ...sastArtifacts], + title: '', + text: 'Download results', }; /** 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 038d7754776..bef538e1ff1 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 @@ -8,7 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { trimText } from 'helpers/text_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { - expectedDownloadDropdownProps, + expectedDownloadDropdownPropsWithText, securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse, securityReportMergeRequestDownloadPathsQueryResponse, sastDiffSuccessMock, @@ -99,7 +99,7 @@ describe('Security reports app', () => { }); it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithText); }); it('renders the expected message', () => { @@ -203,7 +203,7 @@ describe('Security reports app', () => { }); it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithText); }); }); @@ -225,7 +225,7 @@ describe('Security reports app', () => { }); it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithText); }); }); @@ -247,7 +247,7 @@ describe('Security reports app', () => { }); it('renders the download dropdown', () => { - expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownPropsWithText); }); }); diff --git a/spec/frontend/vuex_shared/bindings_spec.js b/spec/frontend/vuex_shared/bindings_spec.js index 0f91a09018f..4e210143c8c 100644 --- a/spec/frontend/vuex_shared/bindings_spec.js +++ b/spec/frontend/vuex_shared/bindings_spec.js @@ -3,7 +3,7 @@ import { mapComputed } from '~/vuex_shared/bindings'; describe('Binding utils', () => { describe('mapComputed', () => { - const defaultArgs = [['baz'], 'bar', 'foo']; + const defaultArgs = [['baz'], 'bar', 'foo', 'qux']; const createDummy = (mapComputedArgs = defaultArgs) => ({ computed: { @@ -29,12 +29,18 @@ describe('Binding utils', () => { }, }; - it('returns an object with keys equal to the first fn parameter ', () => { + it('returns an object with keys equal to the first fn parameter', () => { const keyList = ['foo1', 'foo2']; const result = mapComputed(keyList, 'foo', 'bar'); expect(Object.keys(result)).toEqual(keyList); }); + it('returns an object with keys equal to the first fn parameter when the root is a function', () => { + const keyList = ['foo1', 'foo2']; + const result = mapComputed(keyList, 'foo', (state) => state.bar); + expect(Object.keys(result)).toEqual(keyList); + }); + it('returned object has set and get function', () => { const result = mapComputed(['baz'], 'foo', 'bar'); expect(result.baz.set).toBeDefined(); |