diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-05 16:54:15 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-05 16:54:15 +0300 |
commit | be834a25982746ffd85252ff502df42bb88cb9d5 (patch) | |
tree | b4d6a8ba0931e12fac08f05abea33a3b8ec2c8a2 /spec/frontend | |
parent | ee925a3597f27e92f83a50937a64068109675b3d (diff) |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc32
Diffstat (limited to 'spec/frontend')
285 files changed, 13284 insertions, 4369 deletions
diff --git a/spec/frontend/alert_handler_spec.js b/spec/frontend/alert_handler_spec.js index ba2f4f24aa5..0cee28112a8 100644 --- a/spec/frontend/alert_handler_spec.js +++ b/spec/frontend/alert_handler_spec.js @@ -2,18 +2,26 @@ import { setHTMLFixture } from 'helpers/fixtures'; import initAlertHandler from '~/alert_handler'; describe('Alert Handler', () => { - const ALERT_SELECTOR = 'gl-alert'; - const CLOSE_SELECTOR = 'gl-alert-dismiss'; - const ALERT_HTML = `<div class="${ALERT_SELECTOR}"><button class="${CLOSE_SELECTOR}">Dismiss</button></div>`; + const ALERT_CLASS = 'gl-alert'; + const BANNER_CLASS = 'gl-banner'; + const DISMISS_CLASS = 'gl-alert-dismiss'; + const DISMISS_LABEL = 'Dismiss'; - const findFirstAlert = () => document.querySelector(`.${ALERT_SELECTOR}`); - const findAllAlerts = () => document.querySelectorAll(`.${ALERT_SELECTOR}`); - const findFirstCloseButton = () => document.querySelector(`.${CLOSE_SELECTOR}`); + const generateHtml = parentClass => + `<div class="${parentClass}"> + <button aria-label="${DISMISS_LABEL}">Dismiss</button> + </div>`; + + const findFirstAlert = () => document.querySelector(`.${ALERT_CLASS}`); + const findFirstBanner = () => document.querySelector(`.${BANNER_CLASS}`); + const findAllAlerts = () => document.querySelectorAll(`.${ALERT_CLASS}`); + const findFirstDismissButton = () => document.querySelector(`[aria-label="${DISMISS_LABEL}"]`); + const findFirstDismissButtonByClass = () => document.querySelector(`.${DISMISS_CLASS}`); describe('initAlertHandler', () => { describe('with one alert', () => { beforeEach(() => { - setHTMLFixture(ALERT_HTML); + setHTMLFixture(generateHtml(ALERT_CLASS)); initAlertHandler(); }); @@ -22,14 +30,14 @@ describe('Alert Handler', () => { }); it('should dismiss the alert on click', () => { - findFirstCloseButton().click(); + findFirstDismissButton().click(); expect(findFirstAlert()).not.toExist(); }); }); describe('with two alerts', () => { beforeEach(() => { - setHTMLFixture(ALERT_HTML + ALERT_HTML); + setHTMLFixture(generateHtml(ALERT_CLASS) + generateHtml(ALERT_CLASS)); initAlertHandler(); }); @@ -38,9 +46,46 @@ describe('Alert Handler', () => { }); it('should dismiss only one alert on click', () => { - findFirstCloseButton().click(); + findFirstDismissButton().click(); expect(findAllAlerts()).toHaveLength(1); }); }); + + describe('with a dismissible banner', () => { + beforeEach(() => { + setHTMLFixture(generateHtml(BANNER_CLASS)); + initAlertHandler(); + }); + + it('should render the banner', () => { + expect(findFirstBanner()).toExist(); + }); + + it('should dismiss the banner on click', () => { + findFirstDismissButton().click(); + expect(findFirstBanner()).not.toExist(); + }); + }); + + // Dismiss buttons *should* have the correct aria labels, but some of them won't + // because legacy code isn't always a11y compliant. + // This tests that the fallback for the incorrectly labelled buttons works. + describe('with a mislabelled dismiss button', () => { + beforeEach(() => { + setHTMLFixture(`<div class="${ALERT_CLASS}"> + <button class="${DISMISS_CLASS}">Dismiss</button> + </div>`); + initAlertHandler(); + }); + + it('should render the banner', () => { + expect(findFirstAlert()).toExist(); + }); + + it('should dismiss the banner on click', () => { + findFirstDismissButtonByClass().click(); + expect(findFirstAlert()).not.toExist(); + }); + }); }); }); diff --git a/spec/frontend/alert_management/components/alert_details_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js index 8aa26dbca3b..910bb31b573 100644 --- a/spec/frontend/alert_management/components/alert_details_spec.js +++ b/spec/frontend/alert_management/components/alert_details_spec.js @@ -2,8 +2,10 @@ import { mount, shallowMount } from '@vue/test-utils'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import AlertDetails from '~/alert_management/components/alert_details.vue'; +import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue'; import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql'; import { joinPaths } from '~/lib/utils/url_utility'; import { @@ -24,31 +26,36 @@ describe('AlertDetails', () => { const $router = { replace: jest.fn() }; function mountComponent({ data, loading = false, mountMethod = shallowMount, stubs = {} } = {}) { - wrapper = mountMethod(AlertDetails, { - provide: { - alertId: 'alertId', - projectPath, - projectIssuesPath, - projectId, - }, - data() { - return { alert: { ...mockAlert }, sidebarStatus: false, ...data }; - }, - mocks: { - $apollo: { - mutate: jest.fn(), - queries: { - alert: { - loading, + wrapper = extendedWrapper( + mountMethod(AlertDetails, { + provide: { + alertId: 'alertId', + projectPath, + projectIssuesPath, + projectId, + }, + data() { + return { alert: { ...mockAlert }, sidebarStatus: false, ...data }; + }, + mocks: { + $apollo: { + mutate: jest.fn(), + queries: { + alert: { + loading, + }, + sidebarStatus: {}, }, - sidebarStatus: {}, }, + $router, + $route: { params: {} }, }, - $router, - $route: { params: {} }, - }, - stubs, - }); + stubs: { + ...stubs, + AlertSummaryRow, + }, + }), + ); } beforeEach(() => { @@ -62,9 +69,10 @@ describe('AlertDetails', () => { mock.restore(); }); - const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); - const findViewIncidentBtn = () => wrapper.find('[data-testid="viewIncidentBtn"]'); - const findIncidentCreationAlert = () => wrapper.find('[data-testid="incidentCreationError"]'); + const findCreateIncidentBtn = () => wrapper.findByTestId('createIncidentBtn'); + const findViewIncidentBtn = () => wrapper.findByTestId('viewIncidentBtn'); + const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError'); + const findEnvironmentLink = () => wrapper.findByTestId('environmentUrl'); const findDetailsTable = () => wrapper.find(AlertDetailsTable); describe('Alert details', () => { @@ -74,7 +82,7 @@ describe('AlertDetails', () => { }); it('shows an empty state', () => { - expect(wrapper.find('[data-testid="alertDetailsTabs"]').exists()).toBe(false); + expect(wrapper.findByTestId('alertDetailsTabs').exists()).toBe(false); }); }); @@ -84,28 +92,26 @@ describe('AlertDetails', () => { }); it('renders a tab with overview information', () => { - expect(wrapper.find('[data-testid="overview"]').exists()).toBe(true); + expect(wrapper.findByTestId('overview').exists()).toBe(true); }); it('renders a tab with an activity feed', () => { - expect(wrapper.find('[data-testid="activity"]').exists()).toBe(true); + expect(wrapper.findByTestId('activity').exists()).toBe(true); }); it('renders severity', () => { - expect(wrapper.find('[data-testid="severity"]').text()).toBe( + expect(wrapper.findByTestId('severity').text()).toBe( ALERTS_SEVERITY_LABELS[mockAlert.severity], ); }); it('renders a title', () => { - expect(wrapper.find('[data-testid="title"]').text()).toBe(mockAlert.title); + expect(wrapper.findByTestId('title').text()).toBe(mockAlert.title); }); it('renders a start time', () => { - expect(wrapper.find('[data-testid="startTimeItem"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="startTimeItem"]').props().time).toBe( - mockAlert.startedAt, - ); + expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true); + expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt); }); }); @@ -114,6 +120,8 @@ describe('AlertDetails', () => { field | data | isShown ${'eventCount'} | ${1} | ${true} ${'eventCount'} | ${undefined} | ${false} + ${'environment'} | ${undefined} | ${false} + ${'environment'} | ${'Production'} | ${true} ${'monitoringTool'} | ${'New Relic'} | ${true} ${'monitoringTool'} | ${undefined} | ${false} ${'service'} | ${'Prometheus'} | ${true} @@ -126,15 +134,29 @@ describe('AlertDetails', () => { }); it(`${field} is ${isShown ? 'displayed' : 'hidden'} correctly`, () => { + const element = wrapper.findByTestId(field); if (isShown) { - expect(wrapper.find(`[data-testid="${field}"]`).text()).toBe(data.toString()); + expect(element.text()).toContain(data.toString()); } else { - expect(wrapper.find(`[data-testid="${field}"]`).exists()).toBe(false); + expect(wrapper.findByTestId(field).exists()).toBe(false); } }); }); }); + describe('environment URL fields', () => { + it('should show the environment URL when available', () => { + const environment = 'Production'; + const environmentUrl = 'fake/url'; + mountComponent({ + data: { alert: { ...mockAlert, environment, environmentUrl } }, + }); + + expect(findEnvironmentLink().text()).toBe(environment); + expect(findEnvironmentLink().attributes('href')).toBe(environmentUrl); + }); + }); + describe('Create incident from alert', () => { it('should display "View incident" button that links the incident page when incident exists', () => { const issueIid = '3'; @@ -222,7 +244,7 @@ describe('AlertDetails', () => { mountComponent({ data: { errored: true, sidebarErrorMessage: '<span data-testid="htmlError" />' }, }); - expect(wrapper.find('[data-testid="htmlError"]').exists()).toBe(true); + expect(wrapper.findByTestId('htmlError').exists()).toBe(true); }); it('does not display an error when dismissed', () => { @@ -232,7 +254,7 @@ describe('AlertDetails', () => { }); describe('header', () => { - const findHeader = () => wrapper.find('[data-testid="alert-header"]'); + const findHeader = () => wrapper.findByTestId('alert-header'); const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } }; describe('individual header fields', () => { diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index bcad415eb19..3aa67614369 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -3,8 +3,8 @@ import { GlTable, GlAlert, GlLoadingIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, GlIcon, GlTabs, GlTab, @@ -34,12 +34,12 @@ describe('AlertManagementTable', () => { const findAlerts = () => wrapper.findAll('table tbody tr'); const findAlert = () => wrapper.find(GlAlert); const findLoader = () => wrapper.find(GlLoadingIcon); - const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown); + const findStatusDropdown = () => wrapper.find(GlDropdown); const findStatusFilterTabs = () => wrapper.findAll(GlTab); const findStatusTabs = () => wrapper.find(GlTabs); const findStatusFilterBadge = () => wrapper.findAll(GlBadge); const findDateFields = () => wrapper.findAll(TimeAgo); - const findFirstStatusOption = () => findStatusDropdown().find(GlDeprecatedDropdownItem); + const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); const findPagination = () => wrapper.find(GlPagination); const findSearch = () => wrapper.find(GlSearchBoxByType); const findSeverityColumnHeader = () => @@ -295,10 +295,30 @@ describe('AlertManagementTable', () => { loading: false, }); + expect(visitUrl).not.toHaveBeenCalled(); + findAlerts() .at(0) .trigger('click'); - expect(visitUrl).toHaveBeenCalledWith('/1527542/details'); + expect(visitUrl).toHaveBeenCalledWith('/1527542/details', false); + }); + + it('navigates to the detail page in new tab when alert row is clicked with the metaKey', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + loading: false, + }); + + expect(visitUrl).not.toHaveBeenCalled(); + + findAlerts() + .at(0) + .trigger('click', { + metaKey: true, + }); + + expect(visitUrl).toHaveBeenCalledWith('/1527542/details', true); }); describe('alert issue links', () => { diff --git a/spec/frontend/alert_management/components/alert_summary_row_spec.js b/spec/frontend/alert_management/components/alert_summary_row_spec.js new file mode 100644 index 00000000000..47c715c089a --- /dev/null +++ b/spec/frontend/alert_management/components/alert_summary_row_spec.js @@ -0,0 +1,40 @@ +import { shallowMount } from '@vue/test-utils'; +import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue'; + +const label = 'a label'; +const value = 'a value'; + +describe('AlertSummaryRow', () => { + let wrapper; + + function mountComponent({ mountMethod = shallowMount, props, defaultSlot } = {}) { + wrapper = mountMethod(AlertSummaryRow, { + propsData: props, + scopedSlots: { + default: defaultSlot, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('Alert Summary Row', () => { + beforeEach(() => { + mountComponent({ + props: { + label, + }, + defaultSlot: `<span class="value">${value}</span>`, + }); + }); + + it('should display a label and a value', () => { + expect(wrapper.text()).toBe(`${label} ${value}`); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js index 4c9db02eff4..1d87301aac9 100644 --- a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js +++ b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import SidebarAssignee from '~/alert_management/components/sidebar/sidebar_assignee.vue'; import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue'; import AlertSetAssignees from '~/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql'; @@ -106,7 +106,7 @@ describe('Alert Details Sidebar Assignees', () => { it('renders a unassigned option', async () => { wrapper.setData({ isDropdownSearching: false }); await wrapper.vm.$nextTick(); - expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned'); + expect(wrapper.find(GlDropdownItem).text()).toBe('Unassigned'); }); it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { diff --git a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js index a8fe40687e1..e144d473c12 100644 --- a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js +++ b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import { trackAlertStatusUpdateOptions } from '~/alert_management/constants'; import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue'; import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql'; @@ -10,9 +10,10 @@ const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar Status', () => { let wrapper; - const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown); - const findStatusDropdownItem = () => wrapper.find(GlDeprecatedDropdownItem); + const findStatusDropdown = () => wrapper.find(GlDropdown); + const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findStatusDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]'); function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { wrapper = mount(AlertSidebarStatus, { @@ -56,11 +57,7 @@ describe('Alert Details Sidebar Status', () => { }); it('displays the dropdown status header', () => { - expect( - findStatusDropdown() - .find('.dropdown-title') - .exists(), - ).toBe(true); + expect(findStatusDropdownHeader().exists()).toBe(true); }); describe('updating the alert status', () => { diff --git a/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js b/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js index 8dd663e55d9..65cfc600d76 100644 --- a/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js +++ b/spec/frontend/alert_management/components/system_notes/alert_management_system_note_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import SystemNote from '~/alert_management/components/system_notes/system_note.vue'; import mockAlerts from '../../mocks/alerts.json'; @@ -19,6 +20,7 @@ describe('Alert Details System Note', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); + wrapper = null; } }); @@ -29,10 +31,10 @@ describe('Alert Details System Note', () => { it('renders the correct system note', () => { const noteId = wrapper.find('.note-wrapper').attributes('id'); - const iconRoute = wrapper.find('use').attributes('href'); + const iconName = wrapper.find(GlIcon).attributes('name'); expect(noteId).toBe('note_1628'); - expect(iconRoute.includes('user')).toBe(true); + expect(iconName).toBe(mockAlert.notes.nodes[0].systemNoteIconName); }); }); }); diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap index 16e92bf505a..545be94dcaa 100644 --- a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap +++ b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap @@ -26,7 +26,7 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] </gl-form-group-stub> <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\" label-class=\\"label-bold\\"> <gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> <gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\"> Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in. </gl-modal-stub> @@ -34,16 +34,14 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\" label-class=\\"label-bold\\"> <gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub> </gl-form-group-stub> - <div class=\\"gl-display-flex gl-justify-content-end\\"> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub> - </div> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub> <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\"> - <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\"> - Cancel - </gl-button-stub> - <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\"> + <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\"> Save changes </gl-button-stub> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\"> + Cancel + </gl-button-stub> </div> </gl-form-stub> </div>" diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js new file mode 100644 index 00000000000..242621dc40c --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js @@ -0,0 +1,24 @@ +import { shallowMount } from '@vue/test-utils'; +import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue'; +import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue'; + +describe('InstanceStatisticsApp', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(InstanceStatisticsApp); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays the instance counts component', () => { + expect(wrapper.find(InstanceCounts).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js new file mode 100644 index 00000000000..2274f4c3fde --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import InstanceCounts from '~/analytics/instance_statistics/components/instance_counts.vue'; +import MetricCard from '~/analytics/shared/components/metric_card.vue'; +import countsMockData from '../mock_data'; + +describe('InstanceCounts', () => { + let wrapper; + + const createComponent = ({ loading = false, data = {} } = {}) => { + const $apollo = { + queries: { + counts: { + loading, + }, + }, + }; + + wrapper = shallowMount(InstanceCounts, { + mocks: { $apollo }, + data() { + return { + ...data, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findMetricCard = () => wrapper.find(MetricCard); + + describe('while loading', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('displays the metric card with isLoading=true', () => { + expect(findMetricCard().props('isLoading')).toBe(true); + }); + }); + + describe('with data', () => { + beforeEach(() => { + createComponent({ data: { counts: countsMockData } }); + }); + + it('passes the counts data to the metric card', () => { + expect(findMetricCard().props('metrics')).toEqual(countsMockData); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/instance_statistics/mock_data.js new file mode 100644 index 00000000000..9fabf3a4c65 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/mock_data.js @@ -0,0 +1,4 @@ +export default [ + { key: 'projects', value: 10, label: 'Projects' }, + { key: 'groups', value: 20, label: 'Group' }, +]; diff --git a/spec/frontend/analytics/shared/components/metric_card_spec.js b/spec/frontend/analytics/shared/components/metric_card_spec.js new file mode 100644 index 00000000000..e89d499ed9b --- /dev/null +++ b/spec/frontend/analytics/shared/components/metric_card_spec.js @@ -0,0 +1,129 @@ +import { mount } from '@vue/test-utils'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +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/api_spec.js b/spec/frontend/api_spec.js index 3ae0d06162d..f7c6290ce1c 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1152,4 +1152,44 @@ describe('Api', () => { }); }); }); + + describe('trackRedisHllUserEvent', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/usage_data/increment_unique_users`; + + const event = 'dummy_event'; + const postData = { event }; + const headers = { + 'Content-Type': 'application/json', + }; + + describe('when usage data increment unique users is called with feature flag disabled', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: false }; + }); + + it('returns null', () => { + jest.spyOn(axios, 'post'); + mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true); + + expect(axios.post).toHaveBeenCalledTimes(0); + expect(Api.trackRedisHllUserEvent(event)).toEqual(null); + }); + }); + + describe('when usage data increment unique users is called', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: true }; + }); + + it('resolves the Promise', () => { + jest.spyOn(axios, 'post'); + mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); + + return Api.trackRedisHllUserEvent(event).then(({ data }) => { + expect(data).toEqual(true); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers }); + }); + }); + }); + }); }); diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index f0ed18248f0..7fd6a9e7b87 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -309,6 +309,30 @@ describe('AwardsHandler', () => { expect($('[data-name=alien]').is(':visible')).toBe(true); expect($('.js-emoji-menu-search').val()).toBe(''); }); + + it('should fuzzy filter the emoji', async () => { + await openAndWaitForEmojiMenu(); + + awardsHandler.searchEmojis('sgls'); + + expect($('[data-name=angel]').is(':visible')).toBe(false); + expect($('[data-name=anger]').is(':visible')).toBe(false); + expect($('[data-name=sunglasses]').is(':visible')).toBe(true); + }); + + it('should filter by emoji description', async () => { + await openAndWaitForEmojiMenu(); + + awardsHandler.searchEmojis('baby'); + expect($('[data-name=angel]').is(':visible')).toBe(true); + }); + + it('should filter by emoji unicode value', async () => { + await openAndWaitForEmojiMenu(); + + awardsHandler.searchEmojis('👼'); + expect($('[data-name=angel]').is(':visible')).toBe(true); + }); }); describe('emoji menu', () => { diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js index 2b63ece28ba..8ddad3dacfe 100644 --- a/spec/frontend/batch_comments/components/preview_item_spec.js +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -43,22 +43,6 @@ describe('Batch comments draft preview item component', () => { ); }); - it('adds is last class', () => { - createComponent(true); - - expect(vm.$el.classList).toContain('is-last'); - }); - - it('scrolls to draft on click', () => { - createComponent(); - - jest.spyOn(vm.$store, 'dispatch').mockImplementation(); - - vm.$el.click(); - - expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/scrollToDraft', vm.draft); - }); - describe('for file', () => { it('renders file path', () => { createComponent(false, { file_path: 'index.js', file_hash: 'abc', position: {} }); diff --git a/spec/frontend/batch_comments/components/publish_button_spec.js b/spec/frontend/batch_comments/components/publish_button_spec.js index 4362f62c7f8..4032713150c 100644 --- a/spec/frontend/batch_comments/components/publish_button_spec.js +++ b/spec/frontend/batch_comments/components/publish_button_spec.js @@ -29,17 +29,6 @@ describe('Batch comments publish button component', () => { expect(vm.$store.dispatch).toHaveBeenCalledWith('batchComments/publishReview', undefined); }); - it('dispatches toggleReviewDropdown when shouldPublish is false on click', () => { - vm.shouldPublish = false; - - vm.$el.click(); - - expect(vm.$store.dispatch).toHaveBeenCalledWith( - 'batchComments/toggleReviewDropdown', - undefined, - ); - }); - it('sets loading when isPublishing is true', done => { vm.$store.state.batchComments.isPublishing = true; diff --git a/spec/frontend/batch_comments/components/publish_dropdown_spec.js b/spec/frontend/batch_comments/components/publish_dropdown_spec.js index fb3c532174d..f235867f002 100644 --- a/spec/frontend/batch_comments/components/publish_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/publish_dropdown_spec.js @@ -1,96 +1,39 @@ -import Vue from 'vue'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue'; import { createStore } from '~/mr_notes/stores'; import '~/behaviors/markdown/render_gfm'; import { createDraft } from '../mock_data'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('Batch comments publish dropdown component', () => { - let vm; - let Component; + let wrapper; - function createComponent(extendStore = () => {}) { + function createComponent() { const store = createStore(); store.state.batchComments.drafts.push(createDraft(), { ...createDraft(), id: 2 }); - extendStore(store); - - vm = mountComponentWithStore(Component, { store }); + wrapper = shallowMount(PreviewDropdown, { + store, + }); } - beforeAll(() => { - Component = Vue.extend(PreviewDropdown); - }); - afterEach(() => { - vm.$destroy(); - }); - - it('toggles dropdown when clicking button', done => { - createComponent(); - - jest.spyOn(vm.$store, 'dispatch'); - - vm.$el.querySelector('.review-preview-dropdown-toggle').click(); - - expect(vm.$store.dispatch).toHaveBeenCalledWith( - 'batchComments/toggleReviewDropdown', - expect.anything(), - ); - - setImmediate(() => { - expect(vm.$el.classList).toContain('show'); - - done(); - }); - }); - - it('toggles dropdown when clicking body', () => { - createComponent(); - - vm.$store.state.batchComments.showPreviewDropdown = true; - - jest.spyOn(vm.$store, 'dispatch').mockImplementation(); - - document.body.click(); - - expect(vm.$store.dispatch).toHaveBeenCalledWith( - 'batchComments/toggleReviewDropdown', - undefined, - ); + wrapper.destroy(); }); it('renders list of drafts', () => { - createComponent(store => { - Object.assign(store.state.notes, { - isNotesFetched: true, - }); - }); - - expect(vm.$el.querySelectorAll('.dropdown-content li').length).toBe(2); - }); - - it('adds is-last class to last item', () => { - createComponent(store => { - Object.assign(store.state.notes, { - isNotesFetched: true, - }); - }); - - expect(vm.$el.querySelectorAll('.dropdown-content li')[1].querySelector('.is-last')).not.toBe( - null, - ); - }); - - it('renders draft count in dropdown title', () => { createComponent(); - expect(vm.$el.querySelector('.dropdown-title').textContent).toContain('2 pending comments'); + expect(wrapper.findAll(GlDropdownItem).length).toBe(2); }); - it('renders publish button in footer', () => { + it('renders draft count in dropdown title', () => { createComponent(); - expect(vm.$el.querySelector('.dropdown-footer .js-publish-draft-button')).not.toBe(null); + expect(wrapper.find(GlDropdown).props('headerText')).toEqual('2 pending comments'); }); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index a6942115649..e66f36aa3a2 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -199,42 +199,6 @@ describe('Batch comments store actions', () => { }); }); - describe('discardReview', () => { - it('commits mutations', done => { - const getters = { - getNotesData: { draftsDiscardPath: TEST_HOST }, - }; - const commit = jest.fn(); - mock.onAny().reply(200); - - actions - .discardReview({ getters, commit }) - .then(() => { - expect(commit.mock.calls[0]).toEqual(['REQUEST_DISCARD_REVIEW']); - expect(commit.mock.calls[1]).toEqual(['RECEIVE_DISCARD_REVIEW_SUCCESS']); - }) - .then(done) - .catch(done.fail); - }); - - it('commits error mutations', done => { - const getters = { - getNotesData: { draftsDiscardPath: TEST_HOST }, - }; - const commit = jest.fn(); - mock.onAny().reply(500); - - actions - .discardReview({ getters, commit }) - .then(() => { - expect(commit.mock.calls[0]).toEqual(['REQUEST_DISCARD_REVIEW']); - expect(commit.mock.calls[1]).toEqual(['RECEIVE_DISCARD_REVIEW_ERROR']); - }) - .then(done) - .catch(done.fail); - }); - }); - describe('updateDraft', () => { let getters; @@ -284,56 +248,6 @@ describe('Batch comments store actions', () => { }); }); - describe('toggleReviewDropdown', () => { - it('dispatches openReviewDropdown', done => { - testAction( - actions.toggleReviewDropdown, - null, - { showPreviewDropdown: false }, - [], - [{ type: 'openReviewDropdown' }], - done, - ); - }); - - it('dispatches closeReviewDropdown when showPreviewDropdown is true', done => { - testAction( - actions.toggleReviewDropdown, - null, - { showPreviewDropdown: true }, - [], - [{ type: 'closeReviewDropdown' }], - done, - ); - }); - }); - - describe('openReviewDropdown', () => { - it('commits OPEN_REVIEW_DROPDOWN', done => { - testAction( - actions.openReviewDropdown, - null, - null, - [{ type: 'OPEN_REVIEW_DROPDOWN' }], - [], - done, - ); - }); - }); - - describe('closeReviewDropdown', () => { - it('commits CLOSE_REVIEW_DROPDOWN', done => { - testAction( - actions.closeReviewDropdown, - null, - null, - [{ type: 'CLOSE_REVIEW_DROPDOWN' }], - [], - done, - ); - }); - }); - describe('expandAllDiscussions', () => { it('dispatches expandDiscussion for all drafts', done => { const state = { @@ -383,9 +297,7 @@ describe('Batch comments store actions', () => { actions.scrollToDraft({ dispatch, rootGetters }, draft); - expect(dispatch.mock.calls[0]).toEqual(['closeReviewDropdown']); - - expect(dispatch.mock.calls[1]).toEqual([ + expect(dispatch.mock.calls[0]).toEqual([ 'expandDiscussion', { discussionId: '1' }, { root: true }, diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js index a86726269ef..1406f66fd10 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/mutations_spec.js @@ -89,42 +89,6 @@ describe('Batch comments mutations', () => { }); }); - describe(types.REQUEST_DISCARD_REVIEW, () => { - it('sets isDiscarding to true', () => { - mutations[types.REQUEST_DISCARD_REVIEW](state); - - expect(state.isDiscarding).toBe(true); - }); - }); - - describe(types.RECEIVE_DISCARD_REVIEW_SUCCESS, () => { - it('emptys drafts array', () => { - state.drafts.push('test'); - - mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state); - - expect(state.drafts).toEqual([]); - }); - - it('sets isDiscarding to false', () => { - state.isDiscarding = true; - - mutations[types.RECEIVE_DISCARD_REVIEW_SUCCESS](state); - - expect(state.isDiscarding).toBe(false); - }); - }); - - describe(types.RECEIVE_DISCARD_REVIEW_ERROR, () => { - it('updates isDiscarding to false', () => { - state.isDiscarding = true; - - mutations[types.RECEIVE_DISCARD_REVIEW_ERROR](state); - - expect(state.isDiscarding).toBe(false); - }); - }); - describe(types.RECEIVE_DRAFT_UPDATE_SUCCESS, () => { it('updates draft in store', () => { state.drafts.push({ id: 1 }); @@ -140,20 +104,4 @@ describe('Batch comments mutations', () => { ]); }); }); - - describe(types.OPEN_REVIEW_DROPDOWN, () => { - it('sets showPreviewDropdown to true', () => { - mutations[types.OPEN_REVIEW_DROPDOWN](state); - - expect(state.showPreviewDropdown).toBe(true); - }); - }); - - describe(types.CLOSE_REVIEW_DROPDOWN, () => { - it('sets showPreviewDropdown to false', () => { - mutations[types.CLOSE_REVIEW_DROPDOWN](state); - - expect(state.showPreviewDropdown).toBe(false); - }); - }); }); diff --git a/spec/frontend/behaviors/load_startup_css_spec.js b/spec/frontend/behaviors/load_startup_css_spec.js new file mode 100644 index 00000000000..81222ac5aaa --- /dev/null +++ b/spec/frontend/behaviors/load_startup_css_spec.js @@ -0,0 +1,44 @@ +import { setHTMLFixture } from 'helpers/fixtures'; +import { loadStartupCSS } from '~/behaviors/load_startup_css'; + +describe('behaviors/load_startup_css', () => { + let loadListener; + + const setupListeners = () => { + document + .querySelectorAll('link') + .forEach(x => x.addEventListener('load', () => loadListener(x))); + }; + + beforeEach(() => { + loadListener = jest.fn(); + + setHTMLFixture(` + <meta charset="utf-8" /> + <link media="print" src="./lorem-print.css" /> + <link media="print" src="./ipsum-print.css" /> + <link media="all" src="./dolar-all.css" /> + `); + + setupListeners(); + + loadStartupCSS(); + }); + + it('does nothing at first', () => { + expect(loadListener).not.toHaveBeenCalled(); + }); + + describe('on window load', () => { + beforeEach(() => { + window.dispatchEvent(new Event('load')); + }); + + it('dispatches load to the print links', () => { + expect(loadListener.mock.calls.map(([el]) => el.getAttribute('src'))).toEqual([ + './lorem-print.css', + './ipsum-print.css', + ]); + }); + }); +}); 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 0f5b3cd3f5e..53815820bbe 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 @@ -27,8 +27,10 @@ exports[`Blob Header Filepath rendering matches the snapshot 1`] = ` </small> <clipboard-button-stub + category="tertiary" cssclass="btn-clipboard btn-transparent lh-100 position-static" gfm="\`foo/bar/dummy.md\`" + size="medium" text="foo/bar/dummy.md" title="Copy file path" tooltipplacement="top" diff --git a/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js b/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js deleted file mode 100644 index 8dc71f99010..00000000000 --- a/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import { shallowMount } from '@vue/test-utils'; -import { GlButton, GlAlert } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import WebIdeAlert from '~/blob/suggest_web_ide_ci/components/web_ide_alert.vue'; - -const dismissEndpoint = '/-/user_callouts'; -const featureId = 'web_ide_alert_dismissed'; -const editPath = 'edit/master/-/.gitlab-ci.yml'; - -describe('WebIdeAlert', () => { - let wrapper; - let mock; - - const findButton = () => wrapper.find(GlButton); - const findAlert = () => wrapper.find(GlAlert); - const dismissAlert = alertWrapper => alertWrapper.vm.$emit('dismiss'); - const getPostPayload = () => JSON.parse(mock.history.post[0].data); - - const createComponent = () => { - wrapper = shallowMount(WebIdeAlert, { - propsData: { - dismissEndpoint, - featureId, - editPath, - }, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - - mock.onPost(dismissEndpoint).reply(200); - - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - - mock.restore(); - }); - - describe('with defaults', () => { - it('displays alert correctly', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('web ide button link has correct path', () => { - expect(findButton().attributes('href')).toBe(editPath); - }); - - it('dismisses alert correctly', async () => { - const alertWrapper = findAlert(); - - dismissAlert(alertWrapper); - - await waitForPromises(); - - expect(alertWrapper.exists()).toBe(false); - expect(mock.history.post).toHaveLength(1); - expect(getPostPayload()).toEqual({ feature_name: featureId }); - }); - }); -}); diff --git a/spec/frontend/boards/board_blank_state_spec.js b/spec/frontend/boards/board_blank_state_spec.js deleted file mode 100644 index 3ffdda52f58..00000000000 --- a/spec/frontend/boards/board_blank_state_spec.js +++ /dev/null @@ -1,95 +0,0 @@ -import Vue from 'vue'; -import boardsStore from '~/boards/stores/boards_store'; -import BoardBlankState from '~/boards/components/board_blank_state.vue'; - -describe('Boards blank state', () => { - let vm; - let fail = false; - - beforeEach(done => { - const Comp = Vue.extend(BoardBlankState); - - boardsStore.create(); - - jest.spyOn(boardsStore, 'addList').mockImplementation(); - jest.spyOn(boardsStore, 'removeList').mockImplementation(); - jest.spyOn(boardsStore, 'generateDefaultLists').mockImplementation( - () => - new Promise((resolve, reject) => { - if (fail) { - reject(); - } else { - resolve({ - data: [ - { - id: 1, - title: 'To Do', - label: { id: 1 }, - }, - { - id: 2, - title: 'Doing', - label: { id: 2 }, - }, - ], - }); - } - }), - ); - - vm = new Comp(); - - setImmediate(() => { - vm.$mount(); - done(); - }); - }); - - it('renders pre-defined labels', () => { - expect(vm.$el.querySelectorAll('.board-blank-state-list li').length).toBe(2); - - expect(vm.$el.querySelectorAll('.board-blank-state-list li')[0].textContent.trim()).toEqual( - 'To Do', - ); - - expect(vm.$el.querySelectorAll('.board-blank-state-list li')[1].textContent.trim()).toEqual( - 'Doing', - ); - }); - - it('clears blank state', done => { - vm.$el.querySelector('.btn-default').click(); - - setImmediate(() => { - expect(boardsStore.welcomeIsHidden()).toBeTruthy(); - - done(); - }); - }); - - it('creates pre-defined labels', done => { - vm.$el.querySelector('.btn-success').click(); - - setImmediate(() => { - expect(boardsStore.addList).toHaveBeenCalledTimes(2); - expect(boardsStore.addList).toHaveBeenCalledWith(expect.objectContaining({ title: 'To Do' })); - - expect(boardsStore.addList).toHaveBeenCalledWith(expect.objectContaining({ title: 'Doing' })); - - done(); - }); - }); - - it('resets the store if request fails', done => { - fail = true; - - vm.$el.querySelector('.btn-success').click(); - - setImmediate(() => { - expect(boardsStore.welcomeIsHidden()).toBeFalsy(); - expect(boardsStore.removeList).toHaveBeenCalledWith(undefined, 'label'); - - done(); - }); - }); -}); diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js index 41971137b95..e7c1cf79fdc 100644 --- a/spec/frontend/boards/boards_store_spec.js +++ b/spec/frontend/boards/boards_store_spec.js @@ -1,7 +1,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; -import boardsStore from '~/boards/stores/boards_store'; +import boardsStore, { gqlClient } from '~/boards/stores/boards_store'; import eventHub from '~/boards/eventhub'; import { listObj, listObjDuplicate } from './mock_data'; @@ -503,11 +503,15 @@ describe('boardsStore', () => { beforeEach(() => { requestSpy = jest.fn(); axiosMock.onPut(url).replyOnce(config => requestSpy(config)); + jest.spyOn(gqlClient, 'mutate').mockReturnValue(Promise.resolve({})); }); it('makes a request to update the board', () => { requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); + const expectedResponse = [ + expect.objectContaining({ data: dummyResponse }), + expect.objectContaining({}), + ]; return expect( boardsStore.createBoard({ @@ -555,11 +559,12 @@ describe('boardsStore', () => { beforeEach(() => { requestSpy = jest.fn(); axiosMock.onPost(url).replyOnce(config => requestSpy(config)); + jest.spyOn(gqlClient, 'mutate').mockReturnValue(Promise.resolve({})); }); it('makes a request to create a new board', () => { requestSpy.mockReturnValue([200, dummyResponse]); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); + const expectedResponse = dummyResponse; return expect(boardsStore.createBoard(board)) .resolves.toEqual(expectedResponse) @@ -740,14 +745,6 @@ describe('boardsStore', () => { expect(boardsStore.shouldAddBlankState()).toBe(true); }); - it('adds the blank state', () => { - boardsStore.addBlankState(); - - const list = boardsStore.findList('type', 'blank', 'blank'); - - expect(list).toBeDefined(); - }); - it('removes list from state', () => { boardsStore.addList(listObj); diff --git a/spec/frontend/boards/components/board_configuration_options_spec.js b/spec/frontend/boards/components/board_configuration_options_spec.js new file mode 100644 index 00000000000..e9a1cb6a4e8 --- /dev/null +++ b/spec/frontend/boards/components/board_configuration_options_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import BoardConfigurationOptions from '~/boards/components/board_configuration_options.vue'; + +describe('BoardConfigurationOptions', () => { + let wrapper; + const board = { hide_backlog_list: false, hide_closed_list: false }; + + const defaultProps = { + currentBoard: board, + board, + isNewForm: false, + }; + + const createComponent = () => { + wrapper = shallowMount(BoardConfigurationOptions, { + propsData: { ...defaultProps }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const backlogListCheckbox = el => el.find('[data-testid="backlog-list-checkbox"]'); + const closedListCheckbox = el => el.find('[data-testid="closed-list-checkbox"]'); + + const checkboxAssert = (backlogCheckbox, closedCheckbox) => { + expect(backlogListCheckbox(wrapper).attributes('checked')).toEqual( + backlogCheckbox ? undefined : 'true', + ); + expect(closedListCheckbox(wrapper).attributes('checked')).toEqual( + closedCheckbox ? undefined : 'true', + ); + }; + + it.each` + backlogCheckboxValue | closedCheckboxValue + ${true} | ${true} + ${true} | ${false} + ${false} | ${true} + ${false} | ${false} + `( + 'renders two checkbox when one is $backlogCheckboxValue and other is $closedCheckboxValue', + async ({ backlogCheckboxValue, closedCheckboxValue }) => { + await wrapper.setData({ + hideBacklogList: backlogCheckboxValue, + hideClosedList: closedCheckboxValue, + }); + + return wrapper.vm.$nextTick().then(() => { + checkboxAssert(backlogCheckboxValue, closedCheckboxValue); + }); + }, + ); +}); diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index df117d06cdf..09e38001e2e 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -23,9 +23,6 @@ describe('BoardContent', () => { return new Vuex.Store({ getters, state, - actions: { - fetchIssuesForAllLists: () => {}, - }, }); }; diff --git a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js index 1dbcbd06407..e7139ceaa93 100644 --- a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js +++ b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js @@ -96,12 +96,22 @@ describe('boards sidebar remove issue', () => { expect(findExpanded().isVisible()).toBe(false); }); - it('emits changed event', async () => { + it('emits close event', async () => { document.body.click(); await wrapper.vm.$nextTick(); - expect(wrapper.emitted().changed[1][0]).toBe(false); + expect(wrapper.emitted().close.length).toBe(1); }); }); + + it('emits open when edit button is clicked and edit is initailized to false', async () => { + createComponent({ canUpdate: true }); + + findEditButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted().open.length).toBe(1); + }); }); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index bdbcd435708..6415a5a5d3a 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -6,12 +6,13 @@ import { mockIssueWithModel, mockIssue2WithModel, rawIssue, + mockIssues, } from '../mock_data'; import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import { inactiveId, ListType } from '~/boards/constants'; import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql'; -import { fullBoardId } from '~/boards/boards_util'; +import { fullBoardId, formatListIssues } from '~/boards/boards_util'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -237,6 +238,77 @@ describe('deleteList', () => { expectNotImplemented(actions.deleteList); }); +describe('fetchIssuesForList', () => { + const listId = mockLists[0].id; + + const state = { + endpoints: { + fullPath: 'gitlab-org', + boardId: 1, + }, + filterParams: {}, + boardType: 'group', + }; + + const queryResponse = { + data: { + group: { + board: { + lists: { + nodes: [ + { + id: listId, + issues: { + nodes: mockIssues, + }, + }, + ], + }, + }, + }, + }, + }; + + const formattedIssues = formatListIssues(queryResponse.data.group.board.lists); + + it('should commit mutation RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + testAction( + actions.fetchIssuesForList, + listId, + state, + [ + { + type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, + payload: { listIssues: formattedIssues, listId }, + }, + ], + [], + done, + ); + }); + + it('should commit mutation RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); + + testAction( + actions.fetchIssuesForList, + listId, + state, + [{ type: types.RECEIVE_ISSUES_FOR_LIST_FAILURE, payload: listId }], + [], + done, + ); + }); +}); + +describe('resetIssues', () => { + it('commits RESET_ISSUES mutation', () => { + return testAction(actions.resetIssues, {}, {}, [{ type: types.RESET_ISSUES }], []); + }); +}); + describe('moveIssue', () => { const listIssues = { 'gid://gitlab/List/1': [436, 437], diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index a13a99a507e..c80537bf168 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -145,6 +145,23 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR); }); + describe('RESET_ISSUES', () => { + it('should remove issues from issuesByListId state', () => { + const issuesByListId = { + 'gid://gitlab/List/1': [mockIssue.id], + }; + + state = { + ...state, + issuesByListId, + }; + + mutations[types.RESET_ISSUES](state); + + expect(state.issuesByListId).toEqual({ 'gid://gitlab/List/1': [] }); + }); + }); + describe('RECEIVE_ISSUES_FOR_LIST_SUCCESS', () => { it('updates issuesByListId and issues on state', () => { const listIssues = { @@ -156,7 +173,6 @@ describe('Board Store Mutations', () => { state = { ...state, - isLoadingIssues: true, issuesByListId: {}, issues: {}, boardLists: mockListsWithModel, @@ -172,16 +188,6 @@ describe('Board Store Mutations', () => { }); }); - describe('REQUEST_ISSUES_FOR_ALL_LISTS', () => { - it('sets isLoadingIssues to true', () => { - expect(state.isLoadingIssues).toBe(false); - - mutations.REQUEST_ISSUES_FOR_ALL_LISTS(state); - - expect(state.isLoadingIssues).toBe(true); - }); - }); - describe('RECEIVE_ISSUES_FOR_LIST_FAILURE', () => { it('sets error message', () => { state = { @@ -200,51 +206,10 @@ describe('Board Store Mutations', () => { }); }); - describe('RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS', () => { - it('sets isLoadingIssues to false and updates issuesByListId object', () => { - const listIssues = { - 'gid://gitlab/List/1': [mockIssue.id], - }; - const issues = { - '1': mockIssue, - }; - - state = { - ...state, - isLoadingIssues: true, - issuesByListId: {}, - issues: {}, - }; - - mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS(state, { listData: listIssues, issues }); - - expect(state.isLoadingIssues).toBe(false); - expect(state.issuesByListId).toEqual(listIssues); - expect(state.issues).toEqual(issues); - }); - }); - describe('REQUEST_ADD_ISSUE', () => { expectNotImplemented(mutations.REQUEST_ADD_ISSUE); }); - describe('RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE', () => { - it('sets isLoadingIssues to false and sets error message', () => { - state = { - ...state, - isLoadingIssues: true, - error: undefined, - }; - - mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE(state); - - expect(state.isLoadingIssues).toBe(false); - expect(state.error).toEqual( - 'An error occurred while fetching the board issues. Please reload the page.', - ); - }); - }); - describe('UPDATE_ISSUE_BY_ID', () => { const issueId = '1'; const prop = 'id'; @@ -254,7 +219,6 @@ describe('Board Store Mutations', () => { beforeEach(() => { state = { ...state, - isLoadingIssues: true, error: undefined, issues: { ...issue, diff --git a/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js b/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js new file mode 100644 index 00000000000..e07afb5d736 --- /dev/null +++ b/spec/frontend/ci_settings_pipeline_triggers/components/triggers_list_spec.js @@ -0,0 +1,102 @@ +import { mount } from '@vue/test-utils'; +import { GlTable, GlBadge } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import TriggersList from '~/ci_settings_pipeline_triggers/components/triggers_list.vue'; +import { triggers } from '../mock_data'; + +describe('TriggersList', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(TriggersList, { + propsData: { triggers, ...props }, + }); + }; + + const findTable = () => wrapper.find(GlTable); + const findHeaderAt = i => wrapper.findAll('thead th').at(i); + const findRows = () => wrapper.findAll('tbody tr'); + const findRowAt = i => findRows().at(i); + const findCell = (i, col) => + findRowAt(i) + .findAll('td') + .at(col); + const findClipboardBtn = i => findCell(i, 0).find(ClipboardButton); + const findInvalidBadge = i => findCell(i, 0).find(GlBadge); + const findEditBtn = i => findRowAt(i).find('[data-testid="edit-btn"]'); + const findRevokeBtn = i => findRowAt(i).find('[data-testid="trigger_revoke_button"]'); + + beforeEach(() => { + createComponent(); + + return wrapper.vm.$nextTick(); + }); + + it('displays a table with expected headers', () => { + const headers = ['Token', 'Description', 'Owner', 'Last Used', '']; + headers.forEach((header, i) => { + expect(findHeaderAt(i).text()).toBe(header); + }); + }); + + it('displays a table with rows', () => { + expect(findRows()).toHaveLength(triggers.length); + + const [trigger] = triggers; + + expect(findCell(0, 0).text()).toBe(trigger.token); + expect(findCell(0, 1).text()).toBe(trigger.description); + expect(findCell(0, 2).text()).toContain(trigger.owner.name); + }); + + it('displays a "copy to cliboard" button for exposed tokens', () => { + expect(findClipboardBtn(0).exists()).toBe(true); + expect(findClipboardBtn(0).props('text')).toBe(triggers[0].token); + + expect(findClipboardBtn(1).exists()).toBe(false); + }); + + it('displays an "invalid" label for tokens without access', () => { + expect(findInvalidBadge(0).exists()).toBe(false); + + expect(findInvalidBadge(1).exists()).toBe(true); + }); + + it('displays a time ago label when last used', () => { + expect(findCell(0, 3).text()).toBe('Never'); + + expect( + findCell(1, 3) + .find(TimeAgoTooltip) + .props('time'), + ).toBe(triggers[1].lastUsed); + }); + + it('displays actions in a rows', () => { + const [data] = triggers; + + expect(findEditBtn(0).attributes('href')).toBe(data.editProjectTriggerPath); + + expect(findRevokeBtn(0).attributes('href')).toBe(data.projectTriggerPath); + expect(findRevokeBtn(0).attributes('data-method')).toBe('delete'); + expect(findRevokeBtn(0).attributes('data-confirm')).toBeTruthy(); + }); + + describe('when there are no triggers set', () => { + beforeEach(() => { + createComponent({ triggers: [] }); + }); + + it('does not display a table', () => { + expect(findTable().exists()).toBe(false); + }); + + it('displays a message', () => { + expect(wrapper.text()).toBe( + 'No triggers have been created yet. Add one using the form above.', + ); + }); + }); +}); diff --git a/spec/frontend/ci_settings_pipeline_triggers/mock_data.js b/spec/frontend/ci_settings_pipeline_triggers/mock_data.js new file mode 100644 index 00000000000..6813e941e03 --- /dev/null +++ b/spec/frontend/ci_settings_pipeline_triggers/mock_data.js @@ -0,0 +1,30 @@ +export const triggers = [ + { + hasTokenExposed: true, + token: '0000', + description: 'My trigger', + owner: { + name: 'My User', + username: 'user1', + path: '/user1', + }, + lastUsed: null, + canAccessProject: true, + editProjectTriggerPath: '/triggers/1/edit', + projectTriggerPath: '/trigger/1', + }, + { + hasTokenExposed: false, + token: '1111', + description: "Anothe user's trigger", + owner: { + name: 'Someone else', + username: 'user2', + path: '/user2', + }, + lastUsed: '2020-09-10T08:26:47.410Z', + canAccessProject: false, + editProjectTriggerPath: '/triggers/1/edit', + projectTriggerPath: '/trigger/1', + }, +]; diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index ab32fb12058..5c2d096418d 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -1,6 +1,6 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; -import { GlButton, GlFormCombobox } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants'; import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; import createStore from '~/ci_variable_list/store'; @@ -18,7 +18,6 @@ describe('Ci variable modal', () => { store = createStore(); wrapper = method(CiVariableModal, { attachToDocument: true, - provide: { glFeatures: { ciKeyAutocomplete: true } }, stubs: { GlModal: ModalStub, }, @@ -42,27 +41,6 @@ describe('Ci variable modal', () => { wrapper.destroy(); }); - describe('Feature flag', () => { - describe('when off', () => { - beforeEach(() => { - createComponent(shallowMount, { provide: { glFeatures: { ciKeyAutocomplete: false } } }); - }); - - it('does not render the autocomplete dropdown', () => { - expect(wrapper.find(GlFormCombobox).exists()).toBe(false); - }); - }); - - describe('when on', () => { - beforeEach(() => { - createComponent(shallowMount); - }); - it('renders the autocomplete dropdown', () => { - expect(wrapper.find(GlFormCombobox).exists()).toBe(true); - }); - }); - }); - describe('Basic interactions', () => { beforeEach(() => { createComponent(shallowMount); diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js index c263679a45c..25db8785edc 100644 --- a/spec/frontend/clusters/components/fluentd_output_settings_spec.js +++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlAlert, GlDeprecatedDropdown, GlFormCheckbox } from '@gitlab/ui'; +import { GlAlert, GlDropdown, GlFormCheckbox } from '@gitlab/ui'; import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue'; import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; import eventHub from '~/clusters/event_hub'; @@ -36,7 +36,7 @@ describe('FluentdOutputSettings', () => { }; const findSaveButton = () => wrapper.find({ ref: 'saveBtn' }); const findCancelButton = () => wrapper.find({ ref: 'cancelBtn' }); - const findProtocolDropdown = () => wrapper.find(GlDeprecatedDropdown); + const findProtocolDropdown = () => wrapper.find(GlDropdown); const findCheckbox = name => wrapper.findAll(GlFormCheckbox).wrappers.find(x => x.text() === name); const findHost = () => wrapper.find('#fluentd-host'); diff --git a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js index 3a9a608b2e2..1f07a0b7908 100644 --- a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js +++ b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlAlert, GlToggle, GlDeprecatedDropdown } from '@gitlab/ui'; +import { GlAlert, GlToggle, GlDropdown } from '@gitlab/ui'; import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue'; import { APPLICATION_STATUS, INGRESS } from '~/clusters/constants'; import eventHub from '~/clusters/event_hub'; @@ -28,10 +28,12 @@ describe('IngressModsecuritySettings', () => { }); }; - const findSaveButton = () => wrapper.find('.btn-success'); - const findCancelButton = () => wrapper.find('[variant="secondary"]'); + const findSaveButton = () => + wrapper.find('[data-qa-selector="save_ingress_modsecurity_settings"]'); + const findCancelButton = () => + wrapper.find('[data-qa-selector="cancel_ingress_modsecurity_settings"]'); const findModSecurityToggle = () => wrapper.find(GlToggle); - const findModSecurityDropdown = () => wrapper.find(GlDeprecatedDropdown); + const findModSecurityDropdown = () => wrapper.find(GlDropdown); describe('when ingress is installed', () => { beforeEach(() => { diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js index 11ebe1b5d61..b7f76211fd6 100644 --- a/spec/frontend/clusters/components/knative_domain_editor_spec.js +++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui'; +import { GlDropdownItem, GlButton } from '@gitlab/ui'; import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; import { APPLICATION_STATUS } from '~/clusters/constants'; @@ -112,7 +112,7 @@ describe('KnativeDomainEditor', () => { createComponent({ knative: { ...knative, availableDomains: [newDomain] } }); jest.spyOn(wrapper.vm, 'selectDomain'); - wrapper.find(GlDeprecatedDropdownItem).vm.$emit('click'); + wrapper.find(GlDropdownItem).vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.selectDomain).toHaveBeenCalledWith(newDomain); diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js index 57c538d2650..3e5f8de8e7b 100644 --- a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js +++ b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; describe('CrossplaneProviderStack component', () => { @@ -37,7 +37,7 @@ describe('CrossplaneProviderStack component', () => { createComponent({ crossplane }); }); - const findDropdownElements = () => wrapper.findAll(GlDeprecatedDropdownItem); + const findDropdownElements = () => wrapper.findAll(GlDropdownItem); const findFirstDropdownElement = () => findDropdownElements().at(0); afterEach(() => { diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index 628c35ae839..34d99473eb7 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -164,18 +164,18 @@ describe('Clusters', () => { }); it.each` - nodeSize | lineNumber - ${'Unknown'} | ${0} - ${'1'} | ${1} - ${'2'} | ${2} - ${'1'} | ${3} - ${'1'} | ${4} - ${'Unknown'} | ${5} - `('renders node size for each cluster', ({ nodeSize, lineNumber }) => { + nodeText | lineNumber + ${'Unable to Authenticate'} | ${0} + ${'1'} | ${1} + ${'2'} | ${2} + ${'1'} | ${3} + ${'1'} | ${4} + ${'Unknown Error'} | ${5} + `('renders node size for each cluster', ({ nodeText, lineNumber }) => { const sizes = findTable().findAll('td:nth-child(3)'); const size = sizes.at(lineNumber); - expect(size.text()).toBe(nodeSize); + expect(size.text()).toContain(nodeText); expect(size.find(GlSkeletonLoading).exists()).toBe(false); }); }); diff --git a/spec/frontend/clusters_list/components/node_error_help_text_spec.js b/spec/frontend/clusters_list/components/node_error_help_text_spec.js new file mode 100644 index 00000000000..4d157b3a8ab --- /dev/null +++ b/spec/frontend/clusters_list/components/node_error_help_text_spec.js @@ -0,0 +1,33 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlPopover } from '@gitlab/ui'; +import NodeErrorHelpText from '~/clusters_list/components/node_error_help_text.vue'; + +describe('NodeErrorHelpText', () => { + let wrapper; + + const createWrapper = propsData => { + wrapper = shallowMount(NodeErrorHelpText, { propsData, stubs: { GlPopover } }); + return wrapper.vm.$nextTick(); + }; + + const findPopover = () => wrapper.find(GlPopover); + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + errorType | wrapperText | popoverText + ${'authentication_error'} | ${'Unable to Authenticate'} | ${'GitLab failed to authenticate'} + ${'connection_error'} | ${'Unable to Connect'} | ${'GitLab failed to connect to the cluster'} + ${'http_error'} | ${'Unable to Connect'} | ${'There was an HTTP error when connecting to your cluster'} + ${'default'} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'} + ${'unknown_error_type'} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'} + ${null} | ${'Unknown Error'} | ${'An unknown error occurred while attempting to connect to Kubernetes.'} + `('displays error text', ({ errorType, wrapperText, popoverText }) => { + return createWrapper({ errorType, popoverId: 'id' }).then(() => { + expect(wrapper.text()).toContain(wrapperText); + expect(findPopover().text()).toContain(popoverText); + }); + }); +}); diff --git a/spec/frontend/clusters_list/mock_data.js b/spec/frontend/clusters_list/mock_data.js index 48af3b91c94..ed32655d10e 100644 --- a/spec/frontend/clusters_list/mock_data.js +++ b/spec/frontend/clusters_list/mock_data.js @@ -6,6 +6,11 @@ export const clusterList = [ provider_type: 'gcp', status: 'creating', nodes: null, + kubernetes_errors: { + connection_error: 'authentication_error', + node_connection_error: 'connection_error', + metrics_connection_error: 'http_error', + }, }, { name: 'My Cluster 2', @@ -19,6 +24,7 @@ export const clusterList = [ usage: { cpu: '246155922n', memory: '1255212Ki' }, }, ], + kubernetes_errors: {}, }, { name: 'My Cluster 3', @@ -36,6 +42,7 @@ export const clusterList = [ usage: { cpu: '307051934n', memory: '1379136Ki' }, }, ], + kubernetes_errors: {}, }, { name: 'My Cluster 4', @@ -48,6 +55,7 @@ export const clusterList = [ usage: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' }, }, ], + kubernetes_errors: {}, }, { name: 'My Cluster 5', @@ -59,12 +67,14 @@ export const clusterList = [ status: { allocatable: { cpu: '1missingCpuUnit', memory: '1missingMemoryUnit' } }, }, ], + kubernetes_errors: {}, }, { name: 'My Cluster 6', environment_scope: '*', cluster_type: 'project_type', status: 'cleanup_ongoing', + kubernetes_errors: {}, }, ]; 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 745a163951a..62b751ec59b 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -56,6 +56,7 @@ exports[`Code navigation popover component renders popover 1`] = ` class="popover-body border-top" > <gl-button-stub + buttontextclasses="" category="primary" class="w-100" data-testid="go-to-definition-btn" diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js index fdf3c2e85f3..a196b66daa0 100644 --- a/spec/frontend/commit/pipelines/pipelines_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_spec.js @@ -21,6 +21,10 @@ describe('Pipelines table in Commits and Merge requests', () => { preloadFixtures(jsonFixtureName); + const findRunPipelineBtn = () => vm.$el.querySelector('[data-testid="run_pipeline_button"]'); + const findRunPipelineBtnMobile = () => + vm.$el.querySelector('[data-testid="run_pipeline_button_mobile"]'); + beforeEach(() => { mock = new MockAdapter(axios); @@ -131,7 +135,8 @@ describe('Pipelines table in Commits and Merge requests', () => { vm = mountComponent(PipelinesTable, { ...props }); setImmediate(() => { - expect(vm.$el.querySelector('.js-run-mr-pipeline')).not.toBeNull(); + expect(findRunPipelineBtn()).not.toBeNull(); + expect(findRunPipelineBtnMobile()).not.toBeNull(); done(); }); }); @@ -147,7 +152,8 @@ describe('Pipelines table in Commits and Merge requests', () => { vm = mountComponent(PipelinesTable, { ...props }); setImmediate(() => { - expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull(); + expect(findRunPipelineBtn()).toBeNull(); + expect(findRunPipelineBtnMobile()).toBeNull(); done(); }); }); @@ -157,7 +163,7 @@ describe('Pipelines table in Commits and Merge requests', () => { const findModal = () => document.querySelector('#create-pipeline-for-fork-merge-request-modal'); - beforeEach(() => { + beforeEach(done => { pipelineCopy.flags.detached_merge_request_pipeline = true; mock.onGet('endpoint.json').reply(200, [pipelineCopy]); @@ -168,23 +174,46 @@ describe('Pipelines table in Commits and Merge requests', () => { projectId: '5', mergeRequestId: 3, }); - }); - it('updates the loading state', done => { jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve()); setImmediate(() => { - vm.$el.querySelector('.js-run-mr-pipeline').click(); + done(); + }); + }); - vm.$nextTick(() => { - expect(findModal()).toBeNull(); - expect(vm.state.isRunningMergeRequestPipeline).toBe(true); + it('on desktop, shows a loading button', done => { + findRunPipelineBtn().click(); - setImmediate(() => { - expect(vm.state.isRunningMergeRequestPipeline).toBe(false); + vm.$nextTick(() => { + expect(findModal()).toBeNull(); - done(); - }); + expect(findRunPipelineBtn().disabled).toBe(true); + expect(findRunPipelineBtn().querySelector('.gl-spinner')).not.toBeNull(); + + setImmediate(() => { + expect(findRunPipelineBtn().disabled).toBe(false); + expect(findRunPipelineBtn().querySelector('.gl-spinner')).toBeNull(); + + done(); + }); + }); + }); + + it('on mobile, shows a loading button', done => { + findRunPipelineBtnMobile().click(); + + vm.$nextTick(() => { + expect(findModal()).toBeNull(); + + expect(findModal()).toBeNull(); + expect(findRunPipelineBtn().querySelector('.gl-spinner')).not.toBeNull(); + + setImmediate(() => { + expect(findRunPipelineBtn().disabled).toBe(false); + expect(findRunPipelineBtn().querySelector('.gl-spinner')).toBeNull(); + + done(); }); }); }); @@ -194,7 +223,7 @@ describe('Pipelines table in Commits and Merge requests', () => { const findModal = () => document.querySelector('#create-pipeline-for-fork-merge-request-modal'); - beforeEach(() => { + beforeEach(done => { pipelineCopy.flags.detached_merge_request_pipeline = true; mock.onGet('endpoint.json').reply(200, [pipelineCopy]); @@ -207,18 +236,29 @@ describe('Pipelines table in Commits and Merge requests', () => { sourceProjectFullPath: 'test/parent-project', targetProjectFullPath: 'test/fork-project', }); - }); - it('shows a security warning modal', done => { jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve()); setImmediate(() => { - vm.$el.querySelector('.js-run-mr-pipeline').click(); + done(); + }); + }); - vm.$nextTick(() => { - expect(findModal()).not.toBeNull(); - done(); - }); + it('on desktop, shows a security warning modal', done => { + findRunPipelineBtn().click(); + + vm.$nextTick(() => { + expect(findModal()).not.toBeNull(); + done(); + }); + }); + + it('on mobile, shows a security warning modal', done => { + findRunPipelineBtnMobile().click(); + + vm.$nextTick(() => { + expect(findModal()).not.toBeNull(); + done(); }); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js index 4bf3ac430f5..e0913fe2e88 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js @@ -12,6 +12,7 @@ describe('CreateEksCluster', () => { let vm; let state; const gitlabManagedClusterHelpPath = 'gitlab-managed-cluster-help-path'; + const namespacePerEnvironmentHelpPath = 'namespace-per-environment-help-path'; const accountAndExternalIdsHelpPath = 'account-and-external-id-help-path'; const createRoleArnHelpPath = 'role-arn-help-path'; const kubernetesIntegrationHelpPath = 'kubernetes-integration'; @@ -26,6 +27,7 @@ describe('CreateEksCluster', () => { vm = shallowMount(CreateEksCluster, { propsData: { gitlabManagedClusterHelpPath, + namespacePerEnvironmentHelpPath, accountAndExternalIdsHelpPath, createRoleArnHelpPath, externalLinkIcon, @@ -53,6 +55,12 @@ describe('CreateEksCluster', () => { ); }); + it('help url for namespace per environment cluster documentation', () => { + expect(vm.find(EksClusterConfigurationForm).props('namespacePerEnvironmentHelpPath')).toBe( + namespacePerEnvironmentHelpPath, + ); + }); + it('help url for gitlab managed cluster documentation', () => { expect(vm.find(EksClusterConfigurationForm).props('kubernetesIntegrationHelpPath')).toBe( kubernetesIntegrationHelpPath, diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js index d7dd7072f67..2600415fc9f 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js @@ -169,6 +169,7 @@ describe('EksClusterConfigurationForm', () => { store, propsData: { gitlabManagedClusterHelpPath: '', + namespacePerEnvironmentHelpPath: '', kubernetesIntegrationHelpPath: '', externalLinkIcon: '', }, diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index ed753888790..f929216689a 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -14,6 +14,7 @@ import { SET_ROLE, SET_SECURITY_GROUP, SET_GITLAB_MANAGED_CLUSTER, + SET_NAMESPACE_PER_ENVIRONMENT, SET_INSTANCE_TYPE, SET_NODE_COUNT, REQUEST_CREATE_ROLE, @@ -40,6 +41,7 @@ describe('EKS Cluster Store Actions', () => { let instanceType; let nodeCount; let gitlabManagedCluster; + let namespacePerEnvironment; let mock; let state; let newClusterUrl; @@ -57,6 +59,7 @@ describe('EKS Cluster Store Actions', () => { instanceType = 'small-1'; nodeCount = '5'; gitlabManagedCluster = true; + namespacePerEnvironment = true; newClusterUrl = '/clusters/1'; @@ -76,19 +79,20 @@ describe('EKS Cluster Store Actions', () => { }); it.each` - action | mutation | payload | payloadDescription - ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'} - ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'} - ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'} - ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'} - ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'} - ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'} - ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'} - ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'} - ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'} - ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'} - ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'} - ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} + action | mutation | payload | payloadDescription + ${'setClusterName'} | ${SET_CLUSTER_NAME} | ${{ clusterName }} | ${'cluster name'} + ${'setEnvironmentScope'} | ${SET_ENVIRONMENT_SCOPE} | ${{ environmentScope }} | ${'environment scope'} + ${'setKubernetesVersion'} | ${SET_KUBERNETES_VERSION} | ${{ kubernetesVersion }} | ${'kubernetes version'} + ${'setRole'} | ${SET_ROLE} | ${{ role }} | ${'role'} + ${'setRegion'} | ${SET_REGION} | ${{ region }} | ${'region'} + ${'setKeyPair'} | ${SET_KEY_PAIR} | ${{ keyPair }} | ${'key pair'} + ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'} + ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'} + ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'} + ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'} + ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'} + ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} + ${'setNamespacePerEnvironment'} | ${SET_NAMESPACE_PER_ENVIRONMENT} | ${namespacePerEnvironment} | ${'namespace per environment'} `(`$action commits $mutation with $payloadDescription payload`, data => { const { action, mutation, payload } = data; @@ -179,6 +183,7 @@ describe('EKS Cluster Store Actions', () => { name: clusterName, environment_scope: environmentScope, managed: gitlabManagedCluster, + namespace_per_environment: namespacePerEnvironment, provider_aws_attributes: { kubernetes_version: kubernetesVersion, region, @@ -204,6 +209,7 @@ describe('EKS Cluster Store Actions', () => { selectedInstanceType: instanceType, nodeCount, gitlabManagedCluster, + namespacePerEnvironment, }); }); 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 451c23f0fea..9ebc6ca26a2 100644 --- a/spec/frontend/design_management/components/design_todo_button_spec.js +++ b/spec/frontend/design_management/components/design_todo_button_spec.js @@ -111,7 +111,7 @@ describe('Design management design todo button', () => { }); it('renders correct button text', () => { - expect(wrapper.text()).toBe('Add a To-Do'); + expect(wrapper.text()).toBe('Add a To Do'); }); describe('when clicked', () => { diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap index 822df1f6472..de276bd300b 100644 --- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap +++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap @@ -24,6 +24,7 @@ exports[`Design management list item component with notes renders item with mult <img alt="test" class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img" + data-qa-filename="test" data-qa-selector="design_image" src="" /> @@ -94,6 +95,7 @@ exports[`Design management list item component with notes renders item with sing <img alt="test" class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img" + data-qa-filename="test" data-qa-selector="design_image" src="" /> diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap index a7d6145285c..5eb86d4f9cb 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap @@ -4,15 +4,16 @@ exports[`Design management pagination component hides components when designs ar exports[`Design management pagination component renders navigation buttons 1`] = ` <div - class="d-flex align-items-center" + class="gl-display-flex gl-align-items-center" > 0 of 2 <gl-button-group-stub - class="ml-3 mr-3" + class="gl-mx-5" > <gl-button-stub + buttontextclasses="" category="primary" class="js-previous-design" disabled="true" @@ -23,6 +24,7 @@ exports[`Design management pagination component renders navigation buttons 1`] = /> <gl-button-stub + buttontextclasses="" category="primary" class="js-next-design" icon="angle-right" diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap index b286a74ebb8..723ac0491a7 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap @@ -19,16 +19,16 @@ exports[`Design management toolbar component renders design and updated data 1`] </a> <div - class="overflow-hidden d-flex align-items-center" + class="gl-overflow-hidden gl-display-flex gl-align-items-center" > <h2 - class="m-0 str-truncated-100 gl-font-base" + class="gl-m-0 str-truncated-100 gl-font-base" > test.jpg </h2> <small - class="text-secondary" + class="gl-text-gray-500" > Updated 1 hour ago by Test Name </small> @@ -36,11 +36,12 @@ exports[`Design management toolbar component renders design and updated data 1`] </div> <design-navigation-stub - class="ml-auto flex-shrink-0" + class="gl-ml-auto gl-flex-shrink-0" id="1" /> <gl-button-stub + buttontextclasses="" category="primary" href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d" icon="download" diff --git a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js index 1c6588a9628..1d9b9c002f9 100644 --- a/spec/frontend/design_management/components/toolbar/design_navigation_spec.js +++ b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js @@ -43,7 +43,7 @@ describe('Design management pagination component', () => { it('renders navigation buttons', () => { wrapper.setData({ - designs: [{ id: '1' }, { id: '2' }], + designCollection: { designs: [{ id: '1' }, { id: '2' }] }, }); return wrapper.vm.$nextTick().then(() => { @@ -54,7 +54,7 @@ describe('Design management pagination component', () => { describe('keyboard buttons navigation', () => { beforeEach(() => { wrapper.setData({ - designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }], + designCollection: { designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }] }, }); }); diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap index 3d7939df28e..eaa7460ae15 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -5,6 +5,7 @@ exports[`Design management upload button component renders inverted upload desig isinverted="true" > <gl-button-stub + buttontextclasses="" category="primary" icon="" size="small" @@ -30,6 +31,7 @@ exports[`Design management upload button component renders inverted upload desig exports[`Design management upload button component renders loading icon 1`] = ` <div> <gl-button-stub + buttontextclasses="" category="primary" disabled="true" icon="" @@ -62,6 +64,7 @@ exports[`Design management upload button component renders loading icon 1`] = ` exports[`Design management upload button component renders upload design button 1`] = ` <div> <gl-button-stub + buttontextclasses="" category="primary" icon="" size="small" diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index 1c7806c292f..a8b335c2c46 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -4,6 +4,7 @@ export const designListQueryResponse = { id: '1', issue: { designCollection: { + copyState: 'READY', designs: { nodes: [ { diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap index b80b7fdb43e..7ab2c02c786 100644 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -19,6 +19,7 @@ exports[`Design management index page designs does not render toolbar when there > <design-dropzone-stub class="design-list-item design-list-item-new" + data-qa-selector="design_dropzone_content" hasdesigns="true" /> </li> @@ -110,6 +111,7 @@ exports[`Design management index page designs renders designs list and header wi class="qa-selector-toolbar gl-display-flex gl-align-items-center" > <gl-button-stub + buttontextclasses="" category="primary" class="gl-mr-4 js-select-all" icon="" @@ -126,6 +128,7 @@ exports[`Design management index page designs renders designs list and header wi buttonclass="gl-mr-3" buttonsize="small" buttonvariant="warning" + data-qa-selector="archive_button" > Archive selected @@ -150,6 +153,7 @@ exports[`Design management index page designs renders designs list and header wi > <design-dropzone-stub class="design-list-item design-list-item-new" + data-qa-selector="design_dropzone_content" hasdesigns="true" /> </li> @@ -171,6 +175,8 @@ exports[`Design management index page designs renders designs list and header wi <input class="design-checkbox" + data-qa-design="design-1-name" + data-qa-selector="design_checkbox" type="checkbox" /> </li> @@ -192,6 +198,8 @@ exports[`Design management index page designs renders designs list and header wi <input class="design-checkbox" + data-qa-design="design-2-name" + data-qa-selector="design_checkbox" type="checkbox" /> </li> @@ -213,6 +221,8 @@ exports[`Design management index page designs renders designs list and header wi <input class="design-checkbox" + data-qa-design="design-3-name" + data-qa-selector="design_checkbox" type="checkbox" /> </li> @@ -298,6 +308,7 @@ exports[`Design management index page when has no designs renders design dropzon > <design-dropzone-stub class="" + data-qa-selector="design_dropzone_content" /> </li> </ol> diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index c849e4d4ed6..8546f9fbf51 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -67,6 +67,7 @@ exports[`Design management design index page renders design index 1`] = ` /> <gl-button-stub + buttontextclasses="" category="primary" class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4" data-testid="resolved-comments" diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 661717d29a3..55ccb668e81 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -92,6 +92,8 @@ describe('Design management index page', () => { const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); const findSelectAllButton = () => wrapper.find('.js-select-all'); const findToolbar = () => wrapper.find('.qa-selector-toolbar'); + const findDesignCollectionIsCopying = () => + wrapper.find('[data-testid="design-collection-is-copying"'); const findDeleteButton = () => wrapper.find(DeleteButton); const findDropzone = () => wrapper.findAll(DesignDropzone).at(0); const dropzoneClasses = () => findDropzone().classes(); @@ -99,6 +101,7 @@ describe('Design management index page', () => { const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]'); const findDesigns = () => wrapper.findAll(Design); + const draggableAttributes = () => wrapper.find(VueDraggable).vm.$attrs; async function moveDesigns(localWrapper) { await jest.runOnlyPendingTimers(); @@ -115,8 +118,8 @@ describe('Design management index page', () => { function createComponent({ loading = false, - designs = [], allVersions = [], + designCollection = { designs: mockDesigns, copyState: 'READY' }, createDesign = true, stubs = {}, mockMutate = jest.fn().mockResolvedValue(), @@ -124,7 +127,7 @@ describe('Design management index page', () => { mutate = mockMutate; const $apollo = { queries: { - designs: { + designCollection: { loading, }, permissions: { @@ -137,8 +140,8 @@ describe('Design management index page', () => { wrapper = shallowMount(Index, { data() { return { - designs, allVersions, + designCollection, permissions: { createDesign, }, @@ -200,13 +203,13 @@ describe('Design management index page', () => { }); it('renders a toolbar with buttons when there are designs', () => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + createComponent({ allVersions: [mockVersion] }); expect(findToolbar().exists()).toBe(true); }); it('renders designs list and header with upload button', () => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + createComponent({ allVersions: [mockVersion] }); expect(wrapper.element).toMatchSnapshot(); }); @@ -236,7 +239,7 @@ describe('Design management index page', () => { describe('when has no designs', () => { beforeEach(() => { - createComponent(); + createComponent({ designCollection: { designs: [], copyState: 'READY' } }); }); it('renders design dropzone', () => @@ -259,6 +262,21 @@ describe('Design management index page', () => { })); }); + describe('handling design collection copy state', () => { + it.each` + copyState | isRendered | description + ${'IN_PROGRESS'} | ${true} | ${'renders'} + ${'READY'} | ${false} | ${'does not render'} + ${'ERROR'} | ${false} | ${'does not render'} + `( + '$description the copying message if design collection copyState is $copyState', + ({ copyState, isRendered }) => { + createComponent({ designCollection: { designs: [], copyState } }); + expect(findDesignCollectionIsCopying().exists()).toBe(isRendered); + }, + ); + }); + describe('uploading designs', () => { it('calls mutation on upload', () => { createComponent({ stubs: { GlEmptyState } }); @@ -282,6 +300,10 @@ describe('Design management index page', () => { { __typename: 'Design', id: expect.anything(), + currentUserTodos: { + __typename: 'TodoConnection', + nodes: [], + }, image: '', imageV432x230: '', filename: 'test', @@ -531,13 +553,16 @@ describe('Design management index page', () => { }); it('on latest version when has no designs toolbar buttons are invisible', () => { - createComponent({ designs: [], allVersions: [mockVersion] }); + createComponent({ + designCollection: { designs: [], copyState: 'READY' }, + allVersions: [mockVersion], + }); expect(findToolbar().isVisible()).toBe(false); }); describe('on non-latest version', () => { beforeEach(() => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + createComponent({ allVersions: [mockVersion] }); }); it('does not render design checkboxes', async () => { @@ -628,7 +653,6 @@ describe('Design management index page', () => { it('ensures fullscreen layout is not applied', () => { createComponent(true); - wrapper.vm.$router.push('/'); expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1); expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); }); @@ -676,6 +700,20 @@ describe('Design management index page', () => { ).toBe('2'); }); + it('prevents reordering when reorderDesigns mutation is in progress', async () => { + createComponentWithApollo({}); + + await moveDesigns(wrapper); + + expect(draggableAttributes().disabled).toBe(true); + + await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises) + await wrapper.vm.$nextTick(); // kick off the DOM update + await wrapper.vm.$nextTick(); // kick off the DOM update for finally block + + expect(draggableAttributes().disabled).toBe(false); + }); + it('displays flash if mutation had a recoverable error', async () => { createComponentWithApollo({ moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors), diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js index d4cb9f75a77..fac4f7d368d 100644 --- a/spec/frontend/design_management/router_spec.js +++ b/spec/frontend/design_management/router_spec.js @@ -25,7 +25,7 @@ function factory(routeArg) { mocks: { $apollo: { queries: { - designs: { loading: true }, + designCollection: { loading: true }, design: { loading: true }, permissions: { loading: true }, }, diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js index 7e857d08d25..232cfa2f4ca 100644 --- a/spec/frontend/design_management/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -93,6 +93,10 @@ describe('optimistic responses', () => { fullPath: '', notesCount: 0, event: 'NONE', + currentUserTodos: { + __typename: 'TodoConnection', + nodes: [], + }, diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' }, discussions: { __typename: 'DesignDiscussion', nodes: [] }, versions: { diff --git a/spec/frontend/diff_comments_store_spec.js b/spec/frontend/diff_comments_store_spec.js deleted file mode 100644 index 6f25c9dd3bc..00000000000 --- a/spec/frontend/diff_comments_store_spec.js +++ /dev/null @@ -1,136 +0,0 @@ -/* global CommentsStore */ - -import '~/diff_notes/models/discussion'; -import '~/diff_notes/models/note'; -import '~/diff_notes/stores/comments'; - -function createDiscussion(noteId = 1, resolved = true) { - CommentsStore.create({ - discussionId: 'a', - noteId, - canResolve: true, - resolved, - resolvedBy: 'test', - authorName: 'test', - authorAvatar: 'test', - noteTruncated: 'test...', - }); -} - -beforeEach(() => { - CommentsStore.state = {}; -}); - -describe('New discussion', () => { - it('creates new discussion', () => { - expect(Object.keys(CommentsStore.state).length).toBe(0); - createDiscussion(); - - expect(Object.keys(CommentsStore.state).length).toBe(1); - }); - - it('creates new note in discussion', () => { - createDiscussion(); - createDiscussion(2); - - const discussion = CommentsStore.state.a; - - expect(Object.keys(discussion.notes).length).toBe(2); - }); -}); - -describe('Get note', () => { - beforeEach(() => { - createDiscussion(); - }); - - it('gets note by ID', () => { - const note = CommentsStore.get('a', 1); - - expect(note).toBeDefined(); - expect(note.id).toBe(1); - }); -}); - -describe('Delete discussion', () => { - beforeEach(() => { - createDiscussion(); - }); - - it('deletes discussion by ID', () => { - CommentsStore.delete('a', 1); - - expect(Object.keys(CommentsStore.state).length).toBe(0); - }); - - it('deletes discussion when no more notes', () => { - createDiscussion(); - createDiscussion(2); - - expect(Object.keys(CommentsStore.state).length).toBe(1); - expect(Object.keys(CommentsStore.state.a.notes).length).toBe(2); - - CommentsStore.delete('a', 1); - CommentsStore.delete('a', 2); - - expect(Object.keys(CommentsStore.state).length).toBe(0); - }); -}); - -describe('Update note', () => { - beforeEach(() => { - createDiscussion(); - }); - - it('updates note to be unresolved', () => { - CommentsStore.update('a', 1, false, 'test'); - - const note = CommentsStore.get('a', 1); - - expect(note.resolved).toBe(false); - }); -}); - -describe('Discussion resolved', () => { - beforeEach(() => { - createDiscussion(); - }); - - it('is resolved with single note', () => { - const discussion = CommentsStore.state.a; - - expect(discussion.isResolved()).toBe(true); - }); - - it('is unresolved with 2 notes', () => { - const discussion = CommentsStore.state.a; - createDiscussion(2, false); - - expect(discussion.isResolved()).toBe(false); - }); - - it('is resolved with 2 notes', () => { - const discussion = CommentsStore.state.a; - createDiscussion(2); - - expect(discussion.isResolved()).toBe(true); - }); - - it('resolve all notes', () => { - const discussion = CommentsStore.state.a; - createDiscussion(2, false); - - discussion.resolveAllNotes(); - - expect(discussion.isResolved()).toBe(true); - }); - - it('unresolve all notes', () => { - const discussion = CommentsStore.state.a; - createDiscussion(2); - - discussion.unResolveAllNotes(); - - expect(discussion.isResolved()).toBe(false); - }); -}); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index cd3a6aa0e28..86560470ada 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -699,7 +699,7 @@ describe('diffs/components/app', () => { describe('collapsed files', () => { it('should render the collapsed files warning if there are any collapsed files', () => { createComponent({}, ({ state }) => { - state.diffs.diffFiles = [{ viewer: { collapsed: true } }]; + state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }]; }); expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true); @@ -707,7 +707,7 @@ describe('diffs/components/app', () => { it('should not render the collapsed files warning if the user has dismissed the alert already', async () => { createComponent({}, ({ state }) => { - state.diffs.diffFiles = [{ viewer: { collapsed: true } }]; + state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }]; }); expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true); diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js index 670eab5472f..7bbffb7a1cd 100644 --- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js +++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js @@ -50,7 +50,7 @@ describe('CollapsedFilesWarning', () => { ({ limited, containerClasses }) => { createComponent({ limited }); - expect(wrapper.classes()).toEqual(containerClasses); + expect(wrapper.classes()).toEqual(['col-12'].concat(containerClasses)); }, ); diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index c48445790f7..9e4fcddd1b4 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -25,7 +25,7 @@ describe('diffs/components/commit_item', () => { const getTitleElement = () => wrapper.find('.commit-row-message.item-title'); const getDescElement = () => wrapper.find('pre.commit-row-description'); const getDescExpandElement = () => wrapper.find('.commit-content .js-toggle-button'); - const getShaElement = () => wrapper.find('.commit-sha-group'); + const getShaElement = () => wrapper.find('[data-testid="commit-sha-group"]'); const getAvatarElement = () => wrapper.find('.user-avatar-link'); const getCommitterElement = () => wrapper.find('.committer'); const getCommitActionsElement = () => wrapper.find('.commit-actions'); @@ -84,8 +84,8 @@ describe('diffs/components/commit_item', () => { it('renders commit sha', () => { const shaElement = getShaElement(); - const labelElement = shaElement.find('.label'); - const buttonElement = shaElement.find('button'); + const labelElement = shaElement.find('[data-testid="commit-sha-group"] button'); + const buttonElement = shaElement.find('button.input-group-text'); expect(labelElement.text()).toEqual(commit.short_id); expect(buttonElement.props('text')).toBe(commit.id); diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index a0cad32b9fb..3a236228c40 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -1,9 +1,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { GlIcon } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; -import EditButton from '~/diffs/components/edit_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import diffDiscussionsMockData from '../mock_data/diff_discussions'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -76,15 +74,7 @@ describe('DiffFileHeader component', () => { const findReplacedFileButton = () => wrapper.find({ ref: 'replacedFileButton' }); const findViewFileButton = () => wrapper.find({ ref: 'viewButton' }); const findCollapseIcon = () => wrapper.find({ ref: 'collapseIcon' }); - - const findIconByName = iconName => { - const icons = wrapper.findAll(GlIcon).filter(w => w.props('name') === iconName); - if (icons.length === 0) return icons; - if (icons.length > 1) { - throw new Error(`Multiple icons found for ${iconName}`); - } - return icons.at(0); - }; + const findEditButton = () => wrapper.find({ ref: 'editButton' }); const createComponent = props => { mockStoreConfig = cloneDeep(defaultMockStoreConfig); @@ -203,16 +193,6 @@ describe('DiffFileHeader component', () => { describe('for any file', () => { const otherModes = Object.keys(diffViewerModes).filter(m => m !== 'mode_changed'); - it('when edit button emits showForkMessage event it is re-emitted', () => { - createComponent({ - addMergeRequestButtons: true, - }); - wrapper.find(EditButton).vm.$emit('showForkMessage'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted().showForkMessage).toBeDefined(); - }); - }); - it('for mode_changed file mode displays mode changes', () => { createComponent({ diffFile: { @@ -271,16 +251,16 @@ describe('DiffFileHeader component', () => { }); it('should not render edit button', () => { createComponent({ addMergeRequestButtons: false }); - expect(wrapper.find(EditButton).exists()).toBe(false); + expect(findEditButton().exists()).toBe(false); }); }); describe('when addMergeRequestButtons is true', () => { describe('without discussions', () => { - it('renders a disabled toggle discussions button', () => { + it('does not render a toggle discussions button', () => { diffHasDiscussionsResultMock.mockReturnValue(false); createComponent({ addMergeRequestButtons: true }); - expect(findToggleDiscussionsButton().attributes('disabled')).toBe('true'); + expect(findToggleDiscussionsButton().exists()).toBe(false); }); }); @@ -288,7 +268,7 @@ describe('DiffFileHeader component', () => { it('dispatches toggleFileDiscussionWrappers when user clicks on toggle discussions button', () => { diffHasDiscussionsResultMock.mockReturnValue(true); createComponent({ addMergeRequestButtons: true }); - expect(findToggleDiscussionsButton().attributes('disabled')).toBeFalsy(); + expect(findToggleDiscussionsButton().exists()).toBe(true); findToggleDiscussionsButton().vm.$emit('click'); expect( mockStoreConfig.modules.diffs.actions.toggleFileDiscussionWrappers, @@ -300,7 +280,7 @@ describe('DiffFileHeader component', () => { createComponent({ addMergeRequestButtons: true, }); - expect(wrapper.find(EditButton).exists()).toBe(true); + expect(findEditButton().exists()).toBe(true); }); describe('view on environment button', () => { @@ -334,7 +314,7 @@ describe('DiffFileHeader component', () => { }); it('should not render edit button', () => { - expect(wrapper.find(EditButton).exists()).toBe(false); + expect(findEditButton().exists()).toBe(false); }); }); describe('with file blob', () => { @@ -345,7 +325,7 @@ describe('DiffFileHeader component', () => { addMergeRequestButtons: true, }); expect(findViewFileButton().attributes('href')).toBe(viewPath); - expect(findViewFileButton().attributes('title')).toEqual( + expect(findViewFileButton().text()).toEqual( `View file @ ${diffFile.content_sha.substr(0, 8)}`, ); }); @@ -375,21 +355,6 @@ describe('DiffFileHeader component', () => { addMergeRequestButtons: true, }; - it.each` - iconName | isShowingFullFile - ${'doc-expand'} | ${false} - ${'doc-changes'} | ${true} - `( - 'shows $iconName when isShowingFullFile set to $isShowingFullFile', - ({ iconName, isShowingFullFile }) => { - createComponent({ - ...fullyNotExpandedFileProps, - diffFile: { ...fullyNotExpandedFileProps.diffFile, isShowingFullFile }, - }); - expect(findIconByName(iconName).exists()).toBe(true); - }, - ); - it('renders expand to full file button if not showing full file already', () => { createComponent(fullyNotExpandedFileProps); expect(findExpandButton().exists()).toBe(true); @@ -455,7 +420,7 @@ describe('DiffFileHeader component', () => { it('does not show edit button', () => { createComponent({ diffFile: { ...diffFile, deleted_file: true } }); - expect(wrapper.find(EditButton).exists()).toBe(false); + expect(findEditButton().exists()).toBe(false); }); }); diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 79f0f6bc327..a6f0d2bf11d 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -37,7 +37,7 @@ describe('DiffFile', () => { expect(el.querySelectorAll('.diff-content.hidden').length).toEqual(0); expect(el.querySelector('.js-file-title')).toBeDefined(); - expect(el.querySelector('.btn-clipboard')).toBeDefined(); + expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined(); expect(el.querySelector('.file-title-name').innerText.indexOf(file_path)).toBeGreaterThan(-1); expect(el.querySelector('.js-syntax-highlight')).toBeDefined(); @@ -47,7 +47,7 @@ describe('DiffFile', () => { .then(() => { expect(el.querySelectorAll('.line_content').length).toBe(8); expect(el.querySelectorAll('.js-line-expansion-content').length).toBe(1); - triggerEvent('.btn-clipboard'); + triggerEvent('[data-testid="diff-file-copy-clipboard"]'); }) .then(done) .catch(done.fail); @@ -56,11 +56,11 @@ describe('DiffFile', () => { it('should track a click event on copy to clip board button', done => { const el = vm.$el; - expect(el.querySelector('.btn-clipboard')).toBeDefined(); + expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined(); vm.file.renderIt = true; vm.$nextTick() .then(() => { - triggerEvent('.btn-clipboard'); + triggerEvent('[data-testid="diff-file-copy-clipboard"]'); expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', { label: 'diff_copy_file_path_button', @@ -90,8 +90,8 @@ describe('DiffFile', () => { vm.isCollapsed = true; vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This file is collapsed.'); - expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy(); + expect(vm.$el.innerText).toContain('This diff is collapsed'); + expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); done(); }); @@ -102,8 +102,8 @@ describe('DiffFile', () => { vm.isCollapsed = true; vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This file is collapsed.'); - expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy(); + expect(vm.$el.innerText).toContain('This diff is collapsed'); + expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); done(); }); @@ -121,8 +121,8 @@ describe('DiffFile', () => { vm.isCollapsed = true; vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This file is collapsed.'); - expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy(); + expect(vm.$el.innerText).toContain('This diff is collapsed'); + expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); done(); }); @@ -135,7 +135,7 @@ describe('DiffFile', () => { vm.file.viewer.name = diffViewerModes.renamed; vm.$nextTick(() => { - expect(vm.$el.innerText).not.toContain('This file is collapsed.'); + expect(vm.$el.innerText).not.toContain('This diff is collapsed'); done(); }); @@ -148,7 +148,7 @@ describe('DiffFile', () => { vm.file.viewer.name = diffViewerModes.mode_changed; vm.$nextTick(() => { - expect(vm.$el.innerText).not.toContain('This file is collapsed.'); + expect(vm.$el.innerText).not.toContain('This diff is collapsed'); done(); }); @@ -181,7 +181,7 @@ describe('DiffFile', () => { }); it('updates local state when changing file state', done => { - vm.file.viewer.collapsed = true; + vm.file.viewer.automaticallyCollapsed = true; vm.$nextTick(() => { expect(vm.isCollapsed).toBe(true); diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js new file mode 100644 index 00000000000..394b6cb1914 --- /dev/null +++ b/spec/frontend/diffs/components/diff_row_utils_spec.js @@ -0,0 +1,203 @@ +import * as utils from '~/diffs/components/diff_row_utils'; +import { + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + EMPTY_CELL_TYPE, +} from '~/diffs/constants'; + +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); + }); + + 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); + }); + + 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); + }); +}); + +describe('isContextLine', () => { + it('return true if line type is context', () => { + expect(utils.isContextLine(CONTEXT_LINE_TYPE)).toBe(true); + }); + + it('return false if line type is not context', () => { + expect(utils.isContextLine('xxx')).toBe(false); + }); +}); + +describe('isMatchLine', () => { + it('return true if line type is match', () => { + expect(utils.isMatchLine(MATCH_LINE_TYPE)).toBe(true); + }); + + it('return false if line type is not match', () => { + expect(utils.isMatchLine('xxx')).toBe(false); + }); +}); + +describe('isMetaLine', () => { + it.each` + type | expectation + ${OLD_NO_NEW_LINE_TYPE} | ${true} + ${NEW_NO_NEW_LINE_TYPE} | ${true} + ${EMPTY_CELL_TYPE} | ${true} + ${'xxx'} | ${false} + `('should return $expectation if type is $type', ({ type, expectation }) => { + expect(utils.isMetaLine(type)).toBe(expectation); + }); +}); + +describe('shouldRenderCommentButton', () => { + it('should return false if comment button is not rendered', () => { + expect(utils.shouldRenderCommentButton(true, false)).toBe(false); + }); + + it('should return false if not logged in', () => { + expect(utils.shouldRenderCommentButton(false, true)).toBe(false); + }); + + it('should return true logged in and rendered', () => { + expect(utils.shouldRenderCommentButton(true, true)).toBe(true); + }); +}); + +describe('hasDiscussions', () => { + it('should return false if line is undefined', () => { + expect(utils.hasDiscussions()).toBe(false); + }); + + it('should return false if discussions is undefined', () => { + expect(utils.hasDiscussions({})).toBe(false); + }); + + it('should return false if discussions has legnth of 0', () => { + expect(utils.hasDiscussions({ discussions: [] })).toBe(false); + }); + + it('should return true if discussions has legnth > 0', () => { + expect(utils.hasDiscussions({ discussions: [1] })).toBe(true); + }); +}); + +describe('lineHref', () => { + it(`should return #${LINE_CODE}`, () => { + expect(utils.lineHref({ line_code: LINE_CODE })).toEqual(`#${LINE_CODE}`); + }); + + it(`should return '#' if line is undefined`, () => { + expect(utils.lineHref()).toEqual('#'); + }); + + it(`should return '#' if line_code is undefined`, () => { + expect(utils.lineHref({})).toEqual('#'); + }); +}); + +describe('lineCode', () => { + it(`should return undefined if line_code is undefined`, () => { + expect(utils.lineCode()).toEqual(undefined); + expect(utils.lineCode({ left: {} })).toEqual(undefined); + expect(utils.lineCode({ right: {} })).toEqual(undefined); + }); + + it(`should return ${LINE_CODE}`, () => { + expect(utils.lineCode({ line_code: LINE_CODE })).toEqual(LINE_CODE); + expect(utils.lineCode({ left: { line_code: LINE_CODE } })).toEqual(LINE_CODE); + expect(utils.lineCode({ right: { line_code: LINE_CODE } })).toEqual(LINE_CODE); + }); +}); + +describe('classNameMapCell', () => { + it.each` + line | hll | loggedIn | hovered | expectation + ${undefined} | ${true} | ${true} | ${true} | ${[]} + ${{ type: 'new' }} | ${false} | ${false} | ${false} | ${['new', { hll: false, 'is-over': false }]} + ${{ type: 'new' }} | ${true} | ${true} | ${false} | ${['new', { hll: true, 'is-over': false }]} + ${{ type: 'new' }} | ${true} | ${false} | ${true} | ${['new', { hll: true, 'is-over': false }]} + ${{ type: 'new' }} | ${true} | ${true} | ${true} | ${['new', { hll: true, 'is-over': true }]} + `('should return $expectation', ({ line, hll, loggedIn, hovered, expectation }) => { + const classes = utils.classNameMapCell(line, hll, loggedIn, hovered); + expect(classes).toEqual(expectation); + }); +}); + +describe('addCommentTooltip', () => { + const brokenSymLinkTooltip = + '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.'; + it('should return default tooltip', () => { + expect(utils.addCommentTooltip()).toBeUndefined(); + }); + + it('should return broken symlink tooltip', () => { + expect(utils.addCommentTooltip({ commentsDisabled: { wasSymbolic: true } })).toEqual( + brokenSymLinkTooltip, + ); + expect(utils.addCommentTooltip({ commentsDisabled: { isSymbolic: true } })).toEqual( + brokenSymLinkTooltip, + ); + }); + + it('should return broken real tooltip', () => { + expect(utils.addCommentTooltip({ commentsDisabled: { wasReal: true } })).toEqual( + brokenRealTooltip, + ); + expect(utils.addCommentTooltip({ commentsDisabled: { isReal: true } })).toEqual( + brokenRealTooltip, + ); + }); +}); + +describe('parallelViewLeftLineType', () => { + it(`should return ${OLD_NO_NEW_LINE_TYPE}`, () => { + expect(utils.parallelViewLeftLineType({ right: { type: NEW_NO_NEW_LINE_TYPE } })).toEqual( + OLD_NO_NEW_LINE_TYPE, + ); + }); + + it(`should return 'new'`, () => { + expect(utils.parallelViewLeftLineType({ left: { type: 'new' } })).toContain('new'); + }); + + it(`should return ${EMPTY_CELL_TYPE}`, () => { + expect(utils.parallelViewLeftLineType({})).toContain(EMPTY_CELL_TYPE); + }); + + it(`should return hll:true`, () => { + expect(utils.parallelViewLeftLineType({}, true)[1]).toEqual({ hll: true }); + }); +}); + +describe('shouldShowCommentButton', () => { + it.each` + hover | context | meta | discussions | expectation + ${true} | ${false} | ${false} | ${false} | ${true} + ${false} | ${false} | ${false} | ${false} | ${false} + ${true} | ${true} | ${false} | ${false} | ${false} + ${true} | ${true} | ${true} | ${false} | ${false} + ${true} | ${true} | ${true} | ${true} | ${false} + `( + 'should return $expectation when hover is $hover', + ({ hover, context, meta, discussions, expectation }) => { + expect(utils.shouldShowCommentButton(hover, context, meta, discussions)).toBe(expectation); + }, + ); +}); diff --git a/spec/frontend/diffs/components/diff_table_cell_spec.js b/spec/frontend/diffs/components/diff_table_cell_spec.js deleted file mode 100644 index 02f5c27eecb..00000000000 --- a/spec/frontend/diffs/components/diff_table_cell_spec.js +++ /dev/null @@ -1,279 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { TEST_HOST } from 'helpers/test_constants'; -import DiffTableCell from '~/diffs/components/diff_table_cell.vue'; -import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; -import { LINE_POSITION_RIGHT } from '~/diffs/constants'; -import { createStore } from '~/mr_notes/stores'; -import discussionsMockData from '../mock_data/diff_discussions'; -import diffFileMockData from '../mock_data/diff_file'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const TEST_USER_ID = 'abc123'; -const TEST_USER = { id: TEST_USER_ID }; -const TEST_LINE_NUMBER = 1; -const TEST_LINE_CODE = 'LC_42'; -const TEST_FILE_HASH = diffFileMockData.file_hash; - -describe('DiffTableCell', () => { - 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'; - - let wrapper; - let line; - let store; - - beforeEach(() => { - store = createStore(); - store.state.notes.userData = TEST_USER; - - 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, - }; - }); - - afterEach(() => { - wrapper.destroy(); - }); - - const setWindowLocation = value => { - Object.defineProperty(window, 'location', { - writable: true, - value, - }); - }; - - const createComponent = (props = {}) => { - wrapper = shallowMount(DiffTableCell, { - localVue, - store, - propsData: { - line, - fileHash: TEST_FILE_HASH, - contextLinesPath: '/context/lines/path', - isHighlighted: false, - ...props, - }, - }); - }; - - const findTd = () => wrapper.find({ ref: 'td' }); - const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButton' }); - const findLineNumber = () => wrapper.find({ ref: 'lineNumberRef' }); - const findTooltip = () => wrapper.find({ ref: 'addNoteTooltip' }); - const findAvatars = () => wrapper.find(DiffGutterAvatars); - - describe('td', () => { - it('highlights when isHighlighted true', () => { - createComponent({ isHighlighted: true }); - - expect(findTd().classes()).toContain('hll'); - }); - - it('does not highlight when isHighlighted false', () => { - createComponent({ isHighlighted: false }); - - expect(findTd().classes()).not.toContain('hll'); - }); - }); - - describe('comment button', () => { - it.each` - showCommentButton | userData | query | mergeRefHeadComments | expectation - ${true} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true} - ${true} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true} - ${true} | ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false} - ${false} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${false} - ${false} | ${TEST_USER} | ${'bogus'} | ${true} | ${false} - ${true} | ${null} | ${''} | ${true} | ${false} - `( - 'exists is $expectation - with showCommentButton ($showCommentButton) userData ($userData) query ($query)', - ({ showCommentButton, userData, query, mergeRefHeadComments, expectation }) => { - store.state.notes.userData = userData; - gon.features = { mergeRefHeadComments }; - setWindowLocation({ href: `${TEST_HOST}?${query}` }); - createComponent({ showCommentButton }); - - wrapper.setData({ isCommentButtonRendered: showCommentButton }); - - return wrapper.vm.$nextTick().then(() => { - expect(findNoteButton().exists()).toBe(expectation); - }); - }, - ); - - it.each` - isHover | otherProps | discussions | expectation - ${true} | ${{}} | ${[]} | ${true} - ${false} | ${{}} | ${[]} | ${false} - ${true} | ${{ line: { ...line, type: 'context' } }} | ${[]} | ${false} - ${true} | ${{ line: { ...line, type: 'old-nonewline' } }} | ${[]} | ${false} - ${true} | ${{}} | ${[{}]} | ${false} - `( - 'visible is $expectation - with isHover ($isHover), discussions ($discussions), otherProps ($otherProps)', - ({ isHover, otherProps, discussions, expectation }) => { - line.discussions = discussions; - createComponent({ - showCommentButton: true, - isHover, - ...otherProps, - }); - - wrapper.setData({ - isCommentButtonRendered: true, - }); - - 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 }) => { - line.commentsDisabled = commentsDisabled; - - createComponent({ - showCommentButton: true, - isHover: true, - }); - - wrapper.setData({ isCommentButtonRendered: true }); - - return wrapper.vm.$nextTick().then(() => { - expect(findNoteButton().attributes('disabled')).toBe(disabled); - }); - }, - ); - - 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 }) => { - line.commentsDisabled = commentsDisabled; - - createComponent({ - showCommentButton: true, - isHover: true, - }); - - wrapper.setData({ isCommentButtonRendered: true }); - - return wrapper.vm.$nextTick().then(() => { - expect(findTooltip().attributes('title')).toBe(tooltip); - }); - }, - ); - }); - - describe('line number', () => { - describe('without lineNumber prop', () => { - it('does not render', () => { - createComponent({ lineType: 'old' }); - - expect(findLineNumber().exists()).toBe(false); - }); - }); - - describe('with lineNumber prop', () => { - describe.each` - lineProps | expectedHref | expectedClickArg - ${{ line_code: TEST_LINE_CODE }} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} - ${{ line_code: undefined }} | ${'#'} | ${undefined} - ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE } }} | ${'#'} | ${TEST_LINE_CODE} - ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE } }} | ${'#'} | ${TEST_LINE_CODE} - `('with line ($lineProps)', ({ lineProps, expectedHref, expectedClickArg }) => { - beforeEach(() => { - jest.spyOn(store, 'dispatch').mockImplementation(); - Object.assign(line, lineProps); - createComponent({ lineNumber: TEST_LINE_NUMBER }); - }); - - 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).not.toHaveBeenCalled(); - - findLineNumber().trigger('click'); - - expect(store.dispatch).toHaveBeenCalledWith('diffs/setHighlightedRow', expectedClickArg); - }); - }); - }); - }); - - describe('diff-gutter-avatars', () => { - describe('with showCommentButton', () => { - beforeEach(() => { - jest.spyOn(store, 'dispatch').mockImplementation(); - - createComponent({ showCommentButton: true }); - }); - - it('renders', () => { - expect(findAvatars().props()).toEqual({ - discussions: line.discussions, - discussionsExpanded: line.discussionsExpanded, - }); - }); - - it('toggles line discussion', () => { - expect(store.dispatch).not.toHaveBeenCalled(); - - findAvatars().vm.$emit('toggleLineDiscussions'); - - expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', { - lineCode: TEST_LINE_CODE, - fileHash: TEST_FILE_HASH, - expanded: !line.discussionsExpanded, - }); - }); - }); - - it.each` - props | lineProps | expectation - ${{ showCommentButton: true }} | ${{}} | ${true} - ${{ showCommentButton: false }} | ${{}} | ${false} - ${{ showCommentButton: true, linePosition: LINE_POSITION_RIGHT }} | ${{ type: null }} | ${false} - ${{ showCommentButton: true }} | ${{ discussions: [] }} | ${false} - `( - 'exists is $expectation - with props ($props), line ($lineProps)', - ({ props, lineProps, expectation }) => { - Object.assign(line, lineProps); - createComponent(props); - - expect(findAvatars().exists()).toBe(expectation); - }, - ); - }); -}); diff --git a/spec/frontend/diffs/components/edit_button_spec.js b/spec/frontend/diffs/components/edit_button_spec.js deleted file mode 100644 index 71512c1c4af..00000000000 --- a/spec/frontend/diffs/components/edit_button_spec.js +++ /dev/null @@ -1,75 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; -import EditButton from '~/diffs/components/edit_button.vue'; - -const editPath = 'test-path'; - -describe('EditButton', () => { - let wrapper; - - const createComponent = (props = {}) => { - wrapper = shallowMount(EditButton, { - propsData: { ...props }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('has correct href attribute', () => { - createComponent({ - editPath, - canCurrentUserFork: false, - }); - - expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe(editPath); - }); - - it('emits a show fork message event if current user can fork', () => { - createComponent({ - editPath, - canCurrentUserFork: true, - }); - wrapper.find(GlDeprecatedButton).trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('showForkMessage')).toBeTruthy(); - }); - }); - - it('doesnt emit a show fork message event if current user cannot fork', () => { - createComponent({ - editPath, - canCurrentUserFork: false, - }); - wrapper.find(GlDeprecatedButton).trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('showForkMessage')).toBeFalsy(); - }); - }); - - it('doesnt emit a show fork message event if current user can modify blob', () => { - createComponent({ - editPath, - canCurrentUserFork: true, - canModifyBlob: true, - }); - wrapper.find(GlDeprecatedButton).trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('showForkMessage')).toBeFalsy(); - }); - }); - - it('disables button if editPath is empty', () => { - createComponent({ - editPath: '', - canCurrentUserFork: true, - canModifyBlob: true, - }); - - expect(wrapper.find(GlDeprecatedButton).attributes('disabled')).toBe('true'); - }); -}); diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js index c2a4424ee95..babaaa21dab 100644 --- a/spec/frontend/diffs/mock_data/diff_file.js +++ b/spec/frontend/diffs/mock_data/diff_file.js @@ -27,7 +27,7 @@ export default { viewer: { name: 'text', error: null, - collapsed: false, + automaticallyCollapsed: false, }, added_lines: 2, removed_lines: 0, diff --git a/spec/frontend/diffs/mock_data/diff_file_unreadable.js b/spec/frontend/diffs/mock_data/diff_file_unreadable.js index 8c2df45988e..fca81faabf6 100644 --- a/spec/frontend/diffs/mock_data/diff_file_unreadable.js +++ b/spec/frontend/diffs/mock_data/diff_file_unreadable.js @@ -26,7 +26,7 @@ export default { viewer: { name: 'text', error: null, - collapsed: false, + automaticallyCollapsed: false, }, added_lines: 0, removed_lines: 0, diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 4f647b0cd41..c3e4ee9c531 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -483,14 +483,14 @@ describe('DiffsStoreActions', () => { id: 1, renderIt: false, viewer: { - collapsed: false, + automaticallyCollapsed: false, }, }, { id: 2, renderIt: false, viewer: { - collapsed: false, + automaticallyCollapsed: false, }, }, ], @@ -967,7 +967,7 @@ describe('DiffsStoreActions', () => { { file_hash: 'HASH', viewer: { - collapsed, + automaticallyCollapsed: collapsed, }, renderIt, }, @@ -1167,7 +1167,7 @@ describe('DiffsStoreActions', () => { file_hash: 'testhash', alternate_viewer: { name: updatedViewerName }, }; - const updatedViewer = { name: updatedViewerName, collapsed: false }; + const updatedViewer = { name: updatedViewerName, automaticallyCollapsed: false }; const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }]; let renamedFile; let mock; diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js index dac5be2d656..0083f1d8b44 100644 --- a/spec/frontend/diffs/store/getters_spec.js +++ b/spec/frontend/diffs/store/getters_spec.js @@ -51,13 +51,19 @@ describe('Diffs Module Getters', () => { describe('hasCollapsedFile', () => { it('returns true when all files are collapsed', () => { - localState.diffFiles = [{ viewer: { collapsed: true } }, { viewer: { collapsed: true } }]; + localState.diffFiles = [ + { viewer: { automaticallyCollapsed: true } }, + { viewer: { automaticallyCollapsed: true } }, + ]; expect(getters.hasCollapsedFile(localState)).toEqual(true); }); it('returns true when at least one file is collapsed', () => { - localState.diffFiles = [{ viewer: { collapsed: false } }, { viewer: { collapsed: true } }]; + localState.diffFiles = [ + { viewer: { automaticallyCollapsed: false } }, + { viewer: { automaticallyCollapsed: true } }, + ]; expect(getters.hasCollapsedFile(localState)).toEqual(true); }); @@ -139,50 +145,74 @@ describe('Diffs Module Getters', () => { describe('diffHasExpandedDiscussions', () => { it('returns true when one of the discussions is expanded', () => { - discussionMock1.expanded = false; + const diffFile = { + parallel_diff_lines: [], + highlighted_diff_lines: [ + { + discussions: [discussionMock, discussionMock], + discussionsExpanded: true, + }, + ], + }; - expect( - getters.diffHasExpandedDiscussions(localState, { - getDiffFileDiscussions: () => [discussionMock, discussionMock], - })(diffFileMock), - ).toEqual(true); + expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(true); }); it('returns false when there are no discussions', () => { - expect( - getters.diffHasExpandedDiscussions(localState, { getDiffFileDiscussions: () => [] })( - diffFileMock, - ), - ).toEqual(false); + const diffFile = { + parallel_diff_lines: [], + highlighted_diff_lines: [ + { + discussions: [], + discussionsExpanded: true, + }, + ], + }; + expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false); }); it('returns false when no discussion is expanded', () => { - discussionMock.expanded = false; - discussionMock1.expanded = false; + const diffFile = { + parallel_diff_lines: [], + highlighted_diff_lines: [ + { + discussions: [discussionMock, discussionMock], + discussionsExpanded: false, + }, + ], + }; - expect( - getters.diffHasExpandedDiscussions(localState, { - getDiffFileDiscussions: () => [discussionMock, discussionMock1], - })(diffFileMock), - ).toEqual(false); + expect(getters.diffHasExpandedDiscussions(localState)(diffFile)).toEqual(false); }); }); describe('diffHasDiscussions', () => { it('returns true when getDiffFileDiscussions returns discussions', () => { - expect( - getters.diffHasDiscussions(localState, { - getDiffFileDiscussions: () => [discussionMock], - })(diffFileMock), - ).toEqual(true); + const diffFile = { + parallel_diff_lines: [], + highlighted_diff_lines: [ + { + discussions: [discussionMock, discussionMock], + discussionsExpanded: false, + }, + ], + }; + + expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(true); }); it('returns false when getDiffFileDiscussions returns no discussions', () => { - expect( - getters.diffHasDiscussions(localState, { - getDiffFileDiscussions: () => [], - })(diffFileMock), - ).toEqual(false); + const diffFile = { + parallel_diff_lines: [], + highlighted_diff_lines: [ + { + discussions: [], + discussionsExpanded: false, + }, + ], + }; + + expect(getters.diffHasDiscussions(localState)(diffFile)).toEqual(false); }); }); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index e1d855ae0cf..a84ad63c695 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -130,14 +130,14 @@ describe('DiffsStoreMutations', () => { it('should change the collapsed prop from diffFiles', () => { const diffFile = { viewer: { - collapsed: true, + automaticallyCollapsed: true, }, }; const state = { expandAllFiles: true, diffFiles: [diffFile] }; mutations[types.EXPAND_ALL_FILES](state); - expect(state.diffFiles[0].viewer.collapsed).toEqual(false); + expect(state.diffFiles[0].viewer.automaticallyCollapsed).toEqual(false); }); }); @@ -933,12 +933,12 @@ describe('DiffsStoreMutations', () => { describe('SET_FILE_COLLAPSED', () => { it('sets collapsed', () => { const state = { - diffFiles: [{ file_path: 'test', viewer: { collapsed: false } }], + diffFiles: [{ file_path: 'test', viewer: { automaticallyCollapsed: false } }], }; mutations[types.SET_FILE_COLLAPSED](state, { filePath: 'test', collapsed: true }); - expect(state.diffFiles[0].viewer.collapsed).toBe(true); + expect(state.diffFiles[0].viewer.automaticallyCollapsed).toBe(true); }); }); diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js index 53c6d0835bc..2f174c45ad7 100644 --- a/spec/frontend/emoji/emoji_spec.js +++ b/spec/frontend/emoji/emoji_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import { trimText } from 'helpers/text_helper'; import axios from '~/lib/utils/axios_utils'; -import { initEmojiMap, glEmojiTag, EMOJI_VERSION } from '~/emoji'; +import { initEmojiMap, glEmojiTag, searchEmoji, EMOJI_VERSION } from '~/emoji'; import isEmojiUnicodeSupported, { isFlagEmoji, isRainbowFlagEmoji, @@ -31,25 +31,35 @@ const emptySupportMap = { }; const emojiFixtureMap = { + atom: { + name: 'atom', + moji: '⚛', + description: 'atom symbol', + unicodeVersion: '4.1', + }, bomb: { name: 'bomb', moji: '💣', unicodeVersion: '6.0', + description: 'bomb', }, construction_worker_tone5: { name: 'construction_worker_tone5', moji: '👷🏿', unicodeVersion: '8.0', + description: 'construction worker tone 5', }, five: { name: 'five', moji: '5️⃣', unicodeVersion: '3.0', + description: 'keycap digit five', }, grey_question: { name: 'grey_question', moji: '❔', unicodeVersion: '6.0', + description: 'white question mark ornament', }, }; @@ -57,8 +67,15 @@ describe('gl_emoji', () => { let mock; beforeEach(() => { + const emojiData = Object.fromEntries( + Object.values(emojiFixtureMap).map(m => { + const { name: n, moji: e, unicodeVersion: u, category: c, description: d } = m; + return [n, { c, e, d, u }]; + }), + ); + mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData)); return initEmojiMap().catch(() => {}); }); @@ -378,4 +395,24 @@ describe('gl_emoji', () => { expect(isSupported).toBeFalsy(); }); }); + + describe('searchEmoji', () => { + const { atom, grey_question } = emojiFixtureMap; + const contains = (e, term) => + expect(searchEmoji(term).map(({ name }) => name)).toContain(e.name); + + it('should match by full name', () => contains(grey_question, 'grey_question')); + it('should match by full alias', () => contains(atom, 'atom_symbol')); + it('should match by full description', () => contains(grey_question, 'ornament')); + + it('should match by partial name', () => contains(grey_question, 'question')); + it('should match by partial alias', () => contains(atom, '_symbol')); + it('should match by partial description', () => contains(grey_question, 'ment')); + + it('should fuzzy match by name', () => contains(grey_question, 'greion')); + it('should fuzzy match by alias', () => contains(atom, 'atobol')); + it('should fuzzy match by description', () => contains(grey_question, 'ornt')); + + it('should match by character', () => contains(grey_question, '❔')); + }); }); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 35ca323f5a9..c958fb7ce03 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -1,4 +1,4 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import/no-commonjs, max-classes-per-file */ const path = require('path'); const { ErrorWithStack } = require('jest-util'); @@ -58,6 +58,14 @@ class CustomEnvironment extends JSDOMEnvironment { measure: () => null, getEntriesByName: () => [], }); + + this.global.PerformanceObserver = class { + /* eslint-disable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */ + constructor(callback) {} + disconnect() {} + observe(element, initObject) {} + /* eslint-enable no-useless-constructor, no-unused-vars, no-empty-function, class-methods-use-this */ + }; } async teardown() { diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index fe32bf918dd..22b066fae41 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -40,6 +40,9 @@ describe('Environment', () => { return axios.waitForAll(); }; + const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a'); + const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a'); + beforeEach(() => { mock = new MockAdapter(axios); }); @@ -108,9 +111,16 @@ describe('Environment', () => { it('should make an API request when using tabs', () => { jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); - wrapper.find('.js-environments-tab-stopped').trigger('click'); + findEnvironmentsTabStopped().trigger('click'); expect(wrapper.vm.updateContent).toHaveBeenCalledWith({ scope: 'stopped', page: '1' }); }); + + it('should not make the same API request when clicking on the current scope tab', () => { + // component starts at available + jest.spyOn(wrapper.vm, 'updateContent').mockImplementation(() => {}); + findEnvironmentsTabAvailable().trigger('click'); + expect(wrapper.vm.updateContent).toHaveBeenCalledTimes(0); + }); }); }); }); diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index f33c8de0094..14c710dd7ba 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -46,9 +46,10 @@ describe('Environments Folder View', () => { wrapper = mount(EnvironmentsFolderViewComponent, { propsData: mockData }); }; - const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available'); + const findEnvironmentsTabAvailable = () => + wrapper.find('[data-testid="environments-tab-available"]'); - const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped'); + const findEnvironmentsTabStopped = () => wrapper.find('[data-testid="environments-tab-stopped"]'); beforeEach(() => { mock = new MockAdapter(axios); diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js index 21edcb7235a..f4a765a3d73 100644 --- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js +++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js @@ -1,7 +1,6 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlFormInput } from '@gitlab/ui'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { GlFormInput, GlButton } from '@gitlab/ui'; import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue'; import createStore from '~/error_tracking_settings/store'; import { defaultProps } from '../mock'; @@ -43,7 +42,7 @@ describe('error tracking settings form', () => { .attributes('id'), ).toBe('error-tracking-token'); - expect(wrapper.findAll(LoadingButton).exists()).toBe(true); + expect(wrapper.findAll(GlButton).exists()).toBe(true); }); it('is rendered with labels and placeholders', () => { @@ -72,9 +71,10 @@ describe('error tracking settings form', () => { }); it('shows loading spinner', () => { - const { label, loading } = wrapper.find(LoadingButton).props(); - expect(loading).toBe(true); - expect(label).toBe('Connecting'); + const buttonEl = wrapper.find(GlButton); + + expect(buttonEl.props('loading')).toBe(true); + expect(buttonEl.text()).toBe('Connecting'); }); }); diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js new file mode 100644 index 00000000000..47f786827f1 --- /dev/null +++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js @@ -0,0 +1,159 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal, GlSprintf } from '@gitlab/ui'; +import Component from '~/feature_flags/components/configure_feature_flags_modal.vue'; +import Callout from '~/vue_shared/components/callout.vue'; + +describe('Configure Feature Flags Modal', () => { + const mockEvent = { preventDefault: jest.fn() }; + const provide = { + projectName: 'fakeProjectName', + featureFlagsHelpPagePath: '/help/path', + }; + + const propsData = { + helpClientLibrariesPath: '/help/path/#flags', + helpClientExamplePath: '/feature-flags#clientexample', + apiUrl: '/api/url', + instanceId: 'instance-id-token', + isRotating: false, + hasRotateError: false, + canUserRotateToken: true, + }; + + let wrapper; + const factory = (props = {}, { mountFn = shallowMount, ...options } = {}) => { + wrapper = mountFn(Component, { + provide, + stubs: { GlSprintf }, + propsData: { + ...propsData, + ...props, + }, + ...options, + }); + }; + + const findGlModal = () => wrapper.find(GlModal); + const findPrimaryAction = () => findGlModal().props('actionPrimary'); + const findProjectNameInput = () => wrapper.find('#project_name_verification'); + const findDangerCallout = () => + wrapper.findAll(Callout).filter(c => c.props('category') === 'danger'); + + describe('idle', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory); + + it('should have Primary and Cancel actions', () => { + expect(findGlModal().props('actionCancel').text).toBe('Close'); + expect(findPrimaryAction().text).toBe('Regenerate instance ID'); + }); + + it('should default disable the primary action', async () => { + const [{ disabled }] = findPrimaryAction().attributes; + expect(disabled).toBe(true); + }); + + it('should emit a `token` event when clicking on the Primary action', async () => { + findGlModal().vm.$emit('primary', mockEvent); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('token')).toEqual([[]]); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + + it('should clear the project name input after generating the token', async () => { + findProjectNameInput().vm.$emit('input', provide.projectName); + findGlModal().vm.$emit('primary', mockEvent); + await wrapper.vm.$nextTick(); + expect(findProjectNameInput().attributes('value')).toBe(''); + }); + + it('should provide an input for filling the project name', () => { + expect(findProjectNameInput().exists()).toBe(true); + expect(findProjectNameInput().attributes('value')).toBe(''); + }); + + it('should display an help text', () => { + const help = wrapper.find('p'); + expect(help.text()).toMatch(/More Information/); + }); + + it('should have links to the documentation', () => { + expect(wrapper.find('[data-testid="help-link"]').attributes('href')).toBe( + provide.featureFlagsHelpPagePath, + ); + expect(wrapper.find('[data-testid="help-client-link"]').attributes('href')).toBe( + propsData.helpClientLibrariesPath, + ); + }); + + it('should display one and only one danger callout', () => { + const dangerCallout = findDangerCallout(); + expect(dangerCallout.length).toBe(1); + expect(dangerCallout.at(0).props('message')).toMatch(/Regenerating the instance ID/); + }); + + it('should display a message asking to fill the project name', () => { + expect(wrapper.find('[data-testid="prevent-accident-text"]').text()).toMatch( + provide.projectName, + ); + }); + + it('should display the api URL in an input box', () => { + const input = wrapper.find('#api_url'); + expect(input.element.value).toBe('/api/url'); + }); + + it('should display the instance ID in an input box', () => { + const input = wrapper.find('#instance_id'); + expect(input.element.value).toBe('instance-id-token'); + }); + }); + + describe('verified', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory); + + it('should enable the primary action', async () => { + findProjectNameInput().vm.$emit('input', provide.projectName); + await wrapper.vm.$nextTick(); + const [{ disabled }] = findPrimaryAction().attributes; + expect(disabled).toBe(false); + }); + }); + + describe('cannot rotate token', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { canUserRotateToken: false })); + + it('should not display the primary action', async () => { + expect(findPrimaryAction()).toBe(null); + }); + + it('shold not display regenerating instance ID', async () => { + expect(findDangerCallout().exists()).toBe(false); + }); + + it('should disable the project name input', async () => { + expect(findProjectNameInput().exists()).toBe(false); + }); + }); + + describe('has rotate error', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { hasRotateError: false })); + + it('should display an error', async () => { + expect(wrapper.find('.text-danger')).toExist(); + expect(wrapper.find('[name="warning"]')).toExist(); + }); + }); + + describe('is rotating', () => { + afterEach(() => wrapper.destroy()); + beforeEach(factory.bind(null, { isRotating: true })); + + it('should disable the project name input', async () => { + expect(findProjectNameInput().attributes('disabled')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js new file mode 100644 index 00000000000..f2e587bb8d9 --- /dev/null +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -0,0 +1,197 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { GlToggle, GlAlert } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import { mockTracking } from 'helpers/tracking_helper'; +import { LEGACY_FLAG, NEW_VERSION_FLAG, NEW_FLAG_ALERT } from '~/feature_flags/constants'; +import Form from '~/feature_flags/components/form.vue'; +import editModule from '~/feature_flags/store/modules/edit'; +import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue'; +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`; + +describe('Edit feature flag form', () => { + let wrapper; + let mock; + + const store = new Vuex.Store({ + modules: { + edit: editModule, + }, + }); + + const factory = (opts = {}) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(EditFeatureFlag, { + localVue, + propsData: { + endpoint: `${TEST_HOST}/feature_flags.json`, + path: '/feature_flags', + environmentsEndpoint: 'environments.json', + projectId: '8', + featureFlagIssuesEndpoint: `${TEST_HOST}/feature_flags/5/issues`, + showUserCallout: true, + userCalloutId, + userCalloutsPath, + }, + store, + provide: { + glFeatures: { + featureFlagsNewVersion: true, + }, + }, + ...opts, + }); + }; + + beforeEach(done => { + mock = new MockAdapter(axios); + 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: 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()); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + const findAlert = () => wrapper.find(GlAlert); + + it('should display the iid', () => { + expect(wrapper.find('h3').text()).toContain('^5'); + }); + + it('should render the toggle', () => { + 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 not alert users that feature flags are changing soon', () => { + 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('edit/receiveUpdateFeatureFlagError', { message: ['The name is required'] }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.alert-danger').exists()).toEqual(true); + expect(wrapper.find('.alert-danger').text()).toContain('The name is required'); + }); + }); + }); + + describe('without error', () => { + it('renders form title', () => { + expect(wrapper.text()).toContain('^5 feature_flag'); + }); + + it('should render 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('renders the related issues widget', () => { + const expected = `${TEST_HOST}/feature_flags/5/issues`; + + expect(wrapper.find(Form).props('featureFlagIssuesEndpoint')).toBe(expected); + }); + + it('should track when the toggle is clicked', () => { + const toggle = wrapper.find(GlToggle); + const spy = mockTracking('_category_', toggle.element, jest.spyOn); + + toggle.trigger('click'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'feature_flag_toggle', + }); + }); + }); + + describe('without new version flags', () => { + beforeEach(() => factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } })); + + it('should alert users that feature flags are changing soon', () => { + expect(findAlert().text()).toBe(NEW_FLAG_ALERT); + }); + }); + + describe('dismissing new version alert', () => { + beforeEach(() => { + factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } }); + mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200); + findAlert().vm.$emit('dismiss'); + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should hide the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should send the dismissal event', () => { + expect(mock.history.post.length).toBe(1); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/environments_dropdown_spec.js b/spec/frontend/feature_flags/components/environments_dropdown_spec.js new file mode 100644 index 00000000000..2aa75ef6652 --- /dev/null +++ b/spec/frontend/feature_flags/components/environments_dropdown_spec.js @@ -0,0 +1,145 @@ +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +describe('Feature flags > Environments dropdown ', () => { + let wrapper; + let mock; + const results = ['production', 'staging']; + const factory = props => { + wrapper = shallowMount(EnvironmentsDropdown, { + propsData: { + endpoint: `${TEST_HOST}/environments.json'`, + ...props, + }, + }); + }; + + const findEnvironmentSearchInput = () => wrapper.find(GlSearchBoxByType); + const findDropdownMenu = () => wrapper.find('.dropdown-menu'); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + describe('without value', () => { + it('renders the placeholder', () => { + factory(); + expect(findEnvironmentSearchInput().vm.$attrs.placeholder).toBe('Search an environment spec'); + }); + }); + + describe('with value', () => { + it('sets filter to equal the value', () => { + factory({ value: 'production' }); + expect(findEnvironmentSearchInput().props('value')).toBe('production'); + }); + }); + + describe('on focus', () => { + it('sets results with the received data', async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true); + expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true); + }); + }); + + describe('on keyup', () => { + it('sets results with the received data', async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('keyup'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true); + expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true); + }); + }); + + describe('on input change', () => { + describe('on success', () => { + beforeEach(async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + findEnvironmentSearchInput().vm.$emit('input', 'production'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + }); + + it('sets filter value', () => { + expect(findEnvironmentSearchInput().props('value')).toBe('production'); + }); + + describe('with received data', () => { + it('sets is loading to false', () => { + expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + it('shows the suggestions', () => { + expect(findDropdownMenu().exists()).toBe(true); + }); + + it('emits event when a suggestion is clicked', async () => { + const button = wrapper + .findAll(GlDeprecatedButton) + .filter(b => b.text() === 'production') + .at(0); + button.vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('selectEnvironment')).toEqual([['production']]); + }); + }); + + describe('on click clear button', () => { + beforeEach(async () => { + wrapper.find(GlDeprecatedButton).vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('resets filter value', () => { + expect(findEnvironmentSearchInput().props('value')).toBe(''); + }); + + it('closes list of suggestions', () => { + expect(wrapper.vm.showSuggestions).toBe(false); + }); + }); + }); + }); + + describe('on click create button', () => { + beforeEach(async () => { + mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, []); + factory(); + findEnvironmentSearchInput().vm.$emit('focus'); + findEnvironmentSearchInput().vm.$emit('input', 'production'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + }); + + it('emits create event', async () => { + wrapper + .findAll(GlDeprecatedButton) + .at(0) + .vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('createClicked')).toEqual([['production']]); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_spec.js b/spec/frontend/feature_flags/components/feature_flags_spec.js new file mode 100644 index 00000000000..5ff39937113 --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_spec.js @@ -0,0 +1,343 @@ +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import Api from '~/api'; +import { createStore } from '~/feature_flags/store'; +import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue'; +import FeatureFlagsComponent from '~/feature_flags/components/feature_flags.vue'; +import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue'; +import UserListsTable from '~/feature_flags/components/user_lists_table.vue'; +import ConfigureFeatureFlagsModal from '~/feature_flags/components/configure_feature_flags_modal.vue'; +import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '~/feature_flags/constants'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import axios from '~/lib/utils/axios_utils'; +import { getRequestData, userList } from '../mock_data'; + +describe('Feature flags', () => { + const mockData = { + endpoint: `${TEST_HOST}/endpoint.json`, + csrfToken: 'testToken', + featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients', + featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example', + unleashApiUrl: `${TEST_HOST}/api/unleash`, + unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F', + canUserConfigure: true, + canUserRotateToken: true, + newFeatureFlagPath: 'feature-flags/new', + newUserListPath: '/user-list/new', + projectId: '8', + }; + + let wrapper; + let mock; + let store; + + const factory = (propsData = mockData, fn = shallowMount) => { + store = createStore(); + wrapper = fn(FeatureFlagsComponent, { + store, + propsData, + provide: { + projectName: 'fakeProjectName', + errorStateSvgPath: '/assets/illustrations/feature_flag.svg', + featureFlagsHelpPagePath: '/help/feature-flags', + }, + stubs: { + FeatureFlagsTab, + }, + }); + }; + + const configureButton = () => wrapper.find('[data-testid="ff-configure-button"]'); + const newButton = () => wrapper.find('[data-testid="ff-new-button"]'); + const newUserListButton = () => wrapper.find('[data-testid="ff-new-list-button"]'); + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(Api, 'fetchFeatureFlagUserLists').mockResolvedValue({ + data: [userList], + headers: { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '8', + 'X-Prev-Page': '', + 'X-TOTAL': '40', + 'X-Total-Pages': '5', + }, + }); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + wrapper = null; + }); + + describe('without permissions', () => { + const propsData = { + endpoint: `${TEST_HOST}/endpoint.json`, + csrfToken: 'testToken', + errorStateSvgPath: '/assets/illustrations/feature_flag.svg', + featureFlagsHelpPagePath: '/help/feature-flags', + canUserConfigure: false, + canUserRotateToken: false, + featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients', + featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example', + unleashApiUrl: `${TEST_HOST}/api/unleash`, + unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F', + projectId: '8', + }; + + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + + factory(propsData); + + setImmediate(() => { + done(); + }); + }); + + it('does not render configure button', () => { + expect(configureButton().exists()).toBe(false); + }); + + it('does not render new feature flag button', () => { + expect(newButton().exists()).toBe(false); + }); + + it('does not render new user list button', () => { + expect(newUserListButton().exists()).toBe(false); + }); + }); + + describe('loading state', () => { + it('renders a loading icon', () => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(200, getRequestData, {}); + + factory(); + + const loadingElement = wrapper.find(GlLoadingIcon); + + expect(loadingElement.exists()).toBe(true); + expect(loadingElement.props('label')).toEqual('Loading feature flags'); + }); + }); + + describe('successful request', () => { + describe('without feature flags', () => { + let emptyState; + + beforeEach(async () => { + mock.onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }).reply( + 200, + { + feature_flags: [], + count: { + all: 0, + enabled: 0, + disabled: 0, + }, + }, + {}, + ); + + factory(); + await wrapper.vm.$nextTick(); + + emptyState = wrapper.find(GlEmptyState); + }); + + it('should render the empty state', async () => { + await axios.waitForAll(); + emptyState = wrapper.find(GlEmptyState); + expect(emptyState.exists()).toBe(true); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + + describe('in feature flags tab', () => { + it('renders generic title', () => { + expect(emptyState.props('title')).toEqual('Get started with feature flags'); + }); + }); + }); + + describe('with paginated feature flags', () => { + beforeEach(done => { + mock + .onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(200, getRequestData, { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }); + + factory(); + jest.spyOn(store, 'dispatch'); + setImmediate(() => { + done(); + }); + }); + + it('should render a table with feature flags', () => { + const table = wrapper.find(FeatureFlagsTable); + expect(table.exists()).toBe(true); + expect(table.props(FEATURE_FLAG_SCOPE)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: getRequestData.feature_flags[0].name, + description: getRequestData.feature_flags[0].description, + }), + ]), + ); + }); + + it('should toggle a flag when receiving the toggle-flag event', () => { + const table = wrapper.find(FeatureFlagsTable); + + const [flag] = table.props(FEATURE_FLAG_SCOPE); + table.vm.$emit('toggle-flag', flag); + + expect(store.dispatch).toHaveBeenCalledWith('index/toggleFeatureFlag', flag); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + + describe('pagination', () => { + it('should render pagination', () => { + expect(wrapper.find(TablePagination).exists()).toBe(true); + }); + + it('should make an API request when page is clicked', () => { + jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); + wrapper.find(TablePagination).vm.change(4); + + expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ + scope: FEATURE_FLAG_SCOPE, + page: '4', + }); + }); + + it('should make an API request when using tabs', () => { + jest.spyOn(wrapper.vm, 'updateFeatureFlagOptions'); + wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); + + expect(wrapper.vm.updateFeatureFlagOptions).toHaveBeenCalledWith({ + scope: USER_LIST_SCOPE, + page: '1', + }); + }); + }); + }); + + describe('in user lists tab', () => { + beforeEach(done => { + factory(); + + setImmediate(() => { + done(); + }); + }); + beforeEach(() => { + wrapper.find('[data-testid="user-lists-tab"]').vm.$emit('changeTab'); + return wrapper.vm.$nextTick(); + }); + + it('should display the user list table', () => { + expect(wrapper.find(UserListsTable).exists()).toBe(true); + }); + + it('should set the user lists to display', () => { + expect(wrapper.find(UserListsTable).props('userLists')).toEqual([userList]); + }); + }); + }); + + describe('unsuccessful request', () => { + beforeEach(done => { + mock + .onGet(mockData.endpoint, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .replyOnce(500, {}); + Api.fetchFeatureFlagUserLists.mockRejectedValueOnce(); + + factory(); + + setImmediate(() => { + done(); + }); + }); + + it('should render error state', () => { + const emptyState = wrapper.find(GlEmptyState); + expect(emptyState.props('title')).toEqual('There was an error fetching the feature flags.'); + expect(emptyState.props('description')).toEqual( + 'Try again in a few moments or contact your support team.', + ); + }); + + it('renders configure button', () => { + expect(configureButton().exists()).toBe(true); + }); + + it('renders new feature flag button', () => { + expect(newButton().exists()).toBe(true); + }); + + it('renders new user list button', () => { + expect(newUserListButton().exists()).toBe(true); + expect(newUserListButton().attributes('href')).toBe('/user-list/new'); + }); + }); + + describe('rotate instance id', () => { + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(); + + setImmediate(() => { + done(); + }); + }); + + it('should fire the rotate action when a `token` event is received', () => { + const actionSpy = jest.spyOn(wrapper.vm, 'rotateInstanceId'); + const modal = wrapper.find(ConfigureFeatureFlagsModal); + modal.vm.$emit('token'); + + expect(actionSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_tab_spec.js b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js new file mode 100644 index 00000000000..bc90c5ceb2d --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_tab_spec.js @@ -0,0 +1,168 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlBadge, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import FeatureFlagsTab from '~/feature_flags/components/feature_flags_tab.vue'; + +const DEFAULT_PROPS = { + title: 'test', + count: 5, + alerts: ['an alert', 'another alert'], + isLoading: false, + loadingLabel: 'test loading', + errorState: false, + errorTitle: 'test title', + emptyState: true, + emptyTitle: 'test empty', +}; + +const DEFAULT_PROVIDE = { + errorStateSvgPath: '/error.svg', + featureFlagsHelpPagePath: '/help/page/path', +}; + +describe('feature_flags/components/feature_flags_tab.vue', () => { + let wrapper; + + const factory = (props = {}) => + mount( + { + components: { + GlTabs, + FeatureFlagsTab, + }, + render(h) { + return h(GlTabs, [ + h(FeatureFlagsTab, { props: this.$attrs, on: this.$listeners }, this.$slots.default), + ]); + }, + }, + { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: DEFAULT_PROVIDE, + slots: { + default: '<p data-testid="test-slot">testing</p>', + }, + }, + ); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('alerts', () => { + let alerts; + + beforeEach(() => { + wrapper = factory(); + alerts = wrapper.findAll(GlAlert); + }); + + it('should show any alerts', () => { + expect(alerts).toHaveLength(DEFAULT_PROPS.alerts.length); + alerts.wrappers.forEach((alert, i) => expect(alert.text()).toBe(DEFAULT_PROPS.alerts[i])); + }); + + it('should emit a dismiss event for a dismissed alert', () => { + alerts.at(0).vm.$emit('dismiss'); + + expect(wrapper.find(FeatureFlagsTab).emitted('dismissAlert')).toEqual([[0]]); + }); + }); + + describe('loading', () => { + beforeEach(() => { + wrapper = factory({ isLoading: true }); + }); + + it('should show a loading icon and nothing else', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findAll(GlEmptyState)).toHaveLength(0); + }); + }); + + describe('error', () => { + let emptyState; + + beforeEach(() => { + wrapper = factory({ errorState: true }); + emptyState = wrapper.find(GlEmptyState); + }); + + it('should show an error state if there has been an error', () => { + expect(emptyState.text()).toContain(DEFAULT_PROPS.errorTitle); + expect(emptyState.text()).toContain( + 'Try again in a few moments or contact your support team.', + ); + expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath); + }); + }); + + describe('empty', () => { + let emptyState; + let emptyStateLink; + + beforeEach(() => { + wrapper = factory({ emptyState: true }); + emptyState = wrapper.find(GlEmptyState); + emptyStateLink = emptyState.find(GlLink); + }); + + it('should show an empty state if it is empty', () => { + expect(emptyState.text()).toContain(DEFAULT_PROPS.emptyTitle); + expect(emptyState.text()).toContain( + 'Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.', + ); + expect(emptyState.props('svgPath')).toBe(DEFAULT_PROVIDE.errorStateSvgPath); + expect(emptyStateLink.attributes('href')).toBe(DEFAULT_PROVIDE.featureFlagsHelpPagePath); + expect(emptyStateLink.text()).toBe('More information'); + }); + }); + + describe('slot', () => { + let slot; + + beforeEach(async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + slot = wrapper.find('[data-testid="test-slot"]'); + }); + + it('should display the passed slot', () => { + expect(slot.exists()).toBe(true); + expect(slot.text()).toBe('testing'); + }); + }); + + describe('count', () => { + it('should display a count if there is one', async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlBadge).text()).toBe(DEFAULT_PROPS.count.toString()); + }); + it('should display 0 if there is no count', async () => { + wrapper = factory({ count: undefined }); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlBadge).text()).toBe('0'); + }); + }); + + describe('title', () => { + it('should show the title', async () => { + wrapper = factory(); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="feature-flags-tab-title"]').text()).toBe( + DEFAULT_PROPS.title, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js new file mode 100644 index 00000000000..c59ecbf3b06 --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js @@ -0,0 +1,262 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlToggle, GlBadge } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + 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'; +import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue'; + +const getDefaultProps = () => ({ + featureFlags: [ + { + id: 1, + iid: 1, + active: true, + name: 'flag name', + description: 'flag description', + destroy_path: 'destroy/path', + edit_path: 'edit/path', + version: LEGACY_FLAG, + scopes: [ + { + id: 1, + active: true, + environmentScope: 'scope', + canUpdate: true, + protected: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: DEFAULT_PERCENT_ROLLOUT, + shouldBeDestroyed: false, + }, + ], + }, + ], + csrfToken: 'fakeToken', +}); + +describe('Feature flag table', () => { + let wrapper; + let props; + + const createWrapper = (propsData, opts = {}) => { + wrapper = shallowMount(FeatureFlagsTable, { + propsData, + ...opts, + }); + }; + + beforeEach(() => { + props = getDefaultProps(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with an active scope and a standard rollout strategy', () => { + beforeEach(() => { + createWrapper(props); + }); + + it('Should render a table', () => { + expect(wrapper.classes('table-holder')).toBe(true); + }); + + it('Should render rows', () => { + expect(wrapper.find('.gl-responsive-table-row').exists()).toBe(true); + }); + + it('should render an ID column', () => { + expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true); + expect(trimText(wrapper.find('.js-feature-flag-id').text())).toEqual('^1'); + }); + + it('Should render a status column', () => { + const badge = wrapper.find('[data-testid="feature-flag-status-badge"]'); + + expect(badge.exists()).toBe(true); + expect(trimText(badge.text())).toEqual('Active'); + }); + + it('Should render a feature flag column', () => { + expect(wrapper.find('.js-feature-flag-title').exists()).toBe(true); + expect(trimText(wrapper.find('.feature-flag-name').text())).toEqual('flag name'); + + expect(trimText(wrapper.find('.feature-flag-description').text())).toEqual( + 'flag description', + ); + }); + + 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'); + }); + + it('should render an actions column', () => { + expect(wrapper.find('.table-action-buttons').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-delete-button').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-edit-button').exists()).toBe(true); + expect(wrapper.find('.js-feature-flag-edit-button').attributes('href')).toEqual('edit/path'); + }); + }); + + 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', () => { + expect(toggle.exists()).toBe(true); + expect(toggle.props('value')).toBe(true); + }); + + it('should trigger a toggle event', () => { + toggle.vm.$emit('change'); + const flag = { ...props.featureFlags[0], active: !props.featureFlags[0].active }; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('toggle-flag')).toEqual([[flag]]); + }); + }); + + it('should track a click', () => { + toggle.trigger('click'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'feature_flag_toggle', + }); + }); + }); + + 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'); + + expect(trimText(envColumn.find(GlBadge).text())).toBe('scope: 54%'); + }); + }); + + 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'); + }); + }); + + describe('with a new version flag', () => { + 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', + 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: { glFeatures: { featureFlagsNewVersion: true } } }); + + 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 number of users for users with ID', () => { + expect(badges.at(2).text()).toContain('User IDs - 4 users'); + }); + + 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', () => { + delete props.featureFlags[0].iid; + createWrapper(props); + + expect(wrapper.find('.js-feature-flag-id').exists()).toBe(true); + expect(trimText(wrapper.find('.js-feature-flag-id').text())).toBe(''); + }); +}); diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js new file mode 100644 index 00000000000..451bb4176ef --- /dev/null +++ b/spec/frontend/feature_flags/components/form_spec.js @@ -0,0 +1,485 @@ +import { uniqueId } from 'lodash'; +import { shallowMount } from '@vue/test-utils'; +import { GlFormTextarea, GlFormCheckbox, GlButton } from '@gitlab/ui'; +import Api from '~/api'; +import Form from '~/feature_flags/components/form.vue'; +import EnvironmentsDropdown from '~/feature_flags/components/environments_dropdown.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 ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import { featureFlag, userList, allUsersStrategy } from '../mock_data'; + +jest.mock('~/api.js'); + +describe('feature flag form', () => { + let wrapper; + const requiredProps = { + cancelPath: 'feature_flags', + submitText: 'Create', + environmentsEndpoint: '/environments.json', + projectId: '1', + }; + + const factory = (props = {}) => { + wrapper = shallowMount(Form, { + propsData: props, + provide: { + glFeatures: { + featureFlagPermissions: true, + featureFlagsNewVersion: true, + }, + }, + }); + }; + + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [] }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render provided submitText', () => { + factory(requiredProps); + + expect(wrapper.find('.js-ff-submit').text()).toEqual(requiredProps.submitText); + }); + + it('should render provided cancelPath', () => { + factory(requiredProps); + + expect(wrapper.find('.js-ff-cancel').attributes('href')).toEqual(requiredProps.cancelPath); + }); + + it('does not render the related issues widget without the featureFlagIssuesEndpoint', () => { + factory(requiredProps); + + expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(false); + }); + + it('renders the related issues widget when the featureFlagIssuesEndpoint is provided', () => { + factory({ + ...requiredProps, + featureFlagIssuesEndpoint: '/some/endpoint', + }); + + expect(wrapper.find(RelatedIssuesRoot).exists()).toBe(true); + }); + + describe('without provided data', () => { + beforeEach(() => { + factory(requiredProps); + }); + + it('should render name input text', () => { + expect(wrapper.find('#feature-flag-name').exists()).toBe(true); + }); + + 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(wrapper.find('.js-add-new-scope').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', () => { + wrapper.find(ToggleButton).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('should be disabled if the feature flag is not active', done => { + wrapper.setProps({ active: false }); + wrapper.vm.$nextTick(() => { + expect(wrapper.find(ToggleButton).props('disabledInput')).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.find('.js-feature-flag-delete').exists()).toEqual(true); + }); + + it('renders empty row to add a new scope', () => { + expect(wrapper.find('.js-add-new-scope').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', () => { + wrapper.find(ToggleButton).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(wrapper.find(ToggleButton).props('disabledInput')).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(ToggleButton).vm.disabledInput).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(() => { + wrapper + .find('.js-add-new-scope') + .find(ToggleButton) + .vm.$emit('change', true); + }) + .then(() => { + wrapper.find(ToggleButton).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', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList] }); + factory({ + ...requiredProps, + name: featureFlag.name, + description: featureFlag.description, + active: true, + version: NEW_VERSION_FLAG, + strategies: [ + { + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }, + { + type: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environment_scope: 'review/*' }], + }, + ], + }); + }); + + it('should request the user lists on mount', () => { + return wrapper.vm.$nextTick(() => { + expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1'); + }); + }); + + it('should show the strategy component', () => { + const strategy = wrapper.find(Strategy); + expect(strategy.exists()).toBe(true); + expect(strategy.props('strategy')).toEqual({ + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }); + }); + + it('should show one strategy component per strategy', () => { + expect(wrapper.findAll(Strategy)).toHaveLength(2); + }); + + it('adds an all users strategy when clicking the Add button', () => { + wrapper.find(GlButton).vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + const strategies = wrapper.findAll(Strategy); + + expect(strategies).toHaveLength(3); + expect(strategies.at(2).props('strategy')).toEqual(allUsersStrategy); + }); + }); + + it('should remove a strategy on delete', () => { + const strategy = { + type: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '30' }, + scopes: [], + }; + wrapper.find(Strategy).vm.$emit('delete'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(Strategy)).toHaveLength(1); + expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy); + }); + }); + + it('should provide the user lists to the strategy', () => { + expect(wrapper.find(Strategy).props('userLists')).toEqual([userList]); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js new file mode 100644 index 00000000000..10e9ed4d3bf --- /dev/null +++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js @@ -0,0 +1,103 @@ +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; +import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; + +const TEST_HOST = '/test'; +const TEST_SEARCH = 'production'; + +describe('New Environments Dropdown', () => { + let wrapper; + let axiosMock; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + wrapper = shallowMount(NewEnvironmentsDropdown, { propsData: { endpoint: TEST_HOST } }); + }); + + afterEach(() => { + axiosMock.restore(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('before results', () => { + it('should show a loading icon', () => { + axiosMock.onGet(TEST_HOST).reply(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + return axios.waitForAll(); + }); + + it('should not show any dropdown items', () => { + axiosMock.onGet(TEST_HOST).reply(() => { + expect(wrapper.findAll(GlDropdownItem)).toHaveLength(0); + }); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + return axios.waitForAll(); + }); + }); + + describe('with empty results', () => { + let item; + beforeEach(() => { + axiosMock.onGet(TEST_HOST).reply(200, []); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH); + return axios + .waitForAll() + .then(() => wrapper.vm.$nextTick()) + .then(() => { + item = wrapper.find(GlDropdownItem); + }); + }); + + it('should display a Create item label', () => { + expect(item.text()).toBe('Create production'); + }); + + it('should display that no matching items are found', () => { + expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(true); + }); + + it('should emit a new scope when selected', () => { + item.vm.$emit('click'); + expect(wrapper.emitted('add')).toEqual([[TEST_SEARCH]]); + }); + }); + + describe('with results', () => { + let items; + beforeEach(() => { + axiosMock.onGet(TEST_HOST).reply(httpStatusCodes.OK, ['prod', 'production']); + wrapper.find(GlSearchBoxByType).vm.$emit('focus'); + wrapper.find(GlSearchBoxByType).vm.$emit('input', 'prod'); + return axios.waitForAll().then(() => { + items = wrapper.findAll(GlDropdownItem); + }); + }); + + it('should display one item per result', () => { + expect(items).toHaveLength(2); + }); + + it('should emit an add if an item is clicked', () => { + items.at(0).vm.$emit('click'); + expect(wrapper.emitted('add')).toEqual([['prod']]); + }); + + it('should not display a create label', () => { + items = items.filter(i => i.text().startsWith('Create')); + expect(items).toHaveLength(0); + }); + + it('should not display a message about no results', () => { + expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js new file mode 100644 index 00000000000..284ba09d7fd --- /dev/null +++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js @@ -0,0 +1,145 @@ +import Vuex from 'vuex'; +import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { GlAlert } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import Form from '~/feature_flags/components/form.vue'; +import newModule from '~/feature_flags/store/modules/new'; +import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + DEFAULT_PERCENT_ROLLOUT, + NEW_FLAG_ALERT, +} from '~/feature_flags/constants'; +import axios from '~/lib/utils/axios_utils'; +import { allUsersStrategy } from '../mock_data'; + +const userCalloutId = 'feature_flags_new_version'; +const userCalloutsPath = `${TEST_HOST}/user_callouts`; + +describe('New feature flag form', () => { + let wrapper; + + const store = new Vuex.Store({ + modules: { + new: newModule, + }, + }); + + const factory = (opts = {}) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(NewFeatureFlag, { + propsData: { + endpoint: `${TEST_HOST}/feature_flags.json`, + path: '/feature_flags', + environmentsEndpoint: 'environments.json', + projectId: '8', + showUserCallout: true, + userCalloutId, + userCalloutsPath, + }, + store, + provide: { + glFeatures: { + featureFlagsNewVersion: true, + }, + }, + ...opts, + }); + }; + + beforeEach(() => { + factory(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findAlert = () => wrapper.find(GlAlert); + + describe('with error', () => { + it('should render the error', () => { + store.dispatch('new/receiveCreateFeatureFlagError', { message: ['The name is required'] }); + return wrapper.vm.$nextTick(() => { + expect(wrapper.find('.alert').exists()).toEqual(true); + expect(wrapper.find('.alert').text()).toContain('The name is required'); + }); + }); + }); + + it('renders form title', () => { + expect(wrapper.find('h3').text()).toEqual('New feature flag'); + }); + + it('should render feature flag form', () => { + expect(wrapper.find(Form).exists()).toEqual(true); + }); + + it('does not render the related issues widget', () => { + expect(wrapper.find(Form).props('featureFlagIssuesEndpoint')).toBe(''); + }); + + 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('should not alert users that feature flags are changing soon', () => { + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + + it('should pass in the project ID', () => { + expect(wrapper.find(Form).props('projectId')).toBe('8'); + }); + + it('has an all users strategy by default', () => { + const strategies = wrapper.find(Form).props('strategies'); + + expect(strategies).toEqual([allUsersStrategy]); + }); + + describe('without new version flags', () => { + beforeEach(() => factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } })); + + it('should alert users that feature flags are changing soon', () => { + expect(findAlert().text()).toBe(NEW_FLAG_ALERT); + }); + }); + + describe('dismissing new version alert', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200); + factory({ provide: { glFeatures: { featureFlagsNewVersion: false } } }); + findAlert().vm.$emit('dismiss'); + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should hide the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should send the dismissal event', () => { + expect(mock.history.post.length).toBe(1); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js new file mode 100644 index 00000000000..8436f1cbe97 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategy_spec.js @@ -0,0 +1,320 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormSelect, GlFormTextarea, GlFormInput, GlLink, GlToken, GlButton } from '@gitlab/ui'; +import { + PERCENT_ROLLOUT_GROUP_ID, + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from '~/feature_flags/constants'; +import Strategy from '~/feature_flags/components/strategy.vue'; +import NewEnvironmentsDropdown from '~/feature_flags/components/new_environments_dropdown.vue'; + +import { userList } from '../mock_data'; + +const provide = { + strategyTypeDocsPagePath: 'link-to-strategy-docs', + environmentsScopeDocsPath: 'link-scope-docs', +}; + +describe('Feature flags strategy', () => { + let wrapper; + + const findStrategy = () => wrapper.find('[data-testid="strategy"]'); + const findDocsLinks = () => wrapper.findAll(GlLink); + + const factory = ( + opts = { + propsData: { + strategy: {}, + index: 0, + endpoint: '', + userLists: [userList], + }, + provide, + }, + ) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(Strategy, opts); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('helper links', () => { + const propsData = { strategy: {}, index: 0, endpoint: '', userLists: [userList] }; + factory({ propsData, provide }); + + it('should display 2 helper links', () => { + const links = findDocsLinks(); + expect(links.exists()).toBe(true); + expect(links.at(0).attributes('href')).toContain('docs'); + expect(links.at(1).attributes('href')).toContain('docs'); + }); + }); + + describe.each` + name | parameter | value | newValue | input + ${ROLLOUT_STRATEGY_ALL_USERS} | ${null} | ${null} | ${null} | ${null} + ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${'percentage'} | ${'50'} | ${'20'} | ${GlFormInput} + ${ROLLOUT_STRATEGY_USER_ID} | ${'userIds'} | ${'1,2'} | ${'1,2,3'} | ${GlFormTextarea} + `('with strategy $name', ({ name, parameter, value, newValue, input }) => { + let propsData; + let strategy; + beforeEach(() => { + const parameters = {}; + if (parameter !== null) { + parameters[parameter] = value; + } + strategy = { name, parameters }; + propsData = { strategy, index: 0, endpoint: '' }; + factory({ propsData, provide }); + }); + + it('should set the select to match the strategy name', () => { + expect(wrapper.find(GlFormSelect).attributes('value')).toBe(name); + }); + + it('should not show inputs for other parameters', () => { + [GlFormTextarea, GlFormInput, GlFormSelect] + .filter(component => component !== input) + .map(component => findStrategy().findAll(component)) + .forEach(inputWrapper => expect(inputWrapper).toHaveLength(0)); + }); + + if (parameter !== null) { + it(`should show the input for ${parameter} with the correct value`, () => { + const inputWrapper = findStrategy().find(input); + expect(inputWrapper.exists()).toBe(true); + expect(inputWrapper.attributes('value')).toBe(value); + }); + + it(`should emit a change event when altering ${parameter}`, () => { + const inputWrapper = findStrategy().find(input); + inputWrapper.vm.$emit('input', newValue); + expect(wrapper.emitted('change')).toEqual([ + [{ name, parameters: expect.objectContaining({ [parameter]: newValue }), scopes: [] }], + ]); + }); + } + }); + + describe('with strategy gitlabUserList', () => { + let propsData; + let strategy; + beforeEach(() => { + strategy = { name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, userListId: '2', parameters: {} }; + propsData = { strategy, index: 0, endpoint: '', userLists: [userList] }; + factory({ propsData, provide }); + }); + + it('should set the select to match the strategy name', () => { + expect(wrapper.find(GlFormSelect).attributes('value')).toBe( + ROLLOUT_STRATEGY_GITLAB_USER_LIST, + ); + }); + + it('should not show inputs for other parameters', () => { + expect( + findStrategy() + .find(GlFormTextarea) + .exists(), + ).toBe(false); + expect( + findStrategy() + .find(GlFormInput) + .exists(), + ).toBe(false); + }); + + it('should show the input for userListId with the correct value', () => { + const inputWrapper = findStrategy().find(GlFormSelect); + expect(inputWrapper.exists()).toBe(true); + expect(inputWrapper.attributes('value')).toBe('2'); + }); + + it('should emit a change event when altering the userListId', () => { + const inputWrapper = findStrategy().find(GlFormSelect); + inputWrapper.vm.$emit('input', '3'); + inputWrapper.vm.$emit('change', '3'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('change')).toEqual([ + [ + { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + userListId: '3', + scopes: [], + parameters: {}, + }, + ], + ]); + }); + }); + }); + + describe('with a strategy', () => { + describe('with a single environment scope defined', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50' }, + scopes: [{ environmentScope: 'production' }], + }; + const propsData = { strategy, index: 0, endpoint: '' }; + factory({ propsData, provide }); + }); + + it('should revert to all-environments scope when last scope is removed', () => { + const token = wrapper.find(GlToken); + token.vm.$emit('close'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(0); + expect(wrapper.emitted('change')).toEqual([ + [ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: '*' }], + }, + ], + ]); + }); + }); + }); + + describe('with an all-environments scope defined', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50' }, + scopes: [{ environmentScope: '*' }], + }; + const propsData = { strategy, index: 0, endpoint: '' }; + factory({ propsData, provide }); + }); + + it('should change the parameters if a different strategy is chosen', () => { + const select = wrapper.find(GlFormSelect); + select.vm.$emit('input', ROLLOUT_STRATEGY_ALL_USERS); + select.vm.$emit('change', ROLLOUT_STRATEGY_ALL_USERS); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(GlFormInput).exists()).toBe(false); + expect(wrapper.emitted('change')).toEqual([ + [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [{ environmentScope: '*' }], + }, + ], + ]); + }); + }); + + it('should display selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(1); + expect(wrapper.find(GlToken).text()).toBe('production'); + }); + }); + + it('should display all selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + dropdown.vm.$emit('add', 'staging'); + return wrapper.vm.$nextTick().then(() => { + const tokens = wrapper.findAll(GlToken); + expect(tokens).toHaveLength(2); + expect(tokens.at(0).text()).toBe('production'); + expect(tokens.at(1).text()).toBe('staging'); + }); + }); + + it('should emit selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('change')).toEqual([ + [ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [ + { environmentScope: '*', shouldBeDestroyed: true }, + { environmentScope: 'production' }, + ], + }, + ], + ]); + }); + }); + + it('should emit a delete if the delete button is clicked', () => { + wrapper.find(GlButton).vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + + describe('without scopes defined', () => { + beforeEach(() => { + const strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50' }, + scopes: [], + }; + const propsData = { strategy, index: 0, endpoint: '' }; + factory({ propsData, provide }); + }); + + it('should display selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.findAll(GlToken)).toHaveLength(1); + expect(wrapper.find(GlToken).text()).toBe('production'); + }); + }); + + it('should display all selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + dropdown.vm.$emit('add', 'staging'); + return wrapper.vm.$nextTick().then(() => { + const tokens = wrapper.findAll(GlToken); + expect(tokens).toHaveLength(2); + expect(tokens.at(0).text()).toBe('production'); + expect(tokens.at(1).text()).toBe('staging'); + }); + }); + + it('should emit selected scopes', () => { + const dropdown = wrapper.find(NewEnvironmentsDropdown); + dropdown.vm.$emit('add', 'production'); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('change')).toEqual([ + [ + { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: 'production' }], + }, + ], + ]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/user_lists_table_spec.js b/spec/frontend/feature_flags/components/user_lists_table_spec.js new file mode 100644 index 00000000000..d6ced3be168 --- /dev/null +++ b/spec/frontend/feature_flags/components/user_lists_table_spec.js @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils'; +import * as timeago from 'timeago.js'; +import { GlModal } from '@gitlab/ui'; +import UserListsTable from '~/feature_flags/components/user_lists_table.vue'; +import { userList } from '../mock_data'; + +jest.mock('timeago.js', () => ({ + format: jest.fn().mockReturnValue('2 weeks ago'), + register: jest.fn(), +})); + +describe('User Lists Table', () => { + let wrapper; + let userLists; + + beforeEach(() => { + userLists = new Array(5).fill(userList).map((x, i) => ({ ...x, id: i })); + wrapper = mount(UserListsTable, { + propsData: { userLists }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should display the details of a user list', () => { + expect(wrapper.find('[data-testid="ffUserListName"]').text()).toBe(userList.name); + expect(wrapper.find('[data-testid="ffUserListIds"]').text()).toBe( + userList.user_xids.replace(/,/g, ', '), + ); + expect(wrapper.find('[data-testid="ffUserListTimestamp"]').text()).toBe('created 2 weeks ago'); + expect(timeago.format).toHaveBeenCalledWith(userList.created_at); + }); + + it('should set the title for a tooltip on the created stamp', () => { + expect(wrapper.find('[data-testid="ffUserListTimestamp"]').attributes('title')).toBe( + 'Feb 4, 2020 8:13am GMT+0000', + ); + }); + + it('should display a user list entry per user list', () => { + const lists = wrapper.findAll('[data-testid="ffUserList"]'); + expect(lists).toHaveLength(5); + lists.wrappers.forEach(list => { + expect(list.find('[data-testid="ffUserListName"]').exists()).toBe(true); + expect(list.find('[data-testid="ffUserListIds"]').exists()).toBe(true); + expect(list.find('[data-testid="ffUserListTimestamp"]').exists()).toBe(true); + }); + }); + + describe('edit button', () => { + it('should link to the path for the user list', () => { + expect(wrapper.find('[data-testid="edit-user-list"]').attributes('href')).toBe(userList.path); + }); + }); + + describe('delete button', () => { + it('should display the confirmation modal', () => { + const modal = wrapper.find(GlModal); + + wrapper.find('[data-testid="delete-user-list"]').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(modal.text()).toContain(`Delete ${userList.name}?`); + expect(modal.text()).toContain(`User list ${userList.name} will be removed.`); + }); + }); + }); + + describe('confirmation modal', () => { + let modal; + + beforeEach(() => { + modal = wrapper.find(GlModal); + + wrapper.find('button').trigger('click'); + + return wrapper.vm.$nextTick(); + }); + + it('should emit delete with list on confirmation', () => { + modal.find('[data-testid="modal-confirm"]').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('delete')).toEqual([[userLists[0]]]); + }); + }); + + it('should not emit delete with list when not confirmed', () => { + modal.find('button').trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('delete')).toBeUndefined(); + }); + }); + }); +}); diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js new file mode 100644 index 00000000000..47e4957f208 --- /dev/null +++ b/spec/frontend/feature_flags/mock_data.js @@ -0,0 +1,109 @@ +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, +} from '~/feature_flags/constants'; + +export const featureFlag = { + id: 1, + active: true, + created_at: '2018-12-12T22:07:31.401Z', + updated_at: '2018-12-12T22:07:31.401Z', + name: 'test flag', + description: 'flag for tests', + destroy_path: 'feature_flags/1', + update_path: 'feature_flags/1', + edit_path: 'feature_flags/1/edit', + scopes: [ + { + 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: 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: 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', + }, + }, + ], + }, + ], +}; + +export const getRequestData = { + feature_flags: [featureFlag], + count: { + all: 1, + disabled: 1, + enabled: 0, + }, +}; + +export const rotateData = { token: 'oP6sCNRqtRHmpy1gw2-F' }; + +export const userList = { + name: 'test_users', + user_xids: 'user3,user4,user5', + id: 2, + iid: 2, + project_id: 1, + created_at: '2020-02-04T08:13:10.507Z', + updated_at: '2020-02-04T08:13:10.507Z', + path: '/path/to/user/list', + edit_path: '/path/to/user/list/edit', +}; + +export const allUsersStrategy = { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + scopes: [], +}; diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js new file mode 100644 index 00000000000..4f20b9713bf --- /dev/null +++ b/spec/frontend/feature_flags/store/edit/actions_spec.js @@ -0,0 +1,334 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import { + setEndpoint, + setPath, + updateFeatureFlag, + requestUpdateFeatureFlag, + receiveUpdateFeatureFlagSuccess, + receiveUpdateFeatureFlagError, + fetchFeatureFlag, + requestFeatureFlag, + receiveFeatureFlagSuccess, + receiveFeatureFlagError, + toggleActive, +} from '~/feature_flags/store/modules/edit/actions'; +import state from '~/feature_flags/store/modules/edit/state'; +import { + mapStrategiesToRails, + mapFromScopesViewModel, +} from '~/feature_flags/store/modules/helpers'; +import { + NEW_VERSION_FLAG, + LEGACY_FLAG, + ROLLOUT_STRATEGY_ALL_USERS, +} from '~/feature_flags/constants'; +import * as types from '~/feature_flags/store/modules/edit/mutation_types'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/lib/utils/url_utility'); + +describe('Feature flags Edit Module actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('setEndpoint', () => { + it('should commit SET_ENDPOINT mutation', done => { + testAction( + setEndpoint, + 'feature_flags.json', + mockedState, + [{ type: types.SET_ENDPOINT, payload: 'feature_flags.json' }], + [], + done, + ); + }); + }); + + describe('setPath', () => { + it('should commit SET_PATH mutation', done => { + testAction( + setPath, + '/feature_flags', + mockedState, + [{ type: types.SET_PATH, payload: '/feature_flags' }], + [], + done, + ); + }); + }); + + describe('updateFeatureFlag', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + 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, + parameters: {}, + id: 1, + scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }], + shouldBeDestroyed: false, + }, + ], + }; + mock.onPut(mockedState.endpoint, mapStrategiesToRails(featureFlag)).replyOnce(200); + + testAction( + updateFeatureFlag, + featureFlag, + mockedState, + [], + [ + { + type: 'requestUpdateFeatureFlag', + }, + { + type: 'receiveUpdateFeatureFlagSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', done => { + mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] }); + + testAction( + updateFeatureFlag, + { + name: 'feature_flag', + description: 'feature flag', + scopes: [{ environment_scope: '*', active: true }], + }, + mockedState, + [], + [ + { + type: 'requestUpdateFeatureFlag', + }, + { + type: 'receiveUpdateFeatureFlagError', + payload: { message: [] }, + }, + ], + done, + ); + }); + }); + }); + + describe('requestUpdateFeatureFlag', () => { + it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', done => { + testAction( + requestUpdateFeatureFlag, + null, + mockedState, + [{ type: types.REQUEST_UPDATE_FEATURE_FLAG }], + [], + done, + ); + }); + }); + + describe('receiveUpdateFeatureFlagSuccess', () => { + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', done => { + testAction( + receiveUpdateFeatureFlagSuccess, + null, + mockedState, + [ + { + type: types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveUpdateFeatureFlagError', () => { + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', done => { + testAction( + receiveUpdateFeatureFlagError, + 'There was an error', + mockedState, + [{ type: types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], + [], + done, + ); + }); + }); + + describe('fetchFeatureFlag', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 1 }); + + testAction( + fetchFeatureFlag, + { id: 1 }, + mockedState, + [], + [ + { + type: 'requestFeatureFlag', + }, + { + type: 'receiveFeatureFlagSuccess', + payload: { id: 1 }, + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); + + testAction( + fetchFeatureFlag, + null, + mockedState, + [], + [ + { + type: 'requestFeatureFlag', + }, + { + type: 'receiveFeatureFlagError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestFeatureFlag', () => { + it('should commit REQUEST_FEATURE_FLAG mutation', done => { + testAction( + requestFeatureFlag, + null, + mockedState, + [{ type: types.REQUEST_FEATURE_FLAG }], + [], + done, + ); + }); + }); + + describe('receiveFeatureFlagSuccess', () => { + it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', done => { + testAction( + receiveFeatureFlagSuccess, + { id: 1 }, + mockedState, + [{ type: types.RECEIVE_FEATURE_FLAG_SUCCESS, payload: { id: 1 } }], + [], + done, + ); + }); + }); + + describe('receiveFeatureFlagError', () => { + it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', done => { + testAction( + receiveFeatureFlagError, + null, + mockedState, + [ + { + type: types.RECEIVE_FEATURE_FLAG_ERROR, + }, + ], + [], + done, + ); + }); + }); + + describe('toggelActive', () => { + it('should commit TOGGLE_ACTIVE mutation', done => { + testAction( + toggleActive, + true, + mockedState, + [{ type: types.TOGGLE_ACTIVE, payload: true }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/edit/mutations_spec.js b/spec/frontend/feature_flags/store/edit/mutations_spec.js new file mode 100644 index 00000000000..21d4e962b48 --- /dev/null +++ b/spec/frontend/feature_flags/store/edit/mutations_spec.js @@ -0,0 +1,150 @@ +import state from '~/feature_flags/store/modules/edit/state'; +import mutations from '~/feature_flags/store/modules/edit/mutations'; +import * as types from '~/feature_flags/store/modules/edit/mutation_types'; + +describe('Feature flags Edit Module Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_ENDPOINT', () => { + it('should set endpoint', () => { + mutations[types.SET_ENDPOINT](stateCopy, 'feature_flags.json'); + + expect(stateCopy.endpoint).toEqual('feature_flags.json'); + }); + }); + + describe('SET_PATH', () => { + it('should set provided options', () => { + mutations[types.SET_PATH](stateCopy, 'feature_flags'); + + expect(stateCopy.path).toEqual('feature_flags'); + }); + }); + + describe('REQUEST_FEATURE_FLAG', () => { + it('should set isLoading to true', () => { + mutations[types.REQUEST_FEATURE_FLAG](stateCopy); + + expect(stateCopy.isLoading).toEqual(true); + }); + + it('should set error to an empty array', () => { + mutations[types.REQUEST_FEATURE_FLAG](stateCopy); + + expect(stateCopy.error).toEqual([]); + }); + }); + + describe('RECEIVE_FEATURE_FLAG_SUCCESS', () => { + const data = { + name: '*', + description: 'All environments', + scopes: [{ id: 1 }], + iid: 5, + version: 'new_version_flag', + strategies: [ + { id: 1, scopes: [{ environment_scope: '*' }], name: 'default', parameters: {} }, + ], + }; + + beforeEach(() => { + mutations[types.RECEIVE_FEATURE_FLAG_SUCCESS](stateCopy, data); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to false', () => { + expect(stateCopy.hasError).toEqual(false); + }); + + it('should set name with the provided one', () => { + expect(stateCopy.name).toEqual(data.name); + }); + + it('should set description with the provided one', () => { + expect(stateCopy.description).toEqual(data.description); + }); + + it('should set scope with the provided one', () => { + expect(stateCopy.scope).toEqual(data.scope); + }); + + it('should set the iid to the provided one', () => { + expect(stateCopy.iid).toEqual(data.iid); + }); + + it('should set the version to the provided one', () => { + expect(stateCopy.version).toBe('new_version_flag'); + }); + + it('should set the strategies to the provided one', () => { + expect(stateCopy.strategies).toEqual([ + { + id: 1, + scopes: [{ environmentScope: '*', shouldBeDestroyed: false }], + name: 'default', + parameters: {}, + shouldBeDestroyed: false, + }, + ]); + }); + }); + + describe('RECEIVE_FEATURE_FLAG_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_FEATURE_FLAG_ERROR](stateCopy); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.hasError).toEqual(true); + }); + }); + + describe('REQUEST_UPDATE_FEATURE_FLAG', () => { + beforeEach(() => { + mutations[types.REQUEST_UPDATE_FEATURE_FLAG](stateCopy); + }); + + it('should set isSendingRequest to true', () => { + expect(stateCopy.isSendingRequest).toEqual(true); + }); + + it('should set error to an empty array', () => { + expect(stateCopy.error).toEqual([]); + }); + }); + + describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => { + it('should set isSendingRequest to false', () => { + mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy); + + expect(stateCopy.isSendingRequest).toEqual(false); + }); + }); + + describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, { + message: ['Name is required'], + }); + }); + + it('should set isSendingRequest to false', () => { + expect(stateCopy.isSendingRequest).toEqual(false); + }); + + it('should set error to the given message', () => { + expect(stateCopy.error).toEqual(['Name is required']); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/helpers_spec.js b/spec/frontend/feature_flags/store/helpers_spec.js new file mode 100644 index 00000000000..0bc15ab70aa --- /dev/null +++ b/spec/frontend/feature_flags/store/helpers_spec.js @@ -0,0 +1,514 @@ +import { uniqueId } from 'lodash'; +import { + mapToScopesViewModel, + mapFromScopesViewModel, + createNewEnvironmentScope, + mapStrategiesToViewModel, + mapStrategiesToRails, +} from '~/feature_flags/store/modules/helpers'; +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'; + +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( + mapStrategiesToViewModel([ + { + id: '1', + name: 'default', + parameters: {}, + scopes: [ + { + environment_scope: '*', + id: '1', + }, + ], + }, + ]), + ).toEqual([ + { + id: '1', + name: 'default', + parameters: {}, + shouldBeDestroyed: false, + scopes: [ + { + shouldBeDestroyed: false, + environmentScope: '*', + id: '1', + }, + ], + }, + ]); + }); + + it('inserts spaces between user ids', () => { + const strategy = mapStrategiesToViewModel([ + { + id: '1', + name: 'userWithId', + parameters: { userIds: 'user1,user2,user3' }, + scopes: [], + }, + ])[0]; + + expect(strategy.parameters).toEqual({ userIds: 'user1, user2, user3' }); + }); + }); + + describe('mapStrategiesToRails', () => { + it('should map rails casing to view model casing', () => { + expect( + mapStrategiesToRails({ + name: 'test', + description: 'test description', + version: NEW_VERSION_FLAG, + active: true, + strategies: [ + { + id: '1', + name: 'default', + parameters: {}, + shouldBeDestroyed: true, + scopes: [ + { + environmentScope: '*', + id: '1', + shouldBeDestroyed: true, + }, + ], + }, + ], + }), + ).toEqual({ + operations_feature_flag: { + name: 'test', + description: 'test description', + version: NEW_VERSION_FLAG, + active: true, + strategies_attributes: [ + { + id: '1', + name: 'default', + parameters: {}, + _destroy: true, + scopes_attributes: [ + { + environment_scope: '*', + id: '1', + _destroy: true, + }, + ], + }, + ], + }, + }); + }); + + it('should insert a default * scope if there are none', () => { + expect( + mapStrategiesToRails({ + name: 'test', + description: 'test description', + version: NEW_VERSION_FLAG, + active: true, + strategies: [ + { + id: '1', + name: 'default', + parameters: {}, + scopes: [], + }, + ], + }), + ).toEqual({ + operations_feature_flag: { + name: 'test', + description: 'test description', + version: NEW_VERSION_FLAG, + active: true, + strategies_attributes: [ + { + id: '1', + name: 'default', + parameters: {}, + scopes_attributes: [ + { + environment_scope: '*', + }, + ], + }, + ], + }, + }); + }); + + it('removes white space between user ids', () => { + const result = mapStrategiesToRails({ + name: 'test', + version: NEW_VERSION_FLAG, + active: true, + strategies: [ + { + id: '1', + name: 'userWithId', + parameters: { userIds: 'user1, user2, user3' }, + scopes: [], + }, + ], + }); + + const strategyAttrs = result.operations_feature_flag.strategies_attributes[0]; + + expect(strategyAttrs.parameters).toEqual({ userIds: 'user1,user2,user3' }); + }); + + it('preserves the value of active', () => { + const result = mapStrategiesToRails({ + name: 'test', + version: NEW_VERSION_FLAG, + active: false, + strategies: [], + }); + + expect(result.operations_feature_flag.active).toBe(false); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js new file mode 100644 index 00000000000..0ada84aed33 --- /dev/null +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -0,0 +1,605 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import Api from '~/api'; +import { + requestFeatureFlags, + receiveFeatureFlagsSuccess, + receiveFeatureFlagsError, + fetchFeatureFlags, + setFeatureFlagsEndpoint, + setFeatureFlagsOptions, + setInstanceIdEndpoint, + setInstanceId, + rotateInstanceId, + requestRotateInstanceId, + receiveRotateInstanceIdSuccess, + receiveRotateInstanceIdError, + toggleFeatureFlag, + updateFeatureFlag, + receiveUpdateFeatureFlagSuccess, + receiveUpdateFeatureFlagError, + requestUserLists, + receiveUserListsSuccess, + receiveUserListsError, + fetchUserLists, + deleteUserList, + receiveDeleteUserListError, + clearAlert, +} from '~/feature_flags/store/modules/index/actions'; +import { mapToScopesViewModel } from '~/feature_flags/store/modules/helpers'; +import state from '~/feature_flags/store/modules/index/state'; +import * as types from '~/feature_flags/store/modules/index/mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data'; + +jest.mock('~/api.js'); + +describe('Feature flags actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('setFeatureFlagsEndpoint', () => { + it('should commit SET_FEATURE_FLAGS_ENDPOINT mutation', done => { + testAction( + setFeatureFlagsEndpoint, + 'feature_flags.json', + mockedState, + [{ type: types.SET_FEATURE_FLAGS_ENDPOINT, payload: 'feature_flags.json' }], + [], + done, + ); + }); + }); + + describe('setFeatureFlagsOptions', () => { + it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', done => { + testAction( + setFeatureFlagsOptions, + { page: '1', scope: 'all' }, + mockedState, + [{ type: types.SET_FEATURE_FLAGS_OPTIONS, payload: { page: '1', scope: 'all' } }], + [], + done, + ); + }); + }); + + describe('setInstanceIdEndpoint', () => { + it('should commit SET_INSTANCE_ID_ENDPOINT mutation', done => { + testAction( + setInstanceIdEndpoint, + 'instance_id.json', + mockedState, + [{ type: types.SET_INSTANCE_ID_ENDPOINT, payload: 'instance_id.json' }], + [], + done, + ); + }); + }); + + describe('setInstanceId', () => { + it('should commit SET_INSTANCE_ID mutation', done => { + testAction( + setInstanceId, + 'test_instance_id', + mockedState, + [{ type: types.SET_INSTANCE_ID, payload: 'test_instance_id' }], + [], + done, + ); + }); + }); + + describe('fetchFeatureFlags', () => { + let mock; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, getRequestData, {}); + + testAction( + fetchFeatureFlags, + null, + mockedState, + [], + [ + { + type: 'requestFeatureFlags', + }, + { + payload: { data: getRequestData, headers: {} }, + type: 'receiveFeatureFlagsSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); + + testAction( + fetchFeatureFlags, + null, + mockedState, + [], + [ + { + type: 'requestFeatureFlags', + }, + { + type: 'receiveFeatureFlagsError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestFeatureFlags', () => { + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', done => { + testAction( + requestFeatureFlags, + null, + mockedState, + [{ type: types.REQUEST_FEATURE_FLAGS }], + [], + done, + ); + }); + }); + + describe('receiveFeatureFlagsSuccess', () => { + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', done => { + testAction( + receiveFeatureFlagsSuccess, + { data: getRequestData, headers: {} }, + mockedState, + [ + { + type: types.RECEIVE_FEATURE_FLAGS_SUCCESS, + payload: { data: getRequestData, headers: {} }, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveFeatureFlagsError', () => { + it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', done => { + testAction( + receiveFeatureFlagsError, + null, + mockedState, + [{ type: types.RECEIVE_FEATURE_FLAGS_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchUserLists', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserLists.mockResolvedValue({ data: [userList], headers: {} }); + }); + + describe('success', () => { + it('dispatches requestUserLists and receiveUserListsSuccess ', done => { + testAction( + fetchUserLists, + null, + mockedState, + [], + [ + { + type: 'requestUserLists', + }, + { + payload: { data: [userList], headers: {} }, + type: 'receiveUserListsSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestUserLists and receiveUserListsError ', done => { + Api.fetchFeatureFlagUserLists.mockRejectedValue(); + + testAction( + fetchUserLists, + null, + mockedState, + [], + [ + { + type: 'requestUserLists', + }, + { + type: 'receiveUserListsError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestUserLists', () => { + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => { + testAction( + requestUserLists, + null, + mockedState, + [{ type: types.REQUEST_USER_LISTS }], + [], + done, + ); + }); + }); + + describe('receiveUserListsSuccess', () => { + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', done => { + testAction( + receiveUserListsSuccess, + { data: [userList], headers: {} }, + mockedState, + [ + { + type: types.RECEIVE_USER_LISTS_SUCCESS, + payload: { data: [userList], headers: {} }, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveUserListsError', () => { + it('should commit RECEIVE_USER_LISTS_ERROR mutation', done => { + testAction( + receiveUserListsError, + null, + mockedState, + [{ type: types.RECEIVE_USER_LISTS_ERROR }], + [], + done, + ); + }); + }); + + describe('rotateInstanceId', () => { + let mock; + + beforeEach(() => { + mockedState.rotateEndpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', done => { + mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {}); + + testAction( + rotateInstanceId, + null, + mockedState, + [], + [ + { + type: 'requestRotateInstanceId', + }, + { + payload: { data: rotateData, headers: {} }, + type: 'receiveRotateInstanceIdSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', done => { + mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); + + testAction( + rotateInstanceId, + null, + mockedState, + [], + [ + { + type: 'requestRotateInstanceId', + }, + { + type: 'receiveRotateInstanceIdError', + }, + ], + done, + ); + }); + }); + }); + + describe('requestRotateInstanceId', () => { + it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', done => { + testAction( + requestRotateInstanceId, + null, + mockedState, + [{ type: types.REQUEST_ROTATE_INSTANCE_ID }], + [], + done, + ); + }); + }); + + describe('receiveRotateInstanceIdSuccess', () => { + it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', done => { + testAction( + receiveRotateInstanceIdSuccess, + { data: rotateData, headers: {} }, + mockedState, + [ + { + type: types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS, + payload: { data: rotateData, headers: {} }, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveRotateInstanceIdError', () => { + it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', done => { + testAction( + receiveRotateInstanceIdError, + null, + mockedState, + [{ type: types.RECEIVE_ROTATE_INSTANCE_ID_ERROR }], + [], + done, + ); + }); + }); + + describe('toggleFeatureFlag', () => { + let mock; + + beforeEach(() => { + mockedState.featureFlags = getRequestData.feature_flags.map(flag => ({ + ...flag, + scopes: mapToScopesViewModel(flag.scopes || []), + })); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + describe('success', () => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => { + mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {}); + + testAction( + toggleFeatureFlag, + featureFlag, + mockedState, + [], + [ + { + type: 'updateFeatureFlag', + payload: featureFlag, + }, + { + payload: featureFlag, + type: 'receiveUpdateFeatureFlagSuccess', + }, + ], + done, + ); + }); + }); + describe('error', () => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', done => { + mock.onPut(featureFlag.update_path).replyOnce(500); + + testAction( + toggleFeatureFlag, + featureFlag, + mockedState, + [], + [ + { + type: 'updateFeatureFlag', + payload: featureFlag, + }, + { + payload: featureFlag.id, + type: 'receiveUpdateFeatureFlagError', + }, + ], + done, + ); + }); + }); + }); + describe('updateFeatureFlag', () => { + beforeEach(() => { + mockedState.featureFlags = getRequestData.feature_flags.map(f => ({ + ...f, + scopes: mapToScopesViewModel(f.scopes || []), + })); + }); + + it('commits UPDATE_FEATURE_FLAG with the given flag', done => { + testAction( + updateFeatureFlag, + featureFlag, + mockedState, + [ + { + type: 'UPDATE_FEATURE_FLAG', + payload: featureFlag, + }, + ], + [], + done, + ); + }); + }); + describe('receiveUpdateFeatureFlagSuccess', () => { + beforeEach(() => { + mockedState.featureFlags = getRequestData.feature_flags.map(f => ({ + ...f, + scopes: mapToScopesViewModel(f.scopes || []), + })); + }); + + it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', done => { + testAction( + receiveUpdateFeatureFlagSuccess, + featureFlag, + mockedState, + [ + { + type: 'RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', + payload: featureFlag, + }, + ], + [], + done, + ); + }); + }); + describe('receiveUpdateFeatureFlagError', () => { + beforeEach(() => { + mockedState.featureFlags = getRequestData.feature_flags.map(f => ({ + ...f, + scopes: mapToScopesViewModel(f.scopes || []), + })); + }); + + it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', done => { + testAction( + receiveUpdateFeatureFlagError, + featureFlag.id, + mockedState, + [ + { + type: 'RECEIVE_UPDATE_FEATURE_FLAG_ERROR', + payload: featureFlag.id, + }, + ], + [], + done, + ); + }); + }); + describe('deleteUserList', () => { + beforeEach(() => { + mockedState.userLists = [userList]; + }); + + describe('success', () => { + beforeEach(() => { + Api.deleteFeatureFlagUserList.mockResolvedValue(); + }); + + it('should refresh the user lists', done => { + testAction( + deleteUserList, + userList, + mockedState, + [], + [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }], + done, + ); + }); + }); + + describe('error', () => { + beforeEach(() => { + Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } }); + }); + + it('should dispatch receiveDeleteUserListError', done => { + testAction( + deleteUserList, + userList, + mockedState, + [], + [ + { type: 'requestDeleteUserList', payload: userList }, + { + type: 'receiveDeleteUserListError', + payload: { list: userList, error: 'some error' }, + }, + ], + done, + ); + }); + }); + }); + + describe('receiveDeleteUserListError', () => { + it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', done => { + testAction( + receiveDeleteUserListError, + { list: userList, error: 'mock error' }, + mockedState, + [ + { + type: 'RECEIVE_DELETE_USER_LIST_ERROR', + payload: { list: userList, error: 'mock error' }, + }, + ], + [], + done, + ); + }); + }); + + describe('clearAlert', () => { + it('should commit RECEIVE_CLEAR_ALERT', done => { + const alertIndex = 3; + + testAction( + clearAlert, + alertIndex, + mockedState, + [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/index/mutations_spec.js b/spec/frontend/feature_flags/store/index/mutations_spec.js new file mode 100644 index 00000000000..5e236fe2222 --- /dev/null +++ b/spec/frontend/feature_flags/store/index/mutations_spec.js @@ -0,0 +1,332 @@ +import state from '~/feature_flags/store/modules/index/state'; +import mutations from '~/feature_flags/store/modules/index/mutations'; +import * as types from '~/feature_flags/store/modules/index/mutation_types'; +import { mapToScopesViewModel } from '~/feature_flags/store/modules/helpers'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { getRequestData, rotateData, featureFlag, userList } from '../../mock_data'; + +describe('Feature flags store Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_FEATURE_FLAGS_ENDPOINT', () => { + it('should set endpoint', () => { + mutations[types.SET_FEATURE_FLAGS_ENDPOINT](stateCopy, 'feature_flags.json'); + + expect(stateCopy.endpoint).toEqual('feature_flags.json'); + }); + }); + + describe('SET_FEATURE_FLAGS_OPTIONS', () => { + it('should set provided options', () => { + mutations[types.SET_FEATURE_FLAGS_OPTIONS](stateCopy, { page: '1', scope: 'all' }); + + expect(stateCopy.options).toEqual({ page: '1', scope: 'all' }); + }); + }); + + describe('SET_INSTANCE_ID_ENDPOINT', () => { + it('should set provided endpoint', () => { + mutations[types.SET_INSTANCE_ID_ENDPOINT](stateCopy, 'rotate_token.json'); + + expect(stateCopy.rotateEndpoint).toEqual('rotate_token.json'); + }); + }); + + describe('SET_INSTANCE_ID', () => { + it('should set provided token', () => { + mutations[types.SET_INSTANCE_ID](stateCopy, rotateData.token); + + expect(stateCopy.instanceId).toEqual(rotateData.token); + }); + }); + + describe('REQUEST_FEATURE_FLAGS', () => { + it('should set isLoading to true', () => { + mutations[types.REQUEST_FEATURE_FLAGS](stateCopy); + + expect(stateCopy.isLoading).toEqual(true); + }); + }); + + describe('RECEIVE_FEATURE_FLAGS_SUCCESS', () => { + const headers = { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }; + + beforeEach(() => { + mutations[types.RECEIVE_FEATURE_FLAGS_SUCCESS](stateCopy, { data: getRequestData, headers }); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to false', () => { + 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.featureFlags).toEqual(37); + }); + + it('should set pagination', () => { + expect(stateCopy.pageInfo.featureFlags).toEqual( + parseIntPagination(normalizeHeaders(headers)), + ); + }); + }); + + describe('RECEIVE_FEATURE_FLAGS_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_FEATURE_FLAGS_ERROR](stateCopy); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.hasError).toEqual(true); + }); + }); + + describe('REQUEST_USER_LISTS', () => { + it('sets isLoading to true', () => { + mutations[types.REQUEST_USER_LISTS](stateCopy); + expect(stateCopy.isLoading).toBe(true); + }); + }); + + describe('RECEIVE_USER_LISTS_SUCCESS', () => { + const headers = { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }; + + beforeEach(() => { + mutations[types.RECEIVE_USER_LISTS_SUCCESS](stateCopy, { data: [userList], headers }); + }); + + it('sets isLoading to false', () => { + expect(stateCopy.isLoading).toBe(false); + }); + + it('sets userLists to the received userLists', () => { + expect(stateCopy.userLists).toEqual([userList]); + }); + + it('sets pagination info for user lits', () => { + expect(stateCopy.pageInfo.userLists).toEqual(parseIntPagination(normalizeHeaders(headers))); + }); + + it('sets the count for user lists', () => { + expect(stateCopy.count.userLists).toBe(parseInt(headers['X-TOTAL'], 10)); + }); + }); + + describe('RECEIVE_USER_LISTS_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_USER_LISTS_ERROR](stateCopy); + }); + + it('should set isLoading to false', () => { + expect(stateCopy.isLoading).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.hasError).toEqual(true); + }); + }); + + describe('REQUEST_ROTATE_INSTANCE_ID', () => { + beforeEach(() => { + mutations[types.REQUEST_ROTATE_INSTANCE_ID](stateCopy); + }); + + it('should set isRotating to true', () => { + expect(stateCopy.isRotating).toBe(true); + }); + + it('should set hasRotateError to false', () => { + expect(stateCopy.hasRotateError).toBe(false); + }); + }); + + describe('RECEIVE_ROTATE_INSTANCE_ID_SUCCESS', () => { + beforeEach(() => { + mutations[types.RECEIVE_ROTATE_INSTANCE_ID_SUCCESS](stateCopy, { data: rotateData }); + }); + + it('should set the instance id to the received data', () => { + expect(stateCopy.instanceId).toBe(rotateData.token); + }); + + it('should set isRotating to false', () => { + expect(stateCopy.isRotating).toBe(false); + }); + + it('should set hasRotateError to false', () => { + expect(stateCopy.hasRotateError).toBe(false); + }); + }); + + describe('RECEIVE_ROTATE_INSTANCE_ID_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_ROTATE_INSTANCE_ID_ERROR](stateCopy); + }); + + it('should set isRotating to false', () => { + expect(stateCopy.isRotating).toBe(false); + }); + + it('should set hasRotateError to true', () => { + expect(stateCopy.hasRotateError).toBe(true); + }); + }); + + describe('UPDATE_FEATURE_FLAG', () => { + 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, + }); + }); + + it('should update the flag with the matching ID', () => { + expect(stateCopy.featureFlags).toEqual([ + { + ...featureFlag, + scopes: mapToScopesViewModel(featureFlag.scopes || []), + active: false, + }, + ]); + }); + }); + + describe('RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS', () => { + const runUpdate = (stateCount, flagState, featureFlagUpdateParams) => { + stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({ + ...flag, + ...flagState, + scopes: mapToScopesViewModel(flag.scopes || []), + })); + stateCopy.count.featureFlags = stateCount; + + mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS](stateCopy, { + ...featureFlag, + ...featureFlagUpdateParams, + }); + }; + + it('updates the flag with the matching ID', () => { + runUpdate({ all: 1, enabled: 1, disabled: 0 }, { active: true }, { active: false }); + + expect(stateCopy.featureFlags).toEqual([ + { + ...featureFlag, + scopes: mapToScopesViewModel(featureFlag.scopes || []), + active: false, + }, + ]); + }); + }); + + describe('RECEIVE_UPDATE_FEATURE_FLAG_ERROR', () => { + beforeEach(() => { + stateCopy.featureFlags = getRequestData.feature_flags.map(flag => ({ + ...flag, + scopes: mapToScopesViewModel(flag.scopes || []), + })); + stateCopy.count = { enabled: 1, disabled: 0 }; + + mutations[types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR](stateCopy, featureFlag.id); + }); + + it('should update the flag with the matching ID, toggling active', () => { + expect(stateCopy.featureFlags).toEqual([ + { + ...featureFlag, + scopes: mapToScopesViewModel(featureFlag.scopes || []), + active: false, + }, + ]); + }); + }); + + describe('REQUEST_DELETE_USER_LIST', () => { + beforeEach(() => { + stateCopy.userLists = [userList]; + mutations[types.REQUEST_DELETE_USER_LIST](stateCopy, userList); + }); + + it('should remove the deleted list', () => { + expect(stateCopy.userLists).not.toContain(userList); + }); + }); + + describe('RECEIVE_DELETE_USER_LIST_ERROR', () => { + beforeEach(() => { + stateCopy.userLists = []; + mutations[types.RECEIVE_DELETE_USER_LIST_ERROR](stateCopy, { + list: userList, + error: 'some error', + }); + }); + + it('should set isLoading to false and hasError to false', () => { + expect(stateCopy.isLoading).toBe(false); + expect(stateCopy.hasError).toBe(false); + }); + + it('should add the user list back to the list of user lists', () => { + expect(stateCopy.userLists).toContain(userList); + }); + }); + + describe('RECEIVE_CLEAR_ALERT', () => { + it('clears the alert', () => { + stateCopy.alerts = ['a server error']; + + mutations[types.RECEIVE_CLEAR_ALERT](stateCopy, 0); + + expect(stateCopy.alerts).toEqual([]); + }); + + it('clears the alert at the specified index', () => { + stateCopy.alerts = ['a server error', 'another error', 'final error']; + + mutations[types.RECEIVE_CLEAR_ALERT](stateCopy, 1); + + expect(stateCopy.alerts).toEqual(['a server error', 'final error']); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js new file mode 100644 index 00000000000..cfcddd9451f --- /dev/null +++ b/spec/frontend/feature_flags/store/new/actions_spec.js @@ -0,0 +1,223 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import { + setEndpoint, + setPath, + createFeatureFlag, + requestCreateFeatureFlag, + receiveCreateFeatureFlagSuccess, + receiveCreateFeatureFlagError, +} from '~/feature_flags/store/modules/new/actions'; +import state from '~/feature_flags/store/modules/new/state'; +import * as types from '~/feature_flags/store/modules/new/mutation_types'; +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/modules/helpers'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/lib/utils/url_utility'); + +describe('Feature flags New Module Actions', () => { + let mockedState; + + beforeEach(() => { + mockedState = state(); + }); + + describe('setEndpoint', () => { + it('should commit SET_ENDPOINT mutation', done => { + testAction( + setEndpoint, + 'feature_flags.json', + mockedState, + [{ type: types.SET_ENDPOINT, payload: 'feature_flags.json' }], + [], + done, + ); + }); + }); + + describe('setPath', () => { + it('should commit SET_PATH mutation', done => { + testAction( + setPath, + '/feature_flags', + mockedState, + [{ type: types.SET_PATH, payload: '/feature_flags' }], + [], + done, + ); + }); + }); + + 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); + }); + + afterEach(() => { + mock.restore(); + }); + + 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 = { + name: 'name', + description: 'description', + active: true, + version: NEW_VERSION_FLAG, + strategies: [ + { + name: ROLLOUT_STRATEGY_ALL_USERS, + parameters: {}, + id: 1, + scopes: [{ id: 1, environmentScope: 'environmentScope', shouldBeDestroyed: false }], + shouldBeDestroyed: false, + }, + ], + }; + mock + .onPost(`${TEST_HOST}/endpoint.json`, mapStrategiesToRails(newVersionFlagParams)) + .replyOnce(200); + + testAction( + createFeatureFlag, + newVersionFlagParams, + mockedState, + [], + [ + { + type: 'requestCreateFeatureFlag', + }, + { + type: 'receiveCreateFeatureFlagSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', done => { + const convertedActionParams = mapFromScopesViewModel(actionParams); + + mock + .onPost(`${TEST_HOST}/endpoint.json`, convertedActionParams) + .replyOnce(500, { message: [] }); + + testAction( + createFeatureFlag, + actionParams, + mockedState, + [], + [ + { + type: 'requestCreateFeatureFlag', + }, + { + type: 'receiveCreateFeatureFlagError', + payload: { message: [] }, + }, + ], + done, + ); + }); + }); + }); + + describe('requestCreateFeatureFlag', () => { + it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', done => { + testAction( + requestCreateFeatureFlag, + null, + mockedState, + [{ type: types.REQUEST_CREATE_FEATURE_FLAG }], + [], + done, + ); + }); + }); + + describe('receiveCreateFeatureFlagSuccess', () => { + it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', done => { + testAction( + receiveCreateFeatureFlagSuccess, + null, + mockedState, + [ + { + type: types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS, + }, + ], + [], + done, + ); + }); + }); + + describe('receiveCreateFeatureFlagError', () => { + it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', done => { + testAction( + receiveCreateFeatureFlagError, + 'There was an error', + mockedState, + [{ type: types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/feature_flags/store/new/mutations_spec.js b/spec/frontend/feature_flags/store/new/mutations_spec.js new file mode 100644 index 00000000000..95eba96ed72 --- /dev/null +++ b/spec/frontend/feature_flags/store/new/mutations_spec.js @@ -0,0 +1,65 @@ +import state from '~/feature_flags/store/modules/new/state'; +import mutations from '~/feature_flags/store/modules/new/mutations'; +import * as types from '~/feature_flags/store/modules/new/mutation_types'; + +describe('Feature flags New Module Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_ENDPOINT', () => { + it('should set endpoint', () => { + mutations[types.SET_ENDPOINT](stateCopy, 'feature_flags.json'); + + expect(stateCopy.endpoint).toEqual('feature_flags.json'); + }); + }); + + describe('SET_PATH', () => { + it('should set provided options', () => { + mutations[types.SET_PATH](stateCopy, 'feature_flags'); + + expect(stateCopy.path).toEqual('feature_flags'); + }); + }); + + describe('REQUEST_CREATE_FEATURE_FLAG', () => { + it('should set isSendingRequest to true', () => { + mutations[types.REQUEST_CREATE_FEATURE_FLAG](stateCopy); + + expect(stateCopy.isSendingRequest).toEqual(true); + }); + + it('should set error to an empty array', () => { + mutations[types.REQUEST_CREATE_FEATURE_FLAG](stateCopy); + + expect(stateCopy.error).toEqual([]); + }); + }); + + describe('RECEIVE_CREATE_FEATURE_FLAG_SUCCESS', () => { + it('should set isSendingRequest to false', () => { + mutations[types.RECEIVE_CREATE_FEATURE_FLAG_SUCCESS](stateCopy); + + expect(stateCopy.isSendingRequest).toEqual(false); + }); + }); + + describe('RECEIVE_CREATE_FEATURE_FLAG_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_CREATE_FEATURE_FLAG_ERROR](stateCopy, { + message: ['Name is required'], + }); + }); + + it('should set isSendingRequest to false', () => { + expect(stateCopy.isSendingRequest).toEqual(false); + }); + + it('should set hasError to true', () => { + expect(stateCopy.error).toEqual(['Name is required']); + }); + }); +}); diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb new file mode 100644 index 00000000000..bda62f4850a --- /dev/null +++ b/spec/frontend/fixtures/releases.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Releases (JavaScript fixtures)' do + include ApiHelpers + include JavaScriptFixturesHelpers + + let_it_be(:admin) { create(:admin, username: 'administrator', email: 'admin@example.gitlab.com') } + let_it_be(:namespace) { create(:namespace, path: 'releases-namespace') } + let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'releases-project') } + + let_it_be(:milestone_12_3) do + create(:milestone, + id: 123, + project: project, + title: '12.3', + description: 'The 12.3 milestone', + start_date: Time.zone.parse('2018-12-10'), + due_date: Time.zone.parse('2019-01-10')) + end + + let_it_be(:milestone_12_4) do + create(:milestone, + id: 124, + project: project, + title: '12.4', + description: 'The 12.4 milestone', + start_date: Time.zone.parse('2019-01-10'), + due_date: Time.zone.parse('2019-02-10')) + end + + let_it_be(:open_issues_12_3) do + create_list(:issue, 2, milestone: milestone_12_3, project: project) + end + + let_it_be(:closed_issues_12_3) do + create_list(:issue, 3, :closed, milestone: milestone_12_3, project: project) + end + + let_it_be(:open_issues_12_4) do + create_list(:issue, 3, milestone: milestone_12_4, project: project) + end + + let_it_be(:closed_issues_12_4) do + create_list(:issue, 1, :closed, milestone: milestone_12_4, project: project) + end + + let_it_be(:release) do + create(:release, + milestones: [milestone_12_3, milestone_12_4], + project: project, + tag: 'v1.1', + name: 'The first release', + author: admin, + description: 'Best. Release. **Ever.** :rocket:', + created_at: Time.zone.parse('2018-12-3'), + released_at: Time.zone.parse('2018-12-10')) + end + + let_it_be(:evidence) do + create(:evidence, + release: release, + collected_at: Time.zone.parse('2018-12-03')) + end + + let_it_be(:other_link) do + create(:release_link, + id: 10, + release: release, + name: 'linux-amd64 binaries', + filepath: '/binaries/linux-amd64', + url: 'https://downloads.example.com/bin/gitlab-linux-amd64') + end + + let_it_be(:runbook_link) do + create(:release_link, + id: 11, + release: release, + name: 'Runbook', + url: "#{release.project.web_url}/runbook", + link_type: :runbook) + end + + let_it_be(:package_link) do + create(:release_link, + id: 12, + release: release, + name: 'Package', + url: 'https://example.com/package', + link_type: :package) + end + + let_it_be(:image_link) do + create(:release_link, + id: 13, + release: release, + name: 'Image', + url: 'https://example.com/image', + link_type: :image) + end + + after(:all) do + remove_repository(project) + end + + describe API::Releases, type: :request do + before(:all) do + clean_frontend_fixtures('api/releases/') + end + + it 'api/releases/release.json' do + get api("/projects/#{project.id}/releases/#{release.tag}", admin) + + expect(response).to be_successful + end + end + + graphql_query_path = 'releases/queries/all_releases.query.graphql' + + describe "~/#{graphql_query_path}", type: :request do + include GraphqlHelpers + + before(:all) do + clean_frontend_fixtures('graphql/releases/') + end + + it "graphql/#{graphql_query_path}.json" do + query = File.read(File.join(Rails.root, '/app/assets/javascripts', graphql_query_path)) + + post_graphql(query, current_user: admin, variables: { fullPath: project.full_path }) + + expect_graphql_errors_to_be_empty + end + end +end diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 6c40b1ba3a7..38a0da95080 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -7,15 +7,228 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete import { TEST_HOST } from 'helpers/test_constants'; import { getJSONFixture } from 'helpers/fixtures'; +import waitForPromises from 'jest/helpers/wait_for_promises'; + +import MockAdapter from 'axios-mock-adapter'; +import AjaxCache from '~/lib/utils/ajax_cache'; +import axios from '~/lib/utils/axios_utils'; + const labelsFixture = getJSONFixture('autocomplete_sources/labels.json'); describe('GfmAutoComplete', () => { - const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ - fetchData: () => {}, - }); + const fetchDataMock = { fetchData: jest.fn() }; + let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock); let atwhoInstance; let sorterValue; + let filterValue; + + describe('.typesWithBackendFiltering', () => { + it('should contain vulnerabilities', () => { + expect(GfmAutoComplete.typesWithBackendFiltering).toContain('vulnerabilities'); + }); + }); + + describe('DefaultOptions.filter', () => { + let items; + + beforeEach(() => { + jest.spyOn(fetchDataMock, 'fetchData'); + jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {}); + }); + + describe('assets loading', () => { + beforeEach(() => { + atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' }; + items = ['loading']; + + filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items); + }); + + it('should call the fetchData function without query', () => { + expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+'); + }); + + it('should not call the default atwho filter', () => { + expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); + }); + + it('should return the passed unfiltered items', () => { + expect(filterValue).toEqual(items); + }); + }); + + describe('backend filtering', () => { + beforeEach(() => { + atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' }; + items = []; + }); + + describe('when previous query is different from current one', () => { + beforeEach(() => { + gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ + previousQuery: 'oldquery', + ...fetchDataMock, + }); + filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items); + }); + + it('should call the fetchData function with query', () => { + expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+', 'newquery'); + }); + + it('should not call the default atwho filter', () => { + expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled(); + }); + + it('should return the passed unfiltered items', () => { + expect(filterValue).toEqual(items); + }); + }); + + describe('when previous query is not different from current one', () => { + beforeEach(() => { + gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({ + previousQuery: 'oldquery', + ...fetchDataMock, + }); + filterValue = gfmAutoCompleteCallbacks.filter.call( + atwhoInstance, + 'oldquery', + items, + 'searchKey', + ); + }); + + it('should not call the fetchData function', () => { + expect(fetchDataMock.fetchData).not.toHaveBeenCalled(); + }); + + it('should call the default atwho filter', () => { + expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith( + 'oldquery', + items, + 'searchKey', + ); + }); + }); + }); + }); + + describe('fetchData', () => { + const { fetchData } = GfmAutoComplete.prototype; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(axios, 'get'); + jest.spyOn(AjaxCache, 'retrieve'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('already loading data', () => { + beforeEach(() => { + const context = { + isLoadingData: { '+': true }, + dataSources: {}, + cachedData: {}, + }; + fetchData.call(context, {}, '+', ''); + }); + + it('should not call either axios nor AjaxCache', () => { + expect(axios.get).not.toHaveBeenCalled(); + expect(AjaxCache.retrieve).not.toHaveBeenCalled(); + }); + }); + + describe('backend filtering', () => { + describe('data is not in cache', () => { + let context; + + beforeEach(() => { + context = { + isLoadingData: { '+': false }, + dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, + cachedData: {}, + }; + }); + + it('should call axios with query', () => { + fetchData.call(context, {}, '+', 'query'); + + expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { + params: { search: 'query' }, + }); + }); + + it.each([200, 500])('should set the loading state', async responseStatus => { + mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus); + + fetchData.call(context, {}, '+', 'query'); + + expect(context.isLoadingData['+']).toBe(true); + + await waitForPromises(); + + expect(context.isLoadingData['+']).toBe(false); + }); + }); + + describe('data is in cache', () => { + beforeEach(() => { + const context = { + isLoadingData: { '+': false }, + dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' }, + cachedData: { '+': [{}] }, + }; + fetchData.call(context, {}, '+', 'query'); + }); + + it('should anyway call axios with query ignoring cache', () => { + expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', { + params: { search: 'query' }, + }); + }); + }); + }); + + describe('frontend filtering', () => { + describe('data is not in cache', () => { + beforeEach(() => { + const context = { + isLoadingData: { '#': false }, + dataSources: { issues: 'issues_autocomplete_url' }, + cachedData: {}, + }; + fetchData.call(context, {}, '#', 'query'); + }); + + it('should call AjaxCache', () => { + expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true); + }); + }); + + describe('data is in cache', () => { + beforeEach(() => { + const context = { + isLoadingData: { '#': false }, + dataSources: { issues: 'issues_autocomplete_url' }, + cachedData: { '#': [{}] }, + loadData: () => {}, + }; + fetchData.call(context, {}, '#', 'query'); + }); + + it('should not call AjaxCache', () => { + expect(AjaxCache.retrieve).not.toHaveBeenCalled(); + }); + }); + }); + }); describe('DefaultOptions.sorter', () => { describe('assets loading', () => { diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap index 0befe1aa192..e880f585daa 100644 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -17,6 +17,7 @@ exports[`grafana integration component default state to match the default snapsh </h3> <gl-button-stub + buttontextclasses="" category="primary" class="js-settings-toggle" icon="" @@ -92,20 +93,17 @@ exports[`grafana integration component default state to match the default snapsh </p> </gl-form-group-stub> - <div - class="gl-display-flex gl-justify-content-end" + <gl-button-stub + buttontextclasses="" + category="primary" + icon="" + size="medium" + variant="success" > - <gl-button-stub - category="primary" - icon="" - size="medium" - variant="success" - > - - Save Changes - </gl-button-stub> - </div> + Save Changes + + </gl-button-stub> </form> </div> </section> diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js index f5df8c180d5..d4aa29eaadd 100644 --- a/spec/frontend/groups/components/item_actions_spec.js +++ b/spec/frontend/groups/components/item_actions_spec.js @@ -1,84 +1,87 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import itemActionsComponent from '~/groups/components/item_actions.vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ItemActions from '~/groups/components/item_actions.vue'; import eventHub from '~/groups/event_hub'; import { mockParentGroupItem, mockChildren } from '../mock_data'; -const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { - const Component = Vue.extend(itemActionsComponent); +describe('ItemActions', () => { + let wrapper; + const parentGroup = mockChildren[0]; - return mountComponent(Component, { - group, + const defaultProps = { + group: mockParentGroupItem, parentGroup, - }); -}; - -describe('ItemActionsComponent', () => { - let vm; + }; - beforeEach(() => { - vm = createComponent(); - }); + const createComponent = (props = {}) => { + wrapper = shallowMount(ItemActions, { + propsData: { ...defaultProps, ...props }, + }); + }; afterEach(() => { - vm.$destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); - describe('methods', () => { - describe('onLeaveGroup', () => { - it('emits `showLeaveGroupModal` event with `group` and `parentGroup` props', () => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - vm.onLeaveGroup(); - - expect(eventHub.$emit).toHaveBeenCalledWith( - 'showLeaveGroupModal', - vm.group, - vm.parentGroup, - ); - }); - }); - }); + const findEditGroupBtn = () => wrapper.find('[data-testid="edit-group-btn"]'); + const findEditGroupIcon = () => findEditGroupBtn().find(GlIcon); + const findLeaveGroupBtn = () => wrapper.find('[data-testid="leave-group-btn"]'); + const findLeaveGroupIcon = () => findLeaveGroupBtn().find(GlIcon); describe('template', () => { - it('should render component template correctly', () => { - expect(vm.$el.classList.contains('controls')).toBeTruthy(); - }); + it('renders component template correctly', () => { + createComponent(); - it('should render Edit Group button with correct attribute values', () => { - const group = { ...mockParentGroupItem }; - group.canEdit = true; - const newVm = createComponent(group); + expect(wrapper.classes()).toContain('controls'); + }); - const editBtn = newVm.$el.querySelector('a.edit-group'); + it('renders "Edit group" button with correct attribute values', () => { + const group = { + ...mockParentGroupItem, + canEdit: true, + }; + + createComponent({ group }); + + expect(findEditGroupBtn().exists()).toBe(true); + expect(findEditGroupBtn().classes()).toContain('no-expand'); + expect(findEditGroupBtn().attributes('href')).toBe(group.editPath); + expect(findEditGroupBtn().attributes('aria-label')).toBe('Edit group'); + expect(findEditGroupBtn().attributes('data-original-title')).toBe('Edit group'); + expect(findEditGroupIcon().exists()).toBe(true); + expect(findEditGroupIcon().props('name')).toBe('settings'); + }); - expect(editBtn).toBeDefined(); - expect(editBtn.classList.contains('no-expand')).toBeTruthy(); - expect(editBtn.getAttribute('href')).toBe(group.editPath); - expect(editBtn.getAttribute('aria-label')).toBe('Edit group'); - expect(editBtn.dataset.originalTitle).toBe('Edit group'); - expect(editBtn.querySelectorAll('svg').length).not.toBe(0); - expect(editBtn.querySelector('svg').getAttribute('data-testid')).toBe('settings-icon'); + describe('`canLeave` is true', () => { + const group = { + ...mockParentGroupItem, + canLeave: true, + }; - newVm.$destroy(); - }); + beforeEach(() => { + createComponent({ group }); + }); - it('should render Leave Group button with correct attribute values', () => { - const group = { ...mockParentGroupItem }; - group.canLeave = true; - const newVm = createComponent(group); + it('renders "Leave this group" button with correct attribute values', () => { + expect(findLeaveGroupBtn().exists()).toBe(true); + expect(findLeaveGroupBtn().classes()).toContain('no-expand'); + expect(findLeaveGroupBtn().attributes('href')).toBe(group.leavePath); + expect(findLeaveGroupBtn().attributes('aria-label')).toBe('Leave this group'); + expect(findLeaveGroupBtn().attributes('data-original-title')).toBe('Leave this group'); + expect(findLeaveGroupIcon().exists()).toBe(true); + expect(findLeaveGroupIcon().props('name')).toBe('leave'); + }); - const leaveBtn = newVm.$el.querySelector('a.leave-group'); + it('emits event on "Leave this group" button click', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - expect(leaveBtn).toBeDefined(); - expect(leaveBtn.classList.contains('no-expand')).toBeTruthy(); - expect(leaveBtn.getAttribute('href')).toBe(group.leavePath); - expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group'); - expect(leaveBtn.dataset.originalTitle).toBe('Leave this group'); - expect(leaveBtn.querySelectorAll('svg').length).not.toBe(0); - expect(leaveBtn.querySelector('svg').getAttribute('data-testid')).toBe('leave-icon'); + findLeaveGroupBtn().trigger('click'); - newVm.$destroy(); + expect(eventHub.$emit).toHaveBeenCalledWith('showLeaveGroupModal', group, parentGroup); + }); }); }); }); diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js index 4ff7482414c..b2915607a06 100644 --- a/spec/frontend/groups/components/item_caret_spec.js +++ b/spec/frontend/groups/components/item_caret_spec.js @@ -1,38 +1,48 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ItemCaret from '~/groups/components/item_caret.vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import itemCaretComponent from '~/groups/components/item_caret.vue'; +describe('ItemCaret', () => { + let wrapper; -const createComponent = (isGroupOpen = false) => { - const Component = Vue.extend(itemCaretComponent); + const defaultProps = { + isGroupOpen: false, + }; - return mountComponent(Component, { - isGroupOpen, - }); -}; - -describe('ItemCaretComponent', () => { - let vm; + const createComponent = (props = {}) => { + wrapper = shallowMount(ItemCaret, { + propsData: { ...defaultProps, ...props }, + }); + }; afterEach(() => { - vm.$destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); + const findAllGlIcons = () => wrapper.findAll(GlIcon); + const findGlIcon = () => wrapper.find(GlIcon); + describe('template', () => { - it('should render component template correctly', () => { - vm = createComponent(); - expect(vm.$el.classList.contains('folder-caret')).toBeTruthy(); - expect(vm.$el.querySelectorAll('svg').length).toBe(1); - }); + it('renders component template correctly', () => { + createComponent(); - it('should render caret down icon if `isGroupOpen` prop is `true`', () => { - vm = createComponent(true); - expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-down-icon'); + expect(wrapper.classes()).toContain('folder-caret'); + expect(findAllGlIcons()).toHaveLength(1); }); - it('should render caret right icon if `isGroupOpen` prop is `false`', () => { - vm = createComponent(); - expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-right-icon'); + it.each` + isGroupOpen | icon + ${true} | ${'angle-down'} + ${false} | ${'angle-right'} + `('renders "$icon" icon when `isGroupOpen` is $isGroupOpen', ({ isGroupOpen, icon }) => { + createComponent({ + isGroupOpen, + }); + + expect(findGlIcon().props('name')).toBe(icon); }); }); }); diff --git a/spec/frontend/groups/components/item_stats_spec.js b/spec/frontend/groups/components/item_stats_spec.js index 771643609ec..d8c88a608ac 100644 --- a/spec/frontend/groups/components/item_stats_spec.js +++ b/spec/frontend/groups/components/item_stats_spec.js @@ -1,119 +1,50 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import ItemStats from '~/groups/components/item_stats.vue'; +import ItemStatsValue from '~/groups/components/item_stats_value.vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import itemStatsComponent from '~/groups/components/item_stats.vue'; -import { - mockParentGroupItem, - ITEM_TYPE, - VISIBILITY_TYPE_ICON, - GROUP_VISIBILITY_TYPE, - PROJECT_VISIBILITY_TYPE, -} from '../mock_data'; +import { mockParentGroupItem, ITEM_TYPE } from '../mock_data'; -const createComponent = (item = mockParentGroupItem) => { - const Component = Vue.extend(itemStatsComponent); +describe('ItemStats', () => { + let wrapper; - return mountComponent(Component, { - item, - }); -}; - -describe('ItemStatsComponent', () => { - describe('computed', () => { - describe('visibilityIcon', () => { - it('should return icon class based on `item.visibility` value', () => { - Object.keys(VISIBILITY_TYPE_ICON).forEach(visibility => { - const item = { ...mockParentGroupItem, visibility }; - const vm = createComponent(item); + const defaultProps = { + item: mockParentGroupItem, + }; - expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]); - vm.$destroy(); - }); - }); + const createComponent = (props = {}) => { + wrapper = shallowMount(ItemStats, { + propsData: { ...defaultProps, ...props }, }); + }; - describe('visibilityTooltip', () => { - it('should return tooltip string for Group based on `item.visibility` value', () => { - Object.keys(GROUP_VISIBILITY_TYPE).forEach(visibility => { - const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.GROUP }; - const vm = createComponent(item); - - expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]); - vm.$destroy(); - }); - }); - - it('should return tooltip string for Project based on `item.visibility` value', () => { - Object.keys(PROJECT_VISIBILITY_TYPE).forEach(visibility => { - const item = { ...mockParentGroupItem, visibility, type: ITEM_TYPE.PROJECT }; - const vm = createComponent(item); - - expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]); - vm.$destroy(); - }); - }); - }); - - describe('isProject', () => { - it('should return boolean value representing whether `item.type` is Project or not', () => { - let item; - let vm; - - item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT }; - vm = createComponent(item); - - expect(vm.isProject).toBeTruthy(); - vm.$destroy(); - - item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP }; - vm = createComponent(item); - - expect(vm.isProject).toBeFalsy(); - vm.$destroy(); - }); - }); - - describe('isGroup', () => { - it('should return boolean value representing whether `item.type` is Group or not', () => { - let item; - let vm; - - item = { ...mockParentGroupItem, type: ITEM_TYPE.GROUP }; - vm = createComponent(item); - - expect(vm.isGroup).toBeTruthy(); - vm.$destroy(); - - item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT }; - vm = createComponent(item); - - expect(vm.isGroup).toBeFalsy(); - vm.$destroy(); - }); - }); + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); + const findItemStatsValue = () => wrapper.find(ItemStatsValue); + describe('template', () => { it('renders component container element correctly', () => { - const vm = createComponent(); + createComponent(); - expect(vm.$el.classList.contains('stats')).toBeTruthy(); - - vm.$destroy(); + expect(wrapper.classes()).toContain('stats'); }); it('renders start count and last updated information for project item correctly', () => { - const item = { ...mockParentGroupItem, type: ITEM_TYPE.PROJECT, starCount: 4 }; - const vm = createComponent(item); - - const projectStarIconEl = vm.$el.querySelector('.project-stars'); + const item = { + ...mockParentGroupItem, + type: ITEM_TYPE.PROJECT, + starCount: 4, + }; - expect(projectStarIconEl).not.toBeNull(); - expect(projectStarIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); - expect(projectStarIconEl.querySelectorAll('.stat-value').length).toBeGreaterThan(0); - expect(vm.$el.querySelectorAll('.last-updated').length).toBeGreaterThan(0); + createComponent({ item }); - vm.$destroy(); + expect(findItemStatsValue().exists()).toBe(true); + expect(findItemStatsValue().props('cssClass')).toBe('project-stars'); + expect(wrapper.contains('.last-updated')).toBe(true); }); }); }); diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js index 11246390444..6f018aa79a0 100644 --- a/spec/frontend/groups/components/item_stats_value_spec.js +++ b/spec/frontend/groups/components/item_stats_value_spec.js @@ -1,82 +1,67 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ItemStatsValue from '~/groups/components/item_stats_value.vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import itemStatsValueComponent from '~/groups/components/item_stats_value.vue'; +describe('ItemStatsValue', () => { + let wrapper; -const createComponent = ({ title, cssClass, iconName, tooltipPlacement, value }) => { - const Component = Vue.extend(itemStatsValueComponent); + const defaultProps = { + title: 'Subgroups', + cssClass: 'number-subgroups', + iconName: 'folder', + tooltipPlacement: 'left', + }; - return mountComponent(Component, { - title, - cssClass, - iconName, - tooltipPlacement, - value, + const createComponent = (props = {}) => { + wrapper = shallowMount(ItemStatsValue, { + propsData: { ...defaultProps, ...props }, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); -}; -describe('ItemStatsValueComponent', () => { - describe('computed', () => { - let vm; - const itemConfig = { - title: 'Subgroups', - cssClass: 'number-subgroups', - iconName: 'folder', - tooltipPlacement: 'left', - }; + const findGlIcon = () => wrapper.find(GlIcon); + const findStatValue = () => wrapper.find('[data-testid="itemStatValue"]'); - describe('isValuePresent', () => { - it('returns true if non-empty `value` is present', () => { - vm = createComponent({ ...itemConfig, value: 10 }); + describe('template', () => { + describe('when `value` is not provided', () => { + it('does not render value count', () => { + createComponent(); - expect(vm.isValuePresent).toBeTruthy(); + expect(findStatValue().exists()).toBe(false); }); + }); - it('returns false if empty `value` is present', () => { - vm = createComponent(itemConfig); - - expect(vm.isValuePresent).toBeFalsy(); + describe('when `value` is provided', () => { + beforeEach(() => { + createComponent({ + value: 10, + }); }); - afterEach(() => { - vm.$destroy(); + it('renders component element correctly', () => { + expect(wrapper.classes()).toContain('number-subgroups'); }); - }); - }); - describe('template', () => { - let vm; - beforeEach(() => { - vm = createComponent({ - title: 'Subgroups', - cssClass: 'number-subgroups', - iconName: 'folder', - tooltipPlacement: 'left', - value: 10, + it('renders element tooltip correctly', () => { + expect(wrapper.attributes('data-original-title')).toBe('Subgroups'); + expect(wrapper.attributes('data-placement')).toBe('left'); }); - }); - afterEach(() => { - vm.$destroy(); - }); - - it('renders component element correctly', () => { - expect(vm.$el.classList.contains('number-subgroups')).toBeTruthy(); - expect(vm.$el.querySelectorAll('svg').length).toBeGreaterThan(0); - expect(vm.$el.querySelectorAll('.stat-value').length).toBeGreaterThan(0); - }); - - it('renders element tooltip correctly', () => { - expect(vm.$el.dataset.originalTitle).toBe('Subgroups'); - expect(vm.$el.dataset.placement).toBe('left'); - }); - - it('renders element icon correctly', () => { - expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-icon'); - }); + it('renders element icon correctly', () => { + expect(findGlIcon().exists()).toBe(true); + expect(findGlIcon().props('name')).toBe('folder'); + }); - it('renders value count correctly', () => { - expect(vm.$el.querySelector('.stat-value').innerText.trim()).toContain('10'); + it('renders value count correctly', () => { + expect(findStatValue().classes()).toContain('stat-value'); + expect(findStatValue().text()).toBe('10'); + }); }); }); }); diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js index 477c413ddcd..5e7056be218 100644 --- a/spec/frontend/groups/components/item_type_icon_spec.js +++ b/spec/frontend/groups/components/item_type_icon_spec.js @@ -1,53 +1,53 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import itemTypeIconComponent from '~/groups/components/item_type_icon.vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ItemTypeIcon from '~/groups/components/item_type_icon.vue'; import { ITEM_TYPE } from '../mock_data'; -const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => { - const Component = Vue.extend(itemTypeIconComponent); - - return mountComponent(Component, { - itemType, - isGroupOpen, - }); -}; +describe('ItemTypeIcon', () => { + let wrapper; -describe('ItemTypeIconComponent', () => { - describe('template', () => { - it('should render component template correctly', () => { - const vm = createComponent(); + const defaultProps = { + itemType: ITEM_TYPE.GROUP, + isGroupOpen: false, + }; - expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy(); - vm.$destroy(); + const createComponent = (props = {}) => { + wrapper = shallowMount(ItemTypeIcon, { + propsData: { ...defaultProps, ...props }, }); + }; - it('should render folder open or close icon based `isGroupOpen` prop value', () => { - let vm; - - vm = createComponent(ITEM_TYPE.GROUP, true); + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); - expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-open-icon'); - vm.$destroy(); + const findGlIcon = () => wrapper.find(GlIcon); - vm = createComponent(ITEM_TYPE.GROUP); + describe('template', () => { + it('renders component template correctly', () => { + createComponent(); - expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-o-icon'); - vm.$destroy(); + expect(wrapper.classes()).toContain('item-type-icon'); }); - it('should render bookmark icon based on `isProject` prop value', () => { - let vm; - - vm = createComponent(ITEM_TYPE.PROJECT); - - expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('bookmark-icon'); - vm.$destroy(); - - vm = createComponent(ITEM_TYPE.GROUP); - - expect(vm.$el.querySelector('svg').getAttribute('data-testid')).not.toBe('bookmark-icon'); - vm.$destroy(); - }); + it.each` + type | isGroupOpen | icon + ${ITEM_TYPE.GROUP} | ${true} | ${'folder-open'} + ${ITEM_TYPE.GROUP} | ${false} | ${'folder-o'} + ${ITEM_TYPE.PROJECT} | ${true} | ${'bookmark'} + ${ITEM_TYPE.PROJECT} | ${false} | ${'bookmark'} + `( + 'shows "$icon" icon when `itemType` is "$type" and `isGroupOpen` is $isGroupOpen', + ({ type, isGroupOpen, icon }) => { + createComponent({ + itemType: type, + isGroupOpen, + }); + expect(findGlIcon().props('name')).toBe(icon); + }, + ); }); }); diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js index 70fce0d60fb..95a111ef5da 100644 --- a/spec/frontend/groups/members/index_spec.js +++ b/spec/frontend/groups/members/index_spec.js @@ -1,5 +1,5 @@ import { createWrapper } from '@vue/test-utils'; -import initGroupMembersApp from '~/groups/members'; +import { initGroupMembersApp } from '~/groups/members'; import GroupMembersApp from '~/groups/members/components/app.vue'; import { membersJsonString, membersParsed } from './mock_data'; @@ -9,7 +9,7 @@ describe('initGroupMembersApp', () => { let wrapper; const setup = () => { - vm = initGroupMembersApp(el); + vm = initGroupMembersApp(el, ['account']); wrapper = createWrapper(vm); }; @@ -63,4 +63,10 @@ describe('initGroupMembersApp', () => { expect(vm.$store.state.members).toEqual(membersParsed); }); + + it('sets `tableFields` in Vuex store', () => { + setup(); + + expect(vm.$store.state.tableFields).toEqual(['account']); + }); }); diff --git a/spec/frontend/helpers/experimentation_helper.js b/spec/frontend/helpers/experimentation_helper.js new file mode 100644 index 00000000000..c08c25155e8 --- /dev/null +++ b/spec/frontend/helpers/experimentation_helper.js @@ -0,0 +1,14 @@ +import { merge } from 'lodash'; + +export function withGonExperiment(experimentKey, value = true) { + let origGon; + + beforeEach(() => { + origGon = window.gon; + window.gon = merge({}, window.gon || {}, { experiments: { [experimentKey]: value } }); + }); + + afterEach(() => { + window.gon = origGon; + }); +} diff --git a/spec/frontend/helpers/keep_alive_component_helper.js b/spec/frontend/helpers/keep_alive_component_helper.js new file mode 100644 index 00000000000..54f40bf9093 --- /dev/null +++ b/spec/frontend/helpers/keep_alive_component_helper.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; + +export function keepAlive(KeptAliveComponent) { + return Vue.extend({ + components: { + KeptAliveComponent, + }, + data() { + return { + view: 'KeptAliveComponent', + }; + }, + methods: { + async activate() { + this.view = 'KeptAliveComponent'; + await this.$nextTick(); + }, + async deactivate() { + this.view = 'div'; + await this.$nextTick(); + }, + async reactivate() { + await this.deactivate(); + await this.activate(); + }, + }, + template: `<keep-alive><component :is="view"></component></keep-alive>`, + }); +} diff --git a/spec/frontend/helpers/keep_alive_component_helper_spec.js b/spec/frontend/helpers/keep_alive_component_helper_spec.js new file mode 100644 index 00000000000..dcccc14f396 --- /dev/null +++ b/spec/frontend/helpers/keep_alive_component_helper_spec.js @@ -0,0 +1,32 @@ +import { mount } from '@vue/test-utils'; +import { keepAlive } from './keep_alive_component_helper'; + +const component = { + template: '<div>Test Component</div>', +}; + +describe('keepAlive', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(keepAlive(component)); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('converts a component to a keep-alive component', async () => { + const { element } = wrapper.find(component); + + await wrapper.vm.deactivate(); + expect(wrapper.find(component).exists()).toBe(false); + + await wrapper.vm.activate(); + + // assert that when the component is destroyed and re-rendered, the + // newly rendered component has the reference to the old component + // (i.e. the old component was deactivated and activated) + expect(wrapper.find(component).element).toBe(element); + }); +}); diff --git a/spec/frontend/helpers/local_storage_helper.js b/spec/frontend/helpers/local_storage_helper.js index cd39b660bfd..0318b80aaef 100644 --- a/spec/frontend/helpers/local_storage_helper.js +++ b/spec/frontend/helpers/local_storage_helper.js @@ -35,7 +35,7 @@ export const createLocalStorageSpy = () => { clear: jest.fn(() => { storage = {}; }), - getItem: jest.fn(key => storage[key]), + getItem: jest.fn(key => (key in storage ? storage[key] : null)), setItem: jest.fn((key, value) => { storage[key] = value; }), diff --git a/spec/frontend/helpers/local_storage_helper_spec.js b/spec/frontend/helpers/local_storage_helper_spec.js index 6b44ea3a4c3..5d9961e7631 100644 --- a/spec/frontend/helpers/local_storage_helper_spec.js +++ b/spec/frontend/helpers/local_storage_helper_spec.js @@ -18,11 +18,11 @@ describe('localStorage helper', () => { localStorage.removeItem('test', 'testing'); - expect(localStorage.getItem('test')).toBeUndefined(); + expect(localStorage.getItem('test')).toBe(null); expect(localStorage.getItem('test2')).toBe('testing'); localStorage.clear(); - expect(localStorage.getItem('test2')).toBeUndefined(); + expect(localStorage.getItem('test2')).toBe(null); }); }); diff --git a/spec/frontend/helpers/vue_test_utils_helper.js b/spec/frontend/helpers/vue_test_utils_helper.js index 68326e37ae7..ead898f04d3 100644 --- a/spec/frontend/helpers/vue_test_utils_helper.js +++ b/spec/frontend/helpers/vue_test_utils_helper.js @@ -33,3 +33,10 @@ export const waitForMutation = (store, expectedMutationType) => } }); }); + +export const extendedWrapper = wrapper => + Object.defineProperty(wrapper, 'findByTestId', { + value(id) { + return this.find(`[data-testid="${id}"]`); + }, + }); diff --git a/spec/frontend/helpers/wait_for_text.js b/spec/frontend/helpers/wait_for_text.js new file mode 100644 index 00000000000..6bed8a90a98 --- /dev/null +++ b/spec/frontend/helpers/wait_for_text.js @@ -0,0 +1,3 @@ +import { findByText } from '@testing-library/dom'; + +export const waitForText = async (text, container = document) => findByText(container, text); diff --git a/spec/frontend/ide/components/commit_sidebar/actions_spec.js b/spec/frontend/ide/components/commit_sidebar/actions_spec.js index a303e2b9bee..0003e13c92f 100644 --- a/spec/frontend/ide/components/commit_sidebar/actions_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/actions_spec.js @@ -83,12 +83,12 @@ describe('IDE commit sidebar actions', () => { }); }); - describe('commitToCurrentBranchText', () => { + describe('currentBranchText', () => { it('escapes current branch', () => { const injectedSrc = '<img src="x" />'; createComponent({ currentBranchId: injectedSrc }); - expect(vm.commitToCurrentBranchText).not.toContain(injectedSrc); + expect(vm.currentBranchText).not.toContain(injectedSrc); }); }); diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js index c9ac2ac423d..bcc98669427 100644 --- a/spec/frontend/ide/components/ide_review_spec.js +++ b/spec/frontend/ide/components/ide_review_spec.js @@ -1,14 +1,19 @@ import Vue from 'vue'; +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; import IdeReview from '~/ide/components/ide_review.vue'; +import EditorModeDropdown from '~/ide/components/editor_mode_dropdown.vue'; import { createStore } from '~/ide/stores'; -import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { trimText } from '../../helpers/text_helper'; +import { keepAlive } from '../../helpers/keep_alive_component_helper'; import { file } from '../helpers'; import { projectData } from '../mock_data'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('IDE review mode', () => { - const Component = Vue.extend(IdeReview); - let vm; + let wrapper; let store; beforeEach(() => { @@ -21,15 +26,53 @@ describe('IDE review mode', () => { loading: false, }); - vm = createComponentWithStore(Component, store).$mount(); + wrapper = mount(keepAlive(IdeReview), { + store, + localVue, + }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders list of files', () => { - expect(vm.$el.textContent).toContain('fileName'); + expect(wrapper.text()).toContain('fileName'); + }); + + describe('activated', () => { + let inititializeSpy; + + beforeEach(async () => { + inititializeSpy = jest.spyOn(wrapper.find(IdeReview).vm, 'initialize'); + store.state.viewer = 'editor'; + + await wrapper.vm.reactivate(); + }); + + it('re initializes the component', () => { + expect(inititializeSpy).toHaveBeenCalled(); + }); + + it('updates viewer to "diff" by default', () => { + expect(store.state.viewer).toBe('diff'); + }); + + describe('merge request is defined', () => { + beforeEach(async () => { + store.state.currentMergeRequestId = '1'; + store.state.projects.abcproject.mergeRequests['1'] = { + iid: 123, + web_url: 'testing123', + }; + + await wrapper.vm.reactivate(); + }); + + it('updates viewer to "mrdiff"', async () => { + expect(store.state.viewer).toBe('mrdiff'); + }); + }); }); describe('merge request', () => { @@ -40,32 +83,27 @@ describe('IDE review mode', () => { web_url: 'testing123', }; - return vm.$nextTick(); + return wrapper.vm.$nextTick(); }); it('renders edit dropdown', () => { - expect(vm.$el.querySelector('.btn')).not.toBe(null); + expect(wrapper.find(EditorModeDropdown).exists()).toBe(true); }); - it('renders merge request link & IID', () => { + it('renders merge request link & IID', async () => { store.state.viewer = 'mrdiff'; - return vm.$nextTick(() => { - const link = vm.$el.querySelector('.ide-review-sub-header'); + await wrapper.vm.$nextTick(); - expect(link.querySelector('a').getAttribute('href')).toBe('testing123'); - expect(trimText(link.textContent)).toBe('Merge request (!123)'); - }); + expect(trimText(wrapper.text())).toContain('Merge request (!123)'); }); - it('changes text to latest changes when viewer is not mrdiff', () => { + it('changes text to latest changes when viewer is not mrdiff', async () => { store.state.viewer = 'diff'; - return vm.$nextTick(() => { - expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe( - 'Latest changes', - ); - }); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain('Latest changes'); }); }); }); diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js index 67257b40879..86e4e8d8f89 100644 --- a/spec/frontend/ide/components/ide_side_bar_spec.js +++ b/spec/frontend/ide/components/ide_side_bar_spec.js @@ -1,57 +1,88 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlSkeletonLoading } from '@gitlab/ui'; import { createStore } from '~/ide/stores'; -import ideSidebar from '~/ide/components/ide_side_bar.vue'; +import IdeSidebar from '~/ide/components/ide_side_bar.vue'; +import IdeTree from '~/ide/components/ide_tree.vue'; +import RepoCommitSection from '~/ide/components/repo_commit_section.vue'; import { leftSidebarViews } from '~/ide/constants'; import { projectData } from '../mock_data'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('IdeSidebar', () => { - let vm; + let wrapper; let store; - beforeEach(() => { + function createComponent() { store = createStore(); - const Component = Vue.extend(ideSidebar); - store.state.currentProjectId = 'abcproject'; store.state.projects.abcproject = projectData; - vm = createComponentWithStore(Component, store).$mount(); - }); + return mount(IdeSidebar, { + store, + localVue, + }); + } afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); it('renders a sidebar', () => { - expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull(); + wrapper = createComponent(); + + expect(wrapper.find('[data-testid="ide-side-bar-inner"]').exists()).toBe(true); }); - it('renders loading icon component', done => { - vm.$store.state.loading = true; + it('renders loading components', async () => { + wrapper = createComponent(); - vm.$nextTick(() => { - expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull(); - expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3); + store.state.loading = true; - done(); - }); + await wrapper.vm.$nextTick(); + + expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(3); }); describe('activityBarComponent', () => { it('renders tree component', () => { - expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull(); + wrapper = createComponent(); + + expect(wrapper.find(IdeTree).exists()).toBe(true); }); - it('renders commit component', done => { - vm.$store.state.currentActivityView = leftSidebarViews.commit.name; + it('renders commit component', async () => { + wrapper = createComponent(); + + store.state.currentActivityView = leftSidebarViews.commit.name; - vm.$nextTick(() => { - expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull(); + await wrapper.vm.$nextTick(); - done(); - }); + expect(wrapper.find(RepoCommitSection).exists()).toBe(true); }); }); + + it('keeps the current activity view components alive', async () => { + wrapper = createComponent(); + + const ideTreeComponent = wrapper.find(IdeTree).element; + + store.state.currentActivityView = leftSidebarViews.commit.name; + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(IdeTree).exists()).toBe(false); + expect(wrapper.find(RepoCommitSection).exists()).toBe(true); + + store.state.currentActivityView = leftSidebarViews.edit.name; + + await wrapper.vm.$nextTick(); + + // reference to the elements remains the same, meaning the components were kept alive + expect(wrapper.find(IdeTree).element).toEqual(ideTreeComponent); + }); }); diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js index 4593ef6049b..dd57a5c5f4d 100644 --- a/spec/frontend/ide/components/ide_tree_list_spec.js +++ b/spec/frontend/ide/components/ide_tree_list_spec.js @@ -38,15 +38,9 @@ describe('IDE tree list', () => { beforeEach(() => { bootstrapWithTree(); - jest.spyOn(vm, 'updateViewer'); - vm.$mount(); }); - it('updates viewer on mount', () => { - expect(vm.updateViewer).toHaveBeenCalledWith('edit'); - }); - it('renders loading indicator', done => { store.state.trees['abcproject/master'].loading = true; @@ -67,8 +61,6 @@ describe('IDE tree list', () => { beforeEach(() => { bootstrapWithTree(emptyBranchTree); - jest.spyOn(vm, 'updateViewer'); - vm.$mount(); }); diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js index 899daa0bf57..ad00dec2e48 100644 --- a/spec/frontend/ide/components/ide_tree_spec.js +++ b/spec/frontend/ide/components/ide_tree_spec.js @@ -1,19 +1,22 @@ import Vue from 'vue'; +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; import IdeTree from '~/ide/components/ide_tree.vue'; import { createStore } from '~/ide/stores'; -import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { keepAlive } from '../../helpers/keep_alive_component_helper'; import { file } from '../helpers'; import { projectData } from '../mock_data'; -describe('IdeRepoTree', () => { +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('IdeTree', () => { let store; - let vm; + let wrapper; beforeEach(() => { store = createStore(); - const IdeRepoTree = Vue.extend(IdeTree); - store.state.currentProjectId = 'abcproject'; store.state.currentBranchId = 'master'; store.state.projects.abcproject = { ...projectData }; @@ -22,14 +25,36 @@ describe('IdeRepoTree', () => { loading: false, }); - vm = createComponentWithStore(IdeRepoTree, store).$mount(); + wrapper = mount(keepAlive(IdeTree), { + store, + localVue, + }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders list of files', () => { - expect(vm.$el.textContent).toContain('fileName'); + expect(wrapper.text()).toContain('fileName'); + }); + + describe('activated', () => { + let inititializeSpy; + + beforeEach(async () => { + inititializeSpy = jest.spyOn(wrapper.find(IdeTree).vm, 'initialize'); + store.state.viewer = 'diff'; + + await wrapper.vm.reactivate(); + }); + + it('re initializes the component', () => { + expect(inititializeSpy).toHaveBeenCalled(); + }); + + it('updates viewer to "editor" by default', () => { + expect(store.state.viewer).toBe('editor'); + }); }); }); diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js index 3b837622720..096079308cd 100644 --- a/spec/frontend/ide/components/repo_commit_section_spec.js +++ b/spec/frontend/ide/components/repo_commit_section_spec.js @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils'; import { createStore } from '~/ide/stores'; import { createRouter } from '~/ide/ide_router'; +import { keepAlive } from '../../helpers/keep_alive_component_helper'; import RepoCommitSection from '~/ide/components/repo_commit_section.vue'; import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue'; import { stageKeys } from '~/ide/constants'; @@ -14,7 +15,7 @@ describe('RepoCommitSection', () => { let store; function createComponent() { - wrapper = mount(RepoCommitSection, { store }); + wrapper = mount(keepAlive(RepoCommitSection), { store }); } function setupDefaultState() { @@ -64,6 +65,7 @@ describe('RepoCommitSection', () => { afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('empty state', () => { @@ -168,4 +170,21 @@ describe('RepoCommitSection', () => { expect(wrapper.find(EmptyState).exists()).toBe(false); }); }); + + describe('activated', () => { + let inititializeSpy; + + beforeEach(async () => { + createComponent(); + + inititializeSpy = jest.spyOn(wrapper.find(RepoCommitSection).vm, 'initialize'); + store.state.viewer = 'diff'; + + await wrapper.vm.reactivate(); + }); + + it('re initializes the component', () => { + expect(inititializeSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 307806e0a8a..1b98d488854 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -5,7 +5,6 @@ import { GlTable, GlAvatar, GlPagination, - GlSearchBoxByType, GlTab, GlTabs, GlBadge, @@ -15,13 +14,24 @@ import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility'; import IncidentsList from '~/incidents/components/incidents_list.vue'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { I18N, INCIDENT_STATUS_TABS } from '~/incidents/constants'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import { + I18N, + INCIDENT_STATUS_TABS, + TH_CREATED_AT_TEST_ID, + TH_SEVERITY_TEST_ID, + TH_PUBLISHED_TEST_ID, +} from '~/incidents/constants'; import mockIncidents from '../mocks/incidents.json'; +import mockFilters from '../mocks/incidents_filter.json'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), - joinPaths: jest.fn().mockName('joinPaths'), - mergeUrlParams: jest.fn().mockName('mergeUrlParams'), + joinPaths: jest.fn(), + mergeUrlParams: jest.fn(), + setUrlParams: jest.fn(), + updateHistory: jest.fn(), })); describe('Incidents List', () => { @@ -41,9 +51,7 @@ describe('Incidents List', () => { const findAlert = () => wrapper.find(GlAlert); const findLoader = () => wrapper.find(GlLoadingIcon); const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip); - const findDateColumnHeader = () => - wrapper.find('[data-testid="incident-management-created-at-sort"]'); - const findSearch = () => wrapper.find(GlSearchBoxByType); + const findSearch = () => wrapper.find(FilteredSearchBar); const findAssingees = () => wrapper.findAll('[data-testid="incident-assignees"]'); const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']"); @@ -73,9 +81,13 @@ describe('Incidents List', () => { newIssuePath, incidentTemplateName, incidentType, - issuePath: '/project/isssues', + issuePath: '/project/issues', publishedAvailable: true, emptyListSvgPath, + textQuery: '', + authorUsernamesQuery: '', + assigneeUsernamesQuery: '', + issuesIncidentDetails: false, }, stubs: { GlButton: true, @@ -171,13 +183,6 @@ describe('Incidents List', () => { expect(src).toBe(avatarUrl); }); - it('contains a link to the issue details', () => { - findTableRows() - .at(0) - .trigger('click'); - expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/isssues/`, mockIncidents[0].iid)); - }); - it('renders a closed icon for closed incidents', () => { expect(findClosedIcon().length).toBe( mockIncidents.filter(({ state }) => state === 'closed').length, @@ -188,6 +193,30 @@ describe('Incidents List', () => { it('renders severity per row', () => { expect(findSeverity().length).toBe(mockIncidents.length); }); + + it('contains a link to the issue details page', () => { + findTableRows() + .at(0) + .trigger('click'); + expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/issues/`, mockIncidents[0].iid)); + }); + + it('contains a link to the incident details page', async () => { + beforeEach(() => + mountComponent({ + data: { incidents: { list: mockIncidents }, incidentsCount: {} }, + loading: false, + provide: { glFeatures: { issuesIncidentDetails: true } }, + }), + ); + + findTableRows() + .at(0) + .trigger('click'); + expect(visitUrl).toHaveBeenCalledWith( + joinPaths(`/project/issues/incident`, mockIncidents[0].iid), + ); + }); }); describe('Create Incident', () => { @@ -207,11 +236,10 @@ describe('Incidents List', () => { ); }); - it('sets button loading on click', () => { + it('sets button loading on click', async () => { findCreateIncidentBtn().vm.$emit('click'); - return wrapper.vm.$nextTick().then(() => { - expect(findCreateIncidentBtn().attributes('loading')).toBe('true'); - }); + await wrapper.vm.$nextTick(); + expect(findCreateIncidentBtn().attributes('loading')).toBe('true'); }); it("doesn't show the button when list is empty", () => { @@ -243,51 +271,47 @@ describe('Incidents List', () => { }); describe('prevPage', () => { - it('returns prevPage button', () => { + it('returns prevPage button', async () => { findPagination().vm.$emit('input', 3); - return wrapper.vm.$nextTick(() => { - expect( - findPagination() - .findAll('.page-item') - .at(0) - .text(), - ).toBe('Prev'); - }); + await wrapper.vm.$nextTick(); + expect( + findPagination() + .findAll('.page-item') + .at(0) + .text(), + ).toBe('Prev'); }); - it('returns prevPage number', () => { + it('returns prevPage number', async () => { findPagination().vm.$emit('input', 3); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.prevPage).toBe(2); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.prevPage).toBe(2); }); - it('returns 0 when it is the first page', () => { + it('returns 0 when it is the first page', async () => { findPagination().vm.$emit('input', 1); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.prevPage).toBe(0); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.prevPage).toBe(0); }); }); describe('nextPage', () => { - it('returns nextPage button', () => { + it('returns nextPage button', async () => { findPagination().vm.$emit('input', 3); - return wrapper.vm.$nextTick(() => { - expect( - findPagination() - .findAll('.page-item') - .at(1) - .text(), - ).toBe('Next'); - }); + await wrapper.vm.$nextTick(); + expect( + findPagination() + .findAll('.page-item') + .at(1) + .text(), + ).toBe('Next'); }); - it('returns nextPage number', () => { + it('returns nextPage number', async () => { mountComponent({ data: { incidents: { @@ -301,21 +325,19 @@ describe('Incidents List', () => { }); findPagination().vm.$emit('input', 1); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.nextPage).toBe(2); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.nextPage).toBe(2); }); - it('returns `null` when currentPage is already last page', () => { + it('returns `null` when currentPage is already last page', async () => { findStatusTabs().vm.$emit('input', 1); findPagination().vm.$emit('input', 1); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.nextPage).toBeNull(); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.nextPage).toBeNull(); }); }); - describe('Search', () => { + describe('Filtered search component', () => { beforeEach(() => { mountComponent({ data: { @@ -331,15 +353,62 @@ describe('Incidents List', () => { }); it('renders the search component for incidents', () => { - expect(findSearch().exists()).toBe(true); + expect(findSearch().props('searchInputPlaceholder')).toBe('Search or filter results…'); + expect(findSearch().props('tokens')).toEqual([ + { + type: 'author_username', + icon: 'user', + title: 'Author', + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchPath: '/project/path', + fetchAuthors: expect.any(Function), + }, + { + type: 'assignee_username', + icon: 'user', + title: 'Assignees', + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchPath: '/project/path', + fetchAuthors: expect.any(Function), + }, + ]); + expect(findSearch().props('recentSearchesStorageKey')).toBe('incidents'); + }); + + it('returns correctly applied filter search values', async () => { + const searchTerm = 'foo'; + wrapper.setData({ + searchTerm, + }); + + await wrapper.vm.$nextTick(); + expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]); }); - it('sets the `searchTerm` graphql variable', () => { - const SEARCH_TERM = 'Simple Incident'; + it('updates props tied to getIncidents GraphQL query', () => { + wrapper.vm.handleFilterIncidents(mockFilters); - findSearch().vm.$emit('input', SEARCH_TERM); + expect(wrapper.vm.authorUsername).toBe('root'); + expect(wrapper.vm.assigneeUsernames).toEqual('root2'); + expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data); + }); - expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM); + it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => { + wrapper.setData({ + authorUsername: 'foo', + searchTerm: 'bar', + }); + + wrapper.vm.handleFilterIncidents([]); + + expect(wrapper.vm.authorUsername).toBe(''); + expect(wrapper.vm.searchTerm).toBe(''); }); }); @@ -383,13 +452,25 @@ describe('Incidents List', () => { }); }); - it('updates sort with new direction and column key', () => { - expect(findDateColumnHeader().attributes('aria-sort')).toBe('descending'); + const descSort = 'descending'; + const ascSort = 'ascending'; + const noneSort = 'none'; - findDateColumnHeader().trigger('click'); - return wrapper.vm.$nextTick(() => { - expect(findDateColumnHeader().attributes('aria-sort')).toBe('ascending'); - }); + it.each` + selector | initialSort | firstSort | nextSort + ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort} + ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort} + `('updates sort with new direction', async ({ selector, initialSort, firstSort, nextSort }) => { + const [[attr, value]] = Object.entries(selector); + const columnHeader = () => wrapper.find(`[${attr}="${value}"]`); + expect(columnHeader().attributes('aria-sort')).toBe(initialSort); + columnHeader().trigger('click'); + await wrapper.vm.$nextTick(); + expect(columnHeader().attributes('aria-sort')).toBe(firstSort); + columnHeader().trigger('click'); + await wrapper.vm.$nextTick(); + expect(columnHeader().attributes('aria-sort')).toBe(nextSort); }); }); }); diff --git a/spec/frontend/incidents/mocks/incidents_filter.json b/spec/frontend/incidents/mocks/incidents_filter.json new file mode 100644 index 00000000000..9f54e259b1d --- /dev/null +++ b/spec/frontend/incidents/mocks/incidents_filter.json @@ -0,0 +1,14 @@ + [ + { + "type": "assignee_username", + "value": { "data": "root2" } + }, + { + "type": "author_username", + "value": { "data": "root" } + }, + { + "type": "filtered-search-term", + "value": { "data": "bar" } + } + ]
\ No newline at end of file diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap index cab2165b5db..e4620590e62 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -93,23 +93,20 @@ exports[`Alert integration settings form default state should match the default </gl-form-checkbox-stub> </gl-form-group-stub> - <div - class="gl-display-flex gl-justify-content-end" + <gl-button-stub + buttontextclasses="" + category="primary" + class="js-no-auto-disable" + data-qa-selector="save_changes_button" + icon="" + size="medium" + type="submit" + variant="success" > - <gl-button-stub - category="primary" - class="js-no-auto-disable" - data-qa-selector="save_changes_button" - icon="" - size="medium" - type="submit" - variant="success" - > - - Save changes - </gl-button-stub> - </div> + Save changes + + </gl-button-stub> </form> </div> `; 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 3ad4c13382d..53c3e131466 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 @@ -18,6 +18,7 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` </h4> <gl-button-stub + buttontextclasses="" category="primary" class="js-settings-toggle" icon="" diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index 78bb238fcb6..ea2c512bf40 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -42,24 +42,21 @@ exports[`Alert integration settings form should match the default snapshot 1`] = /> </div> - <div - class="gl-display-flex gl-justify-content-end" + <gl-button-stub + buttontextclasses="" + category="primary" + class="gl-mt-3" + data-testid="webhook-reset-btn" + icon="" + role="button" + size="medium" + tabindex="0" + variant="default" > - <gl-button-stub - category="primary" - class="gl-mt-3" - data-testid="webhook-reset-btn" - icon="" - role="button" - size="medium" - tabindex="0" - variant="default" - > - - Reset webhook URL - </gl-button-stub> - </div> + Reset webhook URL + + </gl-button-stub> <gl-modal-stub modalclass="" @@ -76,22 +73,19 @@ exports[`Alert integration settings form should match the default snapshot 1`] = </gl-modal-stub> </gl-form-group-stub> - <div - class="gl-display-flex gl-justify-content-end" + <gl-button-stub + buttontextclasses="" + category="primary" + class="js-no-auto-disable" + icon="" + size="medium" + type="submit" + variant="success" > - <gl-button-stub - category="primary" - class="js-no-auto-disable" - icon="" - size="medium" - type="submit" - variant="success" - > - - Save changes - </gl-button-stub> - </div> + Save changes + + </gl-button-stub> </form> </div> `; diff --git a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js new file mode 100644 index 00000000000..02f311f579f --- /dev/null +++ b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js @@ -0,0 +1,51 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import { createStore } from '~/integrations/edit/store'; + +import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; + +describe('ConfirmationModal', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(ConfirmationModal, { + store: createStore(), + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findGlModal = () => wrapper.find(GlModal); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlModal with correct copy', () => { + expect(findGlModal().exists()).toBe(true); + expect(findGlModal().attributes('title')).toBe('Save settings?'); + expect(findGlModal().text()).toContain( + 'Saving will update the default settings for all projects that are not using custom settings.', + ); + expect(findGlModal().text()).toContain( + 'Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.', + ); + }); + + it('emits `submit` event when `primary` event is emitted on GlModal', async () => { + expect(wrapper.emitted().submit).toBeUndefined(); + + findGlModal().vm.$emit('primary'); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted().submit).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index eeb5d21d62c..efcc727277a 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -4,6 +4,7 @@ import { createStore } from '~/integrations/edit/store'; import IntegrationForm from '~/integrations/edit/components/integration_form.vue'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; +import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; @@ -22,6 +23,7 @@ describe('IntegrationForm', () => { stubs: { OverrideDropdown, ActiveCheckbox, + ConfirmationModal, JiraTriggerFields, TriggerFields, }, @@ -40,6 +42,7 @@ describe('IntegrationForm', () => { const findOverrideDropdown = () => wrapper.find(OverrideDropdown); const findActiveCheckbox = () => wrapper.find(ActiveCheckbox); + const findConfirmationModal = () => wrapper.find(ConfirmationModal); const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields); const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields); const findTriggerFields = () => wrapper.find(TriggerFields); @@ -63,6 +66,26 @@ describe('IntegrationForm', () => { }); }); + describe('integrationLevel is instance', () => { + it('renders ConfirmationModal', () => { + createComponent({ + integrationLevel: 'instance', + }); + + expect(findConfirmationModal().exists()).toBe(true); + }); + }); + + describe('integrationLevel is not instance', () => { + it('does not render ConfirmationModal', () => { + createComponent({ + integrationLevel: 'project', + }); + + expect(findConfirmationModal().exists()).toBe(false); + }); + }); + describe('type is "slack"', () => { beforeEach(() => { createComponent({ type: 'slack' }); diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js index 821972b7698..27ba0768331 100644 --- a/spec/frontend/integrations/edit/mock_data.js +++ b/spec/frontend/integrations/edit/mock_data.js @@ -2,6 +2,7 @@ export const mockIntegrationProps = { id: 25, initialActivated: true, showActive: true, + editable: true, triggerFieldsProps: { initialTriggerCommit: false, initialTriggerMergeRequest: false, diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js new file mode 100644 index 00000000000..0be0fbbde2d --- /dev/null +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -0,0 +1,115 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink } from '@gitlab/ui'; +import Api from '~/api'; +import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; + +const groupId = '1'; +const groupName = 'testgroup'; +const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }; +const defaultAccessLevel = '10'; +const helpLink = 'https://example.com'; + +const createComponent = () => { + return shallowMount(InviteMembersModal, { + propsData: { + groupId, + groupName, + accessLevels, + defaultAccessLevel, + helpLink, + }, + stubs: { + GlSprintf, + 'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>', + }, + }); +}; + +describe('InviteMembersModal', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findDatepicker = () => wrapper.find(GlDatepicker); + const findLink = () => wrapper.find(GlLink); + const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); + const findInviteButton = () => wrapper.find({ ref: 'inviteButton' }); + + describe('rendering the modal', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the modal with the correct title', () => { + expect(wrapper.attributes('title')).toBe('Invite team members'); + }); + + it('renders the Cancel button text correctly', () => { + expect(findCancelButton().text()).toBe('Cancel'); + }); + + it('renders the Invite button text correctly', () => { + expect(findInviteButton().text()).toBe('Invite'); + }); + + describe('rendering the access levels dropdown', () => { + it('sets the default dropdown text to the default access level name', () => { + expect(findDropdown().attributes('text')).toBe('Guest'); + }); + + it('renders dropdown items for each accessLevel', () => { + expect(findDropdownItems()).toHaveLength(5); + }); + }); + + describe('rendering the help link', () => { + it('renders the correct link', () => { + expect(findLink().attributes('href')).toBe(helpLink); + }); + }); + + describe('rendering the access expiration date field', () => { + it('renders the datepicker', () => { + expect(findDatepicker()).toExist(); + }); + }); + }); + + describe('submitting the invite form', () => { + const postData = { + user_id: '1', + access_level: '10', + expires_at: new Date(), + format: 'json', + }; + + beforeEach(() => { + wrapper = createComponent(); + + jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData }); + wrapper.vm.$toast = { show: jest.fn() }; + + wrapper.vm.submitForm(postData); + }); + + it('calls Api inviteGroupMember with the correct params', () => { + expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData); + }); + + describe('when the invite was sent successfully', () => { + const toastMessageSuccessful = 'Users were succesfully added'; + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( + toastMessageSuccessful, + wrapper.vm.toastOptions, + ); + }); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js new file mode 100644 index 00000000000..450d37a9748 --- /dev/null +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -0,0 +1,58 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlLink } from '@gitlab/ui'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; + +const displayText = 'Invite team members'; +const icon = 'plus'; + +const createComponent = (props = {}) => { + return shallowMount(InviteMembersTrigger, { + propsData: { + displayText, + ...props, + }, + }); +}; + +describe('InviteMembersTrigger', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('displayText', () => { + const findLink = () => wrapper.find(GlLink); + + beforeEach(() => { + wrapper = createComponent(); + }); + + it('includes the correct displayText for the link', () => { + expect(findLink().text()).toBe(displayText); + }); + }); + + describe('icon', () => { + const findIcon = () => wrapper.find(GlIcon); + + it('includes the correct icon when an icon is sent', () => { + wrapper = createComponent({ icon }); + + expect(findIcon().attributes('name')).toBe(icon); + }); + + it('does not include an icon when icon is not sent', () => { + wrapper = createComponent(); + + expect(findIcon().exists()).toBe(false); + }); + + it('does not include an icon when empty string is sent', () => { + wrapper = createComponent({ icon: '' }); + + expect(findIcon().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js index bfbe4ec8e70..17a195df494 100644 --- a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js +++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js @@ -48,7 +48,10 @@ describe('AddIssuableForm', () => { const input = findFormInput(wrapper); if (input) input.blur(); - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); describe('with data', () => { diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js index 553721fa783..f2cb9042ba6 100644 --- a/spec/frontend/issuable/related_issues/components/issue_token_spec.js +++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js @@ -1,241 +1,146 @@ -import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import { PathIdSeparator } from '~/related_issues/constants'; -import issueToken from '~/related_issues/components/issue_token.vue'; +import IssueToken from '~/related_issues/components/issue_token.vue'; describe('IssueToken', () => { const idKey = 200; const displayReference = 'foo/bar#123'; - const title = 'some title'; - const pathIdSeparator = PathIdSeparator.Issue; const eventNamespace = 'pendingIssuable'; - let IssueToken; - let vm; + const path = '/foo/bar/issues/123'; + const pathIdSeparator = PathIdSeparator.Issue; + const title = 'some title'; - beforeEach(() => { - IssueToken = Vue.extend(issueToken); - }); + let wrapper; + + const defaultProps = { + idKey, + displayReference, + pathIdSeparator, + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(IssueToken, { + propsData: { ...defaultProps, ...props }, + }); + }; afterEach(() => { - if (vm) { - vm.$destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; } }); + const findLink = () => wrapper.find({ ref: 'link' }); + const findReference = () => wrapper.find({ ref: 'reference' }); + const findReferenceIcon = () => wrapper.find('[data-testid="referenceIcon"]'); + const findRemoveBtn = () => wrapper.find('[data-testid="removeBtn"]'); + const findTitle = () => wrapper.find({ ref: 'title' }); + describe('with reference supplied', () => { beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - }, - }).$mount(); + createComponent(); }); it('shows reference', () => { - expect(vm.$el.textContent.trim()).toEqual(displayReference); + expect(wrapper.text()).toContain(displayReference); }); it('does not link without path specified', () => { - expect(vm.$refs.link.tagName.toLowerCase()).toEqual('span'); - expect(vm.$refs.link.getAttribute('href')).toBeNull(); + expect(findLink().element.tagName).toBe('SPAN'); + expect(findLink().attributes('href')).toBeUndefined(); }); }); describe('with reference and title supplied', () => { - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - title, - }, - }).$mount(); - }); - it('shows reference and title', () => { - expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference); - expect(vm.$refs.title.textContent.trim()).toEqual(title); - }); - }); - - describe('with path supplied', () => { - const path = '/foo/bar/issues/123'; - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - title, - path, - }, - }).$mount(); - }); + createComponent({ + title, + }); - it('links reference and title', () => { - expect(vm.$refs.link.getAttribute('href')).toEqual(path); + expect(findReference().text()).toBe(displayReference); + expect(findTitle().text()).toBe(title); }); }); - describe('with state supplied', () => { - describe("`state: 'opened'`", () => { - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - state: 'opened', - }, - }).$mount(); + describe('with path and title supplied', () => { + it('links reference and title', () => { + createComponent({ + path, + title, }); - it('shows green circle icon', () => { - expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined(); - }); - }); - - describe("`state: 'reopened'`", () => { - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - state: 'reopened', - }, - }).$mount(); - }); - - it('shows green circle icon', () => { - expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined(); - }); + expect(findLink().attributes('href')).toBe(path); }); + }); - describe("`state: 'closed'`", () => { - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - state: 'closed', - }, - }).$mount(); + describe('with state supplied', () => { + it.each` + state | icon | cssClass + ${'opened'} | ${'issue-open-m'} | ${'issue-token-state-icon-open'} + ${'reopened'} | ${'issue-open-m'} | ${'issue-token-state-icon-open'} + ${'closed'} | ${'issue-close'} | ${'issue-token-state-icon-closed'} + `('shows "$icon" icon when "$state"', ({ state, icon, cssClass }) => { + createComponent({ + path, + state, }); - it('shows red minus icon', () => { - expect(vm.$el.querySelector('.issue-token-state-icon-closed.fa.fa-minus')).toBeDefined(); - }); + expect(findReferenceIcon().props('name')).toBe(icon); + expect(findReferenceIcon().classes()).toContain(cssClass); }); }); describe('with reference, title, state', () => { const state = 'opened'; - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - title, - state, - }, - }).$mount(); - }); it('shows reference, title, and state', () => { - const stateIcon = vm.$refs.reference.querySelector('svg'); + createComponent({ + title, + state, + }); - expect(stateIcon.getAttribute('aria-label')).toEqual(state); - expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference); - expect(vm.$refs.title.textContent.trim()).toEqual(title); + expect(findReferenceIcon().attributes('aria-label')).toBe(state); + expect(findReference().text()).toBe(displayReference); + expect(findTitle().text()).toBe(title); }); }); describe('with canRemove', () => { describe('`canRemove: false` (default)', () => { - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - }, - }).$mount(); - }); - it('does not have remove button', () => { - expect(vm.$el.querySelector('.issue-token-remove-button')).toBeNull(); + createComponent(); + + expect(findRemoveBtn().exists()).toBe(false); }); }); describe('`canRemove: true`', () => { beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - canRemove: true, - }, - }).$mount(); + createComponent({ + eventNamespace, + canRemove: true, + }); }); it('has remove button', () => { - expect(vm.$el.querySelector('.issue-token-remove-button')).toBeDefined(); + expect(findRemoveBtn().exists()).toBe(true); }); - }); - }); - - describe('methods', () => { - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - }, - }).$mount(); - }); - it('when getting checked', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.onRemoveRequest(); + it('emits event when clicked', () => { + findRemoveBtn().trigger('click'); - expect(vm.$emit).toHaveBeenCalledWith('pendingIssuableRemoveRequest', vm.idKey); - }); - }); + const emitted = wrapper.emitted(`${eventNamespace}RemoveRequest`); - describe('tooltip', () => { - beforeEach(() => { - vm = new IssueToken({ - propsData: { - idKey, - eventNamespace, - displayReference, - pathIdSeparator, - canRemove: true, - }, - }).$mount(); - }); - - it('should not be escaped', () => { - const { originalTitle } = vm.$refs.removeButton.dataset; + expect(emitted).toHaveLength(1); + expect(emitted[0]).toEqual([idKey]); + }); - expect(originalTitle).toEqual(`Remove ${displayReference}`); + it('tooltip should not be escaped', () => { + expect(findRemoveBtn().attributes('data-original-title')).toBe( + `Remove ${displayReference}`, + ); + }); }); }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js index 0f88e4d71fe..b758b85beef 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js @@ -18,7 +18,10 @@ describe('RelatedIssuesBlock', () => { const findIssueCountBadgeAddButton = () => wrapper.find(GlButton); afterEach(() => { - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); describe('with defaults', () => { diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js index 6cf0b9d21ea..39bc244297b 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js @@ -14,7 +14,10 @@ describe('RelatedIssuesList', () => { let wrapper; afterEach(() => { - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); describe('with defaults', () => { diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/issuable_create/components/issuable_form_spec.js index e2c6b4d9521..e489d1dae3e 100644 --- a/spec/frontend/issuable_create/components/issuable_form_spec.js +++ b/spec/frontend/issuable_create/components/issuable_form_spec.js @@ -79,6 +79,7 @@ describe('IssuableForm', () => { markdownDocsPath: wrapper.vm.descriptionHelpPath, addSpacingClasses: false, showSuggestPopover: true, + textareaValue: '', }); expect(descriptionFieldEl.find('textarea').exists()).toBe(true); expect(descriptionFieldEl.find('textarea').attributes('placeholder')).toBe( diff --git a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js index 8d50df5e406..766a27015bb 100644 --- a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js +++ b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js @@ -9,6 +9,7 @@ describe('Highlight Bar', () => { let wrapper; const alert = { + iid: 1, startedAt: '2020-05-29T10:39:22Z', detailsUrl: 'http://127.0.0.1:3000/root/unique-alerts/-/alert_management/1/details', eventCount: 1, @@ -39,7 +40,8 @@ describe('Highlight Bar', () => { it('renders a link to the alert page', () => { expect(findLink().exists()).toBe(true); expect(findLink().attributes('href')).toBe(alert.detailsUrl); - expect(findLink().text()).toContain(alert.title); + expect(findLink().attributes('title')).toBe(alert.title); + expect(findLink().text()).toBe(`#${alert.iid}`); }); it('renders formatted start time of the alert', () => { diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js index a51b497cd79..6babba37b57 100644 --- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js @@ -79,7 +79,7 @@ describe('Incident Tabs component', () => { it('renders the alert details table with the correct props', () => { const alert = { iid: mockAlert.iid }; - expect(findAlertDetailsComponent().props('alert')).toEqual(alert); + expect(findAlertDetailsComponent().props('alert')).toMatchObject(alert); expect(findAlertDetailsComponent().props('loading')).toBe(true); }); diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js index befb670c6cd..c0175e774a2 100644 --- a/spec/frontend/issue_show/issue_spec.js +++ b/spec/frontend/issue_show/issue_spec.js @@ -14,12 +14,8 @@ useMockIntersectionObserver(); jest.mock('~/lib/utils/poll'); const setupHTML = initialData => { - document.body.innerHTML = ` - <div id="js-issuable-app"></div> - <script id="js-issuable-app-initial-data" type="application/json"> - ${JSON.stringify(initialData)} - </script> - `; + document.body.innerHTML = `<div id="js-issuable-app"></div>`; + document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(initialData); }; describe('Issue show index', () => { diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 294f88bbc74..e50d304bb08 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -35,6 +35,14 @@ describe('Jobs Store Utils', () => { lines: [], }); }); + + it('pre-closes a section when specified in options', () => { + const headerLine = { content: [{ text: 'foo' }], section_options: { collapsed: 'true' } }; + + const parsedHeaderLine = parseHeaderLine(headerLine, 2); + + expect(parsedHeaderLine.isClosed).toBe(true); + }); }); describe('parseLine', () => { diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js new file mode 100644 index 00000000000..ee1971a4931 --- /dev/null +++ b/spec/frontend/lib/dompurify_spec.js @@ -0,0 +1,98 @@ +import { sanitize } from '~/lib/dompurify'; + +// GDK +const rootGon = { + sprite_file_icons: '/assets/icons-123a.svg', + sprite_icons: '/assets/icons-456b.svg', +}; + +// Production +const absoluteGon = { + sprite_file_icons: `${window.location.protocol}//${window.location.hostname}/assets/icons-123a.svg`, + sprite_icons: `${window.location.protocol}//${window.location.hostname}/assets/icons-456b.svg`, +}; + +const expectedSanitized = '<svg><use></use></svg>'; + +const safeUrls = { + root: Object.values(rootGon).map(url => `${url}#ellipsis_h`), + absolute: Object.values(absoluteGon).map(url => `${url}#ellipsis_h`), +}; + +const unsafeUrls = [ + '/an/evil/url', + '../../../evil/url', + 'https://evil.url/assets/icons-123a.svg', + 'https://evil.url/assets/icons-456b.svg', + `https://evil.url/${rootGon.sprite_icons}`, + `https://evil.url/${rootGon.sprite_file_icons}`, + `https://evil.url/${absoluteGon.sprite_icons}`, + `https://evil.url/${absoluteGon.sprite_file_icons}`, +]; + +describe('~/lib/dompurify', () => { + let originalGon; + + it('uses local configuration when given', () => { + // As dompurify uses a "Persistent Configuration", it might + // ignore config, this check verifies we respect + // https://github.com/cure53/DOMPurify#persistent-configuration + expect(sanitize('<br>', { ALLOWED_TAGS: [] })).toBe(''); + expect(sanitize('<strong></strong>', { ALLOWED_TAGS: [] })).toBe(''); + }); + + describe.each` + type | gon + ${'root'} | ${rootGon} + ${'absolute'} | ${absoluteGon} + `('when gon contains $type icon urls', ({ type, gon }) => { + beforeAll(() => { + originalGon = window.gon; + window.gon = gon; + }); + + afterAll(() => { + window.gon = originalGon; + }); + + it('allows no href attrs', () => { + const htmlHref = `<svg><use></use></svg>`; + expect(sanitize(htmlHref)).toBe(htmlHref); + }); + + it.each(safeUrls[type])('allows safe URL %s', url => { + const htmlHref = `<svg><use href="${url}"></use></svg>`; + expect(sanitize(htmlHref)).toBe(htmlHref); + + const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`; + expect(sanitize(htmlXlink)).toBe(htmlXlink); + }); + + it.each(unsafeUrls)('sanitizes unsafe URL %s', url => { + const htmlHref = `<svg><use href="${url}"></use></svg>`; + const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`; + + expect(sanitize(htmlHref)).toBe(expectedSanitized); + expect(sanitize(htmlXlink)).toBe(expectedSanitized); + }); + }); + + describe('when gon does not contain icon urls', () => { + beforeAll(() => { + originalGon = window.gon; + window.gon = {}; + }); + + afterAll(() => { + window.gon = originalGon; + }); + + it.each([...safeUrls.root, ...safeUrls.absolute, ...unsafeUrls])('sanitizes URL %s', url => { + const htmlHref = `<svg><use href="${url}"></use></svg>`; + const htmlXlink = `<svg><use xlink:href="${url}"></use></svg>`; + + expect(sanitize(htmlHref)).toBe(expectedSanitized); + expect(sanitize(htmlXlink)).toBe(expectedSanitized); + }); + }); +}); diff --git a/spec/frontend/lib/utils/axios_startup_calls_spec.js b/spec/frontend/lib/utils/axios_startup_calls_spec.js index e804cae7914..e12bf725560 100644 --- a/spec/frontend/lib/utils/axios_startup_calls_spec.js +++ b/spec/frontend/lib/utils/axios_startup_calls_spec.js @@ -111,21 +111,44 @@ describe('setupAxiosStartupCalls', () => { }); }); - it('removes GitLab Base URL from startup call', async () => { - const oldGon = window.gon; - window.gon = { gitlab_url: 'https://example.org/gitlab' }; - - window.gl.startup_calls = { - '/startup': { - fetchCall: mockFetchCall(200), - }, - }; - setupAxiosStartupCalls(axios); + describe('startup call', () => { + let oldGon; + + beforeEach(() => { + oldGon = window.gon; + window.gon = { gitlab_url: 'https://example.org/gitlab' }; + }); + + afterEach(() => { + window.gon = oldGon; + }); - const { data } = await axios.get('https://example.org/gitlab/startup'); + it('removes GitLab Base URL from startup call', async () => { + window.gl.startup_calls = { + '/startup': { + fetchCall: mockFetchCall(200), + }, + }; + setupAxiosStartupCalls(axios); - expect(data).toEqual(STARTUP_JS_RESPONSE); + const { data } = await axios.get('https://example.org/gitlab/startup'); - window.gon = oldGon; + expect(data).toEqual(STARTUP_JS_RESPONSE); + }); + + it('sorts the params in the requested API url', async () => { + window.gl.startup_calls = { + '/startup?alpha=true&bravo=true': { + fetchCall: mockFetchCall(200), + }, + }; + setupAxiosStartupCalls(axios); + + // Use a full url instead of passing options = { params: { ... } } to axios.get + // to ensure the params are listed in the specified order. + const { data } = await axios.get('https://example.org/gitlab/startup?bravo=true&alpha=true'); + + expect(data).toEqual(STARTUP_JS_RESPONSE); + }); }); }); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 5b1fdea058b..a7973d66b50 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -667,3 +667,26 @@ describe('differenceInMilliseconds', () => { expect(datetimeUtility.differenceInMilliseconds(startDate, endDate)).toBe(expected); }); }); + +describe('dateAtFirstDayOfMonth', () => { + const date = new Date('2019-07-16T12:00:00.000Z'); + + it('returns the date at the first day of the month', () => { + const startDate = datetimeUtility.dateAtFirstDayOfMonth(date); + const expectedStartDate = new Date('2019-07-01T12:00:00.000Z'); + + expect(startDate).toStrictEqual(expectedStartDate); + }); +}); + +describe('datesMatch', () => { + const date = new Date('2019-07-17T00:00:00.000Z'); + + it.each` + date1 | date2 | expected + ${date} | ${new Date('2019-07-17T00:00:00.000Z')} | ${true} + ${date} | ${new Date('2019-07-17T12:00:00.000Z')} | ${false} + `('returns $expected for $date1 matches $date2', ({ date1, date2, expected }) => { + expect(datetimeUtility.datesMatch(date1, date2)).toBe(expected); + }); +}); diff --git a/spec/frontend/lib/utils/experimentation_spec.js b/spec/frontend/lib/utils/experimentation_spec.js new file mode 100644 index 00000000000..2c5d2f89297 --- /dev/null +++ b/spec/frontend/lib/utils/experimentation_spec.js @@ -0,0 +1,20 @@ +import * as experimentUtils from '~/lib/utils/experimentation'; + +const TEST_KEY = 'abc'; + +describe('experiment Utilities', () => { + describe('isExperimentEnabled', () => { + it.each` + experiments | value + ${{ [TEST_KEY]: true }} | ${true} + ${{ [TEST_KEY]: false }} | ${false} + ${{ def: true }} | ${false} + ${{}} | ${false} + ${null} | ${false} + `('returns correct value of $value for experiments=$experiments', ({ experiments, value }) => { + window.gon = { experiments }; + + expect(experimentUtils.isExperimentEnabled(TEST_KEY)).toEqual(value); + }); + }); +}); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 869ae274a3f..2afc1694281 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -664,6 +664,19 @@ describe('URL utility', () => { }); }); + describe('cleanLeadingSeparator', () => { + it.each` + path | expected + ${'/foo/bar'} | ${'foo/bar'} + ${'foo/bar'} | ${'foo/bar'} + ${'//foo/bar'} | ${'foo/bar'} + ${'/./foo/bar'} | ${'./foo/bar'} + ${''} | ${''} + `('$path becomes $expected', ({ path, expected }) => { + expect(urlUtils.cleanLeadingSeparator(path)).toBe(expected); + }); + }); + describe('joinPaths', () => { it.each` paths | expected @@ -688,6 +701,18 @@ describe('URL utility', () => { }); }); + describe('stripFinalUrlSegment', () => { + it.each` + path | expected + ${'http://fake.domain/twitter/typeahead-js/-/tags/v0.11.0'} | ${'http://fake.domain/twitter/typeahead-js/-/tags/'} + ${'http://fake.domain/bar/cool/-/nested/content'} | ${'http://fake.domain/bar/cool/-/nested/'} + ${'http://fake.domain/bar/cool?q="search"'} | ${'http://fake.domain/bar/'} + ${'http://fake.domain/bar/cool#link-to-something'} | ${'http://fake.domain/bar/'} + `('stripFinalUrlSegment $path => $expected', ({ path, expected }) => { + expect(urlUtils.stripFinalUrlSegment(path)).toBe(expected); + }); + }); + describe('escapeFileUrl', () => { it('encodes URL excluding the slashes', () => { expect(urlUtils.escapeFileUrl('/foo-bar/file.md')).toBe('/foo-bar/file.md'); @@ -787,4 +812,36 @@ describe('URL utility', () => { expect(urlUtils.getHTTPProtocol(url)).toBe(expectation); }); }); + + describe('stripPathTail', () => { + it.each` + path | expected + ${''} | ${''} + ${'index.html'} | ${''} + ${'/'} | ${'/'} + ${'/foo/bar'} | ${'/foo/'} + ${'/foo/bar/'} | ${'/foo/bar/'} + ${'/foo/bar/index.html'} | ${'/foo/bar/'} + `('strips the filename from $path => $expected', ({ path, expected }) => { + expect(urlUtils.stripPathTail(path)).toBe(expected); + }); + }); + + describe('getURLOrigin', () => { + it('when no url passed, returns correct origin from window location', () => { + const origin = 'https://foo.bar'; + + setWindowLocation({ origin }); + expect(urlUtils.getURLOrigin()).toBe(origin); + }); + + it.each` + url | expectation + ${'not-a-url'} | ${null} + ${'wss://example.com'} | ${'wss://example.com'} + ${'https://foo.bar/foo/bar'} | ${'https://foo.bar'} + `('returns correct origin for $url', ({ url, expectation }) => { + expect(urlUtils.getURLOrigin(url)).toBe(expectation); + }); + }); }); diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js index 16f04d032fd..37509f77f71 100644 --- a/spec/frontend/merge_request_spec.js +++ b/spec/frontend/merge_request_spec.js @@ -3,8 +3,6 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; import MergeRequest from '~/merge_request'; -import CloseReopenReportToggle from '~/close_reopen_report_toggle'; -import IssuablesHelper from '~/helpers/issuables_helper'; describe('MergeRequest', () => { const test = {}; @@ -112,66 +110,7 @@ describe('MergeRequest', () => { }); }); - describe('class constructor', () => { - beforeEach(() => { - jest.spyOn($, 'ajax').mockImplementation(); - }); - - it('calls .initCloseReopenReport', () => { - jest.spyOn(IssuablesHelper, 'initCloseReopenReport').mockImplementation(() => {}); - - new MergeRequest(); // eslint-disable-line no-new - - expect(IssuablesHelper.initCloseReopenReport).toHaveBeenCalled(); - }); - - it('calls .initDroplab', () => { - const container = { - querySelector: jest.fn().mockName('container.querySelector'), - }; - const dropdownTrigger = {}; - const dropdownList = {}; - const button = {}; - - jest.spyOn(CloseReopenReportToggle.prototype, 'initDroplab').mockImplementation(() => {}); - jest.spyOn(document, 'querySelector').mockReturnValue(container); - - container.querySelector - .mockReturnValueOnce(dropdownTrigger) - .mockReturnValueOnce(dropdownList) - .mockReturnValueOnce(button); - - new MergeRequest(); // eslint-disable-line no-new - - expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown'); - expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle'); - expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu'); - expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button'); - expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled(); - }); - }); - describe('hideCloseButton', () => { - describe('merge request of another user', () => { - beforeEach(() => { - loadFixtures('merge_requests/merge_request_with_task_list.html'); - test.el = document.querySelector('.js-issuable-actions'); - new MergeRequest(); // eslint-disable-line no-new - MergeRequest.hideCloseButton(); - }); - - it('hides the dropdown close item and selects the next item', () => { - const closeItem = test.el.querySelector('li.close-item'); - const smallCloseItem = test.el.querySelector('.js-close-item'); - const reportItem = test.el.querySelector('li.report-item'); - - expect(closeItem).toHaveClass('hidden'); - expect(smallCloseItem).toHaveClass('hidden'); - expect(reportItem).toHaveClass('droplab-item-selected'); - expect(reportItem).not.toHaveClass('hidden'); - }); - }); - describe('merge request of current_user', () => { beforeEach(() => { loadFixtures('merge_requests/merge_request_of_current_user.html'); @@ -180,10 +119,8 @@ describe('MergeRequest', () => { }); it('hides the close button', () => { - const closeButton = test.el.querySelector('.btn-close'); const smallCloseItem = test.el.querySelector('.js-close-item'); - expect(closeButton).toHaveClass('hidden'); expect(smallCloseItem).toHaveClass('hidden'); }); }); diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap index c30fb572826..9b2aa3a5b5b 100644 --- a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap @@ -1,79 +1,146 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`GroupEmptyState Renders an empty state for BAD_QUERY 1`] = ` -<gl-empty-state-stub - compact="true" - primarybuttonlink="/path/to/settings" - primarybuttontext="Verify configuration" - svgpath="/path/to/empty-group-illustration.svg" - title="Query cannot be processed" -/> +exports[`GroupEmptyState given state BAD_QUERY passes the expected props to GlEmptyState 1`] = ` +Object { + "compact": true, + "description": null, + "primaryButtonLink": "/path/to/settings", + "primaryButtonText": "Verify configuration", + "secondaryButtonLink": null, + "secondaryButtonText": null, + "svgHeight": null, + "svgPath": "/path/to/empty-group-illustration.svg", + "title": "Query cannot be processed", +} `; -exports[`GroupEmptyState Renders an empty state for BAD_QUERY 2`] = `"The Prometheus server responded with \\"bad request\\". Please check your queries are correct and are supported in your Prometheus version. <a href=\\"/path/to/docs\\">More information</a>"`; +exports[`GroupEmptyState given state BAD_QUERY renders the slotted content 1`] = ` +<div> + <div> + The Prometheus server responded with "bad request". Please check your queries are correct and are supported in your Prometheus version. + <a + href="/path/to/docs" + > + More information + </a> + </div> +</div> +`; -exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 1`] = ` -<gl-empty-state-stub - compact="true" - description="We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating." - primarybuttonlink="/path/to/settings" - primarybuttontext="Verify configuration" - svgpath="/path/to/empty-group-illustration.svg" - title="Connection failed" -/> +exports[`GroupEmptyState given state CONNECTION_FAILED passes the expected props to GlEmptyState 1`] = ` +Object { + "compact": true, + "description": "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating.", + "primaryButtonLink": "/path/to/settings", + "primaryButtonText": "Verify configuration", + "secondaryButtonLink": null, + "secondaryButtonText": null, + "svgHeight": null, + "svgPath": "/path/to/empty-group-illustration.svg", + "title": "Connection failed", +} `; -exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 2`] = `undefined`; +exports[`GroupEmptyState given state CONNECTION_FAILED renders the slotted content 1`] = `<div />`; -exports[`GroupEmptyState Renders an empty state for FOO STATE 1`] = ` -<gl-empty-state-stub - compact="true" - description="An error occurred while loading the data. Please try again." - svgpath="/path/to/empty-group-illustration.svg" - title="An error has occurred" -/> +exports[`GroupEmptyState given state FOO STATE passes the expected props to GlEmptyState 1`] = ` +Object { + "compact": true, + "description": "An error occurred while loading the data. Please try again.", + "primaryButtonLink": null, + "primaryButtonText": null, + "secondaryButtonLink": null, + "secondaryButtonText": null, + "svgHeight": null, + "svgPath": "/path/to/empty-group-illustration.svg", + "title": "An error has occurred", +} `; -exports[`GroupEmptyState Renders an empty state for FOO STATE 2`] = `undefined`; +exports[`GroupEmptyState given state FOO STATE renders the slotted content 1`] = `<div />`; -exports[`GroupEmptyState Renders an empty state for LOADING 1`] = ` -<gl-empty-state-stub - compact="true" - description="Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available." - svgpath="/path/to/empty-group-illustration.svg" - title="Waiting for performance data" -/> +exports[`GroupEmptyState given state LOADING passes the expected props to GlEmptyState 1`] = ` +Object { + "compact": true, + "description": "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.", + "primaryButtonLink": null, + "primaryButtonText": null, + "secondaryButtonLink": null, + "secondaryButtonText": null, + "svgHeight": null, + "svgPath": "/path/to/empty-group-illustration.svg", + "title": "Waiting for performance data", +} `; -exports[`GroupEmptyState Renders an empty state for LOADING 2`] = `undefined`; +exports[`GroupEmptyState given state LOADING renders the slotted content 1`] = `<div />`; -exports[`GroupEmptyState Renders an empty state for NO_DATA 1`] = ` -<gl-empty-state-stub - compact="true" - svgpath="/path/to/empty-group-illustration.svg" - title="No data to display" -/> +exports[`GroupEmptyState given state NO_DATA passes the expected props to GlEmptyState 1`] = ` +Object { + "compact": true, + "description": null, + "primaryButtonLink": null, + "primaryButtonText": null, + "secondaryButtonLink": null, + "secondaryButtonText": null, + "svgHeight": null, + "svgPath": "/path/to/empty-group-illustration.svg", + "title": "No data to display", +} `; -exports[`GroupEmptyState Renders an empty state for NO_DATA 2`] = `"The data source is connected, but there is no data to display. <a href=\\"/path/to/docs\\">More information</a>"`; +exports[`GroupEmptyState given state NO_DATA renders the slotted content 1`] = ` +<div> + <div> + The data source is connected, but there is no data to display. + <a + href="/path/to/docs" + > + More information + </a> + </div> +</div> +`; -exports[`GroupEmptyState Renders an empty state for TIMEOUT 1`] = ` -<gl-empty-state-stub - compact="true" - svgpath="/path/to/empty-group-illustration.svg" - title="Connection timed out" -/> +exports[`GroupEmptyState given state TIMEOUT passes the expected props to GlEmptyState 1`] = ` +Object { + "compact": true, + "description": null, + "primaryButtonLink": null, + "primaryButtonText": null, + "secondaryButtonLink": null, + "secondaryButtonText": null, + "svgHeight": null, + "svgPath": "/path/to/empty-group-illustration.svg", + "title": "Connection timed out", +} `; -exports[`GroupEmptyState Renders an empty state for TIMEOUT 2`] = `"Charts can't be displayed as the request for data has timed out. <a href=\\"/path/to/docs\\">More information</a>"`; +exports[`GroupEmptyState given state TIMEOUT renders the slotted content 1`] = ` +<div> + <div> + Charts can't be displayed as the request for data has timed out. + <a + href="/path/to/docs" + > + More information + </a> + </div> +</div> +`; -exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 1`] = ` -<gl-empty-state-stub - compact="true" - description="An error occurred while loading the data. Please try again." - svgpath="/path/to/empty-group-illustration.svg" - title="An error has occurred" -/> +exports[`GroupEmptyState given state UNKNOWN_ERROR passes the expected props to GlEmptyState 1`] = ` +Object { + "compact": true, + "description": "An error occurred while loading the data. Please try again.", + "primaryButtonLink": null, + "primaryButtonText": null, + "secondaryButtonLink": null, + "secondaryButtonText": null, + "svgHeight": null, + "svgPath": "/path/to/empty-group-illustration.svg", + "title": "An error has occurred", +} `; -exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 2`] = `undefined`; +exports[`GroupEmptyState given state UNKNOWN_ERROR renders the slotted content 1`] = `<div />`; diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js index 90bd6f67196..3b94c4c6806 100644 --- a/spec/frontend/monitoring/components/group_empty_state_spec.js +++ b/spec/frontend/monitoring/components/group_empty_state_spec.js @@ -1,7 +1,13 @@ +import { GlEmptyState } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; import { metricStates } from '~/monitoring/constants'; +const MockGlEmptyState = { + props: GlEmptyState.props, + template: '<div><slot name="description"></slot></div>', +}; + function createComponent(props) { return shallowMount(GroupEmptyState, { propsData: { @@ -10,11 +16,20 @@ function createComponent(props) { settingsPath: '/path/to/settings', svgPath: '/path/to/empty-group-illustration.svg', }, + stubs: { + GlEmptyState: MockGlEmptyState, + }, }); } describe('GroupEmptyState', () => { - const supportedStates = [ + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each([ metricStates.NO_DATA, metricStates.TIMEOUT, metricStates.CONNECTION_FAILED, @@ -22,13 +37,17 @@ describe('GroupEmptyState', () => { metricStates.LOADING, metricStates.UNKNOWN_ERROR, 'FOO STATE', // does not fail with unknown states - ]; + ])('given state %s', selectedState => { + beforeEach(() => { + wrapper = createComponent({ selectedState }); + }); - it.each(supportedStates)('Renders an empty state for %s', selectedState => { - const wrapper = createComponent({ selectedState }); + it('renders the slotted content', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - expect(wrapper.element).toMatchSnapshot(); - // slot is not rendered by the stub, test it separately - expect(wrapper.vm.currentState.slottedDescription).toMatchSnapshot(); + it('passes the expected props to GlEmptyState', () => { + expect(wrapper.find(MockGlEmptyState).props()).toMatchSnapshot(); + }); }); }); diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js index affd6c1d1d2..d82590c7e9e 100644 --- a/spec/frontend/notes/components/discussion_counter_spec.js +++ b/spec/frontend/notes/components/discussion_counter_spec.js @@ -1,6 +1,6 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import notesModule from '~/notes/stores/modules'; import DiscussionCounter from '~/notes/components/discussion_counter.vue'; import { noteableDataMock, discussionMock, notesDataMock, userDataMock } from '../mock_data'; @@ -9,6 +9,7 @@ import * as types from '~/notes/stores/mutation_types'; describe('DiscussionCounter component', () => { let store; let wrapper; + let setExpandDiscussionsFn; const localVue = createLocalVue(); localVue.use(Vuex); @@ -16,6 +17,7 @@ describe('DiscussionCounter component', () => { beforeEach(() => { window.mrTabs = {}; const { state, getters, mutations, actions } = notesModule(); + setExpandDiscussionsFn = jest.fn().mockImplementation(actions.setExpandDiscussions); store = new Vuex.Store({ state: { @@ -24,7 +26,10 @@ describe('DiscussionCounter component', () => { }, getters, mutations, - actions, + actions: { + ...actions, + setExpandDiscussions: setExpandDiscussionsFn, + }, }); store.dispatch('setNoteableData', { ...noteableDataMock, @@ -84,7 +89,7 @@ describe('DiscussionCounter component', () => { wrapper = shallowMount(DiscussionCounter, { store, localVue }); expect(wrapper.find(`.is-active`).exists()).toBe(isActive); - expect(wrapper.findAll('[role="group"').length).toBe(groupLength); + expect(wrapper.findAll(GlButton)).toHaveLength(groupLength); }); }); @@ -103,23 +108,22 @@ describe('DiscussionCounter component', () => { it('calls button handler when clicked', () => { updateStoreWithExpanded(true); - wrapper.setMethods({ handleExpandDiscussions: jest.fn() }); - toggleAllButton.trigger('click'); + toggleAllButton.vm.$emit('click'); - expect(wrapper.vm.handleExpandDiscussions).toHaveBeenCalledTimes(1); + expect(setExpandDiscussionsFn).toHaveBeenCalledTimes(1); }); it('collapses all discussions if expanded', () => { updateStoreWithExpanded(true); expect(wrapper.vm.allExpanded).toBe(true); - expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up'); + expect(toggleAllButton.props('icon')).toBe('angle-up'); - toggleAllButton.trigger('click'); + toggleAllButton.vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.allExpanded).toBe(false); - expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down'); + expect(toggleAllButton.props('icon')).toBe('angle-down'); }); }); @@ -127,13 +131,13 @@ describe('DiscussionCounter component', () => { updateStoreWithExpanded(false); expect(wrapper.vm.allExpanded).toBe(false); - expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down'); + expect(toggleAllButton.props('icon')).toBe('angle-down'); - toggleAllButton.trigger('click'); + toggleAllButton.vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.allExpanded).toBe(true); - expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up'); + expect(toggleAllButton.props('icon')).toBe('angle-up'); }); }); }); diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js index 91ff796b9de..e3e3518fd31 100644 --- a/spec/frontend/notes/components/discussion_filter_spec.js +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -74,13 +74,15 @@ describe('DiscussionFilter component', () => { }); it('renders the all filters', () => { - expect(wrapper.findAll('.dropdown-menu li').length).toBe(discussionFiltersMock.length); + expect(wrapper.findAll('.discussion-filter-container .dropdown-item').length).toBe( + discussionFiltersMock.length, + ); }); it('renders the default selected item', () => { expect( wrapper - .find('#discussion-filter-dropdown') + .find('#discussion-filter-dropdown .dropdown-item') .text() .trim(), ).toBe(discussionFiltersMock[0].title); @@ -88,7 +90,7 @@ describe('DiscussionFilter component', () => { it('updates to the selected item', () => { const filterItem = wrapper.find( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, + `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"]`, ); filterItem.trigger('click'); @@ -98,7 +100,9 @@ describe('DiscussionFilter component', () => { it('only updates when selected filter changes', () => { wrapper - .find(`.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`) + .find( + `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"]`, + ) .trigger('click'); expect(filterDiscussion).not.toHaveBeenCalled(); @@ -106,7 +110,7 @@ describe('DiscussionFilter component', () => { it('disables commenting when "Show history only" filter is applied', () => { const filterItem = wrapper.find( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, + `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"]`, ); filterItem.trigger('click'); @@ -115,7 +119,7 @@ describe('DiscussionFilter component', () => { it('enables commenting when "Show history only" filter is not applied', () => { const filterItem = wrapper.find( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`, + `.discussion-filter-container .dropdown-item[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"]`, ); filterItem.trigger('click'); @@ -124,10 +128,10 @@ describe('DiscussionFilter component', () => { it('renders a dropdown divider for the default filter', () => { const defaultFilter = wrapper.findAll( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] > *`, + `.discussion-filter-container .dropdown-item-wrapper > *`, ); - expect(defaultFilter.at(defaultFilter.length - 1).classes('dropdown-divider')).toBe(true); + expect(defaultFilter.at(1).classes('gl-new-dropdown-divider')).toBe(true); }); describe('Merge request tabs', () => { diff --git a/spec/frontend/notes/components/sort_discussion_spec.js b/spec/frontend/notes/components/sort_discussion_spec.js index 575f1057db2..49b85d60a27 100644 --- a/spec/frontend/notes/components/sort_discussion_spec.js +++ b/spec/frontend/notes/components/sort_discussion_spec.js @@ -55,7 +55,7 @@ describe('Sort Discussion component', () => { it('calls the right actions', () => { createComponent(); - wrapper.find('.js-newest-first').trigger('click'); + wrapper.find('.js-newest-first').vm.$emit('click'); expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', DESC); expect(Tracking.event).toHaveBeenCalledWith(undefined, 'change_discussion_sort_direction', { @@ -67,7 +67,7 @@ describe('Sort Discussion component', () => { it('shows the "Oldest First" as the dropdown', () => { createComponent(); - expect(wrapper.find('.js-dropdown-text').text()).toBe('Oldest first'); + expect(wrapper.find('.js-dropdown-text').props('text')).toBe('Oldest first'); }); }); @@ -79,7 +79,7 @@ describe('Sort Discussion component', () => { describe('when the dropdown item is clicked', () => { it('calls the right actions', () => { - wrapper.find('.js-oldest-first').trigger('click'); + wrapper.find('.js-oldest-first').vm.$emit('click'); expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', ASC); expect(Tracking.event).toHaveBeenCalledWith(undefined, 'change_discussion_sort_direction', { @@ -87,13 +87,13 @@ describe('Sort Discussion component', () => { }); }); - it('applies the active class to the correct button in the dropdown', () => { - expect(wrapper.find('.js-newest-first').classes()).toContain('is-active'); + it('sets is-checked to true on the active button in the dropdown', () => { + expect(wrapper.find('.js-newest-first').props('isChecked')).toBe(true); }); }); it('shows the "Newest First" as the dropdown', () => { - expect(wrapper.find('.js-dropdown-text').text()).toBe('Newest first'); + expect(wrapper.find('.js-dropdown-text').props('text')).toBe('Newest first'); }); }); }); diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap index 4d9e0af1545..d317264bdae 100644 --- a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap @@ -2,151 +2,163 @@ exports[`PackageTitle renders with tags 1`] = ` <div - class="gl-display-flex gl-justify-content-space-between gl-py-3" + class="gl-display-flex gl-flex-direction-column" data-qa-selector="package_title" > <div - class="gl-flex-direction-column" + class="gl-display-flex gl-justify-content-space-between gl-py-3" > <div - class="gl-display-flex" + class="gl-flex-direction-column" > - <!----> - <div - class="gl-display-flex gl-flex-direction-column" + class="gl-display-flex" > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" - data-testid="title" - > - Test package - </h1> + <!----> <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + class="gl-display-flex gl-flex-direction-column" > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> + <h1 + class="gl-font-size-h1 gl-mt-3 gl-mb-2" + data-testid="title" + > + Test package + </h1> - <gl-sprintf-stub - message="v%{version} published %{timeAgo}" - /> + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + > + <gl-icon-stub + class="gl-mr-3" + name="eye" + size="16" + /> + + <gl-sprintf-stub + message="v%{version} published %{timeAgo}" + /> + </div> </div> </div> - </div> - - <div - class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" - > - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-type" - icon="package" - link="" - size="s" - text="maven" - /> - </div> - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-size" - icon="disk" - link="" - size="s" - text="300 bytes" - /> - </div> + <div - class="gl-display-flex gl-align-items-center gl-mr-5" + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" > - <package-tags-stub - hidelabel="true" - tagdisplaylimit="2" - tags="[object Object],[object Object],[object Object],[object Object]" - /> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-type" + icon="package" + link="" + size="s" + text="maven" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-size" + icon="disk" + link="" + size="s" + text="300 bytes" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <package-tags-stub + hidelabel="true" + tagdisplaylimit="2" + tags="[object Object],[object Object],[object Object],[object Object]" + /> + </div> </div> </div> + + <!----> </div> - <!----> + <p /> </div> `; exports[`PackageTitle renders without tags 1`] = ` <div - class="gl-display-flex gl-justify-content-space-between gl-py-3" + class="gl-display-flex gl-flex-direction-column" data-qa-selector="package_title" > <div - class="gl-flex-direction-column" + class="gl-display-flex gl-justify-content-space-between gl-py-3" > <div - class="gl-display-flex" + class="gl-flex-direction-column" > - <!----> - <div - class="gl-display-flex gl-flex-direction-column" + class="gl-display-flex" > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" - data-testid="title" - > - Test package - </h1> + <!----> <div - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + class="gl-display-flex gl-flex-direction-column" > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> + <h1 + class="gl-font-size-h1 gl-mt-3 gl-mb-2" + data-testid="title" + > + Test package + </h1> - <gl-sprintf-stub - message="v%{version} published %{timeAgo}" - /> + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + > + <gl-icon-stub + class="gl-mr-3" + name="eye" + size="16" + /> + + <gl-sprintf-stub + message="v%{version} published %{timeAgo}" + /> + </div> </div> </div> - </div> - - <div - class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" - > - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <metadata-item-stub - data-testid="package-type" - icon="package" - link="" - size="s" - text="maven" - /> - </div> + <div - class="gl-display-flex gl-align-items-center gl-mr-5" + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" > - <metadata-item-stub - data-testid="package-size" - icon="disk" - link="" - size="s" - text="300 bytes" - /> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-type" + icon="package" + link="" + size="s" + text="maven" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-size" + icon="disk" + link="" + size="s" + text="300 bytes" + /> + </div> </div> </div> + + <!----> </div> - <!----> + <p /> </div> `; diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js index 0e95ee4cfd3..06e5950eb5d 100644 --- a/spec/frontend/packages/details/store/getters_spec.js +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -69,7 +69,7 @@ describe('Getters PackageDetails Store', () => { const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`; const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`; - const pypiPipCommandStr = `pip install ${pypiPackage.name} --index-url ${registryUrl}`; + const pypiPipCommandStr = `pip install ${pypiPackage.name} --extra-index-url ${registryUrl}`; const composerRegistryIncludeStr = '{"type":"composer","url":"foo"}'; const composerPackageIncludeStr = JSON.stringify({ [packageWithoutBuildInfo.name]: packageWithoutBuildInfo.version, 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 6ff9376565a..794e583a487 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 @@ -1,457 +1,463 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`packages_list_app renders 1`] = ` -<b-tabs-stub - activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo" - class="gl-tabs" - contentclass=",gl-tab-content" - navclass="gl-tabs-nav" - nofade="true" - nonavstyle="true" - tag="div" -> - <template> - - <b-tab-stub - tag="div" - title="All" - titlelinkclass="gl-tab-nav-item" - > - <template> - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" +<div> + <package-title-stub + packagehelpurl="foo" + /> + + <b-tabs-stub + activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo" + class="gl-tabs" + contentclass=",gl-tab-content" + navclass="gl-tabs-nav" + nofade="true" + nonavstyle="true" + tag="div" + > + <template> + + <b-tab-stub + tag="div" + title="All" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" > <div - class="svg-250 svg-content" + class="col-12" > - <img - alt="There are no packages yet" - class="gl-max-w-full" - src="helpSvg" - /> + <div + class="svg-250 svg-content" + > + <img + alt="There are no packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> </div> - </div> - - <div - class="col-12" - > + <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="col-12" > - <h1 - class="h4" + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" > - There are no packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" + <h1 + class="h4" > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div> - <!----> + There are no packages yet + </h1> - <!----> + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> </div> </div> - </div> - </section> - </div> - </template> - </b-tab-stub> - <b-tab-stub - tag="div" - title="Composer" - titlelinkclass="gl-tab-nav-item" - > - <template> - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Composer" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" > <div - class="svg-250 svg-content" + class="col-12" > - <img - alt="There are no Composer packages yet" - class="gl-max-w-full" - src="helpSvg" - /> + <div + class="svg-250 svg-content" + > + <img + alt="There are no Composer packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> </div> - </div> - - <div - class="col-12" - > + <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="col-12" > - <h1 - class="h4" + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" > - There are no Composer packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" + <h1 + class="h4" > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div> - <!----> + There are no Composer packages yet + </h1> - <!----> + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> </div> </div> - </div> - </section> - </div> - </template> - </b-tab-stub> - <b-tab-stub - tag="div" - title="Conan" - titlelinkclass="gl-tab-nav-item" - > - <template> - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Conan" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" > <div - class="svg-250 svg-content" + class="col-12" > - <img - alt="There are no Conan packages yet" - class="gl-max-w-full" - src="helpSvg" - /> + <div + class="svg-250 svg-content" + > + <img + alt="There are no Conan packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> </div> - </div> - - <div - class="col-12" - > + <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="col-12" > - <h1 - class="h4" + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" > - There are no Conan packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" + <h1 + class="h4" > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div> - <!----> + There are no Conan packages yet + </h1> - <!----> + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> </div> </div> - </div> - </section> - </div> - </template> - </b-tab-stub> - <b-tab-stub - tag="div" - title="Maven" - titlelinkclass="gl-tab-nav-item" - > - <template> - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Maven" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" > <div - class="svg-250 svg-content" + class="col-12" > - <img - alt="There are no Maven packages yet" - class="gl-max-w-full" - src="helpSvg" - /> + <div + class="svg-250 svg-content" + > + <img + alt="There are no Maven packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> </div> - </div> - - <div - class="col-12" - > + <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="col-12" > - <h1 - class="h4" + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" > - There are no Maven packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" + <h1 + class="h4" > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div> - <!----> + There are no Maven packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> - <!----> + <div> + <!----> + + <!----> + </div> </div> </div> - </div> - </section> - </div> - </template> - </b-tab-stub> - <b-tab-stub - tag="div" - title="NPM" - titlelinkclass="gl-tab-nav-item" - > - <template> - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="NPM" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" > <div - class="svg-250 svg-content" + class="col-12" > - <img - alt="There are no NPM packages yet" - class="gl-max-w-full" - src="helpSvg" - /> + <div + class="svg-250 svg-content" + > + <img + alt="There are no NPM packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> </div> - </div> - - <div - class="col-12" - > + <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="col-12" > - <h1 - class="h4" + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" > - There are no NPM packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" + <h1 + class="h4" > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div> - <!----> + There are no NPM packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> - <!----> + <div> + <!----> + + <!----> + </div> </div> </div> - </div> - </section> - </div> - </template> - </b-tab-stub> - <b-tab-stub - tag="div" - title="NuGet" - titlelinkclass="gl-tab-nav-item" - > - <template> - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="NuGet" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" > <div - class="svg-250 svg-content" + class="col-12" > - <img - alt="There are no NuGet packages yet" - class="gl-max-w-full" - src="helpSvg" - /> + <div + class="svg-250 svg-content" + > + <img + alt="There are no NuGet packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> </div> - </div> - - <div - class="col-12" - > + <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="col-12" > - <h1 - class="h4" + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" > - There are no NuGet packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" + <h1 + class="h4" > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div> - <!----> + There are no NuGet packages yet + </h1> - <!----> + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> </div> </div> - </div> - </section> - </div> - </template> - </b-tab-stub> - <b-tab-stub - tag="div" - title="PyPi" - titlelinkclass="gl-tab-nav-item" - > - <template> - <div> - <section - class="row empty-state text-center" - > - <div - class="col-12" + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="PyPi" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" > <div - class="svg-250 svg-content" + class="col-12" > - <img - alt="There are no PyPi packages yet" - class="gl-max-w-full" - src="helpSvg" - /> + <div + class="svg-250 svg-content" + > + <img + alt="There are no PyPi packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> </div> - </div> - - <div - class="col-12" - > + <div - class="text-content gl-mx-auto gl-my-0 gl-p-5" + class="col-12" > - <h1 - class="h4" + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" > - There are no PyPi packages yet - </h1> - - <p> - Learn how to - <b-link-stub - class="gl-link" - event="click" - href="helpUrl" - routertag="a" - target="_blank" + <h1 + class="h4" > - publish and share your packages - </b-link-stub> - with GitLab. - </p> - - <div> - <!----> + There are no PyPi packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> - <!----> + <div> + <!----> + + <!----> + </div> </div> </div> - </div> - </section> - </div> - </template> - </b-tab-stub> - - <!----> - </template> - <template> - <div - class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end" - > - <package-filter-stub - class="mr-1" - /> + </section> + </div> + </template> + </b-tab-stub> - <package-sort-stub /> - </div> - </template> -</b-tabs-stub> + <!----> + </template> + <template> + <div + class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end" + > + <package-filter-stub + class="gl-mr-2" + /> + + <package-sort-stub /> + </div> + </template> + </b-tabs-stub> +</div> `; diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js index 19ff4290f50..217096f822a 100644 --- a/spec/frontend/packages/list/components/packages_list_app_spec.js +++ b/spec/frontend/packages/list/components/packages_list_app_spec.js @@ -36,6 +36,7 @@ describe('packages_list_app', () => { resourceId: 'project_id', emptyListIllustration: 'helpSvg', emptyListHelpUrl, + packageHelpUrl: 'foo', }, filterQuery, }, diff --git a/spec/frontend/packages/list/components/packages_title_spec.js b/spec/frontend/packages/list/components/packages_title_spec.js new file mode 100644 index 00000000000..5e9ebd8ecb0 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_title_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import PackageTitle from '~/packages/list/components/package_title.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list//constants'; + +describe('PackageTitle', () => { + let wrapper; + let store; + + const findTitleArea = () => wrapper.find(TitleArea); + const findMetadataItem = () => wrapper.find(MetadataItem); + + const mountComponent = (propsData = { packageHelpUrl: 'foo' }) => { + wrapper = shallowMount(PackageTitle, { + store, + propsData, + stubs: { + TitleArea, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('title area', () => { + it('exists', () => { + mountComponent(); + + expect(findTitleArea().exists()).toBe(true); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findTitleArea().props()).toMatchObject({ + title: LIST_TITLE_TEXT, + infoMessages: [{ text: LIST_INTRO_TEXT, link: 'foo' }], + }); + }); + }); + + describe.each` + packagesCount | exist | text + ${null} | ${false} | ${''} + ${undefined} | ${false} | ${''} + ${0} | ${true} | ${'0 Packages'} + ${1} | ${true} | ${'1 Package'} + ${2} | ${true} | ${'2 Packages'} + `('when packagesCount is $packagesCount metadata item', ({ packagesCount, exist, text }) => { + beforeEach(() => { + mountComponent({ packagesCount, packageHelpUrl: 'foo' }); + }); + + it(`is ${exist} that it exists`, () => { + expect(findMetadataItem().exists()).toBe(exist); + }); + + if (exist) { + it('has the correct props', () => { + expect(findMetadataItem().props()).toMatchObject({ + icon: 'package', + text, + }); + }); + } + }); +}); diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap index 6aaefed92d0..5faae5690db 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -52,27 +52,6 @@ exports[`packages_list_row renders 1`] = ` <!----> <div - class="gl-display-flex gl-align-items-center" - > - <gl-icon-stub - class="gl-ml-3 gl-mr-2 gl-min-w-0" - name="review-list" - size="16" - /> - - <gl-link-stub - class="gl-text-body gl-min-w-0" - data-testid="packages-row-project" - href="/foo/bar/baz" - > - <gl-truncate-stub - position="end" - text="foo/bar/baz" - /> - </gl-link-stub> - </div> - - <div class="d-flex align-items-center" data-testid="package-type" > @@ -86,6 +65,10 @@ exports[`packages_list_row renders 1`] = ` Maven </span> </div> + + <package-path-stub + path="foo/bar/baz" + /> </div> </div> </div> @@ -118,6 +101,7 @@ exports[`packages_list_row renders 1`] = ` > <gl-button-stub aria-label="Remove package" + buttontextclasses="" category="primary" data-testid="action-delete" icon="remove" diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap index 9a0c52cee47..acdf7c49ebd 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap @@ -32,7 +32,8 @@ exports[`publish_method renders 1`] = ` </gl-link-stub> <clipboard-button-stub - cssclass="gl-border-0 gl-py-0 gl-px-2" + category="tertiary" + size="small" text="sha-baz" title="Copy commit SHA" tooltipplacement="top" diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js index f4eabf7bb67..0d0ea4e2122 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; import PackageTags from '~/packages/shared/components/package_tags.vue'; +import PackagePath from '~/packages/shared/components/package_path.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { packageList } from '../../mock_data'; @@ -11,7 +12,7 @@ describe('packages_list_row', () => { const [packageWithoutTags, packageWithTags] = packageList; const findPackageTags = () => wrapper.find(PackageTags); - const findProjectLink = () => wrapper.find('[data-testid="packages-row-project"]'); + const findPackagePath = () => wrapper.find(PackagePath); const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]'); const findPackageType = () => wrapper.find('[data-testid="package-type"]'); @@ -63,8 +64,9 @@ describe('packages_list_row', () => { mountComponent({ isGroup: true }); }); - it('has project field', () => { - expect(findProjectLink().exists()).toBe(true); + it('has a package path component', () => { + expect(findPackagePath().exists()).toBe(true); + expect(findPackagePath().props()).toMatchObject({ path: 'foo/bar/baz' }); }); }); diff --git a/spec/frontend/packages/shared/components/package_path_spec.js b/spec/frontend/packages/shared/components/package_path_spec.js new file mode 100644 index 00000000000..40d455ac77c --- /dev/null +++ b/spec/frontend/packages/shared/components/package_path_spec.js @@ -0,0 +1,86 @@ +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import PackagePath from '~/packages/shared/components/package_path.vue'; + +describe('PackagePath', () => { + let wrapper; + + const mountComponent = (propsData = { path: 'foo' }) => { + wrapper = shallowMount(PackagePath, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const BASE_ICON = 'base-icon'; + const ROOT_LINK = 'root-link'; + const ROOT_CHEVRON = 'root-chevron'; + const ELLIPSIS_ICON = 'ellipsis-icon'; + const ELLIPSIS_CHEVRON = 'ellipsis-chevron'; + const LEAF_LINK = 'leaf-link'; + + const findItem = name => wrapper.find(`[data-testid="${name}"]`); + const findTooltip = w => getBinding(w.element, 'gl-tooltip'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + path | rootUrl | shouldExist | shouldNotExist + ${'foo/bar'} | ${'/foo/bar'} | ${[]} | ${[ROOT_CHEVRON, ELLIPSIS_ICON, ELLIPSIS_CHEVRON, LEAF_LINK]} + ${'foo/bar/baz'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK]} | ${[ELLIPSIS_ICON, ELLIPSIS_CHEVRON]} + ${'foo/bar/baz/baz2'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK, ELLIPSIS_ICON, ELLIPSIS_CHEVRON]} | ${[]} + ${'foo/bar/baz/baz2/bar2'} | ${'/foo/bar'} | ${[ROOT_CHEVRON, LEAF_LINK, ELLIPSIS_ICON, ELLIPSIS_CHEVRON]} | ${[]} + `('given path $path', ({ path, shouldExist, shouldNotExist, rootUrl }) => { + const pathPieces = path.split('/').slice(1); + const hasTooltip = shouldExist.includes(ELLIPSIS_ICON); + + beforeEach(() => { + mountComponent({ path }); + }); + + it('should have a base icon', () => { + expect(findItem(BASE_ICON).exists()).toBe(true); + }); + + it('should have a root link', () => { + const root = findItem(ROOT_LINK); + expect(root.exists()).toBe(true); + expect(root.attributes('href')).toBe(rootUrl); + }); + + if (hasTooltip) { + it('should have a tooltip', () => { + const tooltip = findTooltip(findItem(ELLIPSIS_ICON)); + expect(tooltip).toBeDefined(); + expect(tooltip.value).toMatchObject({ + title: path, + }); + }); + } + + if (shouldExist.length) { + it.each(shouldExist)(`should have %s`, element => { + expect(findItem(element).exists()).toBe(true); + }); + } + + if (shouldNotExist.length) { + it.each(shouldNotExist)(`should not have %s`, element => { + expect(findItem(element).exists()).toBe(false); + }); + } + + if (shouldExist.includes(LEAF_LINK)) { + it('the last link should be the last piece of the path', () => { + const leaf = findItem(LEAF_LINK); + expect(leaf.attributes('href')).toBe(`/${path}`); + expect(leaf.text()).toBe(pathPieces[pathPieces.length - 1]); + }); + } + }); +}); diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap index 2fbc700d4f5..ddeaa2a79db 100644 --- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap @@ -39,6 +39,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`] /> </form> <gl-button-stub + buttontextclasses="" category="primary" icon="" size="medium" @@ -48,6 +49,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`] </gl-button-stub> <gl-button-stub + buttontextclasses="" category="primary" disabled="true" icon="" @@ -60,6 +62,7 @@ exports[`User Operation confirmation modal renders modal with form included 1`] </gl-button-stub> <gl-button-stub + buttontextclasses="" category="primary" disabled="true" icon="" diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js index 5a61f9fca69..5da998d9d2d 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js @@ -1,23 +1,18 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; import PipelineSchedulesCallout from '~/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue'; -import '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; - -jest.mock( - '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg', - () => '<svg></svg>', -); const PipelineSchedulesCalloutComponent = Vue.extend(PipelineSchedulesCallout); const cookieKey = 'pipeline_schedules_callout_dismissed'; const docsUrl = 'help/ci/scheduled_pipelines'; +const imageUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg'; describe('Pipeline Schedule Callout', () => { let calloutComponent; beforeEach(() => { setFixtures(` - <div id='pipeline-schedules-callout' data-docs-url=${docsUrl}></div> + <div id='pipeline-schedules-callout' data-docs-url=${docsUrl} data-image-url=${imageUrl}></div> `); }); @@ -30,13 +25,13 @@ describe('Pipeline Schedule Callout', () => { expect(calloutComponent).toBeDefined(); }); - it('correctly sets illustrationSvg', () => { - expect(calloutComponent.illustrationSvg).toContain('<svg'); - }); - it('correctly sets docsUrl', () => { expect(calloutComponent.docsUrl).toContain(docsUrl); }); + + it('correctly sets imageUrl', () => { + expect(calloutComponent.imageUrl).toContain(imageUrl); + }); }); describe(`when ${cookieKey} cookie is set`, () => { @@ -68,8 +63,8 @@ describe('Pipeline Schedule Callout', () => { expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull(); }); - it('renders the callout svg', () => { - expect(calloutComponent.$el.outerHTML).toContain('<svg'); + it('renders the callout img', () => { + expect(calloutComponent.$el.outerHTML).toContain('<img'); }); it('renders the callout title', () => { diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index 55286e0ec7e..cdbd6d4437e 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -14,9 +14,9 @@ export const mockProjectId = '21'; export const mockPostParams = { ref: 'tag-1', - variables: [ - { key: 'test_var', value: 'test_var_val', variable_type: 'env_var' }, - { key: 'test_file', value: 'test_file_val', variable_type: 'file' }, + variables_attributes: [ + { key: 'test_var', secret_value: 'test_var_val', variable_type: 'env_var' }, + { key: 'test_file', secret_value: 'test_file_val', variable_type: 'file' }, ], }; diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js index 989f6c17197..08a43199594 100644 --- a/spec/frontend/pipelines/components/dag/dag_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_spec.js @@ -4,13 +4,8 @@ import Dag from '~/pipelines/components/dag/dag.vue'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; -import { - ADD_NOTE, - REMOVE_NOTE, - REPLACE_NOTES, - PARSE_FAILURE, - UNSUPPORTED_DATA, -} from '~/pipelines/components/dag//constants'; +import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '~/pipelines/components/dag/constants'; +import { PARSE_FAILURE, UNSUPPORTED_DATA } from '~/pipelines/constants'; import { mockParsedGraphQLNodes, tooSmallGraph, diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index d977db58a0e..062c9759a65 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -3,23 +3,27 @@ import { mount } from '@vue/test-utils'; import { setHTMLFixture } from 'helpers/fixtures'; import PipelineStore from '~/pipelines/stores/pipeline_store'; import graphComponent from '~/pipelines/components/graph/graph_component.vue'; -import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; +import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import graphJSON from './mock_data'; import linkedPipelineJSON from './linked_pipelines_mock_data'; import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; describe('graph component', () => { - const store = new PipelineStore(); - store.storePipeline(linkedPipelineJSON); - const mediator = new PipelinesMediator({ endpoint: '' }); - + let store; + let mediator; let wrapper; const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]'); + const findStageColumns = () => wrapper.findAll(StageColumnComponent); + const findStageColumnAt = i => findStageColumns().at(i); beforeEach(() => { + mediator = new PipelinesMediator({ endpoint: '' }); + store = new PipelineStore(); + store.storePipeline(linkedPipelineJSON); + setHTMLFixture('<div class="layout-page"></div>'); }); @@ -43,7 +47,7 @@ describe('graph component', () => { }); describe('with data', () => { - it('should render the graph', () => { + beforeEach(() => { wrapper = mount(graphComponent, { propsData: { isLoading: false, @@ -51,26 +55,17 @@ describe('graph component', () => { mediator, }, }); + }); + it('renders the graph', () => { expect(wrapper.find('.js-pipeline-graph').exists()).toBe(true); - - expect(wrapper.find(stageColumnComponent).classes()).toContain('no-margin'); - - expect( - wrapper - .findAll(stageColumnComponent) - .at(1) - .classes(), - ).toContain('left-margin'); - - expect(wrapper.find('.stage-column:nth-child(2) .build:nth-child(1)').classes()).toContain( - 'left-connector', - ); - expect(wrapper.find('.loading-icon').exists()).toBe(false); - expect(wrapper.find('.stage-column-list').exists()).toBe(true); }); + + it('renders columns in the graph', () => { + expect(findStageColumns()).toHaveLength(graphJSON.details.stages.length); + }); }); describe('when linked pipelines are present', () => { @@ -93,26 +88,26 @@ describe('graph component', () => { expect(wrapper.find('.fa-spinner').exists()).toBe(false); }); - it('should include the stage column list', () => { - expect(wrapper.find(stageColumnComponent).exists()).toBe(true); - }); - - it('should include the no-margin class on the first child if there is only one job', () => { - const firstStageColumnElement = wrapper.find(stageColumnComponent); - - expect(firstStageColumnElement.classes()).toContain('no-margin'); + it('should include the stage column', () => { + expect(findStageColumnAt(0).exists()).toBe(true); }); - it('should include the has-only-one-job class on the first child', () => { - const firstStageColumnElement = wrapper.find('.stage-column-list .stage-column'); - - expect(firstStageColumnElement.classes()).toContain('has-only-one-job'); + it('stage column should have no-margin, gl-mr-26, has-only-one-job classes if there is only one job', () => { + expect(findStageColumnAt(0).classes()).toEqual( + expect.arrayContaining(['no-margin', 'gl-mr-26', 'has-only-one-job']), + ); }); it('should include the left-margin class on the second child', () => { - const firstStageColumnElement = wrapper.find('.stage-column-list .stage-column:last-child'); + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); + }); - expect(firstStageColumnElement.classes()).toContain('left-margin'); + it('should include the left-connector class in the build of the second child', () => { + expect( + findStageColumnAt(1) + .find('.build:nth-child(1)') + .classes('left-connector'), + ).toBe(true); }); it('should include the js-has-linked-pipelines flag', () => { @@ -134,12 +129,7 @@ describe('graph component', () => { describe('stageConnectorClass', () => { it('it returns left-margin when there is a triggerer', () => { - expect( - wrapper - .findAll(stageColumnComponent) - .at(1) - .classes(), - ).toContain('left-margin'); + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); }); }); }); @@ -248,6 +238,16 @@ describe('graph component', () => { .catch(done.fail); }); }); + + describe('when column requests a refresh', () => { + beforeEach(() => { + findStageColumnAt(0).vm.$emit('refreshPipelineGraph'); + }); + + it('refreshPipelineGraph is emitted', () => { + expect(wrapper.emitted().refreshPipelineGraph).toHaveLength(1); + }); + }); }); }); }); @@ -268,7 +268,7 @@ describe('graph component', () => { it('should include the first column with a no margin', () => { const firstColumn = wrapper.find('.stage-column'); - expect(firstColumn.classes()).toContain('no-margin'); + expect(firstColumn.classes('no-margin')).toBe(true); }); it('should not render a linked pipelines column', () => { @@ -278,16 +278,11 @@ describe('graph component', () => { describe('stageConnectorClass', () => { it('it returns no-margin when no triggerer and there is one job', () => { - expect(wrapper.find(stageColumnComponent).classes()).toContain('no-margin'); + expect(findStageColumnAt(0).classes('no-margin')).toBe(true); }); it('it returns left-margin when no triggerer and not the first stage', () => { - expect( - wrapper - .findAll(stageColumnComponent) - .at(1) - .classes(), - ).toContain('left-margin'); + expect(findStageColumnAt(1).classes('left-margin')).toBe(true); }); }); }); @@ -302,12 +297,9 @@ describe('graph component', () => { }, }); - expect( - wrapper - .find('.stage-column:nth-child(2) .stage-name') - .text() - .trim(), - ).toEqual('Deploy <img src=x onerror=alert(document.domain)>'); + expect(findStageColumnAt(1).props('title')).toEqual( + 'Deploy <img src=x onerror=alert(document.domain)>', + ); }); }); }); diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index 5388d624d3c..2e10b0f068c 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -1,115 +1,164 @@ import { shallowMount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { + mockCancelledPipelineHeader, + mockFailedPipelineHeader, + mockRunningPipelineHeader, + mockSuccessfulPipelineHeader, +} from './mock_data'; +import axios from '~/lib/utils/axios_utils'; import HeaderComponent from '~/pipelines/components/header_component.vue'; -import CiHeader from '~/vue_shared/components/header_ci_component.vue'; -import eventHub from '~/pipelines/event_hub'; describe('Pipeline details header', () => { let wrapper; let glModalDirective; - - const threeWeeksAgo = new Date(); - threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + let mockAxios; const findDeleteModal = () => wrapper.find(GlModal); - - const defaultProps = { - pipeline: { - details: { - status: { - group: 'failed', - icon: 'status_failed', - label: 'failed', - text: 'failed', - details_path: 'path', - }, - }, - id: 123, - created_at: threeWeeksAgo.toISOString(), - user: { - web_url: 'path', - name: 'Foo', - username: 'foobar', - email: 'foo@bar.com', - avatar_url: 'link', - }, - retry_path: 'retry', - cancel_path: 'cancel', - delete_path: 'delete', + const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]'); + const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]'); + const findDeleteButton = () => wrapper.find('[data-testid="deletePipeline"]'); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const defaultProvideOptions = { + pipelineId: 14, + pipelineIid: 1, + paths: { + retry: '/retry', + cancel: '/cancel', + delete: '/delete', + fullProject: '/namespace/my-project', }, - isLoading: false, }; - const createComponent = (props = {}) => { + const createComponent = (pipelineMock = mockRunningPipelineHeader, { isLoading } = false) => { glModalDirective = jest.fn(); - wrapper = shallowMount(HeaderComponent, { - propsData: { - ...props, + const $apollo = { + queries: { + pipeline: { + loading: isLoading, + stopPolling: jest.fn(), + startPolling: jest.fn(), + }, + }, + }; + + return shallowMount(HeaderComponent, { + data() { + return { + pipeline: pipelineMock, + }; + }, + provide: { + ...defaultProvideOptions, }, directives: { glModal: { - bind(el, { value }) { + bind(_, { value }) { glModalDirective(value); }, }, }, + mocks: { $apollo }, }); }; beforeEach(() => { - jest.spyOn(eventHub, '$emit'); - - createComponent(defaultProps); + mockAxios = new MockAdapter(axios); + mockAxios.onGet('*').replyOnce(200); }); afterEach(() => { - eventHub.$off(); - wrapper.destroy(); wrapper = null; + + mockAxios.restore(); }); - it('should render provided pipeline info', () => { - expect(wrapper.find(CiHeader).props()).toMatchObject({ - status: defaultProps.pipeline.details.status, - itemId: defaultProps.pipeline.id, - time: defaultProps.pipeline.created_at, - user: defaultProps.pipeline.user, + describe('initial loading', () => { + beforeEach(() => { + wrapper = createComponent(null, { isLoading: true }); }); - }); - describe('action buttons', () => { - it('should not trigger eventHub when nothing happens', () => { - expect(eventHub.$emit).not.toHaveBeenCalled(); + it('shows a loading state while graphQL is fetching initial data', () => { + expect(findLoadingIcon().exists()).toBe(true); }); + }); + + describe('visible state', () => { + it.each` + state | pipelineData | retryValue | cancelValue + ${'cancelled'} | ${mockCancelledPipelineHeader} | ${true} | ${false} + ${'failed'} | ${mockFailedPipelineHeader} | ${true} | ${false} + ${'running'} | ${mockRunningPipelineHeader} | ${false} | ${true} + ${'successful'} | ${mockSuccessfulPipelineHeader} | ${false} | ${false} + `( + 'with a $state pipeline, it will show actions: retry $retryValue and cancel $cancelValue', + ({ pipelineData, retryValue, cancelValue }) => { + wrapper = createComponent(pipelineData); + + expect(findRetryButton().exists()).toBe(retryValue); + expect(findCancelButton().exists()).toBe(cancelValue); + }, + ); + }); - it('should call postAction when retry button action is clicked', () => { - wrapper.find('[data-testid="retryButton"]').vm.$emit('click'); + describe('actions', () => { + describe('Retry action', () => { + beforeEach(() => { + wrapper = createComponent(mockCancelledPipelineHeader); + }); - expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry'); - }); + it('should call axios with the right path when retry button is clicked', async () => { + jest.spyOn(axios, 'post'); + findRetryButton().vm.$emit('click'); - it('should call postAction when cancel button action is clicked', () => { - wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click'); + await wrapper.vm.$nextTick(); - expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel'); + expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.retry); + }); }); - it('does not show delete modal', () => { - expect(findDeleteModal()).not.toBeVisible(); + describe('Cancel action', () => { + beforeEach(() => { + wrapper = createComponent(mockRunningPipelineHeader); + }); + + it('should call axios with the right path when cancel button is clicked', async () => { + jest.spyOn(axios, 'post'); + findCancelButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.cancel); + }); }); - describe('when delete button action is clicked', () => { - it('displays delete modal', () => { + describe('Delete action', () => { + beforeEach(() => { + wrapper = createComponent(mockFailedPipelineHeader); + }); + + it('displays delete modal when clicking on delete and does not call the delete action', async () => { + jest.spyOn(axios, 'delete'); + findDeleteButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID); expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID); + expect(axios.delete).not.toHaveBeenCalled(); }); - it('should call delete when modal is submitted', () => { + it('should call delete path when modal is submitted', async () => { + jest.spyOn(axios, 'delete'); findDeleteModal().vm.$emit('ok'); - expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete'); + await wrapper.vm.$nextTick(); + + expect(axios.delete).toHaveBeenCalledWith(defaultProvideOptions.paths.delete); }); }); }); diff --git a/spec/frontend/pipelines/legacy_header_component_spec.js b/spec/frontend/pipelines/legacy_header_component_spec.js new file mode 100644 index 00000000000..fb7feb8898a --- /dev/null +++ b/spec/frontend/pipelines/legacy_header_component_spec.js @@ -0,0 +1,116 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import LegacyHeaderComponent from '~/pipelines/components/legacy_header_component.vue'; +import CiHeader from '~/vue_shared/components/header_ci_component.vue'; +import eventHub from '~/pipelines/event_hub'; + +describe('Pipeline details header', () => { + let wrapper; + let glModalDirective; + + const threeWeeksAgo = new Date(); + threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + + const findDeleteModal = () => wrapper.find(GlModal); + + const defaultProps = { + pipeline: { + details: { + status: { + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + }, + id: 123, + created_at: threeWeeksAgo.toISOString(), + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + retry_path: 'retry', + cancel_path: 'cancel', + delete_path: 'delete', + }, + isLoading: false, + }; + + const createComponent = (props = {}) => { + glModalDirective = jest.fn(); + + wrapper = shallowMount(LegacyHeaderComponent, { + propsData: { + ...props, + }, + directives: { + glModal: { + bind(el, { value }) { + glModalDirective(value); + }, + }, + }, + }); + }; + + beforeEach(() => { + jest.spyOn(eventHub, '$emit'); + + createComponent(defaultProps); + }); + + afterEach(() => { + eventHub.$off(); + + wrapper.destroy(); + wrapper = null; + }); + + it('should render provided pipeline info', () => { + expect(wrapper.find(CiHeader).props()).toMatchObject({ + status: defaultProps.pipeline.details.status, + itemId: defaultProps.pipeline.id, + time: defaultProps.pipeline.created_at, + user: defaultProps.pipeline.user, + }); + }); + + describe('action buttons', () => { + it('should not trigger eventHub when nothing happens', () => { + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + + it('should call postAction when retry button action is clicked', () => { + wrapper.find('[data-testid="retryButton"]').vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry'); + }); + + it('should call postAction when cancel button action is clicked', () => { + wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel'); + }); + + it('does not show delete modal', () => { + expect(findDeleteModal()).not.toBeVisible(); + }); + + describe('when delete button action is clicked', () => { + it('displays delete modal', () => { + expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID); + expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID); + }); + + it('should call delete when modal is submitted', () => { + findDeleteModal().vm.$emit('ok'); + + expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete'); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/mock_data.js b/spec/frontend/pipelines/mock_data.js index e63efc543f1..2afdbb05107 100644 --- a/spec/frontend/pipelines/mock_data.js +++ b/spec/frontend/pipelines/mock_data.js @@ -1,3 +1,7 @@ +const PIPELINE_RUNNING = 'RUNNING'; +const PIPELINE_CANCELED = 'CANCELED'; +const PIPELINE_FAILED = 'FAILED'; + export const pipelineWithStages = { id: 20333396, user: { @@ -320,6 +324,80 @@ export const pipelineWithStages = { triggered: [], }; +const threeWeeksAgo = new Date(); +threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); + +export const mockPipelineHeader = { + detailedStatus: {}, + id: 123, + userPermissions: { + destroyPipeline: true, + }, + createdAt: threeWeeksAgo.toISOString(), + user: { + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatarUrl: 'link', + }, +}; + +export const mockFailedPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_FAILED, + retryable: true, + cancelable: false, + detailedStatus: { + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + detailsPath: 'path', + }, +}; + +export const mockRunningPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_RUNNING, + retryable: false, + cancelable: true, + detailedStatus: { + group: 'running', + icon: 'status_running', + label: 'running', + text: 'running', + detailsPath: 'path', + }, +}; + +export const mockCancelledPipelineHeader = { + ...mockPipelineHeader, + status: PIPELINE_CANCELED, + retryable: true, + cancelable: false, + detailedStatus: { + group: 'cancelled', + icon: 'status_cancelled', + label: 'cancelled', + text: 'cancelled', + detailsPath: 'path', + }, +}; + +export const mockSuccessfulPipelineHeader = { + ...mockPipelineHeader, + status: 'SUCCESS', + retryable: false, + cancelable: false, + detailedStatus: { + group: 'success', + icon: 'status_success', + label: 'success', + text: 'success', + detailsPath: 'path', + }, +}; + export const stageReply = { name: 'deploy', title: 'deploy: running', diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index b0ad6bbd228..1298a2a1524 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -1,9 +1,17 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { GlFilteredSearch } from '@gitlab/ui'; +import { GlFilteredSearch, GlButton, GlLoadingIcon } from '@gitlab/ui'; import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; + +import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue'; +import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; +import BlankState from '~/pipelines/components/pipelines_list/blank_state.vue'; +import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; + import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; import Store from '~/pipelines/stores/pipelines_store'; import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data'; @@ -49,6 +57,20 @@ describe('Pipelines', () => { }; const findFilteredSearch = () => wrapper.find(GlFilteredSearch); + const findByTestId = id => wrapper.find(`[data-testid="${id}"]`); + const findNavigationTabs = () => wrapper.find(NavigationTabs); + const findNavigationControls = () => wrapper.find(NavigationControls); + const findTab = tab => findByTestId(`pipelines-tab-${tab}`); + + const findRunPipelineButton = () => findByTestId('run-pipeline-button'); + const findCiLintButton = () => findByTestId('ci-lint-button'); + const findCleanCacheButton = () => findByTestId('clear-cache-button'); + + const findEmptyState = () => wrapper.find(EmptyState); + const findBlankState = () => wrapper.find(BlankState); + const findStagesDropdown = () => wrapper.find('.js-builds-dropdown-button'); + + const findTablePagination = () => wrapper.find(TablePagination); const createComponent = (props = defaultProps, methods) => { wrapper = mount(PipelinesComponent, { @@ -87,19 +109,19 @@ describe('Pipelines', () => { }); it('renders tabs', () => { - expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + expect(findTab('all').text()).toContain('All'); }); it('renders Run Pipeline link', () => { - expect(wrapper.find('.js-run-pipeline').attributes('href')).toBe(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); }); it('renders CI Lint link', () => { - expect(wrapper.find('.js-ci-lint').attributes('href')).toBe(paths.ciLintPath); + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); }); it('renders Clear Runner Cache button', () => { - expect(wrapper.find('.js-clear-cache').text()).toBe('Clear Runner Caches'); + expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); }); it('renders pipelines table', () => { @@ -127,23 +149,31 @@ describe('Pipelines', () => { }); it('renders tabs', () => { - expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + expect(findTab('all').text()).toContain('All'); }); it('renders Run Pipeline link', () => { - expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); }); it('renders CI Lint link', () => { - expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath); + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); }); it('renders Clear Runner Cache button', () => { - expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches'); + expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); }); it('renders tab empty state', () => { - expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.'); + expect(findBlankState().text()).toBe('There are currently no pipelines.'); + }); + + it('renders tab empty state finished scope', () => { + wrapper.vm.scope = 'finished'; + + return wrapper.vm.$nextTick().then(() => { + expect(findBlankState().text()).toBe('There are currently no finished pipelines.'); + }); }); }); @@ -165,18 +195,23 @@ describe('Pipelines', () => { }); it('renders empty state', () => { - expect(wrapper.find('.js-empty-state h4').text()).toEqual('Build with confidence'); - - expect(wrapper.find('.js-get-started-pipelines').attributes('href')).toEqual( - paths.helpPagePath, - ); + expect( + findEmptyState() + .find('h4') + .text(), + ).toBe('Build with confidence'); + expect( + findEmptyState() + .find(GlButton) + .attributes('href'), + ).toBe(paths.helpPagePath); }); it('does not render tabs nor buttons', () => { - expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy(); - expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); - expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); - expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + expect(findTab('all').exists()).toBe(false); + expect(findRunPipelineButton().exists()).toBeFalsy(); + expect(findCiLintButton().exists()).toBeFalsy(); + expect(findCleanCacheButton().exists()).toBeFalsy(); }); }); @@ -189,20 +224,18 @@ describe('Pipelines', () => { }); it('renders tabs', () => { - expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + expect(findTab('all').text()).toContain('All'); }); it('renders buttons', () => { - expect(wrapper.find('.js-run-pipeline').attributes('href')).toEqual(paths.newPipelinePath); + expect(findRunPipelineButton().attributes('href')).toBe(paths.newPipelinePath); - expect(wrapper.find('.js-ci-lint').attributes('href')).toEqual(paths.ciLintPath); - expect(wrapper.find('.js-clear-cache').text()).toEqual('Clear Runner Caches'); + expect(findCiLintButton().attributes('href')).toBe(paths.ciLintPath); + expect(findCleanCacheButton().text()).toBe('Clear Runner Caches'); }); it('renders error state', () => { - expect(wrapper.find('.empty-state').text()).toContain( - 'There was an error fetching the pipelines.', - ); + expect(findBlankState().text()).toContain('There was an error fetching the pipelines.'); }); }); }); @@ -218,13 +251,13 @@ describe('Pipelines', () => { }); it('renders tabs', () => { - expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + expect(findTab('all').text()).toContain('All'); }); it('does not render buttons', () => { - expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); - expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); - expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + expect(findRunPipelineButton().exists()).toBeFalsy(); + expect(findCiLintButton().exists()).toBeFalsy(); + expect(findCleanCacheButton().exists()).toBeFalsy(); }); it('renders pipelines table', () => { @@ -252,17 +285,17 @@ describe('Pipelines', () => { }); it('renders tabs', () => { - expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + expect(findTab('all').text()).toContain('All'); }); it('does not render buttons', () => { - expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); - expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); - expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + expect(findRunPipelineButton().exists()).toBeFalsy(); + expect(findCiLintButton().exists()).toBeFalsy(); + expect(findCleanCacheButton().exists()).toBeFalsy(); }); it('renders tab empty state', () => { - expect(wrapper.find('.empty-state h4').text()).toEqual('There are currently no pipelines.'); + expect(wrapper.find('.empty-state h4').text()).toBe('There are currently no pipelines.'); }); }); @@ -284,18 +317,22 @@ describe('Pipelines', () => { }); it('renders empty state without button to set CI', () => { - expect(wrapper.find('.js-empty-state').text()).toEqual( + expect(findEmptyState().text()).toBe( 'This project is not currently set up to run pipelines.', ); - expect(wrapper.find('.js-get-started-pipelines').exists()).toBeFalsy(); + expect( + findEmptyState() + .find(GlButton) + .exists(), + ).toBeFalsy(); }); it('does not render tabs or buttons', () => { - expect(wrapper.find('.js-pipelines-tab-all').exists()).toBeFalsy(); - expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); - expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); - expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + expect(findTab('all').exists()).toBe(false); + expect(findRunPipelineButton().exists()).toBeFalsy(); + expect(findCiLintButton().exists()).toBeFalsy(); + expect(findCleanCacheButton().exists()).toBeFalsy(); }); }); @@ -309,13 +346,13 @@ describe('Pipelines', () => { }); it('renders tabs', () => { - expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); + expect(findTab('all').text()).toContain('All'); }); it('does not renders buttons', () => { - expect(wrapper.find('.js-run-pipeline').exists()).toBeFalsy(); - expect(wrapper.find('.js-ci-lint').exists()).toBeFalsy(); - expect(wrapper.find('.js-clear-cache').exists()).toBeFalsy(); + expect(findRunPipelineButton().exists()).toBeFalsy(); + expect(findCiLintButton().exists()).toBeFalsy(); + expect(findCleanCacheButton().exists()).toBeFalsy(); }); it('renders error state', () => { @@ -342,14 +379,20 @@ describe('Pipelines', () => { ); }); - it('should render navigation tabs', () => { - expect(wrapper.find('.js-pipelines-tab-all').text()).toContain('All'); - - expect(wrapper.find('.js-pipelines-tab-finished').text()).toContain('Finished'); - - expect(wrapper.find('.js-pipelines-tab-branches').text()).toContain('Branches'); + it('should set up navigation tabs', () => { + expect(findNavigationTabs().props('tabs')).toEqual([ + { name: 'All', scope: 'all', count: '3', isActive: true }, + { name: 'Finished', scope: 'finished', count: undefined, isActive: false }, + { name: 'Branches', scope: 'branches', isActive: false }, + { name: 'Tags', scope: 'tags', isActive: false }, + ]); + }); - expect(wrapper.find('.js-pipelines-tab-tags').text()).toContain('Tags'); + it('should render navigation tabs', () => { + expect(findTab('all').html()).toContain('All'); + expect(findTab('finished').text()).toContain('Finished'); + expect(findTab('branches').text()).toContain('Branches'); + expect(findTab('tags').text()).toContain('Tags'); }); it('should make an API request when using tabs', () => { @@ -362,7 +405,7 @@ describe('Pipelines', () => { ); return waitForPromises().then(() => { - wrapper.find('.js-pipelines-tab-finished').trigger('click'); + findTab('finished').trigger('click'); expect(updateContentMock).toHaveBeenCalledWith({ scope: 'finished', page: '1' }); }); @@ -401,133 +444,172 @@ describe('Pipelines', () => { }); }); - describe('methods', () => { + describe('User Interaction', () => { + let updateContentMock; + beforeEach(() => { jest.spyOn(window.history, 'pushState').mockImplementation(() => null); }); - describe('onChangeTab', () => { - it('should set page to 1', () => { - const updateContentMock = jest.fn(() => {}); - createComponent( - { hasGitlabCi: true, canCreatePipeline: true, ...paths }, - { - updateContent: updateContentMock, - }, - ); + beforeEach(() => { + mock.onGet(paths.endpoint).reply(200, pipelines); + createComponent(); - wrapper.vm.onChangeTab('running'); + updateContentMock = jest.spyOn(wrapper.vm, 'updateContent'); + + return waitForPromises(); + }); + + describe('when user changes tabs', () => { + it('should set page to 1', () => { + findNavigationTabs().vm.$emit('onChangeTab', 'running'); expect(updateContentMock).toHaveBeenCalledWith({ scope: 'running', page: '1' }); }); }); - describe('onChangePage', () => { + describe('when user changes page', () => { it('should update page and keep scope', () => { - const updateContentMock = jest.fn(() => {}); - createComponent( - { hasGitlabCi: true, canCreatePipeline: true, ...paths }, - { - updateContent: updateContentMock, - }, - ); - - wrapper.vm.onChangePage(4); + findTablePagination().vm.change(4); expect(updateContentMock).toHaveBeenCalledWith({ scope: wrapper.vm.scope, page: '4' }); }); }); - }); - describe('computed properties', () => { - beforeEach(() => { - createComponent(); - }); + describe('updates results when a staged is clicked', () => { + beforeEach(() => { + const copyPipeline = { ...pipelineWithStages }; + copyPipeline.id += 1; + mock + .onGet('twitter/flight/pipelines.json') + .reply( + 200, + { + pipelines: [pipelineWithStages], + count: { + all: 1, + finished: 1, + pending: 0, + running: 0, + }, + }, + { + 'POLL-INTERVAL': 100, + }, + ) + .onGet(pipelineWithStages.details.stages[0].dropdown_path) + .reply(200, stageReply); - describe('tabs', () => { - it('returns default tabs', () => { - expect(wrapper.vm.tabs).toEqual([ - { name: 'All', scope: 'all', count: undefined, isActive: true }, - { name: 'Finished', scope: 'finished', count: undefined, isActive: false }, - { name: 'Branches', scope: 'branches', isActive: false }, - { name: 'Tags', scope: 'tags', isActive: false }, - ]); + createComponent(); }); - }); - describe('emptyTabMessage', () => { - it('returns message with finished scope', () => { - wrapper.vm.scope = 'finished'; + describe('when a request is being made', () => { + it('stops polling, cancels the request, & restarts polling', () => { + const stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); + const restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); + const cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel'); + mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no finished pipelines.'); + return waitForPromises() + .then(() => { + wrapper.vm.isMakingRequest = true; + findStagesDropdown().trigger('click'); + }) + .then(() => { + expect(cancelMock).toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); + expect(restartMock).toHaveBeenCalled(); + }); }); }); - it('returns message without scope when scope is `all`', () => { - expect(wrapper.vm.emptyTabMessage).toEqual('There are currently no pipelines.'); + describe('when no request is being made', () => { + it('stops polling & restarts polling', () => { + const stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); + const restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); + mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); + + return waitForPromises() + .then(() => { + findStagesDropdown().trigger('click'); + expect(stopMock).toHaveBeenCalled(); + }) + .then(() => { + expect(restartMock).toHaveBeenCalled(); + }); + }); }); }); + }); - describe('stateToRender', () => { - it('returns loading state when the app is loading', () => { - expect(wrapper.vm.stateToRender).toEqual('loading'); + describe('Rendered content', () => { + beforeEach(() => { + createComponent(); + }); + + describe('displays different content', () => { + it('shows loading state when the app is loading', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); - it('returns error state when app has error', () => { + it('shows error state when app has error', () => { wrapper.vm.hasError = true; wrapper.vm.isLoading = false; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.stateToRender).toEqual('error'); + expect(findBlankState().props('message')).toBe( + 'There was an error fetching the pipelines. Try again in a few moments or contact your support team.', + ); }); }); - it('returns table list when app has pipelines', () => { + it('shows table list when app has pipelines', () => { wrapper.vm.isLoading = false; wrapper.vm.hasError = false; wrapper.vm.state.pipelines = pipelines.pipelines; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.stateToRender).toEqual('tableList'); + expect(wrapper.find(PipelinesTableComponent).exists()).toBe(true); }); }); - it('returns empty tab when app does not have pipelines but project has pipelines', () => { + it('shows empty tab when app does not have pipelines but project has pipelines', () => { wrapper.vm.state.count.all = 10; wrapper.vm.isLoading = false; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.stateToRender).toEqual('emptyTab'); + expect(findBlankState().exists()).toBe(true); + expect(findBlankState().props('message')).toBe('There are currently no pipelines.'); }); }); - it('returns empty tab when project has CI', () => { + it('shows empty tab when project has CI', () => { wrapper.vm.isLoading = false; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.stateToRender).toEqual('emptyTab'); + expect(findBlankState().exists()).toBe(true); + expect(findBlankState().props('message')).toBe('There are currently no pipelines.'); }); }); - it('returns empty state when project does not have pipelines nor CI', () => { + it('shows empty state when project does not have pipelines nor CI', () => { createComponent({ hasGitlabCi: false, canCreatePipeline: true, ...paths }); wrapper.vm.isLoading = false; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.stateToRender).toEqual('emptyState'); + expect(wrapper.find(EmptyState).exists()).toBe(true); }); }); }); - describe('shouldRenderTabs', () => { + describe('displays tabs', () => { it('returns true when state is loading & has already made the first request', () => { wrapper.vm.isLoading = true; wrapper.vm.hasMadeRequest = true; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shouldRenderTabs).toEqual(true); + expect(findNavigationTabs().exists()).toBe(true); }); }); @@ -537,7 +619,7 @@ describe('Pipelines', () => { wrapper.vm.hasMadeRequest = true; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shouldRenderTabs).toEqual(true); + expect(findNavigationTabs().exists()).toBe(true); }); }); @@ -547,7 +629,7 @@ describe('Pipelines', () => { wrapper.vm.hasMadeRequest = true; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shouldRenderTabs).toEqual(true); + expect(findNavigationTabs().exists()).toBe(true); }); }); @@ -557,7 +639,7 @@ describe('Pipelines', () => { wrapper.vm.hasMadeRequest = true; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shouldRenderTabs).toEqual(true); + expect(findNavigationTabs().exists()).toBe(true); }); }); @@ -565,7 +647,7 @@ describe('Pipelines', () => { wrapper.vm.hasMadeRequest = false; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shouldRenderTabs).toEqual(false); + expect(findNavigationTabs().exists()).toBe(false); }); }); @@ -576,17 +658,17 @@ describe('Pipelines', () => { wrapper.vm.hasMadeRequest = true; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shouldRenderTabs).toEqual(false); + expect(findNavigationTabs().exists()).toBe(false); }); }); }); - describe('shouldRenderButtons', () => { + describe('displays buttons', () => { it('returns true when it has paths & has made the first request', () => { wrapper.vm.hasMadeRequest = true; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shouldRenderButtons).toEqual(true); + expect(findNavigationControls().exists()).toBe(true); }); }); @@ -594,77 +676,12 @@ describe('Pipelines', () => { wrapper.vm.hasMadeRequest = false; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shouldRenderButtons).toEqual(false); + expect(findNavigationControls().exists()).toBe(false); }); }); }); }); - describe('updates results when a staged is clicked', () => { - beforeEach(() => { - const copyPipeline = { ...pipelineWithStages }; - copyPipeline.id += 1; - mock - .onGet('twitter/flight/pipelines.json') - .reply( - 200, - { - pipelines: [pipelineWithStages], - count: { - all: 1, - finished: 1, - pending: 0, - running: 0, - }, - }, - { - 'POLL-INTERVAL': 100, - }, - ) - .onGet(pipelineWithStages.details.stages[0].dropdown_path) - .reply(200, stageReply); - - createComponent(); - }); - - describe('when a request is being made', () => { - it('stops polling, cancels the request, & restarts polling', () => { - const stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); - const restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); - const cancelMock = jest.spyOn(wrapper.vm.service.cancelationSource, 'cancel'); - mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); - - return waitForPromises() - .then(() => { - wrapper.vm.isMakingRequest = true; - wrapper.find('.js-builds-dropdown-button').trigger('click'); - }) - .then(() => { - expect(cancelMock).toHaveBeenCalled(); - expect(stopMock).toHaveBeenCalled(); - expect(restartMock).toHaveBeenCalled(); - }); - }); - }); - - describe('when no request is being made', () => { - it('stops polling & restarts polling', () => { - const stopMock = jest.spyOn(wrapper.vm.poll, 'stop'); - const restartMock = jest.spyOn(wrapper.vm.poll, 'restart'); - mock.onGet('twitter/flight/pipelines.json').reply(200, pipelines); - - return waitForPromises() - .then(() => { - wrapper.find('.js-builds-dropdown-button').trigger('click'); - expect(stopMock).toHaveBeenCalled(); - }) - .then(() => { - expect(restartMock).toHaveBeenCalled(); - }); - }); - }); - }); - describe('Pipeline filters', () => { let updateContentMock; diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js index 1d03f0b655f..872cb5c87be 100644 --- a/spec/frontend/pipelines/test_reports/mock_data.js +++ b/spec/frontend/pipelines/test_reports/mock_data.js @@ -9,4 +9,20 @@ export default [ status: TestStatus.SKIPPED, system_output: null, }, + { + classname: 'spec.test_spec', + execution_time: 0, + name: 'Test#error text', + stack_trace: null, + status: TestStatus.ERROR, + system_output: null, + }, + { + classname: 'spec.test_spec', + execution_time: 0, + name: 'Test#unknown text', + stack_trace: null, + status: TestStatus.UNKNOWN, + system_output: null, + }, ]; diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index 2feb6aa5799..af2150be7a0 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -61,18 +61,17 @@ describe('Test reports suite table', () => { expect(allCaseRows().length).toBe(testCases.length); }); - it('renders the correct icon for each status', () => { - const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED); - const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED); - const successTest = testCases.findIndex(x => x.status === TestStatus.SUCCESS); + it.each([ + TestStatus.ERROR, + TestStatus.FAILED, + TestStatus.SKIPPED, + TestStatus.SUCCESS, + 'unknown', + ])('renders the correct icon for test case with %s status', status => { + const test = testCases.findIndex(x => x.status === status); + const row = findCaseRowAtIndex(test); - const failedRow = findCaseRowAtIndex(failedTest); - const skippedRow = findCaseRowAtIndex(skippedTest); - const successRow = findCaseRowAtIndex(successTest); - - expect(findIconForRow(failedRow, TestStatus.FAILED).exists()).toBe(true); - expect(findIconForRow(skippedRow, TestStatus.SKIPPED).exists()).toBe(true); - expect(findIconForRow(successRow, TestStatus.SUCCESS).exists()).toBe(true); + expect(findIconForRow(row, status).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/project_find_file_spec.js index 757a02a04a3..6a50f68a4e9 100644 --- a/spec/frontend/project_find_file_spec.js +++ b/spec/frontend/project_find_file_spec.js @@ -1,11 +1,12 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { TEST_HOST } from 'helpers/test_constants'; -import { sanitize } from 'dompurify'; +import { sanitize } from '~/lib/dompurify'; import ProjectFindFile from '~/project_find_file'; import axios from '~/lib/utils/axios_utils'; -jest.mock('dompurify', () => ({ +jest.mock('~/lib/dompurify', () => ({ + addHook: jest.fn(), sanitize: jest.fn(val => val), })); diff --git a/spec/frontend/projects/commit_box/info/load_branches_spec.js b/spec/frontend/projects/commit_box/info/load_branches_spec.js new file mode 100644 index 00000000000..ebd4ee45dab --- /dev/null +++ b/spec/frontend/projects/commit_box/info/load_branches_spec.js @@ -0,0 +1,68 @@ +import axios from 'axios'; +import waitForPromises from 'helpers/wait_for_promises'; +import MockAdapter from 'axios-mock-adapter'; +import { loadBranches } from '~/projects/commit_box/info/load_branches'; + +const mockCommitPath = '/commit/abcd/branches'; +const mockBranchesRes = + '<a href="/-/commits/master">master</a><span><a href="/-/commits/my-branch">my-branch</a></span>'; + +describe('~/projects/commit_box/info/load_branches', () => { + let mock; + let el; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(mockCommitPath).reply(200, mockBranchesRes); + + el = document.createElement('div'); + el.dataset.commitPath = mockCommitPath; + el.innerHTML = '<div class="commit-info branches"><span class="spinner"/></div>'; + }); + + it('loads and renders branches info', async () => { + loadBranches(el); + await waitForPromises(); + + expect(el.innerHTML).toBe(`<div class="commit-info branches">${mockBranchesRes}</div>`); + }); + + it('does not load when no container is provided', async () => { + loadBranches(null); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(0); + }); + + describe('when braches request returns unsafe content', () => { + beforeEach(() => { + mock + .onGet(mockCommitPath) + .reply(200, '<a onload="alert(\'xss!\');" href="/-/commits/master">master</a>'); + }); + + it('displays sanitized html', async () => { + loadBranches(el); + await waitForPromises(); + + expect(el.innerHTML).toBe( + '<div class="commit-info branches"><a href="/-/commits/master">master</a></div>', + ); + }); + }); + + describe('when braches request fails', () => { + beforeEach(() => { + mock.onGet(mockCommitPath).reply(500, 'Error!'); + }); + + it('attempts to load and renders an error', async () => { + loadBranches(el); + await waitForPromises(); + + expect(el.innerHTML).toBe( + '<div class="commit-info branches">Failed to load branches. Please try again.</div>', + ); + }); + }); +}); 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 455467e7b29..a0fd6012546 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 @@ -17,6 +17,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` /> <gl-button-stub + buttontextclasses="" category="primary" icon="" role="button" diff --git a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap index 692b8f6cf52..4630415f61c 100644 --- a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap +++ b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap @@ -18,6 +18,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = ` /> <gl-button-stub + buttontextclasses="" category="primary" icon="" role="button" @@ -84,6 +85,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = ` <template> <gl-button-stub + buttontextclasses="" category="primary" class="js-modal-action-cancel" icon="" @@ -98,6 +100,7 @@ exports[`Project remove modal intialized matches the snapshot 1`] = ` <!----> <gl-button-stub + buttontextclasses="" category="primary" class="js-modal-action-primary" disabled="true" diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js index 3b375c5610f..41b9c0c3763 100644 --- a/spec/frontend/projects/settings/access_dropdown_spec.js +++ b/spec/frontend/projects/settings/access_dropdown_spec.js @@ -14,6 +14,7 @@ describe('AccessDropdown', () => { `); const $dropdown = $('#dummy-dropdown'); $dropdown.data('defaultLabel', defaultLabel); + gon.features = { deployKeysOnProtectedBranches: true }; const options = { $dropdown, accessLevelsData: { @@ -37,6 +38,9 @@ describe('AccessDropdown', () => { { type: LEVEL_TYPES.GROUP }, { type: LEVEL_TYPES.GROUP }, { type: LEVEL_TYPES.GROUP }, + { type: LEVEL_TYPES.DEPLOY_KEY }, + { type: LEVEL_TYPES.DEPLOY_KEY }, + { type: LEVEL_TYPES.DEPLOY_KEY }, ]; beforeEach(() => { @@ -49,7 +53,7 @@ describe('AccessDropdown', () => { const label = dropdown.toggleLabel(); - expect(label).toBe('1 role, 2 users, 3 groups'); + expect(label).toBe('1 role, 2 users, 3 deploy keys, 3 groups'); expect($dropdownToggleText).not.toHaveClass('is-default'); }); @@ -122,6 +126,21 @@ describe('AccessDropdown', () => { expect($dropdownToggleText).not.toHaveClass('is-default'); }); }); + + describe('with users and deploy keys', () => { + beforeEach(() => { + const selectedTypes = [LEVEL_TYPES.DEPLOY_KEY, LEVEL_TYPES.USER]; + dropdown.setSelectedItems(dummyItems.filter(item => selectedTypes.includes(item.type))); + $dropdownToggleText.addClass('is-default'); + }); + + it('displays number of deploy keys', () => { + const label = dropdown.toggleLabel(); + + expect(label).toBe('2 users, 3 deploy keys'); + expect($dropdownToggleText).not.toHaveClass('is-default'); + }); + }); }); describe('userRowHtml', () => { diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index 0f3b699f6b2..62aeb4ddee5 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -218,9 +218,7 @@ describe('ServiceDeskRoot', () => { .$nextTick() .then(waitForPromises) .then(() => { - expect(wrapper.html()).toContain( - 'An error occurred while saving the template. Please check if the template exists.', - ); + expect(wrapper.html()).toContain('An error occured while making the changes:'); }); }); }); diff --git a/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js new file mode 100644 index 00000000000..17821d8be31 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/partial_cleanup_alert_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import component from '~/registry/explorer/components/details_page/partial_cleanup_alert.vue'; +import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '~/registry/explorer/constants'; + +describe('Partial Cleanup alert', () => { + let wrapper; + + const findAlert = () => wrapper.find(GlAlert); + const findRunLink = () => wrapper.find('[data-testid="run-link"'); + const findHelpLink = () => wrapper.find('[data-testid="help-link"'); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { GlSprintf }, + propsData: { + runCleanupPoliciesHelpPagePath: 'foo', + cleanupPoliciesHelpPagePath: 'bar', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it(`gl-alert has the correct properties`, () => { + mountComponent(); + + expect(findAlert().props()).toMatchObject({ + title: DELETE_ALERT_TITLE, + variant: 'warning', + }); + }); + + it('has the right text', () => { + mountComponent(); + + expect(wrapper.text()).toMatchInterpolatedText(DELETE_ALERT_LINK_TEXT); + }); + + it('contains run link', () => { + mountComponent(); + + const link = findRunLink(); + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: 'foo', + target: '_blank', + }); + }); + + it('contains help link', () => { + mountComponent(); + + const link = findHelpLink(); + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: 'bar', + target: '_blank', + }); + }); + + it('GlAlert dismiss event triggers a dismiss event', () => { + mountComponent(); + + findAlert().vm.$emit('dismiss'); + expect(wrapper.emitted('dismiss')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js index 7a27f8fa431..3c997093d46 100644 --- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlSprintf, GlLink } from '@gitlab/ui'; +import { GlSprintf } from '@gitlab/ui'; import Component from '~/registry/explorer/components/list_page/registry_header.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import { @@ -19,12 +19,8 @@ describe('registry_header', () => { const findTitleArea = () => wrapper.find(TitleArea); const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); - const findInfoArea = () => wrapper.find('[data-testid="info-area"]'); - const findIntroText = () => wrapper.find('[data-testid="default-intro"]'); const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]'); const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]'); - const findDisabledExpirationPolicyMessage = () => - wrapper.find('[data-testid="expiration-disabled-message"]'); const mountComponent = (propsData, slots) => { wrapper = shallowMount(Component, { @@ -123,44 +119,18 @@ describe('registry_header', () => { }); }); - describe('info area', () => { - it('exists', () => { - mountComponent(); - - expect(findInfoArea().exists()).toBe(true); - }); - + describe('info messages', () => { describe('default message', () => { - beforeEach(() => { - return mountComponent({ helpPagePath: 'bar' }); - }); - - it('exists', () => { - expect(findIntroText().exists()).toBe(true); - }); - - it('has the correct copy', () => { - expect(findIntroText().text()).toMatchInterpolatedText(LIST_INTRO_TEXT); - }); + it('is correctly bound to title_area props', () => { + mountComponent({ helpPagePath: 'foo' }); - it('has the correct link', () => { - expect( - findIntroText() - .find(GlLink) - .attributes('href'), - ).toBe('bar'); + expect(findTitleArea().props('infoMessages')).toEqual([ + { text: LIST_INTRO_TEXT, link: 'foo' }, + ]); }); }); describe('expiration policy info message', () => { - describe('when there are no images', () => { - it('is hidden', () => { - mountComponent(); - - expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); - }); - }); - describe('when there are images', () => { describe('when expiration policy is disabled', () => { beforeEach(() => { @@ -170,43 +140,27 @@ describe('registry_header', () => { imagesCount: 1, }); }); - it('message exist', () => { - expect(findDisabledExpirationPolicyMessage().exists()).toBe(true); - }); - it('has the correct copy', () => { - expect(findDisabledExpirationPolicyMessage().text()).toMatchInterpolatedText( - EXPIRATION_POLICY_DISABLED_MESSAGE, - ); - }); - it('has the correct link', () => { - expect( - findDisabledExpirationPolicyMessage() - .find(GlLink) - .attributes('href'), - ).toBe('foo'); + it('the prop is correctly bound', () => { + expect(findTitleArea().props('infoMessages')).toEqual([ + { text: LIST_INTRO_TEXT, link: '' }, + { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: 'foo' }, + ]); }); }); - describe('when expiration policy is enabled', () => { + describe.each` + desc | props + ${'when there are no images'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 0 }} + ${'when expiration policy is enabled'} | ${{ expirationPolicy: { enabled: true }, imagesCount: 1 }} + ${'when the expiration policy is completely disabled'} | ${{ expirationPolicy: { enabled: false }, imagesCount: 1, hideExpirationPolicyData: true }} + `('$desc', ({ props }) => { it('message does not exist', () => { - mountComponent({ - expirationPolicy: { enabled: true }, - imagesCount: 1, - }); - - expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); - }); - }); - describe('when the expiration policy is completely disabled', () => { - it('message does not exist', () => { - mountComponent({ - expirationPolicy: { enabled: true }, - imagesCount: 1, - hideExpirationPolicyData: true, - }); + mountComponent(props); - expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); + expect(findTitleArea().props('infoMessages')).toEqual([ + { text: LIST_INTRO_TEXT, link: '' }, + ]); }); }); }); diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 66e8a4aea0d..86b52c4f06a 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -3,6 +3,7 @@ import { GlPagination } from '@gitlab/ui'; import Tracking from '~/tracking'; import component from '~/registry/explorer/pages/details.vue'; import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue'; +import PartialCleanupAlert from '~/registry/explorer/components/details_page/partial_cleanup_alert.vue'; import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue'; import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; import TagsList from '~/registry/explorer/components/details_page/tags_list.vue'; @@ -30,8 +31,10 @@ describe('Details Page', () => { const findDeleteAlert = () => wrapper.find(DeleteAlert); const findDetailsHeader = () => wrapper.find(DetailsHeader); const findEmptyTagsState = () => wrapper.find(EmptyTagsState); + const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); - const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' })); + const routeIdGenerator = override => + window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar', ...override })); const tagsArrayToSelectedTags = tags => tags.reduce((acc, c) => { @@ -39,7 +42,7 @@ describe('Details Page', () => { return acc; }, {}); - const mountComponent = options => { + const mountComponent = ({ options, routeParams } = {}) => { wrapper = shallowMount(component, { store, stubs: { @@ -48,7 +51,7 @@ describe('Details Page', () => { mocks: { $route: { params: { - id: routeId, + id: routeIdGenerator(routeParams), }, }, }, @@ -224,7 +227,7 @@ describe('Details Page', () => { findDeleteModal().vm.$emit('confirmDelete'); expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', { tag: store.state.tags[0], - params: routeId, + params: routeIdGenerator(), }); }); }); @@ -239,7 +242,7 @@ describe('Details Page', () => { findDeleteModal().vm.$emit('confirmDelete'); expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', { ids: store.state.tags.map(t => t.name), - params: routeId, + params: routeIdGenerator(), }); }); }); @@ -273,11 +276,57 @@ describe('Details Page', () => { it('has the correct props', () => { store.commit(SET_INITIAL_STATE, { ...config }); mountComponent({ - data: () => ({ - deleteAlertType, - }), + options: { + data: () => ({ + deleteAlertType, + }), + }, }); expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType }); }); }); + + describe('Partial Cleanup Alert', () => { + const config = { + runCleanupPoliciesHelpPagePath: 'foo', + cleanupPoliciesHelpPagePath: 'bar', + }; + + describe('when expiration_policy_started is not null', () => { + const routeParams = { cleanup_policy_started_at: Date.now().toString() }; + + it('exists', () => { + mountComponent({ routeParams }); + + expect(findPartialCleanupAlert().exists()).toBe(true); + }); + + it('has the correct props', () => { + store.commit(SET_INITIAL_STATE, { ...config }); + + mountComponent({ routeParams }); + + expect(findPartialCleanupAlert().props()).toEqual({ ...config }); + }); + + it('dismiss hides the component', async () => { + mountComponent({ routeParams }); + + expect(findPartialCleanupAlert().exists()).toBe(true); + findPartialCleanupAlert().vm.$emit('dismiss'); + + await wrapper.vm.$nextTick(); + + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + }); + + describe('when expiration_policy_started is null', () => { + it('the component is hidden', () => { + mountComponent(); + + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap deleted file mode 100644 index 11393c89d06..00000000000 --- a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Registry Settings App renders 1`] = ` -<div> - <settings-form-stub /> -</div> -`; diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js index 9551ee72e51..01d6852e1e5 100644 --- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js @@ -1,28 +1,35 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; import component from '~/registry/settings/components/registry_settings_app.vue'; +import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql'; import SettingsForm from '~/registry/settings/components/settings_form.vue'; -import { createStore } from '~/registry/settings/store/'; -import { SET_SETTINGS, SET_INITIAL_STATE } from '~/registry/settings/store/mutation_types'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/registry/shared/constants'; import { UNAVAILABLE_FEATURE_INTRO_TEXT, UNAVAILABLE_USER_FEATURE_TEXT, } from '~/registry/settings/constants'; -import { stringifiedFormOptions } from '../../shared/mock_data'; +import { expirationPolicyPayload } from '../mock_data'; + +const localVue = createLocalVue(); describe('Registry Settings App', () => { let wrapper; - let store; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + isAdmin: false, + adminSettingsPath: 'settingsPath', + enableHistoricEntries: false, + }; const findSettingsComponent = () => wrapper.find(SettingsForm); const findAlert = () => wrapper.find(GlAlert); - const mountComponent = ({ dispatchMock = 'mockResolvedValue' } = {}) => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); - dispatchSpy[dispatchMock](); - + const mountComponent = (provide = defaultProvidedValues, config) => { wrapper = shallowMount(component, { stubs: { GlSprintf, @@ -32,71 +39,72 @@ describe('Registry Settings App', () => { show: jest.fn(), }, }, - store, + provide, + ...config, }); }; - beforeEach(() => { - store = createStore(); - }); + const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[expirationPolicyQuery, resolver]]; + + fakeApollo = createMockApollo(requestHandlers); + mountComponent(provide, { + localVue, + apolloProvider: fakeApollo, + }); + + return requestHandlers.map(request => request[1]); + }; afterEach(() => { wrapper.destroy(); }); - it('renders', () => { - store.commit(SET_SETTINGS, { foo: 'bar' }); - mountComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('call the store function to load the data on mount', () => { - mountComponent(); - expect(store.dispatch).toHaveBeenCalledWith('fetchSettings'); - }); + it('renders the setting form', async () => { + const requests = mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()), + }); + await Promise.all(requests); - it('renders the setting form', () => { - store.commit(SET_SETTINGS, { foo: 'bar' }); - mountComponent(); expect(findSettingsComponent().exists()).toBe(true); }); describe('the form is disabled', () => { - beforeEach(() => { - store.commit(SET_SETTINGS, undefined); + it('the form is hidden', () => { mountComponent(); - }); - it('the form is hidden', () => { expect(findSettingsComponent().exists()).toBe(false); }); it('shows an alert', () => { + mountComponent(); + const text = findAlert().text(); expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT); }); describe('an admin is visiting the page', () => { - beforeEach(() => { - store.commit(SET_INITIAL_STATE, { - ...stringifiedFormOptions, - isAdmin: true, - adminSettingsPath: 'foo', - }); - }); - it('shows the admin part of the alert message', () => { + mountComponent({ ...defaultProvidedValues, isAdmin: true }); + const sprintf = findAlert().find(GlSprintf); expect(sprintf.text()).toBe('administration settings'); - expect(sprintf.find(GlLink).attributes('href')).toBe('foo'); + expect(sprintf.find(GlLink).attributes('href')).toBe( + defaultProvidedValues.adminSettingsPath, + ); }); }); }); describe('fetchSettingsError', () => { beforeEach(() => { - mountComponent({ dispatchMock: 'mockRejectedValue' }); + const requests = mountComponentWithApollo({ + resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + return Promise.all(requests); }); it('the form is hidden', () => { diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 6f9518808db..77fd71a22fc 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -1,30 +1,37 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Tracking from '~/tracking'; import component from '~/registry/settings/components/settings_form.vue'; import expirationPolicyFields from '~/registry/shared/components/expiration_policy_fields.vue'; -import { createStore } from '~/registry/settings/store/'; +import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.graphql'; +import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '~/registry/shared/constants'; -import { stringifiedFormOptions } from '../../shared/mock_data'; +import { GlCard, GlLoadingIcon } from '../../shared/stubs'; +import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data'; + +const localVue = createLocalVue(); describe('Settings Form', () => { let wrapper; - let store; - let dispatchSpy; - - const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' }; - const GlCard = { - name: 'gl-card-stub', - template: ` - <div> - <slot name="header"></slot> - <slot></slot> - <slot name="footer"></slot> - </div> - `, + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const { + data: { + project: { containerExpirationPolicy }, + }, + } = expirationPolicyPayload(); + + const defaultProps = { + value: { ...containerExpirationPolicy }, }; const trackingPayload = { @@ -35,14 +42,21 @@ describe('Settings Form', () => { const findFields = () => wrapper.find(expirationPolicyFields); const findCancelButton = () => wrapper.find({ ref: 'cancel-button' }); const findSaveButton = () => wrapper.find({ ref: 'save-button' }); - const findLoadingIcon = (parent = wrapper) => parent.find(GlLoadingIcon); - const mountComponent = (data = {}) => { + const mountComponent = ({ + props = defaultProps, + data, + config, + provide = defaultProvidedValues, + mocks, + } = {}) => { wrapper = shallowMount(component, { stubs: { GlCard, GlLoadingIcon, }, + propsData: { ...props }, + provide, data() { return { ...data, @@ -52,15 +66,42 @@ describe('Settings Form', () => { $toast: { show: jest.fn(), }, + ...mocks, }, - store, + ...config, }); }; + const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [updateContainerExpirationPolicyMutation, resolver], + [expirationPolicyQuery, jest.fn().mockResolvedValue(expirationPolicyPayload())], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + fakeApollo.defaultClient.cache.writeQuery({ + query: expirationPolicyQuery, + variables: { + projectPath: provide.projectPath, + }, + ...expirationPolicyPayload(), + }); + + mountComponent({ + provide, + config: { + localVue, + apolloProvider: fakeApollo, + }, + }); + + return requestHandlers.map(resolvers => resolvers[1]); + }; + beforeEach(() => { - store = createStore(); - store.dispatch('setInitialState', stringifiedFormOptions); - dispatchSpy = jest.spyOn(store, 'dispatch'); jest.spyOn(Tracking, 'event'); }); @@ -72,12 +113,12 @@ describe('Settings Form', () => { it('v-model change update the settings property', () => { mountComponent(); findFields().vm.$emit('input', { newValue: 'foo' }); - expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' }); + expect(wrapper.emitted('input')).toEqual([['foo']]); }); it('v-model change update the api error property', () => { const apiErrors = { baz: 'bar' }; - mountComponent({ apiErrors }); + mountComponent({ data: { apiErrors } }); expect(findFields().props('apiErrors')).toEqual(apiErrors); findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' }); expect(findFields().props('apiErrors')).toEqual({}); @@ -85,19 +126,14 @@ describe('Settings Form', () => { }); describe('form', () => { - let form; - beforeEach(() => { - mountComponent(); - form = findForm(); - dispatchSpy.mockReturnValue(); - }); - describe('form reset event', () => { beforeEach(() => { - form.trigger('reset'); + mountComponent(); + + findForm().trigger('reset'); }); it('calls the appropriate function', () => { - expect(dispatchSpy).toHaveBeenCalledWith('resetSettings'); + expect(wrapper.emitted('reset')).toEqual([[]]); }); it('tracks the reset event', () => { @@ -108,54 +144,96 @@ describe('Settings Form', () => { describe('form submit event ', () => { it('save has type submit', () => { mountComponent(); + expect(findSaveButton().attributes('type')).toBe('submit'); }); - it('dispatches the saveSettings action', () => { - dispatchSpy.mockResolvedValue(); - form.trigger('submit'); - expect(dispatchSpy).toHaveBeenCalledWith('saveSettings'); + it('dispatches the correct apollo mutation', async () => { + const [expirationPolicyMutationResolver] = mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + await expirationPolicyMutationResolver(); + expect(expirationPolicyMutationResolver).toHaveBeenCalled(); }); it('tracks the submit event', () => { - dispatchSpy.mockResolvedValue(); - form.trigger('submit'); + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload); }); it('show a success toast when submit succeed', async () => { - dispatchSpy.mockResolvedValue(); - form.trigger('submit'); - await waitForPromises(); + const handlers = mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + await Promise.all(handlers); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success', }); }); describe('when submit fails', () => { - it('shows an error', async () => { - dispatchSpy.mockRejectedValue({ response: {} }); - form.trigger('submit'); - await waitForPromises(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { - type: 'error', + describe('user recoverable errors', () => { + it('when there is an error is shown in a toast', async () => { + const handlers = mountComponentWithApollo({ + resolver: jest + .fn() + .mockResolvedValue(expirationPolicyMutationPayload({ errors: ['foo'] })), + }); + + findForm().trigger('submit'); + await Promise.all(handlers); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo', { + type: 'error', + }); }); }); + describe('global errors', () => { + it('shows an error', async () => { + const handlers = mountComponentWithApollo({ + resolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + await Promise.all(handlers); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); - it('parses the error messages', async () => { - dispatchSpy.mockRejectedValue({ - response: { - data: { - message: { - foo: 'bar', - 'container_expiration_policy.name': ['baz'], + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { + type: 'error', + }); + }); + + it('parses the error messages', async () => { + const mutate = jest.fn().mockRejectedValue({ + graphQLErrors: [ + { + extensions: { + problems: [{ path: ['name'], message: 'baz' }], + }, }, - }, - }, + ], + }); + mountComponent({ mocks: { $apollo: { mutate } } }); + + findForm().trigger('submit'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(findFields().props('apiErrors')).toEqual({ name: 'baz' }); }); - form.trigger('submit'); - await waitForPromises(); - expect(findFields().props('apiErrors')).toEqual({ name: 'baz' }); }); }); }); @@ -163,51 +241,78 @@ describe('Settings Form', () => { describe('form actions', () => { describe('cancel button', () => { - beforeEach(() => { - store.commit('SET_SETTINGS', { foo: 'bar' }); + it('has type reset', () => { mountComponent(); - }); - it('has type reset', () => { expect(findCancelButton().attributes('type')).toBe('reset'); }); - it('is disabled when isEdited is false', () => - wrapper.vm.$nextTick().then(() => { - expect(findCancelButton().attributes('disabled')).toBe('true'); - })); - - it('is disabled isLoading is true', () => { - store.commit('TOGGLE_LOADING'); - store.commit('UPDATE_SETTINGS', { settings: { foo: 'baz' } }); - return wrapper.vm.$nextTick().then(() => { - expect(findCancelButton().attributes('disabled')).toBe('true'); - store.commit('TOGGLE_LOADING'); - }); - }); + it.each` + isLoading | isEdited | mutationLoading | isDisabled + ${true} | ${true} | ${true} | ${true} + ${false} | ${true} | ${true} | ${true} + ${false} | ${false} | ${true} | ${true} + ${true} | ${false} | ${false} | ${true} + ${false} | ${false} | ${false} | ${true} + ${false} | ${true} | ${false} | ${false} + `( + 'when isLoading is $isLoading and isEdited is $isEdited and mutationLoading is $mutationLoading is $isDisabled that the is disabled', + ({ isEdited, isLoading, mutationLoading, isDisabled }) => { + mountComponent({ + props: { ...defaultProps, isEdited, isLoading }, + data: { mutationLoading }, + }); - it('is enabled when isLoading is false and isEdited is true', () => { - store.commit('UPDATE_SETTINGS', { settings: { foo: 'baz' } }); - return wrapper.vm.$nextTick().then(() => { - expect(findCancelButton().attributes('disabled')).toBe(undefined); - }); - }); + const expectation = isDisabled ? 'true' : undefined; + expect(findCancelButton().attributes('disabled')).toBe(expectation); + }, + ); }); - describe('when isLoading is true', () => { - beforeEach(() => { - store.commit('TOGGLE_LOADING'); + describe('submit button', () => { + it('has type submit', () => { mountComponent(); - }); - afterEach(() => { - store.commit('TOGGLE_LOADING'); - }); - it('submit button is disabled and shows a spinner', () => { - const button = findSaveButton(); - expect(button.attributes('disabled')).toBeTruthy(); - expect(findLoadingIcon(button).exists()).toBe(true); + expect(findSaveButton().attributes('type')).toBe('submit'); }); + it.each` + isLoading | fieldsAreValid | mutationLoading | isDisabled + ${true} | ${true} | ${true} | ${true} + ${false} | ${true} | ${true} | ${true} + ${false} | ${false} | ${true} | ${true} + ${true} | ${false} | ${false} | ${true} + ${false} | ${false} | ${false} | ${true} + ${false} | ${true} | ${false} | ${false} + `( + 'when isLoading is $isLoading and fieldsAreValid is $fieldsAreValid and mutationLoading is $mutationLoading is $isDisabled that the is disabled', + ({ fieldsAreValid, isLoading, mutationLoading, isDisabled }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading, fieldsAreValid }, + }); + + const expectation = isDisabled ? 'true' : undefined; + expect(findSaveButton().attributes('disabled')).toBe(expectation); + }, + ); + + it.each` + isLoading | mutationLoading | showLoading + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when isLoading is $isLoading and mutationLoading is $mutationLoading is $showLoading that the loading icon is shown', + ({ isLoading, mutationLoading, showLoading }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading }, + }); + + expect(findSaveButton().props('loading')).toBe(showLoading); + }, + ); }); }); }); diff --git a/spec/frontend/registry/settings/graphql/cache_updated_spec.js b/spec/frontend/registry/settings/graphql/cache_updated_spec.js new file mode 100644 index 00000000000..e5f69a08285 --- /dev/null +++ b/spec/frontend/registry/settings/graphql/cache_updated_spec.js @@ -0,0 +1,56 @@ +import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update'; +import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql'; + +describe('Registry settings cache update', () => { + let client; + + const payload = { + data: { + updateContainerExpirationPolicy: { + containerExpirationPolicy: { + enabled: true, + }, + }, + }, + }; + + const cacheMock = { + project: { + containerExpirationPolicy: { + enabled: false, + }, + }, + }; + + const queryAndVariables = { + query: expirationPolicyQuery, + variables: { projectPath: 'foo' }, + }; + + beforeEach(() => { + client = { + readQuery: jest.fn().mockReturnValue(cacheMock), + writeQuery: jest.fn(), + }; + }); + describe('Registry settings cache update', () => { + it('calls readQuery', () => { + updateContainerExpirationPolicy('foo')(client, payload); + expect(client.readQuery).toHaveBeenCalledWith(queryAndVariables); + }); + + it('writes the correct result in the cache', () => { + updateContainerExpirationPolicy('foo')(client, payload); + expect(client.writeQuery).toHaveBeenCalledWith({ + ...queryAndVariables, + data: { + project: { + containerExpirationPolicy: { + enabled: true, + }, + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/registry/settings/mock_data.js b/spec/frontend/registry/settings/mock_data.js new file mode 100644 index 00000000000..6a936785b7c --- /dev/null +++ b/spec/frontend/registry/settings/mock_data.js @@ -0,0 +1,32 @@ +export const expirationPolicyPayload = override => ({ + data: { + project: { + containerExpirationPolicy: { + cadence: 'EVERY_DAY', + enabled: true, + keepN: 'TEN_TAGS', + nameRegex: 'asdasdssssdfdf', + nameRegexKeep: 'sss', + olderThan: 'FOURTEEN_DAYS', + ...override, + }, + }, + }, +}); + +export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) => ({ + data: { + updateContainerExpirationPolicy: { + containerExpirationPolicy: { + cadence: 'EVERY_DAY', + enabled: true, + keepN: 'TEN_TAGS', + nameRegex: 'asdasdssssdfdf', + nameRegexKeep: 'sss', + olderThan: 'FOURTEEN_DAYS', + ...override, + }, + errors, + }, + }, +}); diff --git a/spec/frontend/registry/settings/store/actions_spec.js b/spec/frontend/registry/settings/store/actions_spec.js deleted file mode 100644 index 51b89f96ef2..00000000000 --- a/spec/frontend/registry/settings/store/actions_spec.js +++ /dev/null @@ -1,90 +0,0 @@ -import testAction from 'helpers/vuex_action_helper'; -import Api from '~/api'; -import * as actions from '~/registry/settings/store/actions'; -import * as types from '~/registry/settings/store/mutation_types'; - -describe('Actions Registry Store', () => { - describe.each` - actionName | mutationName | payload - ${'setInitialState'} | ${types.SET_INITIAL_STATE} | ${'foo'} - ${'updateSettings'} | ${types.UPDATE_SETTINGS} | ${'foo'} - ${'toggleLoading'} | ${types.TOGGLE_LOADING} | ${undefined} - ${'resetSettings'} | ${types.RESET_SETTINGS} | ${undefined} - `( - '$actionName invokes $mutationName with payload $payload', - ({ actionName, mutationName, payload }) => { - it('should set state', done => { - testAction(actions[actionName], payload, {}, [{ type: mutationName, payload }], [], done); - }); - }, - ); - - describe('receiveSettingsSuccess', () => { - it('calls SET_SETTINGS', () => { - testAction( - actions.receiveSettingsSuccess, - 'foo', - {}, - [{ type: types.SET_SETTINGS, payload: 'foo' }], - [], - ); - }); - }); - - describe('fetchSettings', () => { - const state = { - projectId: 'bar', - }; - - const payload = { - data: { - container_expiration_policy: 'foo', - }, - }; - - it('should fetch the data from the API', done => { - Api.project = jest.fn().mockResolvedValue(payload); - testAction( - actions.fetchSettings, - null, - state, - [], - [ - { type: 'toggleLoading' }, - { type: 'receiveSettingsSuccess', payload: payload.data.container_expiration_policy }, - { type: 'toggleLoading' }, - ], - done, - ); - }); - }); - - describe('saveSettings', () => { - const state = { - projectId: 'bar', - settings: 'baz', - }; - - const payload = { - data: { - tag_expiration_policies: 'foo', - }, - }; - - it('should fetch the data from the API', done => { - Api.updateProject = jest.fn().mockResolvedValue(payload); - testAction( - actions.saveSettings, - null, - state, - [], - [ - { type: 'toggleLoading' }, - { type: 'receiveSettingsSuccess', payload: payload.data.container_expiration_policy }, - { type: 'toggleLoading' }, - ], - done, - ); - }); - }); -}); diff --git a/spec/frontend/registry/settings/store/getters_spec.js b/spec/frontend/registry/settings/store/getters_spec.js deleted file mode 100644 index b781d09466c..00000000000 --- a/spec/frontend/registry/settings/store/getters_spec.js +++ /dev/null @@ -1,72 +0,0 @@ -import * as getters from '~/registry/settings/store/getters'; -import * as utils from '~/registry/shared/utils'; -import { formOptions } from '../../shared/mock_data'; - -describe('Getters registry settings store', () => { - const settings = { - enabled: true, - cadence: 'foo', - keep_n: 'bar', - older_than: 'baz', - name_regex: 'name-foo', - name_regex_keep: 'name-keep-bar', - }; - - describe.each` - getter | variable | formOption - ${'getCadence'} | ${'cadence'} | ${'cadence'} - ${'getKeepN'} | ${'keep_n'} | ${'keepN'} - ${'getOlderThan'} | ${'older_than'} | ${'olderThan'} - `('Options getter', ({ getter, variable, formOption }) => { - beforeEach(() => { - utils.findDefaultOption = jest.fn(); - }); - - it(`${getter} returns ${variable} when ${variable} exists in settings`, () => { - expect(getters[getter]({ settings })).toBe(settings[variable]); - }); - - it(`${getter} calls findDefaultOption when ${variable} does not exists in settings`, () => { - getters[getter]({ settings: {}, formOptions }); - expect(utils.findDefaultOption).toHaveBeenCalledWith(formOptions[formOption]); - }); - }); - - describe('getSettings', () => { - it('returns the content of settings', () => { - const computedGetters = { - getCadence: settings.cadence, - getOlderThan: settings.older_than, - getKeepN: settings.keep_n, - }; - expect(getters.getSettings({ settings }, computedGetters)).toEqual(settings); - }); - }); - - describe('getIsEdited', () => { - it('returns false when original is equal to settings', () => { - const same = { foo: 'bar' }; - expect(getters.getIsEdited({ original: same, settings: same })).toBe(false); - }); - - it('returns true when original is different from settings', () => { - expect(getters.getIsEdited({ original: { foo: 'bar' }, settings: { foo: 'baz' } })).toBe( - true, - ); - }); - }); - - describe('getIsDisabled', () => { - it.each` - original | enableHistoricEntries | result - ${undefined} | ${false} | ${true} - ${{ foo: 'bar' }} | ${undefined} | ${false} - ${{}} | ${false} | ${false} - `( - 'returns $result when original is $original and enableHistoricEntries is $enableHistoricEntries', - ({ original, enableHistoricEntries, result }) => { - expect(getters.getIsDisabled({ original, enableHistoricEntries })).toBe(result); - }, - ); - }); -}); diff --git a/spec/frontend/registry/settings/store/mutations_spec.js b/spec/frontend/registry/settings/store/mutations_spec.js deleted file mode 100644 index 1d85e38eb36..00000000000 --- a/spec/frontend/registry/settings/store/mutations_spec.js +++ /dev/null @@ -1,80 +0,0 @@ -import mutations from '~/registry/settings/store/mutations'; -import * as types from '~/registry/settings/store/mutation_types'; -import createState from '~/registry/settings/store/state'; -import { formOptions, stringifiedFormOptions } from '../../shared/mock_data'; - -describe('Mutations Registry Store', () => { - let mockState; - - beforeEach(() => { - mockState = createState(); - }); - - describe('SET_INITIAL_STATE', () => { - it('should set the initial state', () => { - const payload = { - projectId: 'foo', - enableHistoricEntries: false, - adminSettingsPath: 'foo', - isAdmin: true, - }; - const expectedState = { ...mockState, ...payload, formOptions }; - mutations[types.SET_INITIAL_STATE](mockState, { - ...payload, - ...stringifiedFormOptions, - }); - - expect(mockState).toEqual(expectedState); - }); - }); - - describe('UPDATE_SETTINGS', () => { - it('should update the settings', () => { - mockState.settings = { foo: 'bar' }; - const payload = { foo: 'baz' }; - const expectedState = { ...mockState, settings: payload }; - mutations[types.UPDATE_SETTINGS](mockState, { settings: payload }); - expect(mockState.settings).toEqual(expectedState.settings); - }); - }); - - describe('SET_SETTINGS', () => { - it('should set the settings and original', () => { - const payload = { foo: 'baz' }; - const expectedState = { ...mockState, settings: payload }; - mutations[types.SET_SETTINGS](mockState, payload); - expect(mockState.settings).toEqual(expectedState.settings); - expect(mockState.original).toEqual(expectedState.settings); - }); - - it('should keep the default state when settings is not present', () => { - const originalSettings = { ...mockState.settings }; - mutations[types.SET_SETTINGS](mockState); - expect(mockState.settings).toEqual(originalSettings); - expect(mockState.original).toEqual(undefined); - }); - }); - - describe('RESET_SETTINGS', () => { - it('should copy original over settings', () => { - mockState.settings = { foo: 'bar' }; - mockState.original = { foo: 'baz' }; - mutations[types.RESET_SETTINGS](mockState); - expect(mockState.settings).toEqual(mockState.original); - }); - - it('if original is undefined it should initialize to empty object', () => { - mockState.settings = { foo: 'bar' }; - mockState.original = undefined; - mutations[types.RESET_SETTINGS](mockState); - expect(mockState.settings).toEqual({}); - }); - }); - - describe('TOGGLE_LOADING', () => { - it('should toggle the loading', () => { - mutations[types.TOGGLE_LOADING](mockState); - expect(mockState.isLoading).toEqual(true); - }); - }); -}); diff --git a/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap new file mode 100644 index 00000000000..032007bba51 --- /dev/null +++ b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Utils formOptionsGenerator returns an object containing cadence 1`] = ` +Array [ + Object { + "default": true, + "key": "EVERY_DAY", + "label": "Every day", + }, + Object { + "default": false, + "key": "EVERY_WEEK", + "label": "Every week", + }, + Object { + "default": false, + "key": "EVERY_TWO_WEEKS", + "label": "Every two weeks", + }, + Object { + "default": false, + "key": "EVERY_MONTH", + "label": "Every month", + }, + Object { + "default": false, + "key": "EVERY_THREE_MONTHS", + "label": "Every three months", + }, +] +`; + +exports[`Utils formOptionsGenerator returns an object containing keepN 1`] = ` +Array [ + Object { + "default": false, + "key": "ONE_TAG", + "label": "1 tag per image name", + "variable": 1, + }, + Object { + "default": false, + "key": "FIVE_TAGS", + "label": "5 tags per image name", + "variable": 5, + }, + Object { + "default": true, + "key": "TEN_TAGS", + "label": "10 tags per image name", + "variable": 10, + }, + Object { + "default": false, + "key": "TWENTY_FIVE_TAGS", + "label": "25 tags per image name", + "variable": 25, + }, + Object { + "default": false, + "key": "FIFTY_TAGS", + "label": "50 tags per image name", + "variable": 50, + }, + Object { + "default": false, + "key": "ONE_HUNDRED_TAGS", + "label": "100 tags per image name", + "variable": 100, + }, +] +`; + +exports[`Utils formOptionsGenerator returns an object containing olderThan 1`] = ` +Array [ + Object { + "default": false, + "key": "SEVEN_DAYS", + "label": "7 days until tags are automatically removed", + "variable": 7, + }, + Object { + "default": false, + "key": "FOURTEEN_DAYS", + "label": "14 days until tags are automatically removed", + "variable": 14, + }, + Object { + "default": false, + "key": "THIRTY_DAYS", + "label": "30 days until tags are automatically removed", + "variable": 30, + }, + Object { + "default": true, + "key": "NINETY_DAYS", + "label": "90 days until tags are automatically removed", + "variable": 90, + }, +] +`; diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js index ee765ffd1c0..bee9bca5369 100644 --- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js +++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js @@ -40,13 +40,13 @@ describe('Expiration Policy Form', () => { }); describe.each` - elementName | modelName | value | disabledByToggle - ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'} - ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'} - ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'} - ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'} - ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'} - ${'keep-name'} | ${'name_regex_keep'} | ${'bar'} | ${'disabled'} + elementName | modelName | value | disabledByToggle + ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'} + ${'interval'} | ${'olderThan'} | ${'foo'} | ${'disabled'} + ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'} + ${'latest'} | ${'keepN'} | ${'foo'} | ${'disabled'} + ${'name-matching'} | ${'nameRegex'} | ${'foo'} | ${'disabled'} + ${'keep-name'} | ${'nameRegexKeep'} | ${'bar'} | ${'disabled'} `( `${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`, ({ elementName, modelName, value, disabledByToggle }) => { @@ -128,9 +128,9 @@ describe('Expiration Policy Form', () => { }); describe.each` - modelName | elementName - ${'name_regex'} | ${'name-matching'} - ${'name_regex_keep'} | ${'keep-name'} + modelName | elementName + ${'nameRegex'} | ${'name-matching'} + ${'nameRegexKeep'} | ${'keep-name'} `('regex textarea validation', ({ modelName, elementName }) => { const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); diff --git a/spec/frontend/registry/shared/stubs.js b/spec/frontend/registry/shared/stubs.js new file mode 100644 index 00000000000..f6b88d70e49 --- /dev/null +++ b/spec/frontend/registry/shared/stubs.js @@ -0,0 +1,11 @@ +export const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' }; +export const GlCard = { + name: 'gl-card-stub', + template: ` +<div> + <slot name="header"></slot> + <slot></slot> + <slot name="footer"></slot> +</div> +`, +}; diff --git a/spec/frontend/registry/shared/utils_spec.js b/spec/frontend/registry/shared/utils_spec.js new file mode 100644 index 00000000000..edb0c3261be --- /dev/null +++ b/spec/frontend/registry/shared/utils_spec.js @@ -0,0 +1,37 @@ +import { + formOptionsGenerator, + optionLabelGenerator, + olderThanTranslationGenerator, +} from '~/registry/shared/utils'; + +describe('Utils', () => { + describe('optionLabelGenerator', () => { + it('returns an array with a set label', () => { + const result = optionLabelGenerator( + [{ variable: 1 }, { variable: 2 }], + olderThanTranslationGenerator, + ); + expect(result).toEqual([ + { variable: 1, label: '1 day until tags are automatically removed' }, + { variable: 2, label: '2 days until tags are automatically removed' }, + ]); + }); + }); + + describe('formOptionsGenerator', () => { + it('returns an object containing olderThan', () => { + expect(formOptionsGenerator().olderThan).toBeDefined(); + expect(formOptionsGenerator().olderThan).toMatchSnapshot(); + }); + + it('returns an object containing cadence', () => { + expect(formOptionsGenerator().cadence).toBeDefined(); + expect(formOptionsGenerator().cadence).toMatchSnapshot(); + }); + + it('returns an object containing keepN', () => { + expect(formOptionsGenerator().keepN).toBeDefined(); + expect(formOptionsGenerator().keepN).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js index 1b938c93df8..db33a9cdce1 100644 --- a/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js +++ b/spec/frontend/related_merge_requests/components/related_merge_requests_spec.js @@ -19,9 +19,8 @@ describe('RelatedMergeRequests', () => { mockData = getJSONFixture(FIXTURE_PATH); // put the fixture in DOM as the component expects - document.body.innerHTML = `<div id="js-issuable-app-initial-data">${JSON.stringify( - mockData, - )}</div>`; + document.body.innerHTML = `<div id="js-issuable-app"></div>`; + document.getElementById('js-issuable-app').dataset.initial = JSON.stringify(mockData); mock = new MockAdapter(axios); mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 }); diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index f56e296d106..84247e2a5a0 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -5,109 +5,123 @@ Object { "data": Array [ Object { "_links": Object { - "editUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit", - "issuesUrl": null, - "mergeRequestsUrl": null, - "self": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10", - "selfUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10", + "editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit", + "issuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened", + "mergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened", + "self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1", + "selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1", }, "assets": Object { - "count": 7, + "count": 8, "links": Array [ Object { - "directAssetUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-3", "external": true, - "id": "gid://gitlab/Releases::Link/69", - "linkType": "other", - "name": "An example link", - "url": "https://example.com/link", + "id": "gid://gitlab/Releases::Link/13", + "linkType": "image", + "name": "Image", + "url": "https://example.com/image", }, Object { - "directAssetUrl": "https://example.com/package", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-2", "external": true, - "id": "gid://gitlab/Releases::Link/68", + "id": "gid://gitlab/Releases::Link/12", "linkType": "package", - "name": "An example package link", + "name": "Package", "url": "https://example.com/package", }, Object { - "directAssetUrl": "https://example.com/image", + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-1", + "external": false, + "id": "gid://gitlab/Releases::Link/11", + "linkType": "runbook", + "name": "Runbook", + "url": "http://localhost/releases-namespace/releases-project/runbook", + }, + Object { + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/linux-amd64", "external": true, - "id": "gid://gitlab/Releases::Link/67", - "linkType": "image", - "name": "An example image", - "url": "https://example.com/image", + "id": "gid://gitlab/Releases::Link/10", + "linkType": "other", + "name": "linux-amd64 binaries", + "url": "https://downloads.example.com/bin/gitlab-linux-amd64", }, ], "sources": Array [ Object { "format": "zip", - "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.zip", }, Object { "format": "tar.gz", - "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.gz", }, Object { "format": "tar.bz2", - "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.bz2", }, Object { "format": "tar", - "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar", }, ], }, "author": Object { - "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", - "username": "root", - "webUrl": "http://0.0.0.0:3000/root", + "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "username": "administrator", + "webUrl": "http://localhost/administrator", }, "commit": Object { - "shortId": "92e7ea2e", - "title": "Testing a change.", + "shortId": "b83d6e39", + "title": "Merge branch 'branch-merged' into 'master'", }, - "commitPath": "http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7", - "descriptionHtml": "<p data-sourcepos=\\"1:1-1:24\\" dir=\\"auto\\">This is version <strong>1.0</strong>!</p>", + "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", + "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 { - "collectedAt": "2020-08-21T20:15:19Z", - "filepath": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json", - "sha": "22bde8e8b93d870a29ddc339287a1fbb598f45d1396d", + "collectedAt": "2018-12-03T00:00:00Z", + "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json", + "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d", }, ], "milestones": Array [ Object { - "description": "", - "id": "gid://gitlab/Milestone/60", + "description": "The 12.4 milestone", + "id": "gid://gitlab/Milestone/124", "issueStats": Object { - "closed": 0, - "total": 0, + "closed": 1, + "total": 4, }, "stats": undefined, "title": "12.4", "webPath": undefined, - "webUrl": "/root/release-test/-/milestones/2", + "webUrl": "/releases-namespace/releases-project/-/milestones/2", }, Object { - "description": "Milestone 12.3", - "id": "gid://gitlab/Milestone/59", + "description": "The 12.3 milestone", + "id": "gid://gitlab/Milestone/123", "issueStats": Object { - "closed": 1, - "total": 2, + "closed": 3, + "total": 5, }, "stats": undefined, "title": "12.3", "webPath": undefined, - "webUrl": "/root/release-test/-/milestones/1", + "webUrl": "/releases-namespace/releases-project/-/milestones/1", }, ], - "name": "Release 1.0", - "releasedAt": "2020-08-21T20:15:18Z", - "tagName": "v5.10", - "tagPath": "/root/release-test/-/tags/v5.10", - "upcomingRelease": false, + "name": "The first release", + "releasedAt": "2018-12-10T00:00:00Z", + "tagName": "v1.1", + "tagPath": "/releases-namespace/releases-project/-/tags/v1.1", + "upcomingRelease": true, }, ], + "paginationInfo": Object { + "endCursor": "eyJpZCI6IjEiLCJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyJ9", + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "eyJpZCI6IjEiLCJyZWxlYXNlZF9hdCI6IjIwMTgtMTItMTAgMDA6MDA6MDAuMDAwMDAwMDAwIFVUQyJ9", + }, } `; diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index e9727801c1a..3367ca8ba3a 100644 --- a/spec/frontend/releases/components/app_edit_new_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -3,12 +3,15 @@ import { mount } from '@vue/test-utils'; import { merge } from 'lodash'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { getJSONFixture } from 'helpers/fixtures'; import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; -import { release as originalRelease, milestones as originalMilestones } from '../mock_data'; import * as commonUtils from '~/lib/utils/common_utils'; import { BACK_URL_PARAM } from '~/releases/constants'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; +const originalRelease = getJSONFixture('api/releases/release.json'); +const originalMilestones = originalRelease.milestones; + describe('Release edit/new component', () => { let wrapper; let release; diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index bcb87509cc3..9f1577c2f1e 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -2,27 +2,33 @@ import { range as rge } from 'lodash'; import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; +import { getJSONFixture } from 'helpers/fixtures'; import ReleasesApp from '~/releases/components/app_index.vue'; import createStore from '~/releases/stores'; import createListModule from '~/releases/stores/modules/list'; import api from '~/api'; -import { - pageInfoHeadersWithoutPagination, - pageInfoHeadersWithPagination, - release2 as release, - releases, -} from '../mock_data'; +import { pageInfoHeadersWithoutPagination, pageInfoHeadersWithPagination } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import ReleasesPagination from '~/releases/components/releases_pagination.vue'; + +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + getParameterByName: jest.fn().mockImplementation(paramName => { + return `${paramName}_param_value`; + }), +})); const localVue = createLocalVue(); localVue.use(Vuex); +const release = getJSONFixture('api/releases/release.json'); +const releases = [release]; + describe('Releases App ', () => { let wrapper; let fetchReleaseSpy; - const releasesPagination = rge(21).map(index => ({ + const paginatedReleases = rge(21).map(index => ({ ...convertObjectPropsToCamelCase(release, { deep: true }), tagName: `${index}.00`, })); @@ -70,9 +76,13 @@ describe('Releases App ', () => { createComponent(); }); - it('calls fetchRelease with the page parameter', () => { + it('calls fetchRelease with the page, before, and after parameters', () => { expect(fetchReleaseSpy).toHaveBeenCalledTimes(1); - expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { page: null }); + expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { + page: 'page_param_value', + before: 'before_param_value', + after: 'after_param_value', + }); }); }); @@ -91,7 +101,7 @@ describe('Releases App ', () => { expect(wrapper.contains('.js-loading')).toBe(true); expect(wrapper.contains('.js-empty-state')).toBe(false); expect(wrapper.contains('.js-success-state')).toBe(false); - expect(wrapper.contains(TablePagination)).toBe(false); + expect(wrapper.contains(ReleasesPagination)).toBe(false); }); }); @@ -108,7 +118,7 @@ describe('Releases App ', () => { expect(wrapper.contains('.js-loading')).toBe(false); expect(wrapper.contains('.js-empty-state')).toBe(false); expect(wrapper.contains('.js-success-state')).toBe(true); - expect(wrapper.contains(TablePagination)).toBe(true); + expect(wrapper.contains(ReleasesPagination)).toBe(true); }); }); @@ -116,7 +126,7 @@ describe('Releases App ', () => { beforeEach(() => { jest .spyOn(api, 'releases') - .mockResolvedValue({ data: releasesPagination, headers: pageInfoHeadersWithPagination }); + .mockResolvedValue({ data: paginatedReleases, headers: pageInfoHeadersWithPagination }); createComponent(); }); @@ -125,7 +135,7 @@ describe('Releases App ', () => { expect(wrapper.contains('.js-loading')).toBe(false); expect(wrapper.contains('.js-empty-state')).toBe(false); expect(wrapper.contains('.js-success-state')).toBe(true); - expect(wrapper.contains(TablePagination)).toBe(true); + expect(wrapper.contains(ReleasesPagination)).toBe(true); }); }); @@ -154,7 +164,7 @@ describe('Releases App ', () => { const newReleasePath = 'path/to/new/release'; beforeEach(() => { - createComponent({ ...defaultInitialState, newReleasePath }); + createComponent({ newReleasePath }); }); it('renders the "New release" button', () => { @@ -174,4 +184,27 @@ describe('Releases App ', () => { }); }); }); + + describe('when the back button is pressed', () => { + beforeEach(() => { + jest + .spyOn(api, 'releases') + .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); + + createComponent(); + + fetchReleaseSpy.mockClear(); + + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + it('calls fetchRelease with the page parameter', () => { + expect(fetchReleaseSpy).toHaveBeenCalledTimes(1); + expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { + page: 'page_param_value', + before: 'before_param_value', + after: 'after_param_value', + }); + }); + }); }); diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 502a1053663..181fa0150f1 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -1,11 +1,13 @@ import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; +import { getJSONFixture } from 'helpers/fixtures'; import ReleaseShowApp from '~/releases/components/app_show.vue'; -import { release as originalRelease } from '../mock_data'; +import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +const originalRelease = getJSONFixture('api/releases/release.json'); + describe('Release show component', () => { let wrapper; let release; @@ -33,7 +35,7 @@ describe('Release show component', () => { wrapper = shallowMount(ReleaseShowApp, { store }); }; - const findLoadingSkeleton = () => wrapper.find(GlSkeletonLoading); + const findLoadingSkeleton = () => wrapper.find(ReleaseSkeletonLoader); const findReleaseBlock = () => wrapper.find(ReleaseBlock); it('calls fetchRelease when the component is created', () => { diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js index 582c0b32716..e5b8ed267a0 100644 --- a/spec/frontend/releases/components/asset_links_form_spec.js +++ b/spec/frontend/releases/components/asset_links_form_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; +import { getJSONFixture } from 'helpers/fixtures'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; -import { release as originalRelease } from '../mock_data'; import * as commonUtils from '~/lib/utils/common_utils'; import { ENTER_KEY } from '~/lib/utils/keys'; import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; @@ -9,6 +9,8 @@ import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; const localVue = createLocalVue(); localVue.use(Vuex); +const originalRelease = getJSONFixture('api/releases/release.json'); + describe('Release edit component', () => { let wrapper; let release; @@ -223,10 +225,18 @@ describe('Release edit component', () => { }); }); - it('selects the default asset type if no type was provided by the backend', () => { - const selected = wrapper.find({ ref: 'typeSelect' }).element.value; + describe('when no link type was provided by the backend', () => { + beforeEach(() => { + delete release.assets.links[0].linkType; + + factory({ release }); + }); + + it('selects the default asset type', () => { + const selected = wrapper.find({ ref: 'typeSelect' }).element.value; - expect(selected).toBe(DEFAULT_ASSET_LINK_TYPE); + expect(selected).toBe(DEFAULT_ASSET_LINK_TYPE); + }); }); }); diff --git a/spec/frontend/releases/components/evidence_block_spec.js b/spec/frontend/releases/components/evidence_block_spec.js index ba60a79e464..b8c78f90fc2 100644 --- a/spec/frontend/releases/components/evidence_block_spec.js +++ b/spec/frontend/releases/components/evidence_block_spec.js @@ -1,11 +1,13 @@ import { mount } from '@vue/test-utils'; import { GlLink, GlIcon } from '@gitlab/ui'; +import { getJSONFixture } from 'helpers/fixtures'; import { truncateSha } from '~/lib/utils/text_utility'; -import { release as originalRelease } from '../mock_data'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +const originalRelease = getJSONFixture('api/releases/release.json'); + describe('Evidence Block', () => { let wrapper; let release; @@ -35,7 +37,7 @@ describe('Evidence Block', () => { }); it('renders the title for the dowload link', () => { - expect(wrapper.find(GlLink).text()).toBe('v1.1.2-evidences-1.json'); + expect(wrapper.find(GlLink).text()).toBe(`v1.1-evidences-1.json`); }); it('renders the correct hover text for the download', () => { @@ -43,7 +45,7 @@ describe('Evidence Block', () => { }); it('renders the correct file link for download', () => { - expect(wrapper.find(GlLink).attributes().download).toBe('v1.1.2-evidences-1.json'); + expect(wrapper.find(GlLink).attributes().download).toBe(`v1.1-evidences-1.json`); }); describe('sha text', () => { diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js index 3453ecbf8ab..adccd9d87ef 100644 --- a/spec/frontend/releases/components/release_block_assets_spec.js +++ b/spec/frontend/releases/components/release_block_assets_spec.js @@ -1,10 +1,12 @@ import { mount } from '@vue/test-utils'; import { GlCollapse } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; -import { cloneDeep } from 'lodash'; +import { getJSONFixture } from 'helpers/fixtures'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue'; import { ASSET_LINK_TYPE } from '~/releases/constants'; -import { assets } from '../mock_data'; + +const { assets } = getJSONFixture('api/releases/release.json'); describe('Release block assets', () => { let wrapper; @@ -31,7 +33,7 @@ describe('Release block assets', () => { wrapper.findAll('h5').filter(h5 => h5.text() === sections[type]); beforeEach(() => { - defaultProps = { assets: cloneDeep(assets) }; + defaultProps = { assets: convertObjectPropsToCamelCase(assets, { deep: true }) }; }); describe('with default props', () => { @@ -43,7 +45,7 @@ describe('Release block assets', () => { const accordionButton = findAccordionButton(); expect(accordionButton.exists()).toBe(true); - expect(trimText(accordionButton.text())).toBe('Assets 5'); + expect(trimText(accordionButton.text())).toBe('Assets 8'); }); it('renders the accordion as expanded by default', () => { diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index bde01cc0e00..f1c0c24f8ca 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -1,11 +1,13 @@ import { mount } from '@vue/test-utils'; import { GlLink, GlIcon } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; +import { getJSONFixture } from 'helpers/fixtures'; import { cloneDeep } from 'lodash'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; -import { release as originalRelease } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +const originalRelease = getJSONFixture('api/releases/release.json'); + const mockFutureDate = new Date(9999, 0, 0).toISOString(); let mockIsFutureRelease = false; diff --git a/spec/frontend/releases/components/release_block_header_spec.js b/spec/frontend/releases/components/release_block_header_spec.js index 9c6cbc86d3c..f2159871395 100644 --- a/spec/frontend/releases/components/release_block_header_spec.js +++ b/spec/frontend/releases/components/release_block_header_spec.js @@ -1,11 +1,13 @@ import { shallowMount } from '@vue/test-utils'; import { merge } from 'lodash'; import { GlLink } from '@gitlab/ui'; +import { getJSONFixture } from 'helpers/fixtures'; import ReleaseBlockHeader from '~/releases/components/release_block_header.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { release as originalRelease } from '../mock_data'; import { BACK_URL_PARAM } from '~/releases/constants'; +const originalRelease = getJSONFixture('api/releases/release.json'); + describe('Release block header', () => { let wrapper; let release; @@ -49,7 +51,7 @@ describe('Release block header', () => { }); it('renders the title as text', () => { - expect(findHeader().text()).toBe(release.name); + expect(findHeader().text()).toContain(release.name); expect(findHeaderLink().exists()).toBe(false); }); }); diff --git a/spec/frontend/releases/components/release_block_metadata_spec.js b/spec/frontend/releases/components/release_block_metadata_spec.js index 6f184e45600..9038553fc8e 100644 --- a/spec/frontend/releases/components/release_block_metadata_spec.js +++ b/spec/frontend/releases/components/release_block_metadata_spec.js @@ -1,10 +1,12 @@ import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; +import { getJSONFixture } from 'helpers/fixtures'; import { cloneDeep } from 'lodash'; import ReleaseBlockMetadata from '~/releases/components/release_block_metadata.vue'; -import { release as originalRelease } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +const originalRelease = getJSONFixture('api/releases/release.json'); + const mockFutureDate = new Date(9999, 0, 0).toISOString(); let mockIsFutureRelease = false; diff --git a/spec/frontend/releases/components/release_block_milestone_info_spec.js b/spec/frontend/releases/components/release_block_milestone_info_spec.js index 0e79c45b337..45f4eaa01a9 100644 --- a/spec/frontend/releases/components/release_block_milestone_info_spec.js +++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js @@ -1,11 +1,13 @@ import { mount } from '@vue/test-utils'; import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; +import { getJSONFixture } from 'helpers/fixtures'; import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue'; -import { milestones as originalMilestones } from '../mock_data'; import { MAX_MILESTONES_TO_DISPLAY } from '~/releases/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +const { milestones: originalMilestones } = getJSONFixture('api/releases/release.json'); + describe('Release block milestone info', () => { let wrapper; let milestones; @@ -35,7 +37,7 @@ describe('Release block milestone info', () => { beforeEach(() => factory({ milestones })); it('renders the correct percentage', () => { - expect(milestoneProgressBarContainer().text()).toContain('41% complete'); + expect(milestoneProgressBarContainer().text()).toContain('44% complete'); }); it('renders a progress bar that displays the correct percentage', () => { @@ -44,14 +46,24 @@ describe('Release block milestone info', () => { expect(progressBar.exists()).toBe(true); expect(progressBar.attributes()).toEqual( expect.objectContaining({ - value: '22', - max: '54', + value: '4', + max: '9', }), ); }); it('renders a list of links to all associated milestones', () => { - expect(trimText(milestoneListContainer().text())).toContain('Milestones 13.6 • 13.5'); + // The API currently returns the milestones in a non-deterministic order, + // which causes the frontend fixture used by this test to return the + // milestones in one order locally and a different order in the CI pipeline. + // This is a bug and is tracked here: https://gitlab.com/gitlab-org/gitlab/-/issues/259012 + // When this bug is fixed this expectation should be updated to + // assert the expected order. + const containerText = trimText(milestoneListContainer().text()); + expect( + containerText.includes('Milestones 12.4 • 12.3') || + containerText.includes('Milestones 12.3 • 12.4'), + ).toBe(true); milestones.forEach((m, i) => { const milestoneLink = milestoneListContainer() @@ -65,7 +77,7 @@ describe('Release block milestone info', () => { }); it('renders the "Issues" section with a total count of issues associated to the milestone(s)', () => { - const totalIssueCount = 54; + const totalIssueCount = 9; const issuesContainerText = trimText(issuesContainer().text()); expect(issuesContainerText).toContain(`Issues ${totalIssueCount}`); @@ -73,7 +85,7 @@ describe('Release block milestone info', () => { const badge = issuesContainer().find(GlBadge); expect(badge.text()).toBe(totalIssueCount.toString()); - expect(issuesContainerText).toContain('Open: 32 • Closed: 22'); + expect(issuesContainerText).toContain('Open: 5 • Closed: 4'); }); }); diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js index a7f1388664b..af5e538b95e 100644 --- a/spec/frontend/releases/components/release_block_spec.js +++ b/spec/frontend/releases/components/release_block_spec.js @@ -1,15 +1,17 @@ import $ from 'jquery'; import { mount } from '@vue/test-utils'; import { GlIcon } from '@gitlab/ui'; +import { getJSONFixture } from 'helpers/fixtures'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { release as originalRelease } from '../mock_data'; import * as commonUtils from '~/lib/utils/common_utils'; import { BACK_URL_PARAM } from '~/releases/constants'; import * as urlUtility from '~/lib/utils/url_utility'; +const originalRelease = getJSONFixture('api/releases/release.json'); + describe('Release block', () => { let wrapper; let release; @@ -46,7 +48,7 @@ describe('Release block', () => { beforeEach(() => factory(release)); it("renders the block with an id equal to the release's tag name", () => { - expect(wrapper.attributes().id).toBe('v0.3'); + expect(wrapper.attributes().id).toBe(release.tagName); }); it(`renders an edit button that links to the "Edit release" page with a "${BACK_URL_PARAM}" parameter`, () => { @@ -107,7 +109,7 @@ describe('Release block', () => { }); it('does not render external label when link is not external', () => { - expect(wrapper.find('.js-assets-list li:nth-child(2) a').text()).not.toContain( + expect(wrapper.find('.js-assets-list li:nth-child(3) a').text()).not.toContain( 'external source', ); }); diff --git a/spec/frontend/releases/components/release_skeleton_loader_spec.js b/spec/frontend/releases/components/release_skeleton_loader_spec.js new file mode 100644 index 00000000000..7fbf864568a --- /dev/null +++ b/spec/frontend/releases/components/release_skeleton_loader_spec.js @@ -0,0 +1,15 @@ +import { mount } from '@vue/test-utils'; +import { GlSkeletonLoader } from '@gitlab/ui'; +import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; + +describe('release_skeleton_loader.vue', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(ReleaseSkeletonLoader); + }); + + it('renders a GlSkeletonLoader', () => { + expect(wrapper.find(GlSkeletonLoader).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/releases/components/releases_pagination_graphql_spec.js b/spec/frontend/releases/components/releases_pagination_graphql_spec.js index b01a28eb6c3..bba5e532e5e 100644 --- a/spec/frontend/releases/components/releases_pagination_graphql_spec.js +++ b/spec/frontend/releases/components/releases_pagination_graphql_spec.js @@ -29,7 +29,7 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { listModule.state.graphQlPageInfo = pageInfo; - listModule.actions.fetchReleasesGraphQl = jest.fn(); + listModule.actions.fetchReleases = jest.fn(); wrapper = mount(ReleasesPaginationGraphql, { store: createStore({ @@ -141,8 +141,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { findNextButton().trigger('click'); }); - it('calls fetchReleasesGraphQl with the correct after cursor', () => { - expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([ + it('calls fetchReleases with the correct after cursor', () => { + expect(listModule.actions.fetchReleases.mock.calls).toEqual([ [expect.anything(), { after: cursors.endCursor }], ]); }); @@ -159,8 +159,8 @@ describe('~/releases/components/releases_pagination_graphql.vue', () => { findPrevButton().trigger('click'); }); - it('calls fetchReleasesGraphQl with the correct before cursor', () => { - expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([ + it('calls fetchReleases with the correct before cursor', () => { + expect(listModule.actions.fetchReleases.mock.calls).toEqual([ [expect.anything(), { before: cursors.startCursor }], ]); }); diff --git a/spec/frontend/releases/components/releases_pagination_rest_spec.js b/spec/frontend/releases/components/releases_pagination_rest_spec.js index 4fd3e085fc9..59c0c31413a 100644 --- a/spec/frontend/releases/components/releases_pagination_rest_spec.js +++ b/spec/frontend/releases/components/releases_pagination_rest_spec.js @@ -20,9 +20,9 @@ describe('~/releases/components/releases_pagination_rest.vue', () => { const createComponent = pageInfo => { listModule = createListModule({ projectId }); - listModule.state.pageInfo = pageInfo; + listModule.state.restPageInfo = pageInfo; - listModule.actions.fetchReleasesRest = jest.fn(); + listModule.actions.fetchReleases = jest.fn(); wrapper = mount(ReleasesPaginationRest, { store: createStore({ @@ -57,8 +57,8 @@ describe('~/releases/components/releases_pagination_rest.vue', () => { findGlPagination().vm.$emit('input', newPage); }); - it('calls fetchReleasesRest with the correct page', () => { - expect(listModule.actions.fetchReleasesRest.mock.calls).toEqual([ + it('calls fetchReleases with the correct page', () => { + expect(listModule.actions.fetchReleases.mock.calls).toEqual([ [expect.anything(), { page: newPage }], ]); }); diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js index 58cd69a2f6a..c89182faa44 100644 --- a/spec/frontend/releases/mock_data.js +++ b/spec/frontend/releases/mock_data.js @@ -1,139 +1,3 @@ -import { ASSET_LINK_TYPE } from '~/releases/constants'; - -export const milestones = [ - { - id: 50, - iid: 2, - project_id: 18, - title: '13.6', - description: 'The 13.6 milestone!', - state: 'active', - created_at: '2019-08-27T17:22:38.280Z', - updated_at: '2019-08-27T17:22:38.280Z', - due_date: '2019-09-19', - start_date: '2019-08-31', - web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/2', - issue_stats: { - total: 33, - closed: 19, - }, - }, - { - id: 49, - iid: 1, - project_id: 18, - title: '13.5', - description: 'The 13.5 milestone!', - state: 'active', - created_at: '2019-08-26T17:55:48.643Z', - updated_at: '2019-08-26T17:55:48.643Z', - due_date: '2019-10-11', - start_date: '2019-08-19', - web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/1', - issue_stats: { - total: 21, - closed: 3, - }, - }, -]; - -export const release = { - name: 'New release', - tag_name: 'v0.3', - tag_path: '/root/release-test/-/tags/v0.3', - description: 'A super nice release!', - description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>', - created_at: '2019-08-26T17:54:04.952Z', - released_at: '2019-08-26T17:54:04.807Z', - author: { - id: 1, - name: 'Administrator', - username: 'root', - state: 'active', - avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', - web_url: 'http://0.0.0.0:3001/root', - }, - commit: { - id: 'c22b0728d1b465f82898c884d32b01aa642f96c1', - short_id: 'c22b0728', - created_at: '2019-08-26T17:47:07.000Z', - parent_ids: [], - title: 'Initial commit', - message: 'Initial commit', - author_name: 'Administrator', - author_email: 'admin@example.com', - authored_date: '2019-08-26T17:47:07.000Z', - committer_name: 'Administrator', - committer_email: 'admin@example.com', - committed_date: '2019-08-26T17:47:07.000Z', - }, - commit_path: '/root/release-test/commit/c22b0728d1b465f82898c884d32b01aa642f96c1', - upcoming_release: false, - milestones, - evidences: [ - { - filepath: - 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/1.json', - sha: 'fb3a125fd69a0e5048ebfb0ba43eb32ce4911520dd8d', - collected_at: '2018-10-19 15:43:20 +0200', - }, - { - filepath: - 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/2.json', - sha: '6ebd17a66e6a861175735416e49cf677678029805712dd71bb805c609e2d9108', - collected_at: '2018-10-19 15:43:20 +0200', - }, - { - filepath: - 'https://20592.qa-tunnel.gitlab.info/root/test-deployments/-/releases/v1.1.2/evidences/3.json', - sha: '2f65beaf275c3cb4b4e24fb01d481cc475d69c957830833f15338384816b5cba', - collected_at: '2018-10-19 15:43:20 +0200', - }, - ], - assets: { - count: 5, - sources: [ - { - format: 'zip', - url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip', - }, - { - format: 'tar.gz', - url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz', - }, - { - format: 'tar.bz2', - url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2', - }, - { - format: 'tar', - url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar', - }, - ], - links: [ - { - id: 1, - name: 'my link', - url: 'https://google.com', - direct_asset_url: 'https://redirected.google.com', - external: true, - }, - { - id: 2, - name: 'my second link', - url: - 'https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50', - direct_asset_url: 'https://redirected.google.com', - external: false, - }, - ], - }, - _links: { - self: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3', - edit_url: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit', - }, -}; - export const pageInfoHeadersWithoutPagination = { 'X-NEXT-PAGE': '', 'X-PAGE': '1', @@ -151,202 +15,3 @@ export const pageInfoHeadersWithPagination = { 'X-TOTAL': '21', 'X-TOTAL-PAGES': '2', }; - -export const assets = { - count: 5, - sources: [ - { - format: 'zip', - url: 'https://example.gitlab.com/path/to/zip', - }, - ], - links: [ - { - linkType: ASSET_LINK_TYPE.IMAGE, - url: 'https://example.gitlab.com/path/to/image', - directAssetUrl: 'https://example.gitlab.com/path/to/image', - name: 'Example image link', - }, - { - linkType: ASSET_LINK_TYPE.PACKAGE, - url: 'https://example.gitlab.com/path/to/package', - directAssetUrl: 'https://example.gitlab.com/path/to/package', - name: 'Example package link', - }, - { - linkType: ASSET_LINK_TYPE.RUNBOOK, - url: 'https://example.gitlab.com/path/to/runbook', - directAssetUrl: 'https://example.gitlab.com/path/to/runbook', - name: 'Example runbook link', - }, - { - linkType: ASSET_LINK_TYPE.OTHER, - url: 'https://example.gitlab.com/path/to/link', - directAssetUrl: 'https://example.gitlab.com/path/to/link', - name: 'Example link', - }, - ], -}; - -export const release2 = { - name: 'Bionic Beaver', - tag_name: '18.04', - description: '## changelog\n\n* line 1\n* line2', - description_html: '<div><h2>changelog</h2><ul><li>line1</li<li>line 2</li></ul></div>', - author_name: 'Release bot', - author_email: 'release-bot@example.com', - created_at: '2012-05-28T05:00:00-07:00', - commit: { - id: '2695effb5807a22ff3d138d593fd856244e155e7', - short_id: '2695effb', - title: 'Initial commit', - created_at: '2017-07-26T11:08:53.000+02:00', - parent_ids: ['2a4b78934375d7f53875269ffd4f45fd83a84ebe'], - message: 'Initial commit', - author: { - avatar_url: 'uploads/-/system/user/avatar/johndoe/avatar.png', - id: 482476, - name: 'John Doe', - path: '/johndoe', - state: 'active', - status_tooltip_html: null, - username: 'johndoe', - web_url: 'https://gitlab.com/johndoe', - }, - authored_date: '2012-05-28T04:42:42-07:00', - committer_name: 'Jack Smith', - committer_email: 'jack@example.com', - committed_date: '2012-05-28T04:42:42-07:00', - }, - assets, -}; - -export const releases = [release, release2]; - -export const graphqlReleasesResponse = { - data: { - project: { - releases: { - count: 39, - nodes: [ - { - name: 'Release 1.0', - tagName: 'v5.10', - tagPath: '/root/release-test/-/tags/v5.10', - descriptionHtml: - '<p data-sourcepos="1:1-1:24" dir="auto">This is version <strong>1.0</strong>!</p>', - releasedAt: '2020-08-21T20:15:18Z', - upcomingRelease: false, - assets: { - count: 7, - sources: { - nodes: [ - { - format: 'zip', - url: - 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip', - }, - { - format: 'tar.gz', - url: - 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz', - }, - { - format: 'tar.bz2', - url: - 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2', - }, - { - format: 'tar', - url: - 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar', - }, - ], - }, - links: { - nodes: [ - { - id: 'gid://gitlab/Releases::Link/69', - name: 'An example link', - url: 'https://example.com/link', - directAssetUrl: - 'http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook', - linkType: 'OTHER', - external: true, - }, - { - id: 'gid://gitlab/Releases::Link/68', - name: 'An example package link', - url: 'https://example.com/package', - directAssetUrl: 'https://example.com/package', - linkType: 'PACKAGE', - external: true, - }, - { - id: 'gid://gitlab/Releases::Link/67', - name: 'An example image', - url: 'https://example.com/image', - directAssetUrl: 'https://example.com/image', - linkType: 'IMAGE', - external: true, - }, - ], - }, - }, - evidences: { - nodes: [ - { - filepath: - 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json', - collectedAt: '2020-08-21T20:15:19Z', - sha: '22bde8e8b93d870a29ddc339287a1fbb598f45d1396d', - }, - ], - }, - links: { - editUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit', - issuesUrl: null, - mergeRequestsUrl: null, - selfUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10', - }, - commit: { - sha: '92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7', - webUrl: - 'http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7', - title: 'Testing a change.', - }, - author: { - webUrl: 'http://0.0.0.0:3000/root', - avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png', - username: 'root', - }, - milestones: { - nodes: [ - { - id: 'gid://gitlab/Milestone/60', - title: '12.4', - description: '', - webPath: '/root/release-test/-/milestones/2', - stats: { - totalIssuesCount: 0, - closedIssuesCount: 0, - }, - }, - { - id: 'gid://gitlab/Milestone/59', - title: '12.3', - description: 'Milestone 12.3', - webPath: '/root/release-test/-/milestones/1', - stats: { - totalIssuesCount: 2, - closedIssuesCount: 1, - }, - }, - ], - }, - }, - ], - }, - }, - }, -}; diff --git a/spec/frontend/releases/stores/getters_spec.js b/spec/frontend/releases/stores/getters_spec.js new file mode 100644 index 00000000000..01e10567cf0 --- /dev/null +++ b/spec/frontend/releases/stores/getters_spec.js @@ -0,0 +1,22 @@ +import * as getters from '~/releases/stores/getters'; + +describe('~/releases/stores/getters.js', () => { + it.each` + graphqlReleaseData | graphqlReleasesPage | graphqlMilestoneStats | result + ${false} | ${false} | ${false} | ${false} + ${false} | ${false} | ${true} | ${false} + ${false} | ${true} | ${false} | ${false} + ${false} | ${true} | ${true} | ${false} + ${true} | ${false} | ${false} | ${false} + ${true} | ${false} | ${true} | ${false} + ${true} | ${true} | ${false} | ${false} + ${true} | ${true} | ${true} | ${true} + `( + 'returns $result with feature flag values graphqlReleaseData=$graphqlReleaseData, graphqlReleasesPage=$graphqlReleasesPage, and graphqlMilestoneStats=$graphqlMilestoneStats', + ({ result: expectedResult, ...featureFlags }) => { + const actualResult = getters.useGraphQLEndpoint({ featureFlags }); + + expect(actualResult).toBe(expectedResult); + }, + ); +}); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 1b2a705e8f4..955c761d35a 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,10 +1,10 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import { getJSONFixture } from 'helpers/fixtures'; import { cloneDeep } from 'lodash'; import * as actions from '~/releases/stores/modules/detail/actions'; import * as types from '~/releases/stores/modules/detail/mutation_types'; -import { release as originalRelease } from '../../../mock_data'; import createState from '~/releases/stores/modules/detail/state'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; @@ -21,6 +21,8 @@ jest.mock('~/lib/utils/url_utility', () => ({ joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, })); +const originalRelease = getJSONFixture('api/releases/release.json'); + describe('Release detail actions', () => { let state; let release; @@ -207,6 +209,15 @@ describe('Release detail actions', () => { }); }); + describe('updateReleaseGroupMilestones', () => { + it(`commits ${types.UPDATE_RELEASE_GROUP_MILESTONES} with the updated release group milestones`, () => { + const newReleaseGroupMilestones = ['v0.0', 'v0.1']; + return testAction(actions.updateReleaseGroupMilestones, newReleaseGroupMilestones, state, [ + { type: types.UPDATE_RELEASE_GROUP_MILESTONES, payload: newReleaseGroupMilestones }, + ]); + }); + }); + describe('addEmptyAssetLink', () => { it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => { return testAction(actions.addEmptyAssetLink, undefined, state, [ diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index cd7c6b7d275..f3e84262754 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -1,10 +1,12 @@ +import { getJSONFixture } from 'helpers/fixtures'; import createState from '~/releases/stores/modules/detail/state'; import mutations from '~/releases/stores/modules/detail/mutations'; import * as types from '~/releases/stores/modules/detail/mutation_types'; -import { release as originalRelease } from '../../../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; +const originalRelease = getJSONFixture('api/releases/release.json'); + describe('Release detail mutations', () => { let state; let release; @@ -30,6 +32,7 @@ describe('Release detail mutations', () => { name: '', description: '', milestones: [], + groupMilestones: [], assets: { links: [], }, @@ -112,6 +115,26 @@ describe('Release detail mutations', () => { }); }); + describe(`${types.UPDATE_RELEASE_MILESTONES}`, () => { + it("updates the release's milestones", () => { + state.release = release; + const newReleaseMilestones = ['v0.0', 'v0.1']; + mutations[types.UPDATE_RELEASE_MILESTONES](state, newReleaseMilestones); + + expect(state.release.milestones).toBe(newReleaseMilestones); + }); + }); + + describe(`${types.UPDATE_RELEASE_GROUP_MILESTONES}`, () => { + it("updates the release's group milestones", () => { + state.release = release; + const newReleaseGroupMilestones = ['v0.0', 'v0.1']; + mutations[types.UPDATE_RELEASE_GROUP_MILESTONES](state, newReleaseGroupMilestones); + + expect(state.release.groupMilestones).toBe(newReleaseGroupMilestones); + }); + }); + describe(`${types.REQUEST_SAVE_RELEASE}`, () => { it('set state.isUpdatingRelease to true', () => { mutations[types.REQUEST_SAVE_RELEASE](state); diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js index 95e30659d6c..2068d7fee78 100644 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ b/spec/frontend/releases/stores/modules/list/actions_spec.js @@ -1,31 +1,42 @@ import { cloneDeep } from 'lodash'; import testAction from 'helpers/vuex_action_helper'; +import { getJSONFixture } from 'helpers/fixtures'; import { - requestReleases, fetchReleases, - receiveReleasesSuccess, + fetchReleasesGraphQl, + fetchReleasesRest, receiveReleasesError, } from '~/releases/stores/modules/list/actions'; import createState from '~/releases/stores/modules/list/state'; import * as types from '~/releases/stores/modules/list/mutation_types'; import api from '~/api'; import { gqClient, convertGraphQLResponse } from '~/releases/util'; -import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { - pageInfoHeadersWithoutPagination, - releases as originalReleases, - graphqlReleasesResponse as originalGraphqlReleasesResponse, -} from '../../../mock_data'; + normalizeHeaders, + parseIntPagination, + convertObjectPropsToCamelCase, +} from '~/lib/utils/common_utils'; +import { pageInfoHeadersWithoutPagination } from '../../../mock_data'; import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; +import { PAGE_SIZE } from '~/releases/constants'; + +const originalRelease = getJSONFixture('api/releases/release.json'); +const originalReleases = [originalRelease]; + +const originalGraphqlReleasesResponse = getJSONFixture( + 'graphql/releases/queries/all_releases.query.graphql.json', +); describe('Releases State actions', () => { let mockedState; - let pageInfo; let releases; let graphqlReleasesResponse; const projectPath = 'root/test-project'; const projectId = 19; + const before = 'testBeforeCursor'; + const after = 'testAfterCursor'; + const page = 2; beforeEach(() => { mockedState = { @@ -33,178 +44,261 @@ describe('Releases State actions', () => { projectId, projectPath, }), - featureFlags: { - graphqlReleaseData: true, - graphqlReleasesPage: true, - graphqlMilestoneStats: true, - }, }; - pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); }); - describe('requestReleases', () => { - it('should commit REQUEST_RELEASES mutation', done => { - testAction(requestReleases, null, mockedState, [{ type: types.REQUEST_RELEASES }], [], done); + describe('when all the necessary GraphQL feature flags are enabled', () => { + beforeEach(() => { + mockedState.useGraphQLEndpoint = true; + }); + + describe('fetchReleases', () => { + it('dispatches fetchReleasesGraphQl with before and after parameters', () => { + return testAction( + fetchReleases, + { before, after, page }, + mockedState, + [], + [ + { + type: 'fetchReleasesGraphQl', + payload: { before, after }, + }, + ], + ); + }); }); }); - describe('fetchReleases', () => { - describe('success', () => { - it('dispatches requestReleases and receiveReleasesSuccess', done => { - jest.spyOn(gqClient, 'query').mockImplementation(({ query, variables }) => { - expect(query).toBe(allReleasesQuery); - expect(variables).toEqual({ - fullPath: projectPath, + describe('when at least one of the GraphQL feature flags is disabled', () => { + beforeEach(() => { + mockedState.useGraphQLEndpoint = false; + }); + + describe('fetchReleases', () => { + it('dispatches fetchReleasesRest with a page parameter', () => { + return testAction( + fetchReleases, + { before, after, page }, + mockedState, + [], + [ + { + type: 'fetchReleasesRest', + payload: { page }, + }, + ], + ); + }); + }); + }); + + describe('fetchReleasesGraphQl', () => { + describe('GraphQL query variables', () => { + let vuexParams; + + beforeEach(() => { + jest.spyOn(gqClient, 'query'); + + vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState }; + }); + + describe('when neither a before nor an after parameter is provided', () => { + beforeEach(() => { + fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined }); + }); + + it('makes a GraphQl query with a first variable', () => { + expect(gqClient.query).toHaveBeenCalledWith({ + query: allReleasesQuery, + variables: { fullPath: projectPath, first: PAGE_SIZE }, }); - return Promise.resolve(graphqlReleasesResponse); }); + }); - testAction( - fetchReleases, + describe('when only a before parameter is provided', () => { + beforeEach(() => { + fetchReleasesGraphQl(vuexParams, { before, after: undefined }); + }); + + it('makes a GraphQl query with last and before variables', () => { + expect(gqClient.query).toHaveBeenCalledWith({ + query: allReleasesQuery, + variables: { fullPath: projectPath, last: PAGE_SIZE, before }, + }); + }); + }); + + describe('when only an after parameter is provided', () => { + beforeEach(() => { + fetchReleasesGraphQl(vuexParams, { before: undefined, after }); + }); + + it('makes a GraphQl query with first and after variables', () => { + expect(gqClient.query).toHaveBeenCalledWith({ + query: allReleasesQuery, + variables: { fullPath: projectPath, first: PAGE_SIZE, after }, + }); + }); + }); + + describe('when both before and after parameters are provided', () => { + it('throws an error', () => { + const callFetchReleasesGraphQl = () => { + fetchReleasesGraphQl(vuexParams, { before, after }); + }; + + expect(callFetchReleasesGraphQl).toThrowError( + 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.', + ); + }); + }); + }); + + describe('when the request is successful', () => { + beforeEach(() => { + jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse); + }); + + it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => { + const convertedResponse = convertGraphQLResponse(graphqlReleasesResponse); + + return testAction( + fetchReleasesGraphQl, {}, mockedState, - [], [ { - type: 'requestReleases', + type: types.REQUEST_RELEASES, }, { - payload: convertGraphQLResponse(graphqlReleasesResponse), - type: 'receiveReleasesSuccess', + type: types.RECEIVE_RELEASES_SUCCESS, + payload: { + data: convertedResponse.data, + graphQlPageInfo: convertedResponse.paginationInfo, + }, }, ], - done, + [], ); }); }); - describe('error', () => { - it('dispatches requestReleases and receiveReleasesError', done => { - jest.spyOn(gqClient, 'query').mockRejectedValue(); + describe('when the request fails', () => { + beforeEach(() => { + jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!')); + }); - testAction( - fetchReleases, + it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => { + return testAction( + fetchReleasesGraphQl, {}, mockedState, - [], [ { - type: 'requestReleases', + type: types.REQUEST_RELEASES, }, + ], + [ { type: 'receiveReleasesError', }, ], - done, ); }); }); + }); + + describe('fetchReleasesRest', () => { + describe('REST query parameters', () => { + let vuexParams; - describe('when the graphqlReleaseData feature flag is disabled', () => { beforeEach(() => { - mockedState.featureFlags.graphqlReleasesPage = false; - }); + jest + .spyOn(api, 'releases') + .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); - describe('success', () => { - it('dispatches requestReleases and receiveReleasesSuccess', done => { - jest.spyOn(api, 'releases').mockImplementation((id, options) => { - expect(id).toBe(projectId); - expect(options.page).toBe('1'); - return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); - }); + vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState }; + }); - testAction( - fetchReleases, - {}, - mockedState, - [], - [ - { - type: 'requestReleases', - }, - { - payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, - type: 'receiveReleasesSuccess', - }, - ], - done, - ); + describe('when a page parameter is provided', () => { + beforeEach(() => { + fetchReleasesRest(vuexParams, { page: 2 }); }); - it('dispatches requestReleases and receiveReleasesSuccess on page two', done => { - jest.spyOn(api, 'releases').mockImplementation((_, options) => { - expect(options.page).toBe('2'); - return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); - }); - - testAction( - fetchReleases, - { page: '2' }, - mockedState, - [], - [ - { - type: 'requestReleases', - }, - { - payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, - type: 'receiveReleasesSuccess', - }, - ], - done, - ); + it('makes a REST query with a page query parameter', () => { + expect(api.releases).toHaveBeenCalledWith(projectId, { page }); }); }); + }); - describe('error', () => { - it('dispatches requestReleases and receiveReleasesError', done => { - jest.spyOn(api, 'releases').mockReturnValue(Promise.reject()); + describe('when the request is successful', () => { + beforeEach(() => { + jest + .spyOn(api, 'releases') + .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); + }); - testAction( - fetchReleases, - {}, - mockedState, - [], - [ - { - type: 'requestReleases', - }, - { - type: 'receiveReleasesError', + it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => { + return testAction( + fetchReleasesRest, + {}, + mockedState, + [ + { + type: types.REQUEST_RELEASES, + }, + { + type: types.RECEIVE_RELEASES_SUCCESS, + payload: { + data: convertObjectPropsToCamelCase(releases, { deep: true }), + restPageInfo: parseIntPagination( + normalizeHeaders(pageInfoHeadersWithoutPagination), + ), }, - ], - done, - ); - }); + }, + ], + [], + ); }); }); - }); - describe('receiveReleasesSuccess', () => { - it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => { - testAction( - receiveReleasesSuccess, - { data: releases, headers: pageInfoHeadersWithoutPagination }, - mockedState, - [{ type: types.RECEIVE_RELEASES_SUCCESS, payload: { pageInfo, data: releases } }], - [], - done, - ); + describe('when the request fails', () => { + beforeEach(() => { + jest.spyOn(api, 'releases').mockRejectedValue(new Error('Something went wrong!')); + }); + + it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => { + return testAction( + fetchReleasesRest, + {}, + mockedState, + [ + { + type: types.REQUEST_RELEASES, + }, + ], + [ + { + type: 'receiveReleasesError', + }, + ], + ); + }); }); }); describe('receiveReleasesError', () => { - it('should commit RECEIVE_RELEASES_ERROR mutation', done => { - testAction( + it('should commit RECEIVE_RELEASES_ERROR mutation', () => { + return testAction( receiveReleasesError, null, mockedState, [{ type: types.RECEIVE_RELEASES_ERROR }], [], - done, ); }); }); diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js index 27ad05846e7..914f69ec194 100644 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js @@ -1,16 +1,29 @@ +import { getJSONFixture } from 'helpers/fixtures'; import createState from '~/releases/stores/modules/list/state'; import mutations from '~/releases/stores/modules/list/mutations'; import * as types from '~/releases/stores/modules/list/mutation_types'; -import { parseIntPagination } from '~/lib/utils/common_utils'; -import { pageInfoHeadersWithoutPagination, releases } from '../../../mock_data'; +import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { pageInfoHeadersWithoutPagination } from '../../../mock_data'; +import { convertGraphQLResponse } from '~/releases/util'; + +const originalRelease = getJSONFixture('api/releases/release.json'); +const originalReleases = [originalRelease]; + +const graphqlReleasesResponse = getJSONFixture( + 'graphql/releases/queries/all_releases.query.graphql.json', +); describe('Releases Store Mutations', () => { let stateCopy; - let pageInfo; + let restPageInfo; + let graphQlPageInfo; + let releases; beforeEach(() => { stateCopy = createState({}); - pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); + restPageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); + graphQlPageInfo = convertGraphQLResponse(graphqlReleasesResponse).paginationInfo; + releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); }); describe('REQUEST_RELEASES', () => { @@ -23,7 +36,11 @@ describe('Releases Store Mutations', () => { describe('RECEIVE_RELEASES_SUCCESS', () => { beforeEach(() => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { pageInfo, data: releases }); + mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { + restPageInfo, + graphQlPageInfo, + data: releases, + }); }); it('sets is loading to false', () => { @@ -38,18 +55,29 @@ describe('Releases Store Mutations', () => { expect(stateCopy.releases).toEqual(releases); }); - it('sets pageInfo', () => { - expect(stateCopy.pageInfo).toEqual(pageInfo); + it('sets restPageInfo', () => { + expect(stateCopy.restPageInfo).toEqual(restPageInfo); + }); + + it('sets graphQlPageInfo', () => { + expect(stateCopy.graphQlPageInfo).toEqual(graphQlPageInfo); }); }); describe('RECEIVE_RELEASES_ERROR', () => { it('resets data', () => { + mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { + restPageInfo, + graphQlPageInfo, + data: releases, + }); + mutations[types.RECEIVE_RELEASES_ERROR](stateCopy); expect(stateCopy.isLoading).toEqual(false); expect(stateCopy.releases).toEqual([]); - expect(stateCopy.pageInfo).toEqual({}); + expect(stateCopy.restPageInfo).toEqual({}); + expect(stateCopy.graphQlPageInfo).toEqual({}); }); }); }); diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index f40e5729188..a9d0b61695d 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -1,6 +1,10 @@ import { cloneDeep } from 'lodash'; +import { getJSONFixture } from 'helpers/fixtures'; import { releaseToApiJson, apiJsonToRelease, convertGraphQLResponse } from '~/releases/util'; -import { graphqlReleasesResponse as originalGraphqlReleasesResponse } from './mock_data'; + +const originalGraphqlReleasesResponse = getJSONFixture( + 'graphql/releases/queries/all_releases.query.graphql.json', +); describe('releases/util.js', () => { describe('releaseToApiJson', () => { diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index cf2e6b00800..aaa8bf168f2 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -77,24 +77,31 @@ exports[`Repository last commit component renders commit widget 1`] = ` </gl-link-stub> </div> - <div - class="commit-sha-group d-flex" + <gl-button-group-stub + class="gl-ml-4 js-commit-sha-group" > - <div - class="label label-monospace monospace" + <gl-button-stub + buttontextclasses="" + category="primary" + class="gl-font-monospace" + data-testid="last-commit-id-label" + icon="" + label="true" + size="medium" + variant="default" > - - 12345678 - - </div> + 12345678 + </gl-button-stub> <clipboard-button-stub - cssclass="btn-default" + category="secondary" + class="input-group-text" + size="medium" text="123456789" title="Copy commit SHA" - tooltipplacement="bottom" + tooltipplacement="top" /> - </div> + </gl-button-group-stub> </div> </div> </div> @@ -181,24 +188,31 @@ exports[`Repository last commit component renders the signature HTML as returned </gl-link-stub> </div> - <div - class="commit-sha-group d-flex" + <gl-button-group-stub + class="gl-ml-4 js-commit-sha-group" > - <div - class="label label-monospace monospace" + <gl-button-stub + buttontextclasses="" + category="primary" + class="gl-font-monospace" + data-testid="last-commit-id-label" + icon="" + label="true" + size="medium" + variant="default" > - - 12345678 - - </div> + 12345678 + </gl-button-stub> <clipboard-button-stub - cssclass="btn-default" + category="secondary" + class="input-group-text" + size="medium" text="123456789" title="Copy commit SHA" - tooltipplacement="bottom" + tooltipplacement="top" /> - </div> + </gl-button-group-stub> </div> </div> </div> diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index c14a7f0e061..ccba0982c26 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -78,7 +78,7 @@ describe('Repository last commit component', () => { factory(); return vm.vm.$nextTick(() => { - expect(vm.find('.label-monospace').text()).toEqual('12345678'); + expect(vm.find('[data-testid="last-commit-id-label"]').text()).toEqual('12345678'); }); }); diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index 954424b5c8a..ddc95feccd6 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -84,6 +84,14 @@ describe('fetchLogsTree', () => { expect(axios.get.mock.calls.length).toEqual(1); })); + it('calls axios for each path', () => + Promise.all([ + fetchLogsTree(client, '', '0', resolver), + fetchLogsTree(client, '/test', '0', resolver), + ]).then(() => { + expect(axios.get.mock.calls.length).toEqual(2); + })); + it('calls entry resolver', () => fetchLogsTree(client, '', '0', resolver).then(() => { expect(resolver.resolve).toHaveBeenCalledWith( diff --git a/spec/frontend/right_sidebar_spec.js b/spec/frontend/right_sidebar_spec.js index d80d80152a5..3490a99afb4 100644 --- a/spec/frontend/right_sidebar_spec.js +++ b/spec/frontend/right_sidebar_spec.js @@ -6,7 +6,9 @@ import Sidebar from '~/right_sidebar'; let $aside = null; let $toggle = null; -let $icon = null; +let $toggleContainer = null; +let $expandIcon = null; +let $collapseIcon = null; let $page = null; let $labelsIcon = null; @@ -15,10 +17,11 @@ const assertSidebarState = state => { const shouldBeCollapsed = state === 'collapsed'; expect($aside.hasClass('right-sidebar-expanded')).toBe(shouldBeExpanded); expect($page.hasClass('right-sidebar-expanded')).toBe(shouldBeExpanded); - expect($icon.hasClass('fa-angle-double-right')).toBe(shouldBeExpanded); + expect($toggleContainer.data('is-expanded')).toBe(shouldBeExpanded); + expect($expandIcon.hasClass('hidden')).toBe(shouldBeExpanded); expect($aside.hasClass('right-sidebar-collapsed')).toBe(shouldBeCollapsed); expect($page.hasClass('right-sidebar-collapsed')).toBe(shouldBeCollapsed); - expect($icon.hasClass('fa-angle-double-left')).toBe(shouldBeCollapsed); + expect($collapseIcon.hasClass('hidden')).toBe(shouldBeCollapsed); }; describe('RightSidebar', () => { @@ -33,7 +36,9 @@ describe('RightSidebar', () => { new Sidebar(); // eslint-disable-line no-new $aside = $('.right-sidebar'); $page = $('.layout-page'); - $icon = $aside.find('i'); + $toggleContainer = $('.js-sidebar-toggle-container'); + $expandIcon = $aside.find('.js-sidebar-expand'); + $collapseIcon = $aside.find('.js-sidebar-collapse'); $toggle = $aside.find('.js-sidebar-toggle'); $labelsIcon = $aside.find('.sidebar-collapsed-icon'); }); diff --git a/spec/frontend/search/components/state_filter_spec.js b/spec/frontend/search/components/dropdown_filter_spec.js index 26344f2b592..ffac038e1c5 100644 --- a/spec/frontend/search/components/state_filter_spec.js +++ b/spec/frontend/search/components/dropdown_filter_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import StateFilter from '~/search/state_filter/components/state_filter.vue'; +import DropdownFilter from '~/search/components/dropdown_filter.vue'; import { FILTER_STATES, - SCOPES, FILTER_STATES_BY_SCOPE, - FILTER_TEXT, + FILTER_HEADER, + SCOPES, } from '~/search/state_filter/constants'; import * as urlUtils from '~/lib/utils/url_utility'; @@ -15,14 +15,19 @@ jest.mock('~/lib/utils/url_utility', () => ({ })); function createComponent(props = { scope: 'issues' }) { - return shallowMount(StateFilter, { + return shallowMount(DropdownFilter, { propsData: { + filtersArray: FILTER_STATES_BY_SCOPE.issues, + filters: FILTER_STATES, + header: FILTER_HEADER, + param: 'state', + supportedScopes: Object.values(SCOPES), ...props, }, }); } -describe('StateFilter', () => { +describe('DropdownFilter', () => { let wrapper; beforeEach(() => { @@ -41,7 +46,7 @@ describe('StateFilter', () => { describe('template', () => { describe.each` - scope | showStateDropdown + scope | showDropdown ${'issues'} | ${true} ${'merge_requests'} | ${true} ${'projects'} | ${false} @@ -50,26 +55,25 @@ describe('StateFilter', () => { ${'notes'} | ${false} ${'wiki_blobs'} | ${false} ${'blobs'} | ${false} - `(`state dropdown`, ({ scope, showStateDropdown }) => { + `(`dropdown`, ({ scope, showDropdown }) => { beforeEach(() => { wrapper = createComponent({ scope }); }); - it(`does${showStateDropdown ? '' : ' not'} render when scope is ${scope}`, () => { - expect(findGlDropdown().exists()).toBe(showStateDropdown); + it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findGlDropdown().exists()).toBe(showDropdown); }); }); describe.each` - state | label - ${FILTER_STATES.ANY.value} | ${FILTER_TEXT} + initialFilter | label + ${FILTER_STATES.ANY.value} | ${`Any ${FILTER_HEADER}`} ${FILTER_STATES.OPEN.value} | ${FILTER_STATES.OPEN.label} ${FILTER_STATES.CLOSED.value} | ${FILTER_STATES.CLOSED.label} - ${FILTER_STATES.MERGED.value} | ${FILTER_STATES.MERGED.label} - `(`filter text`, ({ state, label }) => { - describe(`when state is ${state}`, () => { + `(`filter text`, ({ initialFilter, label }) => { + describe(`when initialFilter is ${initialFilter}`, () => { beforeEach(() => { - wrapper = createComponent({ scope: 'issues', state }); + wrapper = createComponent({ scope: 'issues', initialFilter }); }); it(`sets dropdown label to ${label}`, () => { diff --git a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap index f4ac2f57261..02d5ca6bdb3 100644 --- a/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap +++ b/spec/frontend/self_monitor/components/__snapshots__/self_monitor_form_spec.js.snap @@ -15,13 +15,16 @@ exports[`self monitor component When the self monitor project has not been creat </h4> - <gl-deprecated-button-stub + <gl-button-stub + buttontextclasses="" + category="primary" class="js-settings-toggle" - size="md" - variant="secondary" + icon="" + size="medium" + variant="default" > Expand - </gl-deprecated-button-stub> + </gl-button-stub> <p class="js-section-sub-header" @@ -56,6 +59,7 @@ exports[`self monitor component When the self monitor project has not been creat <gl-modal-stub cancel-title="Cancel" + category="primary" modalclass="" modalid="delete-self-monitor-modal" ok-title="Delete project" diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js index ec5f7b0a394..618cc16cdf4 100644 --- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js +++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue'; import { createStore } from '~/self_monitor/store'; @@ -42,7 +42,7 @@ describe('self monitor component', () => { it('renders as an expand button by default', () => { wrapper = shallowMount(SelfMonitor, { store }); - const button = wrapper.find(GlDeprecatedButton); + const button = wrapper.find(GlButton); expect(button.text()).toBe('Expand'); }); 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 22689080063..6b3d65ff037 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -11,7 +11,7 @@ exports[`EmptyStateComponent should render content 1`] = ` <p>In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub> </p> <div> - <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" href=\\"/clusters\\">Install Knative</gl-button-stub> + <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" href=\\"/clusters\\">Install Knative</gl-button-stub> <!----> </div> </div> diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js index 9ca4a45dd5f..0bd2e96a068 100644 --- a/spec/frontend/serverless/components/missing_prometheus_spec.js +++ b/spec/frontend/serverless/components/missing_prometheus_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createStore } from '~/serverless/store'; import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue'; @@ -24,7 +24,7 @@ describe('missingPrometheusComponent', () => { 'Function invocation metrics require Prometheus to be installed first.', ); - expect(wrapper.find(GlDeprecatedButton).attributes('variant')).toBe('success'); + expect(wrapper.find(GlButton).attributes('variant')).toBe('success'); }); it('should render no prometheus data message', () => { diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js index 2f11c6a07c2..8c868205295 100644 --- a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; import eventHub from '~/sidebar/event_hub'; @@ -56,11 +55,11 @@ describe('Edit Form Buttons', () => { }); it('disables the toggle button', () => { - expect(findConfidentialToggle().attributes('disabled')).toBe('disabled'); + expect(findConfidentialToggle().props('disabled')).toBe(true); }); - it('finds the GlLoadingIcon', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + it('sets loading on the toggle button', () => { + expect(findConfidentialToggle().props('loading')).toBe(true); }); }); @@ -99,7 +98,7 @@ describe('Edit Form Buttons', () => { describe('when succeeds', () => { beforeEach(() => { createComponent({ data: { isLoading: false }, props: { confidential: true } }); - findConfidentialToggle().trigger('click'); + findConfidentialToggle().vm.$emit('click', new Event('click')); }); it('dispatches the correct action', () => { @@ -109,9 +108,9 @@ describe('Edit Form Buttons', () => { }); }); - it('resets loading', () => { + it('resets loading on the toggle button', () => { return waitForPromises().then(() => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(findConfidentialToggle().props('loading')).toBe(false); }); }); @@ -135,7 +134,7 @@ describe('Edit Form Buttons', () => { props: { confidential: true }, resolved: false, }); - findConfidentialToggle().trigger('click'); + findConfidentialToggle().vm.$emit('click', new Event('click')); }); it('calls flash with the correct message', () => { diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js index de1da3456f8..913646c8f8d 100644 --- a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js @@ -1,5 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue'; import eventHub from '~/sidebar/event_hub'; import { deprecatedCreateFlash as flash } from '~/flash'; @@ -22,7 +21,6 @@ describe('EditFormButtons', () => { }; const findLockToggle = () => wrapper.find('[data-testid="lock-toggle"]'); - const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon); const createComponent = ({ props = {}, data = {}, resolved = true }) => { store = issuableType === ISSUABLE_TYPE_ISSUE ? createStore() : createMrStore(); @@ -33,7 +31,7 @@ describe('EditFormButtons', () => { jest.spyOn(store, 'dispatch').mockRejectedValue(); } - wrapper = shallowMount(EditFormButtons, { + wrapper = mount(EditFormButtons, { store, provide: { fullPath: '', @@ -78,8 +76,8 @@ describe('EditFormButtons', () => { expect(findLockToggle().attributes('disabled')).toBe('disabled'); }); - it('displays the GlLoadingIcon', () => { - expect(findGlLoadingIcon().exists()).toBe(true); + it('sets loading on the toggle button', () => { + expect(findLockToggle().props('loading')).toBe(true); }); }); @@ -121,7 +119,7 @@ describe('EditFormButtons', () => { it('resets loading', async () => { await wrapper.vm.$nextTick().then(() => { - expect(findGlLoadingIcon().exists()).toBe(false); + expect(findLockToggle().props('loading')).toBe(false); }); }); @@ -156,7 +154,7 @@ describe('EditFormButtons', () => { it('resets loading', async () => { await wrapper.vm.$nextTick().then(() => { - expect(findGlLoadingIcon().exists()).toBe(false); + expect(findLockToggle().props('loading')).toBe(false); }); }); diff --git a/spec/frontend/sidebar/reviewer_title_spec.js b/spec/frontend/sidebar/reviewer_title_spec.js new file mode 100644 index 00000000000..eae266688d5 --- /dev/null +++ b/spec/frontend/sidebar/reviewer_title_spec.js @@ -0,0 +1,116 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; +import Component from '~/sidebar/components/reviewers/reviewer_title.vue'; + +describe('ReviewerTitle component', () => { + let wrapper; + + const createComponent = props => { + return shallowMount(Component, { + propsData: { + numberOfReviewers: 0, + editable: false, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('reviewer title', () => { + it('renders reviewer', () => { + wrapper = createComponent({ + numberOfReviewers: 1, + editable: false, + }); + + expect(wrapper.vm.$el.innerText.trim()).toEqual('Reviewer'); + }); + + it('renders 2 reviewers', () => { + wrapper = createComponent({ + numberOfReviewers: 2, + editable: false, + }); + + expect(wrapper.vm.$el.innerText.trim()).toEqual('2 Reviewers'); + }); + }); + + describe('gutter toggle', () => { + it('does not show toggle by default', () => { + wrapper = createComponent({ + numberOfReviewers: 2, + editable: false, + }); + + expect(wrapper.vm.$el.querySelector('.gutter-toggle')).toBeNull(); + }); + + it('shows toggle when showToggle is true', () => { + wrapper = createComponent({ + numberOfReviewers: 2, + editable: false, + showToggle: true, + }); + + expect(wrapper.vm.$el.querySelector('.gutter-toggle')).toEqual(expect.any(Object)); + }); + }); + + it('does not render spinner by default', () => { + wrapper = createComponent({ + numberOfReviewers: 0, + editable: false, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy(); + }); + + it('renders spinner when loading', () => { + wrapper = createComponent({ + loading: true, + numberOfReviewers: 0, + editable: false, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBeTruthy(); + }); + + it('does not render edit link when not editable', () => { + wrapper = createComponent({ + numberOfReviewers: 0, + editable: false, + }); + + expect(wrapper.vm.$el.querySelector('.edit-link')).toBeNull(); + }); + + it('renders edit link when editable', () => { + wrapper = createComponent({ + numberOfReviewers: 0, + editable: true, + }); + + expect(wrapper.vm.$el.querySelector('.edit-link')).not.toBeNull(); + }); + + it('tracks the event when edit is clicked', () => { + wrapper = createComponent({ + numberOfReviewers: 0, + editable: true, + }); + + const spy = mockTracking('_category_', wrapper.element, jest.spyOn); + triggerEvent('.js-sidebar-dropdown-toggle'); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', { + label: 'right_sidebar', + property: 'reviewer', + }); + }); +}); diff --git a/spec/frontend/sidebar/reviewers_spec.js b/spec/frontend/sidebar/reviewers_spec.js new file mode 100644 index 00000000000..effcac266f0 --- /dev/null +++ b/spec/frontend/sidebar/reviewers_spec.js @@ -0,0 +1,169 @@ +import { mount } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import { GlIcon } from '@gitlab/ui'; +import Reviewer from '~/sidebar/components/reviewers/reviewers.vue'; +import UsersMock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +describe('Reviewer component', () => { + const getDefaultProps = () => ({ + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }); + let wrapper; + + const createWrapper = (propsData = getDefaultProps()) => { + wrapper = mount(Reviewer, { + propsData, + }); + }; + + const findCollapsedChildren = () => wrapper.findAll('.sidebar-collapsed-icon > *'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('No reviewers/users', () => { + it('displays no reviewer icon when collapsed', () => { + createWrapper(); + const collapsedChildren = findCollapsedChildren(); + const userIcon = collapsedChildren.at(0).find(GlIcon); + + expect(collapsedChildren.length).toBe(1); + expect(collapsedChildren.at(0).attributes('aria-label')).toBe('None'); + expect(userIcon.exists()).toBe(true); + expect(userIcon.props('name')).toBe('user'); + }); + }); + + describe('One reviewer/user', () => { + it('displays one reviewer icon when collapsed', () => { + createWrapper({ + ...getDefaultProps(), + users: [UsersMock.user], + }); + + const collapsedChildren = findCollapsedChildren(); + const reviewer = collapsedChildren.at(0); + + expect(collapsedChildren.length).toBe(1); + expect(reviewer.find('.avatar').attributes('src')).toBe(UsersMock.user.avatar); + expect(reviewer.find('.avatar').attributes('alt')).toBe(`${UsersMock.user.name}'s avatar`); + + expect(trimText(reviewer.find('.author').text())).toBe(UsersMock.user.name); + }); + }); + + describe('Two or more reviewers/users', () => { + it('displays two reviewer icons when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + createWrapper({ + ...getDefaultProps(), + users, + }); + + const collapsedChildren = findCollapsedChildren(); + + expect(collapsedChildren.length).toBe(2); + + const first = collapsedChildren.at(0); + + expect(first.find('.avatar').attributes('src')).toBe(users[0].avatar_url); + expect(first.find('.avatar').attributes('alt')).toBe(`${users[0].name}'s avatar`); + + expect(trimText(first.find('.author').text())).toBe(users[0].name); + + const second = collapsedChildren.at(1); + + expect(second.find('.avatar').attributes('src')).toBe(users[1].avatar_url); + expect(second.find('.avatar').attributes('alt')).toBe(`${users[1].name}'s avatar`); + + expect(trimText(second.find('.author').text())).toBe(users[1].name); + }); + + it('displays one reviewer icon and counter when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(3); + createWrapper({ + ...getDefaultProps(), + users, + }); + + const collapsedChildren = findCollapsedChildren(); + + expect(collapsedChildren.length).toBe(2); + + const first = collapsedChildren.at(0); + + expect(first.find('.avatar').attributes('src')).toBe(users[0].avatar_url); + expect(first.find('.avatar').attributes('alt')).toBe(`${users[0].name}'s avatar`); + + expect(trimText(first.find('.author').text())).toBe(users[0].name); + + const second = collapsedChildren.at(1); + + expect(trimText(second.find('.avatar-counter').text())).toBe('+2'); + }); + + it('Shows two reviewers', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + createWrapper({ + ...getDefaultProps(), + users, + editable: true, + }); + + expect(wrapper.findAll('.user-item').length).toBe(users.length); + expect(wrapper.find('.user-list-more').exists()).toBe(false); + }); + + it('shows sorted reviewer where "can merge" users are sorted first', () => { + const users = UsersMockHelper.createNumberRandomUsers(3); + users[0].can_merge = false; + users[1].can_merge = false; + users[2].can_merge = true; + + createWrapper({ + ...getDefaultProps(), + users, + editable: true, + }); + + expect(wrapper.vm.sortedReviewers[0].can_merge).toBe(true); + }); + + it('passes the sorted reviewers to the uncollapsed-reviewer-list', () => { + const users = UsersMockHelper.createNumberRandomUsers(3); + users[0].can_merge = false; + users[1].can_merge = false; + users[2].can_merge = true; + + createWrapper({ + ...getDefaultProps(), + users, + }); + + const userItems = wrapper.findAll('.user-list .user-item a'); + + expect(userItems.length).toBe(3); + expect(userItems.at(0).attributes('title')).toBe(users[2].name); + }); + + it('passes the sorted reviewers to the collapsed-reviewer-list', () => { + const users = UsersMockHelper.createNumberRandomUsers(3); + users[0].can_merge = false; + users[1].can_merge = false; + users[2].can_merge = true; + + createWrapper({ + ...getDefaultProps(), + users, + }); + + const collapsedButton = wrapper.find('.sidebar-collapsed-user button'); + + expect(trimText(collapsedButton.text())).toBe(users[2].name); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index 29333a344e1..9d59dc750fb 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -114,7 +114,7 @@ describe('sidebar labels', () => { const expected = { [defaultProps.issuableType]: { - label_ids: [27, 28, 40], + label_ids: [27, 28, 29, 40], }, }; diff --git a/spec/frontend/snippet/snippet_edit_spec.js b/spec/frontend/snippet/snippet_edit_spec.js index 7c12c0cac03..42a55ac0d3e 100644 --- a/spec/frontend/snippet/snippet_edit_spec.js +++ b/spec/frontend/snippet/snippet_edit_spec.js @@ -5,6 +5,7 @@ import initSnippet from '~/snippet/snippet_bundle'; jest.mock('~/snippet/snippet_bundle'); jest.mock('~/snippets'); +jest.mock('~/gl_form'); describe('Snippet edit form initialization', () => { const setFF = flag => { 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 1cf1ee74ddf..e742a6b9eaf 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 @@ -3,6 +3,7 @@ exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = ` <div class="file-holder snippet" + data-qa-selector="file_holder_container" > <blob-header-edit-stub candelete="true" diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index b6abb9f389a..c1fad8cebe6 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -148,17 +148,17 @@ describe('Snippet Edit app', () => { // Ideally we wouldn't call this method directly, but we don't have a way to trigger // apollo responses yet. - const loadSnippet = (...edges) => { - if (edges.length) { + const loadSnippet = (...nodes) => { + if (nodes.length) { wrapper.setData({ - snippet: edges[0], + snippet: nodes[0], }); } wrapper.vm.onSnippetFetch({ data: { snippets: { - edges, + nodes, }, }, }); diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index 9c4b2734a3f..1ccecd7b5ba 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -140,10 +140,10 @@ describe('Blob Embeddable', () => { async ({ snippetBlobs, currentBlob, expectedContent }) => { const apolloData = { snippets: { - edges: [ + nodes: [ { - node: { - blobs: snippetBlobs, + blobs: { + nodes: snippetBlobs, }, }, ], diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js index d861f6c9cd7..0f2456cd9ea 100644 --- a/spec/frontend/static_site_editor/mock_data.js +++ b/spec/frontend/static_site_editor/mock_data.js @@ -23,7 +23,10 @@ export const username = 'gitlabuser'; export const projectId = '123456'; export const returnUrl = 'https://www.gitlab.com'; export const sourcePath = 'foobar.md.html'; - +export const mergeRequestMeta = { + title: `Update ${sourcePath} file`, + description: 'Copy update', +}; export const savedContentMeta = { branch: { label: 'foobar', diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index 41f8a1075c0..10d34d9651c 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -1,4 +1,3 @@ -import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import Home from '~/static_site_editor/pages/home.vue'; @@ -7,6 +6,7 @@ import EditArea from '~/static_site_editor/components/edit_area.vue'; import InvalidContentMessage from '~/static_site_editor/components/invalid_content_message.vue'; import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue'; import submitContentChangesMutation from '~/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql'; +import hasSubmittedChangesMutation from '~/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql'; import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants'; import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constants'; @@ -17,6 +17,7 @@ import { sourceContentTitle as title, sourcePath, username, + mergeRequestMeta, savedContentMeta, submitChangesError, trackingCategory, @@ -24,8 +25,6 @@ import { const localVue = createLocalVue(); -localVue.use(Vuex); - describe('static_site_editor/pages/home', () => { let wrapper; let store; @@ -33,6 +32,19 @@ describe('static_site_editor/pages/home', () => { let $router; let mutateMock; let trackingSpy; + const defaultAppData = { + isSupportedContent: true, + hasSubmittedChanges: false, + returnUrl, + project, + username, + sourcePath, + }; + const hasSubmittedChangesMutationPayload = { + data: { + appData: { ...defaultAppData, hasSubmittedChanges: true }, + }, + }; const buildApollo = (queries = {}) => { mutateMock = jest.fn(); @@ -64,7 +76,7 @@ describe('static_site_editor/pages/home', () => { }, data() { return { - appData: { isSupportedContent: true, returnUrl, project, username, sourcePath }, + appData: { ...defaultAppData }, sourceContent: { title, content }, ...data, }; @@ -152,8 +164,14 @@ describe('static_site_editor/pages/home', () => { }); describe('when submitting changes fails', () => { + const setupMutateMock = () => { + mutateMock + .mockResolvedValueOnce(hasSubmittedChangesMutationPayload) + .mockRejectedValueOnce(new Error(submitChangesError)); + }; + beforeEach(() => { - mutateMock.mockRejectedValue(new Error(submitChangesError)); + setupMutateMock(); buildWrapper(); findEditArea().vm.$emit('submit', { content }); @@ -166,6 +184,8 @@ describe('static_site_editor/pages/home', () => { }); it('retries submitting changes when retry button is clicked', () => { + setupMutateMock(); + findSubmitChangesError().vm.$emit('retry'); expect(mutateMock).toHaveBeenCalled(); @@ -190,7 +210,11 @@ describe('static_site_editor/pages/home', () => { const newContent = `new ${content}`; beforeEach(() => { - mutateMock.mockResolvedValueOnce({ data: { submitContentChanges: savedContentMeta } }); + mutateMock.mockResolvedValueOnce(hasSubmittedChangesMutationPayload).mockResolvedValueOnce({ + data: { + submitContentChanges: savedContentMeta, + }, + }); buildWrapper(); findEditArea().vm.$emit('submit', { content: newContent }); @@ -198,8 +222,19 @@ describe('static_site_editor/pages/home', () => { return wrapper.vm.$nextTick(); }); + it('dispatches hasSubmittedChanges mutation', () => { + expect(mutateMock).toHaveBeenNthCalledWith(1, { + mutation: hasSubmittedChangesMutation, + variables: { + input: { + hasSubmittedChanges: true, + }, + }, + }); + }); + it('dispatches submitContentChanges mutation', () => { - expect(mutateMock).toHaveBeenCalledWith({ + expect(mutateMock).toHaveBeenNthCalledWith(2, { mutation: submitContentChangesMutation, variables: { input: { @@ -207,6 +242,8 @@ describe('static_site_editor/pages/home', () => { project, sourcePath, username, + images: undefined, + mergeRequestMeta, }, }, }); diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js index 3e19e2413e7..3fc69dc4586 100644 --- a/spec/frontend/static_site_editor/pages/success_spec.js +++ b/spec/frontend/static_site_editor/pages/success_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; -import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import Success from '~/static_site_editor/pages/success.vue'; import { savedContentMeta, returnUrl, sourcePath } from '../mock_data'; import { HOME_ROUTE } from '~/static_site_editor/router/constants'; -describe('static_site_editor/pages/success', () => { +describe('~/static_site_editor/pages/success.vue', () => { const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg'; let wrapper; let router; @@ -15,14 +15,15 @@ describe('static_site_editor/pages/success', () => { }; }; - const buildWrapper = (data = {}) => { + const buildWrapper = (data = {}, appData = {}) => { wrapper = shallowMount(Success, { mocks: { $router: router, }, stubs: { - GlEmptyState, GlButton, + GlEmptyState, + GlLoadingIcon, }, propsData: { mergeRequestsIllustrationPath, @@ -33,6 +34,8 @@ describe('static_site_editor/pages/success', () => { appData: { returnUrl, sourcePath, + hasSubmittedChanges: true, + ...appData, }, ...data, }; @@ -40,8 +43,9 @@ describe('static_site_editor/pages/success', () => { }); }; - const findEmptyState = () => wrapper.find(GlEmptyState); const findReturnUrlButton = () => wrapper.find(GlButton); + const findEmptyState = () => wrapper.find(GlEmptyState); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); beforeEach(() => { buildRouter(); @@ -52,50 +56,76 @@ describe('static_site_editor/pages/success', () => { wrapper = null; }); - it('renders empty state with a link to the created merge request', () => { - buildWrapper(); + describe('when savedContentMeta is valid', () => { + it('renders empty state with a link to the created merge request', () => { + buildWrapper(); + + expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props()).toMatchObject({ + primaryButtonText: 'View merge request', + primaryButtonLink: savedContentMeta.mergeRequest.url, + title: 'Your merge request has been created', + svgPath: mergeRequestsIllustrationPath, + svgHeight: 146, + }); + }); - expect(findEmptyState().exists()).toBe(true); - expect(findEmptyState().props()).toMatchObject({ - primaryButtonText: 'View merge request', - primaryButtonLink: savedContentMeta.mergeRequest.url, - title: 'Your merge request has been created', - svgPath: mergeRequestsIllustrationPath, + it('displays merge request instructions in the empty state', () => { + buildWrapper(); + + expect(findEmptyState().text()).toContain( + 'To see your changes live you will need to do the following things:', + ); + expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.'); + expect(findEmptyState().text()).toContain( + '2. Add a description to explain why the change is being made.', + ); + expect(findEmptyState().text()).toContain( + '3. Assign a person to review and accept the merge request.', + ); }); - }); - it('displays merge request instructions in the empty state', () => { - buildWrapper(); - - expect(findEmptyState().text()).toContain( - 'To see your changes live you will need to do the following things:', - ); - expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.'); - expect(findEmptyState().text()).toContain( - '2. Add a description to explain why the change is being made.', - ); - expect(findEmptyState().text()).toContain( - '3. Assign a person to review and accept the merge request.', - ); - }); + it('displays return to site button', () => { + buildWrapper(); + + expect(findReturnUrlButton().text()).toBe('Return to site'); + expect(findReturnUrlButton().attributes().href).toBe(returnUrl); + }); - it('displays return to site button', () => { - buildWrapper(); + it('displays source path', () => { + buildWrapper(); - expect(findReturnUrlButton().text()).toBe('Return to site'); - expect(findReturnUrlButton().attributes().href).toBe(returnUrl); + expect(wrapper.text()).toContain(`Update ${sourcePath} file`); + }); }); - it('displays source path', () => { - buildWrapper(); + describe('when savedContentMeta is invalid', () => { + it('renders empty state with a loader', () => { + buildWrapper({ savedContentMeta: null }); - expect(wrapper.text()).toContain(`Update ${sourcePath} file`); - }); + expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props()).toMatchObject({ + title: 'Creating your merge request', + svgPath: mergeRequestsIllustrationPath, + }); + expect(findLoadingIcon().exists()).toBe(true); + }); - it('redirects to the HOME route when content has not been submitted', () => { - buildWrapper({ savedContentMeta: null }); + it('displays helper info in the empty state', () => { + buildWrapper({ savedContentMeta: null }); - expect(router.push).toHaveBeenCalledWith(HOME_ROUTE); - expect(wrapper.html()).toBe(''); + expect(findEmptyState().text()).toContain( + 'You can set an assignee to get your changes reviewed and deployed once your merge request is created', + ); + expect(findEmptyState().text()).toContain( + 'A link to view the merge request will appear once ready', + ); + }); + + it('redirects to the HOME route when content has not been submitted', () => { + buildWrapper({ savedContentMeta: null }, { hasSubmittedChanges: false }); + + expect(router.push).toHaveBeenCalledWith(HOME_ROUTE); + }); }); }); diff --git a/spec/frontend/static_site_editor/services/front_matterify_spec.js b/spec/frontend/static_site_editor/services/front_matterify_spec.js new file mode 100644 index 00000000000..dbaedc30849 --- /dev/null +++ b/spec/frontend/static_site_editor/services/front_matterify_spec.js @@ -0,0 +1,47 @@ +import { + sourceContentYAML as content, + sourceContentHeaderObjYAML as yamlFrontMatterObj, + sourceContentSpacing as spacing, + sourceContentBody as body, +} from '../mock_data'; + +import { frontMatterify, stringify } from '~/static_site_editor/services/front_matterify'; + +describe('static_site_editor/services/front_matterify', () => { + const frontMatterifiedContent = { + source: content, + matter: yamlFrontMatterObj, + spacing, + content: body, + delimiter: '---', + type: 'yaml', + }; + const frontMatterifiedBody = { + source: body, + matter: null, + spacing: null, + content: body, + delimiter: null, + type: null, + }; + + describe('frontMatterify', () => { + it.each` + frontMatterified | target + ${frontMatterify(content)} | ${frontMatterifiedContent} + ${frontMatterify(body)} | ${frontMatterifiedBody} + `('returns $target from $frontMatterified', ({ frontMatterified, target }) => { + expect(frontMatterified).toEqual(target); + }); + }); + + describe('stringify', () => { + it.each` + stringified | target + ${stringify(frontMatterifiedContent)} | ${content} + ${stringify(frontMatterifiedBody)} | ${body} + `('returns $target from $stringified', ({ stringified, target }) => { + expect(stringified).toBe(target); + }); + }); +}); 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 d464e6b1895..5018da7300b 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 @@ -19,6 +19,7 @@ import { commitBranchResponse, commitMultipleResponse, createMergeRequestResponse, + mergeRequestMeta, sourcePath, sourceContentYAML as content, trackingCategory, @@ -28,11 +29,20 @@ import { jest.mock('~/static_site_editor/services/generate_branch_name'); describe('submitContentChanges', () => { - const mergeRequestTitle = `Update ${sourcePath} file`; const branch = 'branch-name'; let trackingSpy; let origPage; + const buildPayload = (overrides = {}) => ({ + username, + projectId, + sourcePath, + content, + images, + mergeRequestMeta, + ...overrides, + }); + beforeEach(() => { jest.spyOn(Api, 'createBranch').mockResolvedValue({ data: commitBranchResponse }); jest.spyOn(Api, 'commitMultiple').mockResolvedValue({ data: commitMultipleResponse }); @@ -53,7 +63,7 @@ describe('submitContentChanges', () => { }); it('creates a branch named after the username and target branch', () => { - return submitContentChanges({ username, projectId }).then(() => { + return submitContentChanges(buildPayload()).then(() => { expect(Api.createBranch).toHaveBeenCalledWith(projectId, { ref: DEFAULT_TARGET_BRANCH, branch, @@ -64,16 +74,16 @@ describe('submitContentChanges', () => { it('notifies error when branch could not be created', () => { Api.createBranch.mockRejectedValueOnce(); - return expect(submitContentChanges({ username, projectId })).rejects.toThrow( + return expect(submitContentChanges(buildPayload())).rejects.toThrow( SUBMIT_CHANGES_BRANCH_ERROR, ); }); it('commits the content changes to the branch when creating branch succeeds', () => { - return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => { + return submitContentChanges(buildPayload()).then(() => { expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { branch, - commit_message: mergeRequestTitle, + commit_message: mergeRequestMeta.title, actions: [ { action: 'update', @@ -93,16 +103,11 @@ describe('submitContentChanges', () => { it('does not commit an image if it has been removed from the content', () => { const contentWithoutImages = '## Content without images'; - return submitContentChanges({ - username, - projectId, - sourcePath, - content: contentWithoutImages, - images, - }).then(() => { + const payload = buildPayload({ content: contentWithoutImages }); + return submitContentChanges(payload).then(() => { expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, { branch, - commit_message: mergeRequestTitle, + commit_message: mergeRequestMeta.title, actions: [ { action: 'update', @@ -117,17 +122,19 @@ describe('submitContentChanges', () => { it('notifies error when content could not be committed', () => { Api.commitMultiple.mockRejectedValueOnce(); - return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow( + return expect(submitContentChanges(buildPayload())).rejects.toThrow( SUBMIT_CHANGES_COMMIT_ERROR, ); }); - it('creates a merge request when commiting changes succeeds', () => { - return submitContentChanges({ username, projectId, sourcePath, content, images }).then(() => { + it('creates a merge request when committing changes succeeds', () => { + return submitContentChanges(buildPayload()).then(() => { + const { title, description } = mergeRequestMeta; expect(Api.createProjectMergeRequest).toHaveBeenCalledWith( projectId, convertObjectPropsToSnakeCase({ - title: mergeRequestTitle, + title, + description, targetBranch: DEFAULT_TARGET_BRANCH, sourceBranch: branch, }), @@ -138,7 +145,7 @@ describe('submitContentChanges', () => { it('notifies error when merge request could not be created', () => { Api.createProjectMergeRequest.mockRejectedValueOnce(); - return expect(submitContentChanges({ username, projectId, images })).rejects.toThrow( + return expect(submitContentChanges(buildPayload())).rejects.toThrow( SUBMIT_CHANGES_MERGE_REQUEST_ERROR, ); }); @@ -147,11 +154,9 @@ describe('submitContentChanges', () => { let result; beforeEach(() => { - return submitContentChanges({ username, projectId, sourcePath, content, images }).then( - _result => { - result = _result; - }, - ); + return submitContentChanges(buildPayload()).then(_result => { + result = _result; + }); }); it('returns the branch name', () => { @@ -179,7 +184,7 @@ describe('submitContentChanges', () => { describe('sends the correct tracking event', () => { beforeEach(() => { - return submitContentChanges({ username, projectId, sourcePath, content, images }); + return submitContentChanges(buildPayload()); }); it('for committing changes', () => { diff --git a/spec/frontend/static_site_editor/services/templater_spec.js b/spec/frontend/static_site_editor/services/templater_spec.js index 1e7ae872b7e..cb3a0a0c106 100644 --- a/spec/frontend/static_site_editor/services/templater_spec.js +++ b/spec/frontend/static_site_editor/services/templater_spec.js @@ -39,6 +39,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese <p>Some paragraph...</p> </div> \`\`\` + +Below this line is a iframe that should be ignored and preserved + +<iframe></iframe> `; const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example. @@ -87,6 +91,10 @@ Below this line is a codeblock of the same HTML that should be ignored and prese <p>Some paragraph...</p> </div> \`\`\` + +Below this line is a iframe that should be ignored and preserved + +<iframe></iframe> `; it.each` diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 544c19da57b..eebec7de9d4 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import 'jquery'; + import * as jqueryMatchers from 'custom-jquery-matchers'; import { config as testUtilsConfig } from '@vue/test-utils'; import Translate from '~/vue_shared/translate'; @@ -9,7 +11,6 @@ import customMatchers from './matchers'; import './helpers/dom_shims'; import './helpers/jquery'; -import '~/commons/jquery'; import '~/commons/bootstrap'; process.on('unhandledRejection', global.promiseRejectionHandler); diff --git a/spec/frontend/user_lists/components/add_user_modal_spec.js b/spec/frontend/user_lists/components/add_user_modal_spec.js new file mode 100644 index 00000000000..82ce195d7cd --- /dev/null +++ b/spec/frontend/user_lists/components/add_user_modal_spec.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils'; +import AddUserModal from '~/user_lists/components/add_user_modal.vue'; + +describe('Add User Modal', () => { + let wrapper; + + const click = testId => wrapper.find(`[data-testid="${testId}"]`).trigger('click'); + + beforeEach(() => { + wrapper = mount(AddUserModal, { + propsData: { visible: true }, + }); + }); + + it('should explain the format of user IDs to enter', () => { + expect(wrapper.find('[data-testid="add-userids-description"]').text()).toContain( + 'Enter a comma separated list of user IDs', + ); + }); + + describe('events', () => { + beforeEach(() => { + wrapper.find('#add-user-ids').setValue('1, 2, 3, 4'); + }); + + it('should emit the users entered when Add Users is clicked', () => { + click('confirm-add-user-ids'); + expect(wrapper.emitted('addUsers')).toContainEqual(['1, 2, 3, 4']); + }); + + it('should clear the input after emitting', async () => { + click('confirm-add-user-ids'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('#add-user-ids').element.value).toBe(''); + }); + + it('should not emit the users entered if cancel is clicked', () => { + click('cancel-add-user-ids'); + expect(wrapper.emitted('addUsers')).toBeUndefined(); + }); + + it('should clear the input after cancelling', async () => { + click('cancel-add-user-ids'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('#add-user-ids').element.value).toBe(''); + }); + }); +}); diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js new file mode 100644 index 00000000000..51a38e12916 --- /dev/null +++ b/spec/frontend/user_lists/components/edit_user_list_spec.js @@ -0,0 +1,150 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import createStore from '~/user_lists/store/edit'; +import EditUserList from '~/user_lists/components/edit_user_list.vue'; +import UserListForm from '~/user_lists/components/user_list_form.vue'; +import { userList } from '../../feature_flags/mock_data'; +import { redirectTo } from '~/lib/utils/url_utility'; + +jest.mock('~/api'); +jest.mock('~/lib/utils/url_utility'); + +const localVue = createLocalVue(Vue); +localVue.use(Vuex); + +describe('user_lists/components/edit_user_list', () => { + let wrapper; + + const setInputValue = value => wrapper.find('[data-testid="user-list-name"]').setValue(value); + + const click = button => wrapper.find(`[data-testid="${button}"]`).trigger('click'); + const clickSave = () => click('save-user-list'); + + const destroy = () => wrapper?.destroy(); + + const factory = () => { + destroy(); + + wrapper = mount(EditUserList, { + localVue, + store: createStore({ projectId: '1', userListIid: '2' }), + provide: { + userListsDocsPath: '/docs/user_lists', + }, + }); + }; + + afterEach(() => { + destroy(); + }); + + describe('loading', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserList.mockReturnValue(new Promise(() => {})); + factory(); + }); + + it('should show a loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('loading error', () => { + const message = 'error creating list'; + let alert; + + beforeEach(async () => { + Api.fetchFeatureFlagUserList.mockRejectedValue({ message }); + factory(); + await waitForPromises(); + + alert = wrapper.find(GlAlert); + }); + + it('should show a flash with the error respopnse', () => { + expect(alert.text()).toContain(message); + }); + + it('should not be dismissible', async () => { + expect(alert.props('dismissible')).toBe(false); + }); + + it('should not show a user list form', () => { + expect(wrapper.find(UserListForm).exists()).toBe(false); + }); + }); + + describe('update', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList }); + factory(); + + return wrapper.vm.$nextTick(); + }); + + it('should link to the documentation', () => { + const link = wrapper.find('[data-testid="user-list-docs-link"]'); + expect(link.attributes('href')).toBe('/docs/user_lists'); + }); + + it('should link the cancel button to the user list details path', () => { + const link = wrapper.find('[data-testid="user-list-cancel"]'); + expect(link.attributes('href')).toBe(userList.path); + }); + + it('should show the user list name in the title', () => { + expect(wrapper.find('[data-testid="user-list-title"]').text()).toBe(`Edit ${userList.name}`); + }); + + describe('success', () => { + beforeEach(() => { + Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList }); + setInputValue('test'); + clickSave(); + return wrapper.vm.$nextTick(); + }); + + it('should create a user list with the entered name', () => { + expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', { + name: 'test', + iid: userList.iid, + }); + }); + + it('should redirect to the feature flag details page', () => { + expect(redirectTo).toHaveBeenCalledWith(userList.path); + }); + }); + + describe('error', () => { + let alert; + let message; + + beforeEach(async () => { + message = 'error creating list'; + Api.updateFeatureFlagUserList.mockRejectedValue({ message }); + setInputValue('test'); + clickSave(); + await waitForPromises(); + + alert = wrapper.find(GlAlert); + }); + + it('should show a flash with the error respopnse', () => { + expect(alert.text()).toContain(message); + }); + + it('should dismiss the error if dismiss is clicked', async () => { + alert.find('button').trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(alert.exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js new file mode 100644 index 00000000000..62fb0ca0859 --- /dev/null +++ b/spec/frontend/user_lists/components/new_user_list_spec.js @@ -0,0 +1,93 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { GlAlert } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import Api from '~/api'; +import createStore from '~/user_lists/store/new'; +import NewUserList from '~/user_lists/components/new_user_list.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { userList } from '../../feature_flags/mock_data'; + +jest.mock('~/api'); +jest.mock('~/lib/utils/url_utility'); + +const localVue = createLocalVue(Vue); +localVue.use(Vuex); + +describe('user_lists/components/new_user_list', () => { + let wrapper; + + const setInputValue = value => wrapper.find('[data-testid="user-list-name"]').setValue(value); + + const click = button => wrapper.find(`[data-testid="${button}"]`).trigger('click'); + + beforeEach(() => { + wrapper = mount(NewUserList, { + localVue, + store: createStore({ projectId: '1' }), + provide: { + featureFlagsPath: '/feature_flags', + userListsDocsPath: '/docs/user_lists', + }, + }); + }); + + it('should link to the documentation', () => { + const link = wrapper.find('[data-testid="user-list-docs-link"]'); + expect(link.attributes('href')).toBe('/docs/user_lists'); + }); + + it('should link the cancel buton back to feature flags', () => { + const cancel = wrapper.find('[data-testid="user-list-cancel"'); + expect(cancel.attributes('href')).toBe('/feature_flags'); + }); + + describe('create', () => { + describe('success', () => { + beforeEach(() => { + Api.createFeatureFlagUserList.mockResolvedValue({ data: userList }); + setInputValue('test'); + click('save-user-list'); + return wrapper.vm.$nextTick(); + }); + + it('should create a user list with the entered name', () => { + expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', { + name: 'test', + user_xids: '', + }); + }); + + it('should redirect to the feature flag details page', () => { + expect(redirectTo).toHaveBeenCalledWith(userList.path); + }); + }); + + describe('error', () => { + let alert; + + beforeEach(async () => { + Api.createFeatureFlagUserList.mockRejectedValue({ message: 'error creating list' }); + setInputValue('test'); + click('save-user-list'); + + await waitForPromises(); + + alert = wrapper.find(GlAlert); + }); + + it('should show a flash with the error respopnse', () => { + expect(alert.text()).toContain('error creating list'); + }); + + it('should dismiss the error when the dismiss button is clicked', async () => { + alert.find('button').trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(alert.exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/user_lists/components/user_list_form_spec.js b/spec/frontend/user_lists/components/user_list_form_spec.js new file mode 100644 index 00000000000..42f7659600e --- /dev/null +++ b/spec/frontend/user_lists/components/user_list_form_spec.js @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; +import Form from '~/user_lists/components/user_list_form.vue'; +import { userList } from '../../feature_flags/mock_data'; + +describe('user_lists/components/user_list_form', () => { + let wrapper; + let input; + + beforeEach(() => { + wrapper = mount(Form, { + propsData: { + cancelPath: '/cancel', + saveButtonLabel: 'Save', + userListsDocsPath: '/docs', + userList, + }, + }); + + input = wrapper.find('[data-testid="user-list-name"]'); + }); + + it('should set the name to the name of the given user list', () => { + expect(input.element.value).toBe(userList.name); + }); + + it('should link to the user lists docs', () => { + expect(wrapper.find('[data-testid="user-list-docs-link"]').attributes('href')).toBe('/docs'); + }); + + it('should emit an updated user list when save is clicked', () => { + input.setValue('test'); + wrapper.find('[data-testid="save-user-list"]').trigger('click'); + + expect(wrapper.emitted('submit')).toEqual([[{ ...userList, name: 'test' }]]); + }); + + it('should set the cancel button to the passed url', () => { + expect(wrapper.find('[data-testid="user-list-cancel"]').attributes('href')).toBe('/cancel'); + }); +}); diff --git a/spec/frontend/user_lists/components/user_list_spec.js b/spec/frontend/user_lists/components/user_list_spec.js new file mode 100644 index 00000000000..5f9b7967846 --- /dev/null +++ b/spec/frontend/user_lists/components/user_list_spec.js @@ -0,0 +1,196 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import { mount } from '@vue/test-utils'; +import { uniq } from 'lodash'; +import { GlAlert, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; +import Api from '~/api'; +import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils'; +import createStore from '~/user_lists/store/show'; +import UserList from '~/user_lists/components/user_list.vue'; +import { userList } from '../../feature_flags/mock_data'; + +jest.mock('~/api'); + +Vue.use(Vuex); + +describe('User List', () => { + let wrapper; + + const click = testId => wrapper.find(`[data-testid="${testId}"]`).trigger('click'); + + const findUserIds = () => wrapper.findAll('[data-testid="user-id"]'); + + const destroy = () => wrapper?.destroy(); + + const factory = () => { + destroy(); + + wrapper = mount(UserList, { + store: createStore({ projectId: '1', userListIid: '2' }), + propsData: { + emptyStatePath: '/empty_state.svg', + }, + }); + }; + + describe('loading', () => { + let resolveFn; + + beforeEach(() => { + Api.fetchFeatureFlagUserList.mockReturnValue( + new Promise(resolve => { + resolveFn = resolve; + }), + ); + factory(); + }); + + afterEach(() => { + resolveFn(); + }); + + it('shows a loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('success', () => { + let userIds; + + beforeEach(() => { + userIds = parseUserIds(userList.user_xids); + Api.fetchFeatureFlagUserList.mockResolvedValueOnce({ data: userList }); + factory(); + + return wrapper.vm.$nextTick(); + }); + + it('requests the user list on mount', () => { + expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'); + }); + + it('shows the list name', () => { + expect(wrapper.find('h3').text()).toBe(userList.name); + }); + + it('shows an add users button', () => { + expect(wrapper.find('[data-testid="add-users"]').text()).toBe('Add Users'); + }); + + it('shows an edit list button', () => { + expect(wrapper.find('[data-testid="edit-user-list"]').text()).toBe('Edit'); + }); + + it('shows a row for every id', () => { + expect(wrapper.findAll('[data-testid="user-id-row"]')).toHaveLength(userIds.length); + }); + + it('shows one id on each row', () => { + findUserIds().wrappers.forEach((w, i) => expect(w.text()).toBe(userIds[i])); + }); + + it('shows a delete button for every row', () => { + expect(wrapper.findAll('[data-testid="delete-user-id"]')).toHaveLength(userIds.length); + }); + + describe('adding users', () => { + const newIds = ['user3', 'user4', 'user5', 'test', 'example', 'foo']; + let receivedUserIds; + let parsedReceivedUserIds; + + beforeEach(async () => { + Api.updateFeatureFlagUserList.mockResolvedValue(userList); + click('add-users'); + await wrapper.vm.$nextTick(); + wrapper.find('#add-user-ids').setValue(`${stringifyUserIds(newIds)},`); + click('confirm-add-user-ids'); + await wrapper.vm.$nextTick(); + [[, { user_xids: receivedUserIds }]] = Api.updateFeatureFlagUserList.mock.calls; + parsedReceivedUserIds = parseUserIds(receivedUserIds); + }); + + it('should add user IDs to the user list', () => { + newIds.forEach(id => expect(receivedUserIds).toContain(id)); + }); + + it('should not remove existing user ids', () => { + userIds.forEach(id => expect(receivedUserIds).toContain(id)); + }); + + it('should not submit empty IDs', () => { + parsedReceivedUserIds.forEach(id => expect(id).not.toBe('')); + }); + + it('should not create duplicate entries', () => { + expect(uniq(parsedReceivedUserIds)).toEqual(parsedReceivedUserIds); + }); + + it('should display the new IDs', () => { + const userIdWrappers = findUserIds(); + newIds.forEach(id => { + const userIdWrapper = userIdWrappers.wrappers.find(w => w.text() === id); + expect(userIdWrapper.exists()).toBe(true); + }); + }); + }); + + describe('deleting users', () => { + let receivedUserIds; + + beforeEach(async () => { + Api.updateFeatureFlagUserList.mockResolvedValue(userList); + click('delete-user-id'); + await wrapper.vm.$nextTick(); + [[, { user_xids: receivedUserIds }]] = Api.updateFeatureFlagUserList.mock.calls; + }); + + it('should remove the ID clicked', () => { + expect(receivedUserIds).not.toContain(userIds[0]); + }); + + it('should not display the deleted user', () => { + const userIdWrappers = findUserIds(); + const userIdWrapper = userIdWrappers.wrappers.find(w => w.text() === userIds[0]); + expect(userIdWrapper).toBeUndefined(); + }); + }); + }); + + describe('error', () => { + const findAlert = () => wrapper.find(GlAlert); + + beforeEach(() => { + Api.fetchFeatureFlagUserList.mockRejectedValue(); + factory(); + + return wrapper.vm.$nextTick(); + }); + + it('displays the alert message', () => { + const alert = findAlert(); + expect(alert.text()).toBe('Something went wrong on our end. Please try again!'); + }); + + it('can dismiss the alert', async () => { + const alert = findAlert(); + alert.find('button').trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(alert.exists()).toBe(false); + }); + }); + + describe('empty list', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserList.mockResolvedValueOnce({ data: { ...userList, user_xids: '' } }); + factory(); + + return wrapper.vm.$nextTick(); + }); + + it('displays an empty state', () => { + expect(wrapper.find(GlEmptyState).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/edit/actions_spec.js b/spec/frontend/user_lists/store/edit/actions_spec.js new file mode 100644 index 00000000000..7f0fb8e5401 --- /dev/null +++ b/spec/frontend/user_lists/store/edit/actions_spec.js @@ -0,0 +1,121 @@ +import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; +import createState from '~/user_lists/store/edit/state'; +import * as types from '~/user_lists/store/edit/mutation_types'; +import * as actions from '~/user_lists/store/edit/actions'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { userList } from '../../../feature_flags/mock_data'; + +jest.mock('~/api'); +jest.mock('~/lib/utils/url_utility'); + +describe('User Lists Edit Actions', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '1', userListIid: '2' }); + }); + + describe('fetchUserList', () => { + describe('success', () => { + beforeEach(() => { + Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList }); + }); + + it('should commit RECEIVE_USER_LIST_SUCCESS', () => { + return testAction( + actions.fetchUserList, + undefined, + state, + [ + { type: types.REQUEST_USER_LIST }, + { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList }, + ], + [], + () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'), + ); + }); + }); + + describe('error', () => { + let error; + beforeEach(() => { + error = { response: { data: { message: ['error'] } } }; + Api.fetchFeatureFlagUserList.mockRejectedValue(error); + }); + + it('should commit RECEIVE_USER_LIST_ERROR', () => { + return testAction( + actions.fetchUserList, + undefined, + state, + [ + { type: types.REQUEST_USER_LIST }, + { type: types.RECEIVE_USER_LIST_ERROR, payload: ['error'] }, + ], + [], + () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'), + ); + }); + }); + }); + + describe('dismissErrorAlert', () => { + it('should commit DISMISS_ERROR_ALERT', () => { + return testAction(actions.dismissErrorAlert, undefined, state, [ + { type: types.DISMISS_ERROR_ALERT }, + ]); + }); + }); + + describe('updateUserList', () => { + let updatedList; + + beforeEach(() => { + updatedList = { + ...userList, + name: 'new', + }; + }); + describe('success', () => { + beforeEach(() => { + Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList }); + state.userList = userList; + }); + + it('should commit RECEIVE_USER_LIST_SUCCESS', () => { + return testAction(actions.updateUserList, updatedList, state, [], [], () => { + expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', { + name: updatedList.name, + iid: updatedList.iid, + }); + expect(redirectTo).toHaveBeenCalledWith(userList.path); + }); + }); + }); + + describe('error', () => { + let error; + + beforeEach(() => { + error = { message: 'error' }; + Api.updateFeatureFlagUserList.mockRejectedValue(error); + }); + + it('should commit RECEIVE_USER_LIST_ERROR', () => { + return testAction( + actions.updateUserList, + updatedList, + state, + [{ type: types.RECEIVE_USER_LIST_ERROR, payload: ['error'] }], + [], + () => + expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', { + name: updatedList.name, + iid: updatedList.iid, + }), + ); + }); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js new file mode 100644 index 00000000000..3d4d2a59717 --- /dev/null +++ b/spec/frontend/user_lists/store/edit/mutations_spec.js @@ -0,0 +1,61 @@ +import statuses from '~/user_lists/constants/edit'; +import createState from '~/user_lists/store/edit/state'; +import * as types from '~/user_lists/store/edit/mutation_types'; +import mutations from '~/user_lists/store/edit/mutations'; +import { userList } from '../../../feature_flags/mock_data'; + +describe('User List Edit Mutations', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '1', userListIid: '2' }); + }); + + describe(types.REQUEST_USER_LIST, () => { + beforeEach(() => { + mutations[types.REQUEST_USER_LIST](state); + }); + + it('sets the state to loading', () => { + expect(state.status).toBe(statuses.LOADING); + }); + }); + + describe(types.RECEIVE_USER_LIST_SUCCESS, () => { + beforeEach(() => { + mutations[types.RECEIVE_USER_LIST_SUCCESS](state, userList); + }); + + it('sets the state to success', () => { + expect(state.status).toBe(statuses.SUCCESS); + }); + + it('sets the user list to the one received', () => { + expect(state.userList).toEqual(userList); + }); + }); + + describe(types.RECIEVE_USER_LIST_ERROR, () => { + beforeEach(() => { + mutations[types.RECEIVE_USER_LIST_ERROR](state, ['network error']); + }); + + it('sets the state to error', () => { + expect(state.status).toBe(statuses.ERROR); + }); + + it('sets the error message to the recieved one', () => { + expect(state.errorMessage).toEqual(['network error']); + }); + }); + + describe(types.DISMISS_ERROR_ALERT, () => { + beforeEach(() => { + mutations[types.DISMISS_ERROR_ALERT](state); + }); + + it('sets the state to error dismissed', () => { + expect(state.status).toBe(statuses.UNSYNCED); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/new/actions_spec.js b/spec/frontend/user_lists/store/new/actions_spec.js new file mode 100644 index 00000000000..9cc6212a125 --- /dev/null +++ b/spec/frontend/user_lists/store/new/actions_spec.js @@ -0,0 +1,69 @@ +import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; +import createState from '~/user_lists/store/new/state'; +import * as types from '~/user_lists/store/new/mutation_types'; +import * as actions from '~/user_lists/store/new/actions'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { userList } from '../../../feature_flags/mock_data'; + +jest.mock('~/api'); +jest.mock('~/lib/utils/url_utility'); + +describe('User Lists Edit Actions', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '1' }); + }); + + describe('dismissErrorAlert', () => { + it('should commit DISMISS_ERROR_ALERT', () => { + return testAction(actions.dismissErrorAlert, undefined, state, [ + { type: types.DISMISS_ERROR_ALERT }, + ]); + }); + }); + + describe('createUserList', () => { + let createdList; + + beforeEach(() => { + createdList = { + ...userList, + name: 'new', + }; + }); + describe('success', () => { + beforeEach(() => { + Api.createFeatureFlagUserList.mockResolvedValue({ data: userList }); + }); + + it('should redirect to the user list page', () => { + return testAction(actions.createUserList, createdList, state, [], [], () => { + expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', createdList); + expect(redirectTo).toHaveBeenCalledWith(userList.path); + }); + }); + }); + + describe('error', () => { + let error; + + beforeEach(() => { + error = { message: 'error' }; + Api.createFeatureFlagUserList.mockRejectedValue(error); + }); + + it('should commit RECEIVE_USER_LIST_ERROR', () => { + return testAction( + actions.createUserList, + createdList, + state, + [{ type: types.RECEIVE_CREATE_USER_LIST_ERROR, payload: ['error'] }], + [], + () => expect(Api.createFeatureFlagUserList).toHaveBeenCalledWith('1', createdList), + ); + }); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/new/mutations_spec.js b/spec/frontend/user_lists/store/new/mutations_spec.js new file mode 100644 index 00000000000..89e8a83eb25 --- /dev/null +++ b/spec/frontend/user_lists/store/new/mutations_spec.js @@ -0,0 +1,38 @@ +import createState from '~/user_lists/store/new/state'; +import * as types from '~/user_lists/store/new/mutation_types'; +import mutations from '~/user_lists/store/new/mutations'; + +describe('User List Edit Mutations', () => { + let state; + + beforeEach(() => { + state = createState({ projectId: '1' }); + }); + + describe(types.RECIEVE_USER_LIST_ERROR, () => { + beforeEach(() => { + mutations[types.RECEIVE_CREATE_USER_LIST_ERROR](state, ['network error']); + }); + + it('sets the error message to the recieved one', () => { + expect(state.errorMessage).toEqual(['network error']); + }); + + it('sets the error message to the recevied API message if present', () => { + const message = ['name is blank', 'name is too short']; + + mutations[types.RECEIVE_CREATE_USER_LIST_ERROR](state, message); + expect(state.errorMessage).toEqual(message); + }); + }); + + describe(types.DISMISS_ERROR_ALERT, () => { + beforeEach(() => { + mutations[types.DISMISS_ERROR_ALERT](state); + }); + + it('clears the error message', () => { + expect(state.errorMessage).toBe(''); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/show/actions_spec.js b/spec/frontend/user_lists/store/show/actions_spec.js new file mode 100644 index 00000000000..25a6b9ec0e4 --- /dev/null +++ b/spec/frontend/user_lists/store/show/actions_spec.js @@ -0,0 +1,117 @@ +import testAction from 'helpers/vuex_action_helper'; +import { userList } from 'jest/feature_flags/mock_data'; +import Api from '~/api'; +import { stringifyUserIds } from '~/user_lists/store/utils'; +import createState from '~/user_lists/store/show/state'; +import * as types from '~/user_lists/store/show/mutation_types'; +import * as actions from '~/user_lists/store/show/actions'; + +jest.mock('~/api'); + +describe('User Lists Show Actions', () => { + let mockState; + + beforeEach(() => { + mockState = createState({ projectId: '1', userListIid: '2' }); + }); + + describe('fetchUserList', () => { + it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_SUCCESS on success', () => { + Api.fetchFeatureFlagUserList.mockResolvedValue({ data: userList }); + return testAction( + actions.fetchUserList, + undefined, + mockState, + [ + { type: types.REQUEST_USER_LIST }, + { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList }, + ], + [], + () => expect(Api.fetchFeatureFlagUserList).toHaveBeenCalledWith('1', '2'), + ); + }); + + it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_ERROR on error', () => { + Api.fetchFeatureFlagUserList.mockRejectedValue({ message: 'fail' }); + return testAction( + actions.fetchUserList, + undefined, + mockState, + [{ type: types.REQUEST_USER_LIST }, { type: types.RECEIVE_USER_LIST_ERROR }], + [], + ); + }); + }); + + describe('dismissErrorAlert', () => { + it('commits DISMISS_ERROR_ALERT', () => { + return testAction( + actions.dismissErrorAlert, + undefined, + mockState, + [{ type: types.DISMISS_ERROR_ALERT }], + [], + ); + }); + }); + + describe('addUserIds', () => { + it('adds the given IDs and tries to update the user list', () => { + return testAction( + actions.addUserIds, + '1,2,3', + mockState, + [{ type: types.ADD_USER_IDS, payload: '1,2,3' }], + [{ type: 'updateUserList' }], + ); + }); + }); + + describe('removeUserId', () => { + it('removes the given ID and tries to update the user list', () => { + return testAction( + actions.removeUserId, + 'user3', + mockState, + [{ type: types.REMOVE_USER_ID, payload: 'user3' }], + [{ type: 'updateUserList' }], + ); + }); + }); + + describe('updateUserList', () => { + beforeEach(() => { + mockState.userList = userList; + mockState.userIds = ['user1', 'user2', 'user3']; + }); + + it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_SUCCESS on success', () => { + Api.updateFeatureFlagUserList.mockResolvedValue({ data: userList }); + return testAction( + actions.updateUserList, + undefined, + mockState, + [ + { type: types.REQUEST_USER_LIST }, + { type: types.RECEIVE_USER_LIST_SUCCESS, payload: userList }, + ], + [], + () => + expect(Api.updateFeatureFlagUserList).toHaveBeenCalledWith('1', { + ...userList, + user_xids: stringifyUserIds(mockState.userIds), + }), + ); + }); + it('commits REQUEST_USER_LIST and RECEIVE_USER_LIST_ERROR on error', () => { + Api.updateFeatureFlagUserList.mockRejectedValue({ message: 'fail' }); + return testAction( + actions.updateUserList, + undefined, + mockState, + [{ type: types.REQUEST_USER_LIST }, { type: types.RECEIVE_USER_LIST_ERROR }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/show/mutations_spec.js b/spec/frontend/user_lists/store/show/mutations_spec.js new file mode 100644 index 00000000000..364cc6a0225 --- /dev/null +++ b/spec/frontend/user_lists/store/show/mutations_spec.js @@ -0,0 +1,86 @@ +import { uniq } from 'lodash'; +import { userList } from 'jest/feature_flags/mock_data'; +import createState from '~/user_lists/store/show/state'; +import mutations from '~/user_lists/store/show/mutations'; +import { states } from '~/user_lists/constants/show'; +import * as types from '~/user_lists/store/show/mutation_types'; + +describe('User Lists Show Mutations', () => { + let mockState; + + beforeEach(() => { + mockState = createState({ projectId: '1', userListIid: '2' }); + }); + + describe(types.REQUEST_USER_LIST, () => { + it('puts us in the loading state', () => { + mutations[types.REQUEST_USER_LIST](mockState); + + expect(mockState.state).toBe(states.LOADING); + }); + }); + + describe(types.RECEIVE_USER_LIST_SUCCESS, () => { + beforeEach(() => { + mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList); + }); + + it('sets the state to LOADED', () => { + expect(mockState.state).toBe(states.SUCCESS); + }); + + it('sets the active user list', () => { + expect(mockState.userList).toEqual(userList); + }); + + it('splits the user IDs into an Array', () => { + expect(mockState.userIds).toEqual(userList.user_xids.split(',')); + }); + + it('sets user IDs to an empty Array if an empty string is received', () => { + mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, { ...userList, user_xids: '' }); + expect(mockState.userIds).toEqual([]); + }); + }); + describe(types.RECEIVE_USER_LIST_ERROR, () => { + it('sets the state to error', () => { + mutations[types.RECEIVE_USER_LIST_ERROR](mockState); + expect(mockState.state).toBe(states.ERROR); + }); + }); + describe(types.ADD_USER_IDS, () => { + const newIds = ['user3', 'test1', '1', '3', '']; + + beforeEach(() => { + mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList); + mutations[types.ADD_USER_IDS](mockState, newIds.join(', ')); + }); + + it('adds the new IDs to the state unless empty', () => { + newIds.filter(id => id).forEach(id => expect(mockState.userIds).toContain(id)); + }); + + it('does not add duplicate IDs to the state', () => { + expect(mockState.userIds).toEqual(uniq(mockState.userIds)); + }); + }); + describe(types.REMOVE_USER_ID, () => { + let userIds; + let removedId; + + beforeEach(() => { + mutations[types.RECEIVE_USER_LIST_SUCCESS](mockState, userList); + userIds = mockState.userIds; + removedId = 'user3'; + mutations[types.REMOVE_USER_ID](mockState, removedId); + }); + + it('should remove the given id', () => { + expect(mockState).not.toContain(removedId); + }); + + it('should leave the rest of the IDs alone', () => { + userIds.filter(id => id !== removedId).forEach(id => expect(mockState.userIds).toContain(id)); + }); + }); +}); diff --git a/spec/frontend/user_lists/store/utils_spec.js b/spec/frontend/user_lists/store/utils_spec.js new file mode 100644 index 00000000000..9547b463eec --- /dev/null +++ b/spec/frontend/user_lists/store/utils_spec.js @@ -0,0 +1,23 @@ +import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils'; + +describe('User List Store Utils', () => { + describe('parseUserIds', () => { + it('should split comma-seperated user IDs into an array', () => { + expect(parseUserIds('1,2,3')).toEqual(['1', '2', '3']); + }); + + it('should filter whitespace before the comma', () => { + expect(parseUserIds('1\t,2 ,3')).toEqual(['1', '2', '3']); + }); + + it('should filter whitespace after the comma', () => { + expect(parseUserIds('1,\t2, 3')).toEqual(['1', '2', '3']); + }); + }); + + describe('stringifyUserIds', () => { + it('should convert a list of user IDs into a comma-separated string', () => { + expect(stringifyUserIds(['1', '2', '3'])).toBe('1,2,3'); + }); + }); +}); 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 caea9a757ae..015f8bbac51 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 @@ -130,7 +130,7 @@ describe('MRWidgetHeader', () => { }); it('renders clipboard button', () => { - expect(vm.$el.querySelector('.btn-clipboard')).not.toEqual(null); + expect(vm.$el.querySelector('[data-testid="mr-widget-copy-clipboard"]')).not.toEqual(null); }); it('renders target branch', () => { 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 6ec30493f8b..9923434a7dd 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 @@ -6,6 +6,10 @@ import component from '~/vue_merge_request_widget/components/states/mr_widget_re describe('Merge request widget rebase component', () => { let Component; let vm; + + const findRebaseMessageEl = () => vm.$el.querySelector('[data-testid="rebase-message"]'); + const findRebaseMessageElText = () => findRebaseMessageEl().textContent.trim(); + beforeEach(() => { Component = Vue.extend(component); }); @@ -21,9 +25,7 @@ describe('Merge request widget rebase component', () => { service: {}, }); - expect( - vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(), - ).toContain('Rebase in progress'); + expect(findRebaseMessageElText()).toContain('Rebase in progress'); }); }); @@ -39,9 +41,7 @@ describe('Merge request widget rebase component', () => { }); it('it should render rebase button and warning message', () => { - const text = vm.$el - .querySelector('.rebase-state-find-class-convention span') - .textContent.trim(); + const text = findRebaseMessageElText(); expect(text).toContain('Fast-forward merge is not possible.'); expect(text.replace(/\s\s+/g, ' ')).toContain( @@ -53,9 +53,7 @@ describe('Merge request widget rebase component', () => { vm.rebasingError = 'Something went wrong!'; Vue.nextTick(() => { - expect( - vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(), - ).toContain('Something went wrong!'); + expect(findRebaseMessageElText()).toContain('Something went wrong!'); done(); }); }); @@ -72,9 +70,7 @@ describe('Merge request widget rebase component', () => { service: {}, }); - const text = vm.$el - .querySelector('.rebase-state-find-class-convention span') - .textContent.trim(); + const text = findRebaseMessageElText(); expect(text).toContain('Fast-forward merge is not possible.'); expect(text).toContain('Rebase the source branch onto'); @@ -93,7 +89,7 @@ describe('Merge request widget rebase component', () => { service: {}, }); - const elem = vm.$el.querySelector('.rebase-state-find-class-convention span'); + const elem = findRebaseMessageEl(); expect(elem.innerHTML).toContain( `Fast-forward merge is not possible. Rebase the source branch onto <span class="label-branch">${targetBranch}</span> to allow this merge request to be merged.`, diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js index 98af44b0975..aae9b8660e2 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js @@ -1,12 +1,12 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import AutoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; describe('MRWidgetAutoMergeFailed', () => { let wrapper; const mergeError = 'This is the merge error'; - const findButton = () => wrapper.find('button'); + const findButton = () => wrapper.find(GlButton); const createComponent = (props = {}) => { wrapper = shallowMount(AutoMergeFailedComponent, { @@ -38,17 +38,13 @@ describe('MRWidgetAutoMergeFailed', () => { it('emits event and shows loading icon when button is clicked', () => { jest.spyOn(eventHub, '$emit'); - findButton().trigger('click'); + findButton().vm.$emit('click'); expect(eventHub.$emit.mock.calls[0][0]).toBe('MRWidgetUpdateRequested'); return wrapper.vm.$nextTick(() => { - expect(findButton().attributes('disabled')).toEqual('disabled'); - expect( - findButton() - .find(GlLoadingIcon) - .exists(), - ).toBe(true); + expect(findButton().attributes('disabled')).toBe('true'); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); }); }); 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 5eb24315ca6..9057ffaea45 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 @@ -101,8 +101,6 @@ describe('ReadyToMerge', () => { expect(vm.isMakingRequest).toBeFalsy(); expect(vm.isMergingImmediately).toBeFalsy(); expect(vm.commitMessage).toBe(vm.mr.commitMessage); - expect(vm.successSvg).toBeDefined(); - expect(vm.warningSvg).toBeDefined(); }); }); @@ -494,19 +492,6 @@ describe('ReadyToMerge', () => { }); }); - it('hides close button', done => { - jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged')); - jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {}); - - vm.handleMergePolling(() => {}, () => {}); - - setImmediate(() => { - expect(document.querySelector('.btn-close').classList.contains('hidden')).toBeTruthy(); - - done(); - }); - }); - it('updates merge request count badge', done => { jest.spyOn(vm.service, 'poll').mockReturnValue(returnPromise('merged')); jest.spyOn(vm, 'initiateRemoveSourceBranchPolling').mockImplementation(() => {}); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js index 1711efb5512..13c0665f929 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js @@ -31,10 +31,7 @@ describe('DeploymentAction component', () => { wrapper.destroy(); } - wrapper = mount(DeploymentActions, { - ...options, - provide: { glFeatures: { deployFromFooter: true } }, - }); + wrapper = mount(DeploymentActions, options); }; const findStopButton = () => wrapper.find('.js-stop-env'); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js index ce395de3b5d..17d7fcc4bff 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js @@ -19,10 +19,7 @@ describe('Deployment component', () => { if (wrapper && wrapper.destroy) { wrapper.destroy(); } - wrapper = mount(DeploymentComponent, { - ...options, - provide: { glFeatures: { deployFromFooter: true } }, - }); + wrapper = mount(DeploymentComponent, options); }; beforeEach(() => { diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index a2ade44b7c4..5fe8ff58d31 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import mountComponent from 'helpers/vue_mount_component_helper'; +import { withGonExperiment } from 'helpers/experimentation_helper'; import axios from '~/lib/utils/axios_utils'; import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; @@ -812,43 +813,61 @@ describe('mrWidgetOptions', () => { }); }); - describe('given suggestPipeline feature flag is enabled', () => { + describe('suggestPipeline Experiment', () => { beforeEach(() => { mock.onAny().reply(200); // This is needed because some grandchildren Bootstrap components throw warnings // https://gitlab.com/gitlab-org/gitlab/issues/208458 jest.spyOn(console, 'warn').mockImplementation(); + }); - gon.features = { suggestPipeline: true }; + describe('given experiment is enabled', () => { + withGonExperiment('suggestPipeline'); - createComponent(); + beforeEach(() => { + createComponent(); - vm.mr.hasCI = false; - }); + vm.mr.hasCI = false; + }); - it('should suggest pipelines when none exist', () => { - expect(findSuggestPipeline()).toEqual(expect.any(Element)); - }); + it('should suggest pipelines when none exist', () => { + expect(findSuggestPipeline()).toEqual(expect.any(Element)); + }); - it.each([ - { isDismissedSuggestPipeline: true }, - { mergeRequestAddCiConfigPath: null }, - { hasCI: true }, - ])('with %s, should not suggest pipeline', async obj => { - Object.assign(vm.mr, obj); + it.each([ + { isDismissedSuggestPipeline: true }, + { mergeRequestAddCiConfigPath: null }, + { hasCI: true }, + ])('with %s, should not suggest pipeline', async obj => { + Object.assign(vm.mr, obj); - await vm.$nextTick(); + await vm.$nextTick(); - expect(findSuggestPipeline()).toBeNull(); + expect(findSuggestPipeline()).toBeNull(); + }); + + it('should allow dismiss of the suggest pipeline message', async () => { + findSuggestPipelineButton().click(); + + await vm.$nextTick(); + + expect(findSuggestPipeline()).toBeNull(); + }); }); - it('should allow dismiss of the suggest pipeline message', async () => { - findSuggestPipelineButton().click(); + describe('given suggestPipeline experiment is not enabled', () => { + withGonExperiment('suggestPipeline', false); - await vm.$nextTick(); + beforeEach(() => { + createComponent(); - expect(findSuggestPipeline()).toBeNull(); + vm.mr.hasCI = false; + }); + + it('should not suggest pipelines when none exist', () => { + expect(findSuggestPipeline()).toBeNull(); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index dfd114a2d1c..ec4a81054db 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -39,6 +39,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub + buttontextclasses="" category="primary" class="d-inline-flex" data-clipboard-text="ssh://foo.bar" @@ -80,6 +81,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub + buttontextclasses="" category="primary" class="d-inline-flex" data-clipboard-text="http://foo.bar" diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js index 9c38ccad8a7..dbdb7705d3c 100644 --- a/spec/frontend/vue_shared/components/alert_detail_table_spec.js +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -1,5 +1,5 @@ +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; -import { GlTable, GlLoadingIcon } from '@gitlab/ui'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; const mockAlert = { @@ -14,6 +14,7 @@ const mockAlert = { assignees: { nodes: [] }, notes: { nodes: [] }, todos: { nodes: [] }, + __typename: 'AlertManagementAlert', }; describe('AlertDetails', () => { @@ -35,6 +36,8 @@ describe('AlertDetails', () => { }); const findTableComponent = () => wrapper.find(GlTable); + const findTableKeys = () => findTableComponent().findAll('tbody td:first-child'); + const findTableField = (fields, fieldName) => fields.filter(row => row.text() === fieldName); describe('Alert details', () => { describe('empty state', () => { @@ -58,8 +61,10 @@ describe('AlertDetails', () => { }); describe('with table data', () => { + const environment = 'myEnvironment'; + const environmentUrl = 'fake/url'; beforeEach(() => { - mountComponent(); + mountComponent({ alert: { ...mockAlert, environment, environmentUrl } }); }); it('renders a table', () => { @@ -69,6 +74,26 @@ describe('AlertDetails', () => { it('renders a cell based on alert data', () => { expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token'); }); + + it('should show allowed alert fields', () => { + const fields = findTableKeys(); + + expect(findTableField(fields, 'Iid').exists()).toBe(true); + expect(findTableField(fields, 'Title').exists()).toBe(true); + expect(findTableField(fields, 'Severity').exists()).toBe(true); + expect(findTableField(fields, 'Status').exists()).toBe(true); + expect(findTableField(fields, 'Environment').exists()).toBe(true); + }); + + it('should not show disallowed alert fields', () => { + const fields = findTableKeys(); + + expect(findTableField(fields, 'Typename').exists()).toBe(false); + expect(findTableField(fields, 'Todos').exists()).toBe(false); + expect(findTableField(fields, 'Notes').exists()).toBe(false); + expect(findTableField(fields, 'Assignees').exists()).toBe(false); + expect(findTableField(fields, 'EnvironmentUrl').exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 7f0b7ba8cf8..51a2653befc 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('clipboard button', () => { @@ -26,9 +26,8 @@ describe('clipboard button', () => { }); it('renders a button for clipboard', () => { - expect(wrapper.find(GlDeprecatedButton).exists()).toBe(true); + expect(wrapper.find(GlButton).exists()).toBe(true); expect(wrapper.attributes('data-clipboard-text')).toBe('copy me'); - expect(wrapper.find(GlIcon).props('name')).toBe('copy-to-clipboard'); }); it('should have a tooltip with default values', () => { diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js index 5d92af64de0..8456ca9d125 100644 --- a/spec/frontend/vue_shared/components/confirm_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js @@ -86,6 +86,22 @@ describe('vue_shared/components/confirm_modal', () => { expect(findForm().element.submit).not.toHaveBeenCalled(); }); + describe('with handleSubmit prop', () => { + const handleSubmit = jest.fn(); + beforeEach(() => { + createComponent({ handleSubmit }); + findModal().vm.$emit('primary'); + }); + + it('will call handleSubmit', () => { + expect(handleSubmit).toHaveBeenCalled(); + }); + + it('does not submit the form', () => { + expect(findForm().element.submit).not.toHaveBeenCalled(); + }); + }); + describe('when modal submitted', () => { beforeEach(() => { findModal().vm.$emit('primary'); diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js index 5470171a21e..3ff4c0917f2 100644 --- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js +++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js @@ -12,7 +12,9 @@ describe('Local Storage Sync', () => { }; afterEach(() => { - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + } wrapper = null; localStorage.clear(); }); @@ -45,23 +47,23 @@ describe('Local Storage Sync', () => { expect(wrapper.emitted('input')).toBeFalsy(); }); - it('saves updated value to localStorage', () => { - createComponent({ - props: { - storageKey, - value: 'ascending', - }, - }); - - const newValue = 'descending'; - wrapper.setProps({ - value: newValue, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(localStorage.getItem(storageKey)).toBe(newValue); - }); - }); + it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })( + 'saves updated value to localStorage', + newValue => { + createComponent({ + props: { + storageKey, + value: 'initial', + }, + }); + + wrapper.setProps({ value: newValue }); + + return wrapper.vm.$nextTick().then(() => { + expect(localStorage.getItem(storageKey)).toBe(String(newValue)); + }); + }, + ); it('does not save default value', () => { const value = 'ascending'; @@ -125,4 +127,88 @@ describe('Local Storage Sync', () => { }); }); }); + + describe('with "asJson" prop set to "true"', () => { + const storageKey = 'testStorageKey'; + + describe.each` + value | serializedValue + ${null} | ${'null'} + ${''} | ${'""'} + ${true} | ${'true'} + ${false} | ${'false'} + ${42} | ${'42'} + ${'42'} | ${'"42"'} + ${'{ foo: '} | ${'"{ foo: "'} + ${['test']} | ${'["test"]'} + ${{ foo: 'bar' }} | ${'{"foo":"bar"}'} + `('given $value', ({ value, serializedValue }) => { + describe('is a new value', () => { + beforeEach(() => { + createComponent({ + props: { + storageKey, + value: 'initial', + asJson: true, + }, + }); + + wrapper.setProps({ value }); + + return wrapper.vm.$nextTick(); + }); + + it('serializes the value correctly to localStorage', () => { + expect(localStorage.getItem(storageKey)).toBe(serializedValue); + }); + }); + + describe('is already stored', () => { + beforeEach(() => { + localStorage.setItem(storageKey, serializedValue); + + createComponent({ + props: { + storageKey, + value: 'initial', + asJson: true, + }, + }); + }); + + it('emits an input event with the deserialized value', () => { + expect(wrapper.emitted('input')).toEqual([[value]]); + }); + }); + }); + + describe('with bad JSON in storage', () => { + const badJSON = '{ badJSON'; + + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(); + localStorage.setItem(storageKey, badJSON); + + createComponent({ + props: { + storageKey, + value: 'initial', + asJson: true, + }, + }); + }); + + it('should console warn', () => { + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith( + `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`, + badJSON, + ); + }); + + it('should not emit an input event', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap index cdd7a3ccaf0..b8a9143bc79 100644 --- a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap +++ b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap @@ -10,6 +10,7 @@ exports[`Suggestion Diff component matches snapshot 1`] = ` helppagepath="path_to_docs" isapplyingbatch="true" isbatched="true" + suggestionscount="0" /> <table diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 3da0a35f05a..f1ead33ec68 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -7,6 +7,7 @@ import axios from '~/lib/utils/axios_utils'; const markdownPreviewPath = `${TEST_HOST}/preview`; const markdownDocsPath = `${TEST_HOST}/docs`; +const textareaValue = 'testing\n123'; function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) { expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite); @@ -20,23 +21,11 @@ function createComponent() { markdownDocsPath, markdownPreviewPath, isSubmitting: false, + textareaValue, }, slots: { - textarea: '<textarea>testing\n123</textarea>', + textarea: `<textarea>${textareaValue}</textarea>`, }, - template: ` - <field-component - markdown-preview-path="${markdownPreviewPath}" - markdown-docs-path="${markdownDocsPath}" - :isSubmitting="false" - > - <textarea - slot="textarea" - v-model="text"> - <slot>this is a test</slot> - </textarea> - </field-component> - `, }); return wrapper; } diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index a521668b15c..b19e74b5b11 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -57,7 +57,9 @@ describe('Suggestion Diff component', () => { }); it('renders apply suggestion and add to batch buttons', () => { - createComponent(); + createComponent({ + suggestionsCount: 2, + }); const applyBtn = findApplyButton(); const addToBatchBtn = findAddToBatchButton(); @@ -104,7 +106,9 @@ describe('Suggestion Diff component', () => { describe('when add to batch is clicked', () => { it('emits addToBatch', () => { - createComponent(); + createComponent({ + suggestionsCount: 2, + }); findAddToBatchButton().vm.$emit('click'); diff --git a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js new file mode 100644 index 00000000000..d6f5773295c --- /dev/null +++ b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js @@ -0,0 +1,46 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { GlAvatarLink } from '@gitlab/ui'; +import { group as member } from '../mock_data'; +import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const group = member.sharedWithGroup; + + const createComponent = (propsData = {}) => { + wrapper = mount(GroupAvatar, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders link to group', () => { + const link = wrapper.find(GlAvatarLink); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(group.webUrl); + }); + + it("renders group's full name", () => { + expect(getByText(group.fullName).exists()).toBe(true); + }); + + it("renders group's avatar", () => { + expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js new file mode 100644 index 00000000000..7948da7eb40 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js @@ -0,0 +1,38 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { invite as member } from '../mock_data'; +import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const { invite } = member; + + const createComponent = (propsData = {}) => { + wrapper = mount(InviteAvatar, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders email as name', () => { + expect(getByText(invite.email).exists()).toBe(true); + }); + + it('renders avatar', () => { + expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js new file mode 100644 index 00000000000..6c0ba8afede --- /dev/null +++ b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js @@ -0,0 +1,85 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { GlAvatarLink, GlBadge } from '@gitlab/ui'; +import { member as memberMock, orphanedMember } from '../mock_data'; +import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; + +describe('UserAvatar', () => { + let wrapper; + + const { user } = memberMock; + + const createComponent = (propsData = {}) => { + wrapper = mount(UserAvatar, { + propsData: { + member: memberMock, + isCurrentUser: false, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).findByText(text, options)); + + afterEach(() => { + wrapper.destroy(); + }); + + it("renders link to user's profile", () => { + createComponent(); + + const link = wrapper.find(GlAvatarLink); + + expect(link.exists()).toBe(true); + expect(link.attributes()).toMatchObject({ + href: user.webUrl, + 'data-user-id': `${user.id}`, + 'data-username': user.username, + }); + }); + + it("renders user's name", () => { + createComponent(); + + expect(getByText(user.name).exists()).toBe(true); + }); + + it("renders user's username", () => { + createComponent(); + + expect(getByText(`@${user.username}`).exists()).toBe(true); + }); + + it("renders user's avatar", () => { + createComponent(); + + expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl); + }); + + describe('when user property does not exist', () => { + it('displays an orphaned user', () => { + createComponent({ member: orphanedMember }); + + expect(getByText('Orphaned member').exists()).toBe(true); + }); + }); + + describe('badges', () => { + it.each` + member | badgeText + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'} + ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'} + `('renders the "$badgeText" badge', ({ member, badgeText }) => { + createComponent({ member }); + + expect(wrapper.find(GlBadge).text()).toBe(badgeText); + }); + + it('renders the "It\'s you" badge when member is current user', () => { + createComponent({ isCurrentUser: true }); + + expect(getByText("It's you").exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js new file mode 100644 index 00000000000..3195f04f202 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/mock_data.js @@ -0,0 +1,61 @@ +export const member = { + requestedAt: null, + canUpdate: false, + canRemove: false, + canOverride: false, + accessLevel: { integerValue: 50, stringValue: 'Owner' }, + source: { + id: 178, + name: 'Foo Bar', + webUrl: 'https://gitlab.com/groups/foo-bar', + }, + user: { + id: 123, + name: 'Administrator', + username: 'root', + webUrl: 'https://gitlab.com/root', + avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon', + blocked: false, + twoFactorEnabled: false, + }, + id: 238, + createdAt: '2020-07-17T16:22:46.923Z', + expiresAt: null, + usingLicense: false, + groupSso: false, + groupManagedAccount: false, +}; + +export const group = { + accessLevel: { integerValue: 10, stringValue: 'Guest' }, + sharedWithGroup: { + id: 24, + name: 'Commit451', + avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40', + fullPath: 'parent-group/commit451', + fullName: 'Parent group / Commit451', + webUrl: 'https://gitlab.com/groups/parent-group/commit451', + }, + id: 3, + createdAt: '2020-08-06T15:31:07.662Z', + expiresAt: null, +}; + +const { user, ...memberNoUser } = member; +export const invite = { + ...memberNoUser, + invite: { + email: 'jewel@hudsonwalter.biz', + avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon', + canResend: true, + }, +}; + +export const orphanedMember = memberNoUser; + +export const accessRequest = { + ...member, + requestedAt: '2020-07-17T16:22:46.923Z', +}; + +export const members = [member]; diff --git a/spec/frontend/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/vue_shared/components/members/table/created_at_spec.js new file mode 100644 index 00000000000..cf3821baf44 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/created_at_spec.js @@ -0,0 +1,61 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { useFakeDate } from 'helpers/fake_date'; +import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +describe('CreatedAt', () => { + // March 15th, 2020 + useFakeDate(2020, 2, 15); + + const date = '2020-03-01T00:00:00.000'; + const dateTimeAgo = '2 weeks ago'; + + let wrapper; + + const createComponent = propsData => { + wrapper = mount(CreatedAt, { + propsData: { + date, + ...propsData, + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).getByText(text, options)); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('created at text', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays created at text', () => { + expect(getByText(dateTimeAgo).exists()).toBe(true); + }); + + it('uses `TimeAgoTooltip` component to display tooltip', () => { + expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); + }); + }); + + describe('when `createdBy` prop is provided', () => { + it('displays a link to the user that created the member', () => { + createComponent({ + createdBy: { + name: 'Administrator', + webUrl: 'https://gitlab.com/root', + }, + }); + + const link = getByText('Administrator'); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('https://gitlab.com/root'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js new file mode 100644 index 00000000000..95ae251b0fd --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js @@ -0,0 +1,86 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { within } from '@testing-library/dom'; +import { useFakeDate } from 'helpers/fake_date'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; + +describe('ExpiresAt', () => { + // March 15th, 2020 + useFakeDate(2020, 2, 15); + + let wrapper; + + const createComponent = propsData => { + wrapper = mount(ExpiresAt, { + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const getByText = (text, options) => + createWrapper(within(wrapper.element).getByText(text, options)); + + const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when no expiration date is set', () => { + it('displays "No expiration set"', () => { + createComponent({ date: null }); + + expect(getByText('No expiration set').exists()).toBe(true); + }); + }); + + describe('when expiration date is in the past', () => { + let expiredText; + + beforeEach(() => { + createComponent({ date: '2019-03-15T00:00:00.000' }); + + expiredText = getByText('Expired'); + }); + + it('displays "Expired"', () => { + expect(expiredText.exists()).toBe(true); + expect(expiredText.classes()).toContain('gl-text-red-500'); + }); + + it('displays tooltip with formatted date', () => { + const tooltipDirective = getTooltipDirective(expiredText); + + expect(tooltipDirective).not.toBeUndefined(); + expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000'); + }); + }); + + describe('when expiration date is in the future', () => { + it.each` + date | expected | warningColor + ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false} + ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true} + ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true} + ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true} + ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true} + ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true} + ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true} + ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true} + `('displays "$expected"', ({ date, expected, warningColor }) => { + createComponent({ date }); + + const expiredText = getByText(expected); + + expect(expiredText.exists()).toBe(true); + + if (warningColor) { + expect(expiredText.classes()).toContain('gl-text-orange-500'); + } else { + expect(expiredText.classes()).not.toContain('gl-text-orange-500'); + } + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js new file mode 100644 index 00000000000..a171dd830c1 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js @@ -0,0 +1,39 @@ +import { shallowMount } from '@vue/test-utils'; +import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../mock_data'; +import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; +import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue'; +import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue'; +import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue'; + +describe('MemberList', () => { + let wrapper; + + const createComponent = propsData => { + wrapper = shallowMount(MemberAvatar, { + propsData: { + isCurrentUser: false, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + test.each` + memberType | member | expectedComponent | expectedComponentName + ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'} + ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'} + ${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'} + ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'} + `( + 'renders $expectedComponentName when `memberType` is $memberType', + ({ memberType, member, expectedComponent }) => { + createComponent({ memberType, member }); + + expect(wrapper.find(expectedComponent).exists()).toBe(true); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/vue_shared/components/members/table/member_source_spec.js new file mode 100644 index 00000000000..8b914d76674 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_source_spec.js @@ -0,0 +1,71 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; + +describe('MemberSource', () => { + let wrapper; + + const createComponent = propsData => { + wrapper = mount(MemberSource, { + propsData: { + memberSource: { + id: 102, + name: 'Foo bar', + webUrl: 'https://gitlab.com/groups/foo-bar', + }, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('direct member', () => { + it('displays "Direct member"', () => { + createComponent({ + isDirectMember: true, + }); + + expect(getByText('Direct member').exists()).toBe(true); + }); + }); + + describe('inherited member', () => { + let sourceGroupLink; + + beforeEach(() => { + createComponent({ + isDirectMember: false, + }); + + sourceGroupLink = getByText('Foo bar'); + }); + + it('displays a link to source group', () => { + createComponent({ + isDirectMember: false, + }); + + expect(sourceGroupLink.exists()).toBe(true); + expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar'); + }); + + it('displays tooltip with "Inherited"', () => { + const tooltipDirective = getTooltipDirective(sourceGroupLink); + + expect(tooltipDirective).not.toBeUndefined(); + expect(sourceGroupLink.attributes('title')).toBe('Inherited'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js new file mode 100644 index 00000000000..960d9bc164c --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js @@ -0,0 +1,130 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { MEMBER_TYPES } from '~/vue_shared/components/members/constants'; +import { member as memberMock, group, invite, accessRequest } from '../mock_data'; +import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue'; + +describe('MemberList', () => { + const WrappedComponent = { + props: { + memberType: { + type: String, + required: true, + }, + isDirectMember: { + type: Boolean, + required: true, + }, + isCurrentUser: { + type: Boolean, + required: true, + }, + }, + render(createElement) { + return createElement('div', this.memberType); + }, + }; + + const localVue = createLocalVue(); + localVue.use(Vuex); + localVue.component('wrapped-component', WrappedComponent); + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + sourceId: 1, + currentUserId: 1, + ...state, + }, + }); + }; + + let wrapper; + + const createComponent = (propsData, state = {}) => { + wrapper = mount(MembersTableCell, { + localVue, + propsData, + store: createStore(state), + scopedSlots: { + default: ` + <wrapped-component + :member-type="props.memberType" + :is-direct-member="props.isDirectMember" + :is-current-user="props.isCurrentUser" + /> + `, + }, + }); + }; + + const findWrappedComponent = () => wrapper.find(WrappedComponent); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + test.each` + member | expectedMemberType + ${memberMock} | ${MEMBER_TYPES.user} + ${group} | ${MEMBER_TYPES.group} + ${invite} | ${MEMBER_TYPES.invite} + ${accessRequest} | ${MEMBER_TYPES.accessRequest} + `( + 'sets scoped slot prop `memberType` to $expectedMemberType', + ({ member, expectedMemberType }) => { + createComponent({ member }); + + expect(findWrappedComponent().props('memberType')).toBe(expectedMemberType); + }, + ); + + describe('isDirectMember', () => { + it('returns `true` when member source has same ID as `sourceId`', () => { + createComponent({ + member: { + ...memberMock, + source: { + ...memberMock.source, + id: 1, + }, + }, + }); + + expect(findWrappedComponent().props('isDirectMember')).toBe(true); + }); + + it('returns `false` when member is inherited', () => { + createComponent({ + member: memberMock, + }); + + expect(findWrappedComponent().props('isDirectMember')).toBe(false); + }); + }); + + describe('isCurrentUser', () => { + it('returns `true` when `member.user` has the same ID as `currentUserId`', () => { + createComponent({ + member: { + ...memberMock, + user: { + ...memberMock.user, + id: 1, + }, + }, + }); + + expect(findWrappedComponent().props('isCurrentUser')).toBe(true); + }); + + it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => { + createComponent({ + member: memberMock, + }); + + expect(findWrappedComponent().props('isCurrentUser')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js new file mode 100644 index 00000000000..4979a7096ac --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js @@ -0,0 +1,104 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { + getByText as getByTextHelper, + getByTestId as getByTestIdHelper, +} from '@testing-library/dom'; +import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; +import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue'; +import MemberSource from '~/vue_shared/components/members/table/member_source.vue'; +import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue'; +import CreatedAt from '~/vue_shared/components/members/table/created_at.vue'; +import * as initUserPopovers from '~/user_popovers'; +import { member as memberMock, invite, accessRequest } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('MemberList', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + members: [], + tableFields: [], + ...state, + }, + }); + }; + + const createComponent = state => { + wrapper = mount(MembersTable, { + localVue, + store: createStore(state), + stubs: ['member-avatar', 'member-source', 'expires-at', 'created-at'], + }); + }; + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + const getByTestId = (id, options) => + createWrapper(getByTestIdHelper(wrapper.element, id, options)); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('fields', () => { + it.each` + field | label | member | expectedComponent + ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} + ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} + ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} + ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} + ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} + ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt} + ${'maxRole'} | ${'Max role'} | ${memberMock} | ${null} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null} + `('renders the $label field', ({ field, label, member, expectedComponent }) => { + createComponent({ + members: [member], + tableFields: [field], + }); + + expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true); + + if (expectedComponent) { + expect( + wrapper + .find(`[data-label="${label}"][role="cell"]`) + .find(expectedComponent) + .exists(), + ).toBe(true); + } + }); + + it('renders "Actions" field for screen readers', () => { + createComponent({ tableFields: ['actions'] }); + + const actionField = getByTestId('col-actions'); + + expect(actionField.exists()).toBe(true); + expect(actionField.classes('gl-sr-only')).toBe(true); + }); + }); + + describe('when `members` is an empty array', () => { + it('displays a "No members found" message', () => { + createComponent(); + + expect(getByText('No members found').exists()).toBe(true); + }); + }); + + it('initializes user popovers when mounted', () => { + const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default'); + + createComponent(); + + expect(initUserPopoversMock).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js new file mode 100644 index 00000000000..f183abc08d6 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/utils_spec.js @@ -0,0 +1,29 @@ +import { generateBadges } from '~/vue_shared/components/members/utils'; +import { member as memberMock } from './mock_data'; + +describe('Members Utils', () => { + describe('generateBadges', () => { + it('has correct properties for each badge', () => { + const badges = generateBadges(memberMock, true); + + badges.forEach(badge => { + expect(badge).toEqual( + expect.objectContaining({ + show: expect.any(Boolean), + text: expect.any(String), + variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/), + }), + ); + }); + }); + + it.each` + member | expected + ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }} + ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }} + ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }} + `('returns expected output for "$expected.text" badge', ({ member, expected }) => { + expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected)); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index 16094a42668..27276faf333 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -38,7 +38,8 @@ exports[`Package code instruction single line to match the default snapshot 1`] data-testid="instruction-button" > <button - class="btn input-group-text btn-secondary btn-md btn-default" + aria-label="Copy this value" + class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon" data-clipboard-text="npm i @my-package" title="Copy npm install command" type="button" @@ -53,6 +54,8 @@ exports[`Package code instruction single line to match the default snapshot 1`] href="#copy-to-clipboard" /> </svg> + + <!----> </button> </span> </div> diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js index 6740d6097a4..a513f178f45 100644 --- a/spec/frontend/vue_shared/components/registry/title_area_spec.js +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -1,4 +1,4 @@ -import { GlAvatar } from '@gitlab/ui'; +import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import component from '~/vue_shared/components/registry/title_area.vue'; @@ -10,10 +10,12 @@ describe('title area', () => { const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`); const findTitle = () => wrapper.find('[data-testid="title"]'); const findAvatar = () => wrapper.find(GlAvatar); + const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]'); const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => { wrapper = shallowMount(component, { propsData, + stubs: { GlSprintf }, slots: { 'sub-header': '<div data-testid="sub-header" />', 'right-actions': '<div data-testid="right-actions" />', @@ -95,4 +97,33 @@ describe('title area', () => { }); }); }); + + describe('info-messages', () => { + it('shows a message when the props contains one', () => { + mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } }); + + const messages = findInfoMessages(); + expect(messages).toHaveLength(1); + expect(messages.at(0).text()).toBe('foo foo bar bar'); + }); + + it('shows a link when the props contains one', () => { + mountComponent({ + propsData: { + infoMessages: [{ text: 'foo %{docLinkStart}link%{docLinkEnd}', link: 'bar' }], + }, + }); + + const message = findInfoMessages().at(0); + + expect(message.find(GlLink).attributes('href')).toBe('bar'); + expect(message.text()).toBe('foo link'); + }); + + it('multiple messages generates multiple spans', () => { + mountComponent({ propsData: { infoMessages: [{ text: 'foo' }, { text: 'bar' }] } }); + + expect(findInfoMessages()).toHaveLength(2); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index 16f60b5ff21..81d31a284df 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -9,9 +9,11 @@ import { } from '~/vue_shared/components/rich_content_editor/services/editor_service'; import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; +import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html'; jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer'); +jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html'); describe('Editor Service', () => { let mockInstance; @@ -143,5 +145,14 @@ describe('Editor Service', () => { getEditorOptions(externalOptions); expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers); }); + + it('uses the internal sanitizeHTML service for HTML sanitization', () => { + const options = getEditorOptions(); + const html = '<div></div>'; + + options.customHTMLSanitizer(html); + + expect(sanitizeHTML).toHaveBeenCalledWith(html); + }); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js index a6c712eeb31..b31684a400e 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js @@ -1,22 +1,21 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block'; import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import { normalTextNode } from './mock_data'; +describe('rich_content_editor/services/renderers/render_html_block', () => { + const htmlBlockNode = { + literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', + type: 'htmlBlock', + }; -const htmlBlockNode = { - firstChild: null, - literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', - type: 'htmlBlock', -}; - -describe('Render HTML renderer', () => { describe('canRender', () => { - it('should return true when the argument is an html block', () => { - expect(renderer.canRender(htmlBlockNode)).toBe(true); - }); - - it('should return false when the argument is not an html block', () => { - expect(renderer.canRender(normalTextNode)).toBe(false); + it.each` + input | result + ${htmlBlockNode} | ${true} + ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true} + ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false} + ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false} + `('returns $result when input=$input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js new file mode 100644 index 00000000000..f2182ef60d7 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js @@ -0,0 +1,11 @@ +import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html'; + +describe('rich_content_editor/services/sanitize_html', () => { + it.each` + input | result + ${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'} + ${'<iframe src="https://gitlab.com"></iframe>'} | ${''} + `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => { + expect(sanitizeHTML(input)).toBe(result); + }); +}); 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 589be0ad7a4..a9350bc059d 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 @@ -69,6 +69,16 @@ describe('DropdownContentsLabelsView', () => { expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); }); + it('returns matching labels with fuzzy filtering', () => { + wrapper.setData({ + searchKey: 'bg', + }); + + expect(wrapper.vm.visibleLabels.length).toBe(2); + expect(wrapper.vm.visibleLabels[0].title).toBe('Bug'); + expect(wrapper.vm.visibleLabels[1].title).toBe('Boog'); + }); + it('returns all labels when `searchKey` is empty', () => { wrapper.setData({ searchKey: '', @@ -133,6 +143,19 @@ describe('DropdownContentsLabelsView', () => { expect(wrapper.vm.currentHighlightItem).toBe(2); }); + it('resets the search text when the Enter key is pressed', () => { + wrapper.setData({ + currentHighlightItem: 1, + searchKey: 'bug', + }); + + wrapper.vm.handleKeyDown({ + keyCode: ENTER_KEY_CODE, + }); + + expect(wrapper.vm.searchKey).toBe(''); + }); + it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => { jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); wrapper.setData({ diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index e1008d13fc2..9697d6c30f2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -24,6 +24,13 @@ export const mockLabels = [ color: '#FF0000', textColor: '#FFFFFF', }, + { + id: 29, + title: 'Boog', + description: 'Label for bugs', + color: '#FF0000', + textColor: '#FFFFFF', + }, ]; export const mockConfig = { diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/todo_button_spec.js index 482b5de11f6..1f8a214d632 100644 --- a/spec/frontend/vue_shared/components/todo_button_spec.js +++ b/spec/frontend/vue_shared/components/todo_button_spec.js @@ -33,7 +33,7 @@ describe('Todo Button', () => { it.each` label | isTodo ${'Mark as done'} | ${true} - ${'Add a To-Do'} | ${false} + ${'Add a To Do'} | ${false} `('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => { createComponent({ isTodo }); 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 57f511903d9..5532a27b767 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -60,6 +60,7 @@ describe('Web IDE link component', () => { it.each` props | expectedActions ${{}} | ${[ACTION_WEB_IDE]} + ${{ webIdeIsFork: true }} | ${[{ ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' }]} ${{ needsToFork: true }} | ${[ACTION_WEB_IDE_FORK]} ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }} | ${[ACTION_GITPOD]} ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_GITPOD_ENABLE]} diff --git a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js deleted file mode 100644 index e57c730ecee..00000000000 --- a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js +++ /dev/null @@ -1,132 +0,0 @@ -import { mount } from '@vue/test-utils'; - -import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue'; - -const mockActions = [ - { - title: 'Foo', - description: 'Some foo action', - }, - { - title: 'Bar', - description: 'Some bar action', - }, -]; - -const createComponent = ({ - size = '', - dropdownClass = '', - actions = mockActions, - defaultAction = 0, -}) => - mount(DroplabDropdownButton, { - propsData: { - size, - dropdownClass, - actions, - defaultAction, - }, - }); - -describe('DroplabDropdownButton', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent({}); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('data', () => { - it('contains `selectedAction` representing value of `defaultAction` prop', () => { - expect(wrapper.vm.selectedAction).toBe(0); - }); - }); - - describe('computed', () => { - describe('selectedActionTitle', () => { - it('returns string containing title of selected action', () => { - wrapper.setData({ selectedAction: 0 }); - - expect(wrapper.vm.selectedActionTitle).toBe(mockActions[0].title); - - wrapper.setData({ selectedAction: 1 }); - - expect(wrapper.vm.selectedActionTitle).toBe(mockActions[1].title); - }); - }); - - describe('buttonSizeClass', () => { - it('returns string containing button sizing class based on `size` prop', done => { - const wrapperWithSize = createComponent({ - size: 'sm', - }); - - wrapperWithSize.vm.$nextTick(() => { - expect(wrapperWithSize.vm.buttonSizeClass).toBe('btn-sm'); - - done(); - wrapperWithSize.destroy(); - }); - }); - }); - }); - - describe('methods', () => { - describe('handlePrimaryActionClick', () => { - it('emits `onActionClick` event on component with selectedAction object as param', () => { - jest.spyOn(wrapper.vm, '$emit'); - - wrapper.setData({ selectedAction: 0 }); - wrapper.vm.handlePrimaryActionClick(); - - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionClick', mockActions[0]); - }); - }); - - describe('handleActionClick', () => { - it('emits `onActionSelect` event on component with selectedAction index as param', () => { - jest.spyOn(wrapper.vm, '$emit'); - - wrapper.vm.handleActionClick(1); - - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionSelect', 1); - }); - }); - }); - - describe('template', () => { - it('renders default action button', () => { - const defaultButton = wrapper.findAll('.btn').at(0); - - expect(defaultButton.text()).toBe(mockActions[0].title); - }); - - it('renders dropdown button', () => { - const dropdownButton = wrapper.findAll('.dropdown-toggle').at(0); - - expect(dropdownButton.isVisible()).toBe(true); - }); - - it('renders dropdown actions', () => { - const dropdownActions = wrapper.findAll('.dropdown-menu li button'); - - Array(dropdownActions.length) - .fill() - .forEach((_, index) => { - const actionContent = dropdownActions.at(index).find('.description'); - - expect(actionContent.find('strong').text()).toBe(mockActions[index].title); - expect(actionContent.find('p').text()).toBe(mockActions[index].description); - }); - }); - - it('renders divider between dropdown actions', () => { - const dropdownDivider = wrapper.find('.dropdown-menu .divider'); - - expect(dropdownDivider.isVisible()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index 59d05f68fdd..157faa90efa 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -1,6 +1,7 @@ import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; import { GlDrawer } from '@gitlab/ui'; +import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; import App from '~/whats_new/components/app.vue'; const localVue = createLocalVue(); @@ -11,7 +12,8 @@ describe('App', () => { let store; let actions; let state; - let propsData = { features: '[ {"title":"Whats New Drawer"} ]' }; + let propsData = { features: '[ {"title":"Whats New Drawer"} ]', storageKey: 'storage-key' }; + let trackingSpy; const buildWrapper = () => { actions = { @@ -36,11 +38,16 @@ describe('App', () => { }; beforeEach(() => { + document.body.dataset.page = 'test-page'; + document.body.dataset.namespaceId = 'namespace-840'; + + trackingSpy = mockTracking('_category_', null, jest.spyOn); buildWrapper(); }); afterEach(() => { wrapper.destroy(); + unmockTracking(); }); const getDrawer = () => wrapper.find(GlDrawer); @@ -50,7 +57,11 @@ describe('App', () => { }); it('dispatches openDrawer when mounted', () => { - expect(actions.openDrawer).toHaveBeenCalled(); + expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { + label: 'namespace_id', + value: 'namespace-840', + }); }); it('dispatches closeDrawer when clicking close', () => { @@ -71,9 +82,30 @@ describe('App', () => { }); it('handles bad json argument gracefully', () => { - propsData = { features: 'this is not json' }; + propsData = { features: 'this is not json', storageKey: 'storage-key' }; buildWrapper(); expect(getDrawer().exists()).toBe(true); }); + + it('send an event when feature item is clicked', () => { + propsData = { + features: '[ {"title":"Whats New Drawer", "url": "www.url.com"} ]', + storageKey: 'storage-key', + }; + buildWrapper(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + + const link = wrapper.find('[data-testid="whats-new-title-link"]'); + triggerEvent(link.element); + + expect(trackingSpy.mock.calls[2]).toMatchObject([ + '_category_', + 'click_whats_new_item', + { + label: 'Whats New Drawer', + property: 'www.url.com', + }, + ]); + }); }); diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js index d95453c9175..6f550222074 100644 --- a/spec/frontend/whats_new/store/actions_spec.js +++ b/spec/frontend/whats_new/store/actions_spec.js @@ -1,11 +1,16 @@ import testAction from 'helpers/vuex_action_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import actions from '~/whats_new/store/actions'; import * as types from '~/whats_new/store/mutation_types'; describe('whats new actions', () => { describe('openDrawer', () => { + useLocalStorageSpy(); + it('should commit openDrawer', () => { - testAction(actions.openDrawer, {}, {}, [{ type: types.OPEN_DRAWER }]); + testAction(actions.openDrawer, 'storage-key', {}, [{ type: types.OPEN_DRAWER }]); + + expect(window.localStorage.setItem).toHaveBeenCalledWith('storage-key', 'false'); }); }); |