diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
commit | 48aff82709769b098321c738f3444b9bdaa694c6 (patch) | |
tree | e00c7c43e2d9b603a5a6af576b1685e400410dee /spec/frontend | |
parent | 879f5329ee916a948223f8f43d77fba4da6cd028 (diff) | |
download | gitlab-ce-48aff82709769b098321c738f3444b9bdaa694c6.tar.gz |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'spec/frontend')
495 files changed, 23654 insertions, 7251 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..f3ebdfc5cc2 100644 --- a/spec/frontend/alert_management/components/alert_details_spec.js +++ b/spec/frontend/alert_management/components/alert_details_spec.js @@ -1,54 +1,76 @@ -import { mount, shallowMount } from '@vue/test-utils'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import AlertDetails from '~/alert_management/components/alert_details.vue'; -import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql'; -import { joinPaths } from '~/lib/utils/url_utility'; +import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue'; import { - trackAlertsDetailsViewsOptions, ALERTS_SEVERITY_LABELS, + trackAlertsDetailsViewsOptions, } from '~/alert_management/constants'; +import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql'; +import { joinPaths } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import mockAlerts from '../mocks/alerts.json'; const mockAlert = mockAlerts[0]; +const environmentName = 'Production'; +const environmentPath = '/fake/path'; describe('AlertDetails', () => { - let wrapper; + let environmentData = { + name: environmentName, + path: environmentPath, + }; + let glFeatures = { exposeEnvironmentPathInAlertDetails: false }; let mock; + let wrapper; const projectPath = 'root/alerts'; const projectIssuesPath = 'root/alerts/-/issues'; const projectId = '1'; 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: { + wrapper = extendedWrapper( + mountMethod(AlertDetails, { + provide: { + alertId: 'alertId', + projectPath, + projectIssuesPath, + projectId, + glFeatures, + }, + data() { + return { alert: { - loading, + ...mockAlert, + environment: environmentData, + }, + 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 +84,11 @@ 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 findEnvironmentName = () => wrapper.findByTestId('environmentName'); + const findEnvironmentPath = () => wrapper.findByTestId('environmentPath'); const findDetailsTable = () => wrapper.find(AlertDetailsTable); describe('Alert details', () => { @@ -74,7 +98,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 +108,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); }); }); @@ -126,15 +148,47 @@ 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 fields', () => { + describe('when exposeEnvironmentPathInAlertDetails is disabled', () => { + beforeEach(mountComponent); + + it('should not show the environment', () => { + expect(findEnvironmentName().exists()).toBe(false); + expect(findEnvironmentPath().exists()).toBe(false); + }); + }); + + describe('when exposeEnvironmentPathInAlertDetails is enabled', () => { + beforeEach(() => { + glFeatures = { exposeEnvironmentPathInAlertDetails: true }; + mountComponent(); + }); + + it('should show the environment name with link to path', () => { + expect(findEnvironmentName().exists()).toBe(false); + expect(findEnvironmentPath().text()).toBe(environmentName); + expect(findEnvironmentPath().attributes('href')).toBe(environmentPath); + }); + + it('should only show the environment name if the path is not provided', () => { + environmentData = { name: environmentName, path: null }; + mountComponent(); + expect(findEnvironmentPath().exists()).toBe(false); + expect(findEnvironmentName().text()).toBe(environmentName); + }); + }); + }); + describe('Create incident from alert', () => { it('should display "View incident" button that links the incident page when incident exists', () => { const issueIid = '3'; @@ -222,7 +276,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 +286,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_empty_state_spec.js b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js index 6712282503d..ddb102339cc 100644 --- a/spec/frontend/alert_management/components/alert_management_empty_state_spec.js +++ b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js @@ -1,25 +1,17 @@ import { shallowMount } from '@vue/test-utils'; import { GlEmptyState } from '@gitlab/ui'; import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue'; +import defaultProvideValues from '../mocks/alerts_provide_config.json'; describe('AlertManagementEmptyState', () => { let wrapper; - function mountComponent({ - props = { - alertManagementEnabled: false, - userCanEnableAlertManagement: false, - }, - stubs = {}, - } = {}) { + function mountComponent({ provide = {} } = {}) { wrapper = shallowMount(AlertManagementEmptyState, { - propsData: { - enableAlertManagementPath: '/link', - alertsHelpUrl: '/link', - emptyAlertSvgPath: 'illustration/path', - ...props, + provide: { + ...defaultProvideValues, + ...provide, }, - stubs, }); } @@ -42,7 +34,7 @@ describe('AlertManagementEmptyState', () => { it('show OpsGenie integration state when OpsGenie mcv is true', () => { mountComponent({ - props: { + provide: { alertManagementEnabled: false, userCanEnableAlertManagement: false, opsgenieMvcEnabled: true, diff --git a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js index c36107c28ce..1d79b10a796 100644 --- a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js +++ b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js @@ -1,33 +1,18 @@ import { shallowMount } from '@vue/test-utils'; import AlertManagementList from '~/alert_management/components/alert_management_list_wrapper.vue'; -import { trackAlertListViewsOptions } from '~/alert_management/constants'; -import mockAlerts from '../mocks/alerts.json'; -import Tracking from '~/tracking'; +import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue'; +import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; +import defaultProvideValues from '../mocks/alerts_provide_config.json'; describe('AlertManagementList', () => { let wrapper; - function mountComponent({ - props = { - alertManagementEnabled: false, - userCanEnableAlertManagement: false, - }, - data = {}, - stubs = {}, - } = {}) { + function mountComponent({ provide = {} } = {}) { wrapper = shallowMount(AlertManagementList, { - propsData: { - projectPath: 'gitlab-org/gitlab', - enableAlertManagementPath: '/link', - alertsHelpUrl: '/link', - populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data', - emptyAlertSvgPath: 'illustration/path', - ...props, + provide: { + ...defaultProvideValues, + ...provide, }, - data() { - return data; - }, - stubs, }); } @@ -41,18 +26,21 @@ describe('AlertManagementList', () => { } }); - describe('Snowplow tracking', () => { - beforeEach(() => { - jest.spyOn(Tracking, 'event'); + describe('Alert List Wrapper', () => { + it('should show the empty state when alerts are not enabled', () => { + expect(wrapper.find(AlertManagementEmptyState).exists()).toBe(true); + expect(wrapper.find(AlertManagementTable).exists()).toBe(false); + }); + + it('should show the alerts table when alerts are enabled', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts } }, + provide: { + alertManagementEnabled: true, + }, }); - }); - it('should track alert list page views', () => { - const { category, action } = trackAlertListViewsOptions; - expect(Tracking.event).toHaveBeenCalledWith(category, action); + expect(wrapper.find(AlertManagementEmptyState).exists()).toBe(false); + expect(wrapper.find(AlertManagementTable).exists()).toBe(true); }); }); }); 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..f7a629142f9 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -1,26 +1,13 @@ import { mount } from '@vue/test-utils'; -import { - GlTable, - GlAlert, - GlLoadingIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlIcon, - GlTabs, - GlTab, - GlBadge, - GlPagination, - GlSearchBoxByType, - GlAvatar, -} from '@gitlab/ui'; -import waitForPromises from 'helpers/wait_for_promises'; +import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar } from '@gitlab/ui'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; -import { ALERTS_STATUS_TABS, trackAlertStatusUpdateOptions } from '~/alert_management/constants'; -import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import mockAlerts from '../mocks/alerts.json'; -import Tracking from '~/tracking'; +import defaultProvideValues from '../mocks/alerts_provide_config.json'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), @@ -29,26 +16,21 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('AlertManagementTable', () => { let wrapper; + let mock; const findAlertsTable = () => wrapper.find(GlTable); const findAlerts = () => wrapper.findAll('table tbody tr'); const findAlert = () => wrapper.find(GlAlert); const findLoader = () => wrapper.find(GlLoadingIcon); - const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown); - const findStatusFilterTabs = () => wrapper.findAll(GlTab); - const findStatusTabs = () => wrapper.find(GlTabs); - const findStatusFilterBadge = () => wrapper.findAll(GlBadge); + const findStatusDropdown = () => wrapper.find(GlDropdown); const findDateFields = () => wrapper.findAll(TimeAgo); - const findFirstStatusOption = () => findStatusDropdown().find(GlDeprecatedDropdownItem); - const findPagination = () => wrapper.find(GlPagination); - const findSearch = () => wrapper.find(GlSearchBoxByType); + const findSearch = () => wrapper.find(FilteredSearchBar); const findSeverityColumnHeader = () => wrapper.find('[data-testid="alert-management-severity-sort"]'); const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0); const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]'); const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]'); - const findAlertError = () => wrapper.find('[data-testid="alert-error"]'); const alertsCount = { open: 24, triggered: 20, @@ -56,26 +38,14 @@ describe('AlertManagementTable', () => { resolved: 11, all: 26, }; - const selectFirstStatusOption = () => { - findFirstStatusOption().vm.$emit('click'); - return waitForPromises(); - }; - - function mountComponent({ - props = { - alertManagementEnabled: false, - userCanEnableAlertManagement: false, - }, - data = {}, - loading = false, - stubs = {}, - } = {}) { + function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) { wrapper = mount(AlertManagementTable, { - propsData: { - projectPath: 'gitlab-org/gitlab', - populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data', - ...props, + provide: { + ...defaultProvideValues, + alertManagementEnabled: true, + userCanEnableAlertManagement: true, + ...provide, }, data() { return data; @@ -95,41 +65,21 @@ describe('AlertManagementTable', () => { }); } + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { if (wrapper) { wrapper.destroy(); wrapper = null; } - }); - - describe('Status Filter Tabs', () => { - beforeEach(() => { - mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: mockAlerts, alertsCount }, - loading: false, - stubs: { - GlTab: true, - }, - }); - }); - - it('should display filter tabs with alerts count badge for each status', () => { - const tabs = findStatusFilterTabs().wrappers; - const badges = findStatusFilterBadge(); - - tabs.forEach((tab, i) => { - const status = ALERTS_STATUS_TABS[i].status.toLowerCase(); - expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title); - expect(badges.at(i).text()).toContain(alertsCount[status]); - }); - }); + mock.restore(); }); describe('Alerts table', () => { it('loading state', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: {}, alertsCount: null }, loading: true, }); @@ -144,8 +94,7 @@ describe('AlertManagementTable', () => { it('error state', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { errors: ['error'] }, alertsCount: null, hasError: true }, + data: { alerts: { errors: ['error'] }, alertsCount: null, errored: true }, loading: false, }); expect(findAlertsTable().exists()).toBe(true); @@ -161,10 +110,17 @@ describe('AlertManagementTable', () => { it('empty state', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: [], pageInfo: {} }, alertsCount: { all: 0 }, hasError: false }, + data: { + alerts: { list: [], pageInfo: {} }, + alertsCount: { all: 0 }, + errored: false, + isErrorAlertDismissed: false, + searchTerm: '', + assigneeUsername: '', + }, loading: false, }); + expect(findAlertsTable().exists()).toBe(true); expect(findAlertsTable().text()).toContain('No alerts to display'); expect(findLoader().exists()).toBe(false); @@ -178,8 +134,7 @@ describe('AlertManagementTable', () => { it('has data state', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); expect(findLoader().exists()).toBe(false); @@ -194,8 +149,7 @@ describe('AlertManagementTable', () => { it('displays the alert ID and title formatted correctly', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); @@ -205,8 +159,7 @@ describe('AlertManagementTable', () => { it('displays status dropdown', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); expect(findStatusDropdown().exists()).toBe(true); @@ -214,8 +167,7 @@ describe('AlertManagementTable', () => { it('does not display a dropdown status header', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); expect( @@ -225,27 +177,25 @@ describe('AlertManagementTable', () => { ).toBe(false); }); - it('shows correct severity icons', () => { + it('shows correct severity icons', async () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlTable).exists()).toBe(true); - expect( - findAlertsTable() - .find(GlIcon) - .classes('icon-critical'), - ).toBe(true); - }); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlTable).exists()).toBe(true); + expect( + findAlertsTable() + .find(GlIcon) + .classes('icon-critical'), + ).toBe(true); }); it('renders severity text', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); @@ -258,8 +208,7 @@ describe('AlertManagementTable', () => { it('renders Unassigned when no assignee(s) present', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); @@ -272,8 +221,7 @@ describe('AlertManagementTable', () => { it('renders user avatar when assignee present', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); @@ -290,22 +238,39 @@ describe('AlertManagementTable', () => { it('navigates to the detail page when alert row is clicked', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, 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({ + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + loading: false, + }); + + expect(visitUrl).not.toHaveBeenCalled(); + + findAlerts() + .at(0) + .trigger('click', { + metaKey: true, + }); + + expect(visitUrl).toHaveBeenCalledWith('/1527542/details', true); }); describe('alert issue links', () => { beforeEach(() => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); }); @@ -335,7 +300,6 @@ describe('AlertManagementTable', () => { describe('handle date fields', () => { it('should display time ago dates when values provided', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: [ @@ -349,7 +313,7 @@ describe('AlertManagementTable', () => { ], }, alertsCount, - hasError: false, + errored: false, }, loading: false, }); @@ -358,7 +322,6 @@ describe('AlertManagementTable', () => { it('should not display time ago dates when values not provided', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: [ { @@ -369,7 +332,7 @@ describe('AlertManagementTable', () => { }, ], alertsCount, - hasError: false, + errored: false, }, loading: false, }); @@ -383,8 +346,7 @@ describe('AlertManagementTable', () => { it('should highlight the row when alert is new', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: [newAlert] }, alertsCount, hasError: false }, + data: { alerts: { list: [newAlert] }, alertsCount, errored: false }, loading: false, }); @@ -397,8 +359,7 @@ describe('AlertManagementTable', () => { it('should not highlight the row when alert is not new', () => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: [oldAlert] }, alertsCount, hasError: false }, + data: { alerts: { list: [oldAlert] }, alertsCount, errored: false }, loading: false, }); @@ -415,10 +376,9 @@ describe('AlertManagementTable', () => { describe('sorting the alert list by column', () => { beforeEach(() => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, - hasError: false, + errored: false, sort: 'STARTED_AT_DESC', alertsCount, }, @@ -438,184 +398,10 @@ describe('AlertManagementTable', () => { }); }); - describe('updating the alert status', () => { - const iid = '1527542'; - const mockUpdatedMutationResult = { - data: { - updateAlertStatus: { - errors: [], - alert: { - iid, - status: 'acknowledged', - }, - }, - }, - }; - - beforeEach(() => { - mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, - loading: false, - }); - }); - - it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - findFirstStatusOption().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateAlertStatus, - variables: { - iid, - status: 'TRIGGERED', - projectPath: 'gitlab-org/gitlab', - }, - }); - }); - - describe('when a request fails', () => { - beforeEach(() => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - }); - - it('shows an error', async () => { - await selectFirstStatusOption(); - - expect(findAlertError().text()).toContain( - 'There was an error while updating the status of the alert.', - ); - }); - - it('shows an error when triggered a second time', async () => { - await selectFirstStatusOption(); - - wrapper.find(GlAlert).vm.$emit('dismiss'); - - await wrapper.vm.$nextTick(); - - // Assert that the error has been dismissed in the setup - expect(findAlertError().exists()).toBe(false); - - await selectFirstStatusOption(); - - expect(findAlertError().exists()).toBe(true); - }); - }); - - it('shows an error when response includes HTML errors', async () => { - const mockUpdatedMutationErrorResult = { - data: { - updateAlertStatus: { - errors: ['<span data-testid="htmlError" />'], - alert: { - iid, - status: 'acknowledged', - }, - }, - }, - }; - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult); - - await selectFirstStatusOption(); - - expect(findAlertError().exists()).toBe(true); - expect( - findAlertError() - .find('[data-testid="htmlError"]') - .exists(), - ).toBe(true); - }); - }); - - describe('Snowplow tracking', () => { - beforeEach(() => { - jest.spyOn(Tracking, 'event'); - mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount }, - loading: false, - }); - }); - - it('should track alert status updates', () => { - Tracking.event.mockClear(); - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); - findFirstStatusOption().vm.$emit('click'); - const status = findFirstStatusOption().text(); - setImmediate(() => { - const { category, action, label } = trackAlertStatusUpdateOptions; - expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status }); - }); - }); - }); - - describe('Pagination', () => { - beforeEach(() => { - mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts, pageInfo: {} }, alertsCount, hasError: false }, - loading: false, - }); - }); - - it('does NOT show pagination control when list is smaller than default page size', () => { - findStatusTabs().vm.$emit('input', 3); - return wrapper.vm.$nextTick(() => { - expect(findPagination().exists()).toBe(false); - }); - }); - - it('shows pagination control when list is larger than default page size', () => { - findStatusTabs().vm.$emit('input', 0); - return wrapper.vm.$nextTick(() => { - expect(findPagination().exists()).toBe(true); - }); - }); - - describe('prevPage', () => { - it('returns prevPage number', () => { - findPagination().vm.$emit('input', 3); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.prevPage).toBe(2); - }); - }); - - it('returns 0 when it is the first page', () => { - findPagination().vm.$emit('input', 1); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.prevPage).toBe(0); - }); - }); - }); - - describe('nextPage', () => { - it('returns nextPage number', () => { - findPagination().vm.$emit('input', 1); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.nextPage).toBe(2); - }); - }); - - it('returns `null` when currentPage is already last page', () => { - findStatusTabs().vm.$emit('input', 3); - findPagination().vm.$emit('input', 1); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.nextPage).toBeNull(); - }); - }); - }); - }); - describe('Search', () => { beforeEach(() => { mountComponent({ - props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, loading: false, }); }); @@ -623,13 +409,5 @@ describe('AlertManagementTable', () => { it('renders the search component', () => { expect(findSearch().exists()).toBe(true); }); - - it('sets the `searchTerm` graphql variable', () => { - const SEARCH_TERM = 'Simple Alert'; - - findSearch().vm.$emit('input', SEARCH_TERM); - - expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM); - }); }); }); diff --git a/spec/frontend/alert_management/components/alert_status_spec.js b/spec/frontend/alert_management/components/alert_status_spec.js new file mode 100644 index 00000000000..f5916b8b265 --- /dev/null +++ b/spec/frontend/alert_management/components/alert_status_spec.js @@ -0,0 +1,151 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import { trackAlertStatusUpdateOptions } from '~/alert_management/constants'; +import AlertManagementStatus from '~/alert_management/components/alert_status.vue'; +import updateAlertStatusMutation from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql'; +import Tracking from '~/tracking'; +import mockAlerts from '../mocks/alerts.json'; + +const mockAlert = mockAlerts[0]; + +describe('AlertManagementStatus', () => { + let wrapper; + const findStatusDropdown = () => wrapper.find(GlDropdown); + const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + + const selectFirstStatusOption = () => { + findFirstStatusOption().vm.$emit('click'); + + return waitForPromises(); + }; + + function mountComponent({ props = {}, loading = false, stubs = {} } = {}) { + wrapper = shallowMount(AlertManagementStatus, { + propsData: { + alert: { ...mockAlert }, + projectPath: 'gitlab-org/gitlab', + isSidebar: false, + ...props, + }, + mocks: { + $apollo: { + mutate: jest.fn(), + queries: { + alert: { + loading, + }, + }, + }, + }, + stubs, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('updating the alert status', () => { + const iid = '1527542'; + const mockUpdatedMutationResult = { + data: { + updateAlertStatus: { + errors: [], + alert: { + iid, + status: 'acknowledged', + }, + }, + }, + }; + + beforeEach(() => { + mountComponent({}); + }); + + it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + findFirstStatusOption().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateAlertStatusMutation, + variables: { + iid, + status: 'TRIGGERED', + projectPath: 'gitlab-org/gitlab', + }, + }); + }); + + describe('when a request fails', () => { + beforeEach(() => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); + }); + + it('emits an error', async () => { + await selectFirstStatusOption(); + + expect(wrapper.emitted('alert-error')[0]).toEqual([ + 'There was an error while updating the status of the alert. Please try again.', + ]); + }); + + it('emits an error when triggered a second time', async () => { + await selectFirstStatusOption(); + await wrapper.vm.$nextTick(); + await selectFirstStatusOption(); + // Should emit two errors [0,1] + expect(wrapper.emitted('alert-error').length > 1).toBe(true); + }); + }); + + it('shows an error when response includes HTML errors', async () => { + const mockUpdatedMutationErrorResult = { + data: { + updateAlertStatus: { + errors: ['<span data-testid="htmlError" />'], + alert: { + iid, + status: 'acknowledged', + }, + }, + }, + }; + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult); + + await selectFirstStatusOption(); + + expect(wrapper.emitted('alert-error').length > 0).toBe(true); + expect(wrapper.emitted('alert-error')[0]).toEqual([ + 'There was an error while updating the status of the alert. <span data-testid="htmlError" />', + ]); + }); + }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent({}); + }); + + it('should track alert status updates', () => { + Tracking.event.mockClear(); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({}); + findFirstStatusOption().vm.$emit('click'); + const status = findFirstStatusOption().text(); + setImmediate(() => { + const { category, action, label } = trackAlertStatusUpdateOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status }); + }); + }); + }); +}); 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..bef4a341985 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,8 +1,8 @@ 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'; +import updateAlertStatusMutation from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql'; import Tracking from '~/tracking'; import mockAlerts from '../../mocks/alerts.json'; @@ -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', () => { @@ -88,7 +85,7 @@ describe('Alert Details Sidebar Status', () => { findStatusDropdownItem().vm.$emit('click'); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateAlertStatus, + mutation: updateAlertStatusMutation, variables: { iid: '1527542', status: 'TRIGGERED', 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_management/mocks/alerts_provide_config.json b/spec/frontend/alert_management/mocks/alerts_provide_config.json new file mode 100644 index 00000000000..af543e641bc --- /dev/null +++ b/spec/frontend/alert_management/mocks/alerts_provide_config.json @@ -0,0 +1,13 @@ +{ + "textQuery": "foo", + "authorUsernameQuery": "root", + "assigneeUsernameQuery": "root", + "projectPath": "gitlab-org/gitlab", + "enableAlertManagementPath": "/link", + "populatingAlertsHelpUrl": "/link", + "emptyAlertSvgPath": "/link", + "alertManagementEnabled": false, + "userCanEnableAlertManagement": false, + "opsgenieMvcTargetUrl": "/link", + "opsgenieMvcEnabled": false +}
\ No newline at end of file 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..5800b160efe 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 @@ -2,48 +2,48 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] = ` "<div> - <!----> - <div data-testid=\\"alert-settings-description\\" class=\\"gl-mt-5\\"> - <p> - <gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub> - </p> - <p> - <gl-sprintf-stub message=\\"Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.\\"></gl-sprintf-stub> - </p> - </div> + <integrations-list-stub integrations=\\"[object Object],[object Object]\\"></integrations-list-stub> <gl-form-stub> - <gl-form-group-stub label=\\"Integrations\\" label-for=\\"integrations\\" label-class=\\"label-bold\\"> - <gl-form-select-stub options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-200\\"><gl-sprintf-stub message=\\"Learn more about our %{linkStart}upcoming integrations%{linkEnd}\\"></gl-sprintf-stub></span> + <h5 class=\\"gl-font-lg gl-my-5\\">Add new integrations</h5> + <!----> + <div data-testid=\\"alert-settings-description\\"> + <p> + <gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub> + </p> + <p> + <gl-sprintf-stub message=\\"Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.\\"></gl-sprintf-stub> + </p> + </div> + <gl-form-group-stub label-for=\\"integration-type\\" label=\\"Integration\\"> + <gl-form-select-stub id=\\"integration-type\\" options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our improvements for %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span> </gl-form-group-stub> - <gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\" label-class=\\"label-bold\\"> + <gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\"> <toggle-button-stub id=\\"activated\\"></toggle-button-stub> </gl-form-group-stub> <!----> - <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\" label-class=\\"label-bold\\"> - <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-200\\"> + <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\"> + <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-500\\"> </span> </gl-form-group-stub> - <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\" label-class=\\"label-bold\\"> + <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\"> <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> </gl-form-group-stub> - <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\" label-class=\\"label-bold\\"> + <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\"> <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/alert_settings/alert_settings_form_spec.js b/spec/frontend/alert_settings/alert_settings_form_spec.js index 87a631bda56..6e1ea31ed6a 100644 --- a/spec/frontend/alert_settings/alert_settings_form_spec.js +++ b/spec/frontend/alert_settings/alert_settings_form_spec.js @@ -1,9 +1,12 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; import { shallowMount } from '@vue/test-utils'; import { GlModal, GlAlert } from '@gitlab/ui'; import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue'; +import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue'; +import { i18n } from '~/alerts_settings/constants'; +import service from '~/alerts_settings/services'; + +jest.mock('~/alerts_settings/services'); const PROMETHEUS_URL = '/prometheus/alerts/notify.json'; const GENERIC_URL = '/alerts/notify.json'; @@ -13,7 +16,6 @@ const ACTIVATED = false; describe('AlertsSettingsForm', () => { let wrapper; - let mockAxios; const createComponent = ({ methods } = {}, data) => { wrapper = shallowMount(AlertsSettingsForm, { @@ -53,7 +55,6 @@ describe('AlertsSettingsForm', () => { const findApiUrl = () => wrapper.find('#api-url'); beforeEach(() => { - mockAxios = new MockAdapter(axios); setFixtures(` <div> <span class="js-service-active-status fa fa-circle" data-value="true"></span> @@ -63,7 +64,6 @@ describe('AlertsSettingsForm', () => { afterEach(() => { wrapper.destroy(); - mockAxios.restore(); }); describe('with default values', () => { @@ -76,6 +76,11 @@ describe('AlertsSettingsForm', () => { }); }); + it('renders alerts integrations list', () => { + createComponent(); + expect(wrapper.find(IntegrationsList).exists()).toBe(true); + }); + describe('reset key', () => { it('triggers resetKey method', () => { const resetKey = jest.fn(); @@ -99,8 +104,7 @@ describe('AlertsSettingsForm', () => { }); it('shows a alert message on error', () => { - const formPath = 'some/path'; - mockAxios.onPut(formPath).replyOnce(404); + service.updateGenericKey.mockRejectedValueOnce({}); createComponent(); @@ -122,8 +126,7 @@ describe('AlertsSettingsForm', () => { describe('error is encountered', () => { it('restores previous value', () => { - const formPath = 'some/path'; - mockAxios.onPut(formPath).replyOnce(500); + service.updateGenericKey.mockRejectedValueOnce({}); createComponent(); return wrapper.vm.resetKey().then(() => { expect(wrapper.find(ToggleButton).props('value')).toBe(false); @@ -193,18 +196,34 @@ describe('AlertsSettingsForm', () => { }); describe('alert service is toggled', () => { - it('should show a error alert if failed', () => { - const formPath = 'some/path'; + describe('error handling', () => { const toggleService = true; - mockAxios.onPut(formPath).replyOnce(422, { - errors: 'Error message to display', - }); - createComponent(); + it('should show generic error', async () => { + service.updateGenericActive.mockRejectedValueOnce({}); + + createComponent(); - return wrapper.vm.toggleActivated(toggleService).then(() => { + await wrapper.vm.toggleActivated(toggleService); expect(wrapper.vm.active).toBe(false); expect(wrapper.find(GlAlert).attributes('variant')).toBe('danger'); + expect(wrapper.find(GlAlert).text()).toBe(i18n.errorMsg); + }); + + it('should show first field specific error when available', async () => { + const err1 = "can't be blank"; + const err2 = 'is not a valid URL'; + const key = 'api_url'; + service.updateGenericActive.mockRejectedValueOnce({ + response: { data: { errors: { [key]: [err1, err2] } } }, + }); + + createComponent(); + + await wrapper.vm.toggleActivated(toggleService); + + expect(wrapper.find(GlAlert).text()).toContain(i18n.errorMsg); + expect(wrapper.find(GlAlert).text()).toContain(`${key} ${err1}`); }); }); }); diff --git a/spec/frontend/alert_settings/alerts_integrations_list_spec.js b/spec/frontend/alert_settings/alerts_integrations_list_spec.js new file mode 100644 index 00000000000..6fc9901db2a --- /dev/null +++ b/spec/frontend/alert_settings/alerts_integrations_list_spec.js @@ -0,0 +1,89 @@ +import { GlTable, GlIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Tracking from '~/tracking'; +import AlertIntegrationsList, { + i18n, +} from '~/alerts_settings/components/alerts_integrations_list.vue'; +import { trackAlertIntergrationsViewsOptions } from '~/alerts_settings/constants'; + +const mockIntegrations = [ + { + activated: true, + name: 'Integration 1', + type: 'HTTP endpoint', + }, + { + activated: false, + name: 'Integration 2', + type: 'HTTP endpoint', + }, +]; + +describe('AlertIntegrationsList', () => { + let wrapper; + + function mountComponent(propsData = {}) { + wrapper = mount(AlertIntegrationsList, { + propsData: { + integrations: mockIntegrations, + ...propsData, + }, + stubs: { + GlIcon: true, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + beforeEach(() => { + mountComponent(); + }); + + const findTableComponent = () => wrapper.find(GlTable); + const finsStatusCell = () => wrapper.findAll('[data-testid="integration-activated-status"]'); + + it('renders a table', () => { + expect(findTableComponent().exists()).toBe(true); + }); + + it('renders an empty state when no integrations provided', () => { + mountComponent({ integrations: [] }); + expect(findTableComponent().text()).toContain(i18n.emptyState); + }); + + describe('integration status', () => { + it('enabled', () => { + const cell = finsStatusCell().at(0); + const activatedIcon = cell.find(GlIcon); + expect(cell.text()).toBe(i18n.status.enabled.name); + expect(activatedIcon.attributes('name')).toBe('check-circle-filled'); + expect(activatedIcon.attributes('title')).toBe(i18n.status.enabled.tooltip); + }); + + it('disabled', () => { + const cell = finsStatusCell().at(1); + const notActivatedIcon = cell.find(GlIcon); + expect(cell.text()).toBe(i18n.status.disabled.name); + expect(notActivatedIcon.attributes('name')).toBe('warning-solid'); + expect(notActivatedIcon.attributes('title')).toBe(i18n.status.disabled.tooltip); + }); + }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + it('should track alert list page views', () => { + const { category, action } = trackAlertIntergrationsViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js new file mode 100644 index 00000000000..2e4eaf3fc96 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js @@ -0,0 +1,30 @@ +const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null }; + +export function getApolloResponse(options = {}) { + const { + pipelinesTotal = [], + pipelinesSucceeded = [], + pipelinesFailed = [], + pipelinesCanceled = [], + pipelinesSkipped = [], + hasNextPage = false, + } = options; + return { + data: { + pipelinesTotal: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesTotal }, + pipelinesSucceeded: { + pageInfo: { ...defaultPageInfo, hasNextPage }, + nodes: pipelinesSucceeded, + }, + pipelinesFailed: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesFailed }, + pipelinesCanceled: { + pageInfo: { ...defaultPageInfo, hasNextPage }, + nodes: pipelinesCanceled, + }, + pipelinesSkipped: { + pageInfo: { ...defaultPageInfo, hasNextPage }, + nodes: pipelinesSkipped, + }, + }, + }; +} diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap b/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap new file mode 100644 index 00000000000..0b3b685a9f2 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap @@ -0,0 +1,161 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelinesChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` +Array [ + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Total", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Succeeded", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-07-01", + 41, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Failed", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Canceled", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Skipped", + }, +] +`; + +exports[`PipelinesChart with data passes the data to the line chart 1`] = ` +Array [ + Object { + "data": Array [ + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-07-01", + 41, + ], + ], + "name": "Total", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + ], + "name": "Succeeded", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + ], + "name": "Failed", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-07-01", + 41, + ], + ], + "name": "Canceled", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-07-01", + 41, + ], + ], + "name": "Skipped", + }, +] +`; 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..df13c9f82a9 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js @@ -0,0 +1,34 @@ +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'; +import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue'; +import UsersChart from '~/analytics/instance_statistics/components/users_chart.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); + }); + + it('displays the pipelines chart component', () => { + expect(wrapper.find(PipelinesChart).exists()).toBe(true); + }); + + it('displays the users chart component', () => { + expect(wrapper.find(UsersChart).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..12b5e14b9c4 --- /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 { mockInstanceCounts } 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: mockInstanceCounts } }); + }); + + it('passes the counts data to the metric card', () => { + expect(findMetricCard().props('metrics')).toEqual(mockInstanceCounts); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js new file mode 100644 index 00000000000..a06d66f783e --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js @@ -0,0 +1,189 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue'; +import pipelinesStatsQuery from '~/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { mockCountsData1, mockCountsData2 } from '../mock_data'; +import { getApolloResponse } from '../apollo_mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('PipelinesChart', () => { + let wrapper; + let queryHandler; + + const createApolloProvider = pipelineStatsHandler => { + return createMockApollo([[pipelinesStatsQuery, pipelineStatsHandler]]); + }; + + const createComponent = apolloProvider => { + return shallowMount(PipelinesChart, { + localVue, + apolloProvider, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findLoader = () => wrapper.find(ChartSkeletonLoader); + const findChart = () => wrapper.find(GlLineChart); + const findAlert = () => wrapper.find(GlAlert); + + describe('while loading', () => { + beforeEach(() => { + queryHandler = jest.fn().mockReturnValue(new Promise(() => {})); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + }); + + it('requests data', () => { + expect(queryHandler).toBeCalledTimes(1); + }); + + it('displays the skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + + it('does not show an error', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('without data', () => { + beforeEach(() => { + const emptyResponse = getApolloResponse(); + queryHandler = jest.fn().mockResolvedValue(emptyResponse); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + }); + + it('renders an no data message', () => { + expect(findAlert().text()).toBe('There is no data available.'); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('with data', () => { + beforeEach(() => { + const response = getApolloResponse({ + pipelinesTotal: mockCountsData1, + pipelinesSucceeded: mockCountsData2, + pipelinesFailed: mockCountsData2, + pipelinesCanceled: mockCountsData1, + pipelinesSkipped: mockCountsData1, + }); + queryHandler = jest.fn().mockResolvedValue(response); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + }); + + it('requests data', () => { + expect(queryHandler).toBeCalledTimes(1); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toMatchSnapshot(); + }); + + it('does not show an error', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('when fetching more data', () => { + const recordedAt = '2020-08-01'; + describe('when the fetchMore query returns data', () => { + beforeEach(async () => { + const newData = { recordedAt, count: 5 }; + const firstResponse = getApolloResponse({ + pipelinesTotal: mockCountsData2, + pipelinesSucceeded: mockCountsData2, + pipelinesFailed: mockCountsData1, + pipelinesCanceled: mockCountsData2, + pipelinesSkipped: mockCountsData2, + hasNextPage: true, + }); + const secondResponse = getApolloResponse({ + pipelinesTotal: [newData], + pipelinesSucceeded: [newData], + pipelinesFailed: [newData], + pipelinesCanceled: [newData], + pipelinesSkipped: [newData], + hasNextPage: false, + }); + queryHandler = jest + .fn() + .mockResolvedValueOnce(firstResponse) + .mockResolvedValueOnce(secondResponse); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + + await wrapper.vm.$nextTick(); + }); + + it('requests data twice', () => { + expect(queryHandler).toBeCalledTimes(2); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toMatchSnapshot(); + }); + }); + + describe('when the fetchMore query throws an error', () => { + beforeEach(async () => { + const response = getApolloResponse({ + pipelinesTotal: mockCountsData2, + pipelinesSucceeded: mockCountsData2, + pipelinesFailed: mockCountsData1, + pipelinesCanceled: mockCountsData2, + pipelinesSkipped: mockCountsData2, + hasNextPage: true, + }); + queryHandler = jest.fn().mockResolvedValue(response); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + jest + .spyOn(wrapper.vm.$apollo.queries.pipelineStats, 'fetchMore') + .mockImplementation(jest.fn().mockRejectedValue()); + await wrapper.vm.$nextTick(); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries.pipelineStats.fetchMore).toHaveBeenCalledTimes(1); + }); + + it('show an error message', () => { + expect(findAlert().text()).toBe( + 'Could not load the pipelines chart. Please refresh the page to try again.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js new file mode 100644 index 00000000000..7509c1e6626 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js @@ -0,0 +1,200 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import { useFakeDate } from 'helpers/fake_date'; +import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql'; +import { mockCountsData2, roundedSortedCountsMonthlyChartData2, mockPageInfo } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('UsersChart', () => { + let wrapper; + let queryHandler; + + const mockApolloResponse = ({ loading = false, hasNextPage = false, users }) => ({ + data: { + users: { + pageInfo: { ...mockPageInfo, hasNextPage }, + nodes: users, + loading, + }, + }, + }); + + const mockQueryResponse = ({ users, loading = false, hasNextPage = false }) => { + const apolloQueryResponse = mockApolloResponse({ loading, hasNextPage, users }); + if (loading) { + return jest.fn().mockReturnValue(new Promise(() => {})); + } + if (hasNextPage) { + return jest + .fn() + .mockResolvedValueOnce(apolloQueryResponse) + .mockResolvedValueOnce( + mockApolloResponse({ + loading, + hasNextPage: false, + users: [{ recordedAt: '2020-07-21', count: 5 }], + }), + ); + } + return jest.fn().mockResolvedValue(apolloQueryResponse); + }; + + const createComponent = ({ + loadingError = false, + loading = false, + users = [], + hasNextPage = false, + } = {}) => { + queryHandler = mockQueryResponse({ users, loading, hasNextPage }); + + return shallowMount(UsersChart, { + props: { + startDate: useFakeDate(2020, 9, 26), + endDate: useFakeDate(2020, 10, 1), + totalDataPoints: mockCountsData2.length, + }, + localVue, + apolloProvider: createMockApollo([[usersQuery, queryHandler]]), + data() { + return { loadingError }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findLoader = () => wrapper.find(ChartSkeletonLoader); + const findAlert = () => wrapper.find(GlAlert); + const findChart = () => wrapper.find(GlAreaChart); + + describe('while loading', () => { + beforeEach(() => { + wrapper = createComponent({ loading: true }); + }); + + it('displays the skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('without data', () => { + beforeEach(async () => { + wrapper = createComponent({ users: [] }); + await wrapper.vm.$nextTick(); + }); + + it('renders an no data message', () => { + expect(findAlert().text()).toBe('There is no data available.'); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('with data', () => { + beforeEach(async () => { + wrapper = createComponent({ users: mockCountsData2 }); + await wrapper.vm.$nextTick(); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toEqual([ + { data: roundedSortedCountsMonthlyChartData2, name: 'Total users' }, + ]); + }); + }); + + describe('with errors', () => { + beforeEach(async () => { + wrapper = createComponent({ loadingError: true }); + await wrapper.vm.$nextTick(); + }); + + it('renders an error message', () => { + expect(findAlert().text()).toBe( + 'Could not load the user chart. Please refresh the page to try again.', + ); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('when fetching more data', () => { + describe('when the fetchMore query returns data', () => { + beforeEach(async () => { + wrapper = createComponent({ + users: mockCountsData2, + hasNextPage: true, + }); + + jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore'); + await wrapper.vm.$nextTick(); + }); + + it('requests data twice', () => { + expect(queryHandler).toBeCalledTimes(2); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the fetchMore query throws an error', () => { + beforeEach(() => { + wrapper = createComponent({ + users: mockCountsData2, + hasNextPage: true, + }); + + jest + .spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore') + .mockImplementation(jest.fn().mockRejectedValue()); + return wrapper.vm.$nextTick(); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1); + }); + + it('renders an error message', () => { + expect(findAlert().text()).toBe( + 'Could not load the user chart. Please refresh the page to try again.', + ); + }); + }); + }); +}); 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..b737db4c55f --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/mock_data.js @@ -0,0 +1,42 @@ +export const mockInstanceCounts = [ + { key: 'projects', value: 10, label: 'Projects' }, + { key: 'groups', value: 20, label: 'Group' }, +]; + +export const mockCountsData1 = [ + { recordedAt: '2020-07-23', count: 52 }, + { recordedAt: '2020-07-22', count: 40 }, + { recordedAt: '2020-07-21', count: 31 }, + { recordedAt: '2020-06-14', count: 23 }, + { recordedAt: '2020-06-12', count: 20 }, +]; + +export const countsMonthlyChartData1 = [ + ['2020-07-01', 41], // average of 2020-07-x items + ['2020-06-01', 21.5], // average of 2020-06-x items +]; + +export const mockCountsData2 = [ + { recordedAt: '2020-07-28', count: 10 }, + { recordedAt: '2020-07-27', count: 9 }, + { recordedAt: '2020-06-26', count: 14 }, + { recordedAt: '2020-06-25', count: 23 }, + { recordedAt: '2020-06-24', count: 25 }, +]; + +export const countsMonthlyChartData2 = [ + ['2020-07-01', 9.5], // average of 2020-07-x items + ['2020-06-01', 20.666666666666668], // average of 2020-06-x items +]; + +export const roundedSortedCountsMonthlyChartData2 = [ + ['2020-06-01', 21], // average of 2020-06-x items + ['2020-07-01', 10], // average of 2020-07-x items +]; + +export const mockPageInfo = { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, +}; diff --git a/spec/frontend/analytics/instance_statistics/utils_spec.js b/spec/frontend/analytics/instance_statistics/utils_spec.js new file mode 100644 index 00000000000..d480238419b --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/utils_spec.js @@ -0,0 +1,84 @@ +import { + getAverageByMonth, + extractValues, + sortByDate, +} from '~/analytics/instance_statistics/utils'; +import { + mockCountsData1, + mockCountsData2, + countsMonthlyChartData1, + countsMonthlyChartData2, +} from './mock_data'; + +describe('getAverageByMonth', () => { + it('collects data into average by months', () => { + expect(getAverageByMonth(mockCountsData1)).toStrictEqual(countsMonthlyChartData1); + expect(getAverageByMonth(mockCountsData2)).toStrictEqual(countsMonthlyChartData2); + }); + + it('it transforms a data point to the first of the month', () => { + const item = mockCountsData1[0]; + const firstOfTheMonth = item.recordedAt.replace(/-[0-9]{2}$/, '-01'); + expect(getAverageByMonth([item])).toStrictEqual([[firstOfTheMonth, item.count]]); + }); + + it('it uses sane defaults', () => { + expect(getAverageByMonth()).toStrictEqual([]); + }); + + it('it errors when passing null', () => { + expect(() => { + getAverageByMonth(null); + }).toThrow(); + }); + + describe('when shouldRound = true', () => { + const options = { shouldRound: true }; + + it('rounds the averages', () => { + const roundedData1 = countsMonthlyChartData1.map(([date, avg]) => [date, Math.round(avg)]); + const roundedData2 = countsMonthlyChartData2.map(([date, avg]) => [date, Math.round(avg)]); + expect(getAverageByMonth(mockCountsData1, options)).toStrictEqual(roundedData1); + expect(getAverageByMonth(mockCountsData2, options)).toStrictEqual(roundedData2); + }); + }); +}); + +describe('extractValues', () => { + it('extracts only requested values', () => { + const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; + expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' }); + }); + + it('is able to extract multiple values', () => { + const data = { + fooBar: { baz: 'quis' }, + fooBaz: { baz: 'quis' }, + fooQuis: { baz: 'quis' }, + }; + expect(extractValues(data, ['fooBar', 'fooBaz', 'fooQuis'], 'foo', 'baz')).toEqual({ + bazBar: 'quis', + bazBaz: 'quis', + bazQuis: 'quis', + }); + }); + + it('returns empty data set when keys are not found', () => { + const data = { foo: { baz: 'quis' }, ignored: 'ignored' }; + expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({}); + }); + + it('returns empty data when params are missing', () => { + expect(extractValues()).toEqual({}); + }); +}); + +describe('sortByDate', () => { + it('sorts the array by date', () => { + expect(sortByDate(mockCountsData1)).toStrictEqual([...mockCountsData1].reverse()); + }); + + it('does not modify the original array', () => { + expect(sortByDate(countsMonthlyChartData1)).not.toBe(countsMonthlyChartData1); + }); +}); 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..9924525929b 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -421,6 +421,25 @@ describe('Api', () => { }); }); + describe('addProjectIssueAsTodo', () => { + it('adds issue ID as a todo', () => { + const projectId = 1; + const issueIid = 11; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/issues/11/todo`; + mock.onPost(expectedUrl).reply(200, { + id: 112, + project: { + id: 1, + }, + }); + + return Api.addProjectIssueAsTodo(projectId, issueIid).then(({ data }) => { + expect(data.id).toBe(112); + expect(data.project.id).toBe(projectId); + }); + }); + }); + describe('newLabel', () => { it('creates a new label', done => { const namespace = 'some namespace'; @@ -672,6 +691,27 @@ describe('Api', () => { }); }); + describe('pipelineJobs', () => { + it('fetches the jobs for a given pipeline', done => { + const projectId = 123; + const pipelineId = 456; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`; + const payload = [ + { + name: 'test', + }, + ]; + mock.onGet(expectedUrl).reply(httpStatus.OK, payload); + + Api.pipelineJobs(projectId, pipelineId) + .then(({ data }) => { + expect(data).toEqual(payload); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('createBranch', () => { it('creates new branch', done => { const ref = 'master'; @@ -1152,4 +1192,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/badges/components/badge_settings_spec.js b/spec/frontend/badges/components/badge_settings_spec.js index b6a86746598..769be7cb1bd 100644 --- a/spec/frontend/badges/components/badge_settings_spec.js +++ b/spec/frontend/badges/components/badge_settings_spec.js @@ -1,117 +1,71 @@ -import Vue from 'vue'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; import store from '~/badges/store'; import BadgeSettings from '~/badges/components/badge_settings.vue'; +import BadgeList from '~/badges/components/badge_list.vue'; +import BadgeListRow from '~/badges/components/badge_list_row.vue'; import { createDummyBadge } from '../dummy_badge'; -describe('BadgeSettings component', () => { - const Component = Vue.extend(BadgeSettings); - let vm; +const localVue = createLocalVue(); +localVue.use(Vuex); - beforeEach(() => { - setFixtures(` - <div id="dummy-element"></div> - <button - id="dummy-modal-button" - type="button" - data-toggle="modal" - data-target="#delete-badge-modal" - >Show modal</button> - `); +describe('BadgeSettings component', () => { + let wrapper; + const badge = createDummyBadge(); - // Can be removed once GlLoadingIcon no longer throws a warning - jest.spyOn(global.console, 'warn').mockImplementation(() => jest.fn()); + const createComponent = (isEditing = false) => { + store.state.badges = [badge]; + store.state.kind = 'project'; + store.state.isEditing = isEditing; - vm = mountComponentWithStore(Component, { - el: '#dummy-element', + wrapper = shallowMount(BadgeSettings, { store, + localVue, + stubs: { + 'badge-list': BadgeList, + 'badge-list-row': BadgeListRow, + }, }); + }; + + beforeEach(() => { + createComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); - it('displays modal if button is clicked', done => { - const badge = createDummyBadge(); - store.state.badgeInModal = badge; - const modal = vm.$el.querySelector('#delete-badge-modal'); - const button = document.getElementById('dummy-modal-button'); + it('displays modal if button for deleting a badge is clicked', async () => { + const button = wrapper.find('[data-testid="delete-badge"]'); - button.click(); + button.vm.$emit('click'); + await wrapper.vm.$nextTick(); - Vue.nextTick() - .then(() => { - expect(modal.innerText).toMatch('Delete badge?'); - const badgeElement = modal.querySelector('img.project-badge'); - expect(badgeElement).not.toBe(null); - expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl); - }) - .then(done) - .catch(done.fail); + const modal = wrapper.find(GlModal); + expect(modal.isVisible()).toBe(true); }); it('displays a form to add a badge', () => { - const form = vm.$el.querySelector('form:nth-of-type(2)'); - - expect(form).not.toBe(null); - const button = form.querySelector('.btn-success'); - - expect(button).not.toBe(null); - expect(button).toHaveText(/Add badge/); + expect(wrapper.find('[data-testid="add-new-badge"]').isVisible()).toBe(true); }); it('displays badge list', () => { - const badgeListElement = vm.$el.querySelector('.card'); - - expect(badgeListElement).not.toBe(null); - expect(badgeListElement).toBeVisible(); - expect(badgeListElement.innerText).toMatch('Your badges'); + expect(wrapper.find(BadgeList).isVisible()).toBe(true); }); describe('when editing', () => { - beforeEach(done => { - store.state.isEditing = true; - - Vue.nextTick() - .then(done) - .catch(done.fail); + beforeEach(() => { + createComponent(true); }); it('displays a form to edit a badge', () => { - const form = vm.$el.querySelector('form:nth-of-type(1)'); - - expect(form).not.toBe(null); - const cancelButton = form.querySelector('[data-testid="cancelEditing"]'); - - expect(cancelButton).not.toBe(null); - expect(cancelButton).toHaveText(/Cancel/); - const submitButton = form.querySelector('[data-testid="saveEditing"]'); - - expect(submitButton).not.toBe(null); - expect(submitButton).toHaveText(/Save changes/); + expect(wrapper.find('[data-testid="edit-badge"]').isVisible()).toBe(true); }); it('displays no badge list', () => { - const badgeListElement = vm.$el.querySelector('.card'); - - expect(badgeListElement).toBeHidden(); - }); - }); - - describe('methods', () => { - describe('onSubmitModal', () => { - it('triggers ', () => { - jest.spyOn(vm, 'deleteBadge').mockImplementation(() => Promise.resolve()); - const modal = vm.$el.querySelector('#delete-badge-modal'); - const deleteButton = modal.querySelector('.btn-danger'); - - deleteButton.click(); - - const badge = store.state.badgeInModal; - - expect(vm.deleteBadge).toHaveBeenCalledWith(badge); - }); + expect(wrapper.find(BadgeList).isVisible()).toBe(false); }); }); }); 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/behaviors/shortcuts/keybindings_spec.js b/spec/frontend/behaviors/shortcuts/keybindings_spec.js new file mode 100644 index 00000000000..23fea79f828 --- /dev/null +++ b/spec/frontend/behaviors/shortcuts/keybindings_spec.js @@ -0,0 +1,66 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +describe('~/behaviors/shortcuts/keybindings.js', () => { + let keysFor; + let TOGGLE_PERFORMANCE_BAR; + let LOCAL_STORAGE_KEY; + + beforeAll(() => { + useLocalStorageSpy(); + }); + + const setupCustomizations = async customizationsAsString => { + localStorage.clear(); + + if (customizationsAsString) { + localStorage.setItem(LOCAL_STORAGE_KEY, customizationsAsString); + } + + jest.resetModules(); + ({ keysFor, TOGGLE_PERFORMANCE_BAR, LOCAL_STORAGE_KEY } = await import( + '~/behaviors/shortcuts/keybindings' + )); + }; + + describe('when a command has not been customized', () => { + beforeEach(async () => { + await setupCustomizations('{}'); + }); + + it('returns the default keybinding for the command', () => { + expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']); + }); + }); + + describe('when a command has been customized', () => { + const customization = ['p b a r']; + + beforeEach(async () => { + await setupCustomizations(JSON.stringify({ [TOGGLE_PERFORMANCE_BAR]: customization })); + }); + + it('returns the default keybinding for the command', () => { + expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(customization); + }); + }); + + describe("when the localStorage entry isn't valid JSON", () => { + beforeEach(async () => { + await setupCustomizations('{'); + }); + + it('returns the default keybinding for the command', () => { + expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']); + }); + }); + + describe(`when localStorage doesn't contain the ${LOCAL_STORAGE_KEY} key`, () => { + beforeEach(async () => { + await setupCustomizations(); + }); + + it('returns the default keybinding for the command', () => { + expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']); + }); + }); +}); 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/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js index 50db1675e13..a02c968c4b5 100644 --- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js +++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js @@ -16,6 +16,7 @@ describe('PipelineTourSuccessModal', () => { stubs: { GlModal, GlSprintf, + 'gl-emoji': '<img/>', }, }); }; @@ -66,9 +67,11 @@ describe('PipelineTourSuccessModal', () => { it('has expected structure', () => { const modal = wrapper.find(GlModal); const sprintf = modal.find(GlSprintf); + const emoji = modal.find('img'); - expect(modal.attributes('title')).toContain("That's it, well done!"); + expect(wrapper.text()).toContain("That's it, well done!"); expect(sprintf.exists()).toBe(true); + expect(emoji.exists()).toBe(true); }); it('renders the link for codeQualityLink', () => { diff --git a/spec/frontend/blob/sketch/index_spec.js b/spec/frontend/blob/sketch/index_spec.js index f5e9da21b2a..cd12d5e17a8 100644 --- a/spec/frontend/blob/sketch/index_spec.js +++ b/spec/frontend/blob/sketch/index_spec.js @@ -8,13 +8,6 @@ describe('Sketch viewer', () => { beforeEach(() => { loadFixtures('static/sketch_viewer.html'); - window.URL = { - createObjectURL: jest.fn(() => 'http://foo/bar'), - }; - }); - - afterEach(() => { - window.URL = {}; }); describe('with error message', () => { 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/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 97ac42a10bf..69ec22b1f94 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; +import { setTestTimeout } from 'helpers/timeout'; import BlobViewer from '~/blob/viewer/index'; import axios from '~/lib/utils/axios_utils'; @@ -13,26 +14,22 @@ describe('Blob viewer', () => { tooltip: jest.fn(), }; - preloadFixtures('snippets/show.html'); + setTestTimeout(2000); + + preloadFixtures('blob/show_readme.html'); beforeEach(() => { $.fn.extend(jQueryMock); mock = new MockAdapter(axios); - loadFixtures('snippets/show.html'); + loadFixtures('blob/show_readme.html'); $('#modal-upload-blob').remove(); - blob = new BlobViewer(); - - mock.onGet('http://test.host/-/snippets/1.json?viewer=rich').reply(200, { - html: '<div>testing</div>', - }); - - mock.onGet('http://test.host/-/snippets/1.json?viewer=simple').reply(200, { + mock.onGet(/blob\/master\/README\.md/).reply(200, { html: '<div>testing</div>', }); - jest.spyOn(axios, 'get'); + blob = new BlobViewer(); }); afterEach(() => { @@ -71,12 +68,11 @@ describe('Blob viewer', () => { }); it('doesnt reload file if already loaded', () => { - const asyncClick = () => - new Promise(resolve => { - document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + const asyncClick = async () => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); - setImmediate(resolve); - }); + await axios.waitForAll(); + }; return asyncClick() .then(() => asyncClick()) @@ -163,17 +159,30 @@ describe('Blob viewer', () => { expect(simpleBtn.blur).toHaveBeenCalled(); }); - it('sends AJAX request when switching to simple view', () => { - blob.switchToViewer('simple'); - - expect(axios.get).toHaveBeenCalled(); + it('makes request for initial view', () => { + expect(mock.history).toMatchObject({ + get: [{ url: expect.stringMatching(/README\.md\?.*viewer=rich/) }], + }); }); - it('does not send AJAX request when switching to rich view', () => { - blob.switchToViewer('simple'); - blob.switchToViewer('rich'); + describe.each` + views + ${['simple']} + ${['simple', 'rich']} + `('when view switches to $views', ({ views }) => { + beforeEach(async () => { + views.forEach(view => blob.switchToViewer(view)); + await axios.waitForAll(); + }); - expect(axios.get.mock.calls.length).toBe(1); + it('sends 1 AJAX request for new view', async () => { + expect(mock.history).toMatchObject({ + get: [ + { url: expect.stringMatching(/README\.md\?.*viewer=rich/) }, + { url: expect.stringMatching(/README\.md\?.*viewer=simple/) }, + ], + }); + }); }); }); }); diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js index a105b62586b..eecc54be35b 100644 --- a/spec/frontend/blob_edit/blob_bundle_spec.js +++ b/spec/frontend/blob_edit/blob_bundle_spec.js @@ -1,10 +1,25 @@ import $ from 'jquery'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import blobBundle from '~/blob_edit/blob_bundle'; +import EditorLite from '~/blob_edit/edit_blob'; + jest.mock('~/blob_edit/edit_blob'); describe('BlobBundle', () => { + it('does not load EditorLite by default', () => { + blobBundle(); + expect(EditorLite).not.toHaveBeenCalled(); + }); + + it('loads EditorLite for the edit screen', async () => { + setFixtures(`<div class="js-edit-blob-form"></div>`); + blobBundle(); + await waitForPromises(); + expect(EditorLite).toHaveBeenCalled(); + }); + describe('No Suggest Popover', () => { beforeEach(() => { setFixtures(` diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js index 8f92e8498b9..ac8b916e448 100644 --- a/spec/frontend/blob_edit/edit_blob_spec.js +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -1,3 +1,4 @@ +import waitForPromises from 'helpers/wait_for_promises'; import EditBlob from '~/blob_edit/edit_blob'; import EditorLite from '~/editor/editor_lite'; import MarkdownExtension from '~/editor/editor_markdown_ext'; @@ -7,7 +8,12 @@ jest.mock('~/editor/editor_lite'); jest.mock('~/editor/editor_markdown_ext'); describe('Blob Editing', () => { - const mockInstance = 'foo'; + const useMock = jest.fn(); + const mockInstance = { + use: useMock, + getValue: jest.fn(), + focus: jest.fn(), + }; beforeEach(() => { setFixtures( `<div class="js-edit-blob-form"><div id="file_path"></div><div id="editor"></div><input id="file-content"></div>`, @@ -15,36 +21,33 @@ describe('Blob Editing', () => { jest.spyOn(EditorLite.prototype, 'createInstance').mockReturnValue(mockInstance); }); - const initEditor = (isMarkdown = false) => { + const editorInst = isMarkdown => { return new EditBlob({ isMarkdown, - monacoEnabled: true, }); }; + const initEditor = async (isMarkdown = false) => { + editorInst(isMarkdown); + await waitForPromises(); + }; + it('loads FileTemplateExtension by default', async () => { await initEditor(); - expect(EditorLite.prototype.use).toHaveBeenCalledWith( - expect.arrayContaining([FileTemplateExtension]), - mockInstance, - ); + expect(useMock).toHaveBeenCalledWith(FileTemplateExtension); }); describe('Markdown', () => { it('does not load MarkdownExtension by default', async () => { await initEditor(); - expect(EditorLite.prototype.use).not.toHaveBeenCalledWith( - expect.arrayContaining([MarkdownExtension]), - mockInstance, - ); + expect(useMock).not.toHaveBeenCalledWith(MarkdownExtension); }); it('loads MarkdownExtension only for the markdown files', async () => { await initEditor(true); - expect(EditorLite.prototype.use).toHaveBeenCalledWith( - [MarkdownExtension, FileTemplateExtension], - mockInstance, - ); + expect(useMock).toHaveBeenCalledTimes(2); + expect(useMock).toHaveBeenNthCalledWith(1, FileTemplateExtension); + expect(useMock).toHaveBeenNthCalledWith(2, MarkdownExtension); }); }); }); 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/board_list_new_spec.js b/spec/frontend/boards/board_list_new_spec.js new file mode 100644 index 00000000000..163611c2197 --- /dev/null +++ b/spec/frontend/boards/board_list_new_spec.js @@ -0,0 +1,234 @@ +/* global List */ +/* global ListIssue */ + +import Vuex from 'vuex'; +import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; +import { createLocalVue, mount } from '@vue/test-utils'; +import eventHub from '~/boards/eventhub'; +import BoardList from '~/boards/components/board_list_new.vue'; +import BoardCard from '~/boards/components/board_card.vue'; +import '~/boards/models/issue'; +import '~/boards/models/list'; +import { listObj, mockIssuesByListId, issues } from './mock_data'; +import defaultState from '~/boards/stores/state'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const actions = { + fetchIssuesForList: jest.fn(), +}; + +const createStore = (state = defaultState) => { + return new Vuex.Store({ + state, + actions, + }); +}; + +const createComponent = ({ + listIssueProps = {}, + componentProps = {}, + listProps = {}, + state = {}, +} = {}) => { + const store = createStore({ + issuesByListId: mockIssuesByListId, + issues, + pageInfoByListId: { + 'gid://gitlab/List/1': { hasNextPage: true }, + 'gid://gitlab/List/2': {}, + }, + listsFlags: { + 'gid://gitlab/List/1': {}, + 'gid://gitlab/List/2': {}, + }, + ...state, + }); + + const list = new List({ + ...listObj, + id: 'gid://gitlab/List/1', + ...listProps, + doNotFetchIssues: true, + }); + const issue = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [], + assignees: [], + ...listIssueProps, + }); + if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { + list.issuesSize = 1; + } + + const component = mount(BoardList, { + localVue, + propsData: { + disabled: false, + list, + issues: [issue], + ...componentProps, + }, + store, + provide: { + groupId: null, + rootPath: '/', + }, + }); + + return component; +}; + +describe('Board list component', () => { + let wrapper; + useFakeRequestAnimationFrame(); + + describe('When Expanded', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders component', () => { + expect(wrapper.find('.board-list-component').exists()).toBe(true); + }); + + it('renders loading icon', () => { + wrapper = createComponent({ + state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } }, + }); + + expect(wrapper.find('[data-testid="board_list_loading"').exists()).toBe(true); + }); + + it('renders issues', () => { + expect(wrapper.findAll(BoardCard).length).toBe(1); + }); + + it('sets data attribute with issue id', () => { + expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1'); + }); + + it('shows new issue form', async () => { + wrapper.vm.toggleForm(); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); + }); + + it('shows new issue form after eventhub event', async () => { + eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-new-issue-form').exists()).toBe(true); + }); + + it('does not show new issue form for closed list', () => { + wrapper.setProps({ list: { type: 'closed' } }); + wrapper.vm.toggleForm(); + + expect(wrapper.find('.board-new-issue-form').exists()).toBe(false); + }); + + it('shows count list item', async () => { + wrapper.vm.showCount = true; + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').exists()).toBe(true); + + expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues'); + }); + + it('sets data attribute with invalid id', async () => { + wrapper.vm.showCount = true; + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1'); + }); + + it('shows how many more issues to load', async () => { + wrapper.vm.showCount = true; + wrapper.setProps({ list: { issuesSize: 20 } }); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues'); + }); + }); + + describe('load more issues', () => { + beforeEach(() => { + wrapper = createComponent({ + listProps: { issuesSize: 25 }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('loads more issues after scrolling', () => { + wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); + + expect(actions.fetchIssuesForList).toHaveBeenCalled(); + }); + + it('does not load issues if already loading', () => { + wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); + wrapper.vm.$refs.list.dispatchEvent(new Event('scroll')); + + expect(actions.fetchIssuesForList).toHaveBeenCalledTimes(1); + }); + + it('shows loading more spinner', async () => { + wrapper.vm.showCount = true; + wrapper.vm.list.loadingMore = true; + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true); + }); + }); + + describe('max issue count warning', () => { + beforeEach(() => { + wrapper = createComponent({ + listProps: { issuesSize: 50 }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when issue count exceeds max issue count', () => { + it('sets background to bg-danger-100', async () => { + wrapper.setProps({ list: { issuesSize: 4, maxIssueCount: 3 } }); + + await wrapper.vm.$nextTick(); + expect(wrapper.find('.bg-danger-100').exists()).toBe(true); + }); + }); + + describe('when list issue count does NOT exceed list max issue count', () => { + it('does not sets background to bg-danger-100', () => { + wrapper.setProps({ list: { issuesSize: 2, maxIssueCount: 3 } }); + + expect(wrapper.find('.bg-danger-100').exists()).toBe(false); + }); + }); + + describe('when list max issue count is 0', () => { + it('does not sets background to bg-danger-100', () => { + wrapper.setProps({ list: { maxIssueCount: 0 } }); + + expect(wrapper.find('.bg-danger-100').exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 88883ae61d4..0fe3c88f518 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -44,7 +44,6 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP disabled: false, list, issues: list.issues, - loading: false, ...componentProps, }, provide: { @@ -94,7 +93,7 @@ describe('Board list component', () => { }); it('renders loading icon', () => { - component.loading = true; + component.list.loading = true; return Vue.nextTick().then(() => { expect(component.$el.querySelector('.board-list-loading')).not.toBeNull(); 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..d7df2ff1563 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,34 @@ 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); + }); + + it('does not emits events when collapsing with false `emitEvent`', async () => { + createComponent({ canUpdate: true }); + + findEditButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + + wrapper.vm.collapse({ emitEvent: false }); + + expect(wrapper.emitted().close).toBeUndefined(); + }); }); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js new file mode 100644 index 00000000000..da000d21f6a --- /dev/null +++ b/spec/frontend/boards/components/sidebar/board_sidebar_labels_select_spec.js @@ -0,0 +1,143 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLabel } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import { labels as TEST_LABELS, mockIssue as TEST_ISSUE } from 'jest/boards/mock_data'; +import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createStore } from '~/boards/stores'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); + +const TEST_LABELS_PAYLOAD = TEST_LABELS.map(label => ({ ...label, set: true })); +const TEST_LABELS_TITLES = TEST_LABELS.map(label => label.title); + +describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => { + let wrapper; + let store; + + afterEach(() => { + wrapper.destroy(); + store = null; + wrapper = null; + }); + + const createWrapper = ({ labels = [] } = {}) => { + store = createStore(); + store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } }; + store.state.activeId = TEST_ISSUE.id; + + wrapper = shallowMount(BoardSidebarLabelsSelect, { + store, + provide: { + canUpdate: true, + labelsFetchPath: TEST_HOST, + labelsManagePath: TEST_HOST, + labelsFilterBasePath: TEST_HOST, + }, + stubs: { + 'board-editable-item': BoardEditableItem, + 'labels-select': '<div></div>', + }, + }); + }; + + const findLabelsSelect = () => wrapper.find({ ref: 'labelsSelect' }); + const findLabelsTitles = () => wrapper.findAll(GlLabel).wrappers.map(item => item.props('title')); + const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); + + it('renders "None" when no labels are selected', () => { + createWrapper(); + + expect(findCollapsed().text()).toBe('None'); + }); + + it('renders labels when set', () => { + createWrapper({ labels: TEST_LABELS }); + + expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES); + }); + + describe('when labels are submitted', () => { + beforeEach(async () => { + createWrapper(); + + jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => TEST_LABELS); + findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD); + store.state.issues[TEST_ISSUE.id].labels = TEST_LABELS; + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders labels', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES); + }); + + it('commits change to the server', () => { + expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({ + addLabelIds: TEST_LABELS.map(label => label.id), + projectPath: 'gitlab-org/test-subgroup/gitlab-test', + removeLabelIds: [], + }); + }); + }); + + describe('when labels are updated over existing labels', () => { + const testLabelsPayload = [{ id: 5, set: true }, { id: 7, set: true }]; + const expectedLabels = [{ id: 5 }, { id: 7 }]; + + beforeEach(async () => { + createWrapper({ labels: TEST_LABELS }); + + jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => expectedLabels); + findLabelsSelect().vm.$emit('updateSelectedLabels', testLabelsPayload); + await wrapper.vm.$nextTick(); + }); + + it('commits change to the server', () => { + expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({ + addLabelIds: [5, 7], + removeLabelIds: [6], + projectPath: 'gitlab-org/test-subgroup/gitlab-test', + }); + }); + }); + + describe('when removing individual labels', () => { + const testLabel = TEST_LABELS[0]; + + beforeEach(async () => { + createWrapper({ labels: [testLabel] }); + + jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => {}); + }); + + it('commits change to the server', () => { + wrapper.find(GlLabel).vm.$emit('close', testLabel); + + expect(wrapper.vm.setActiveIssueLabels).toHaveBeenCalledWith({ + removeLabelIds: [getIdFromGraphQLId(testLabel.id)], + projectPath: 'gitlab-org/test-subgroup/gitlab-test', + }); + }); + }); + + describe('when the mutation fails', () => { + beforeEach(async () => { + createWrapper({ labels: TEST_LABELS }); + + jest.spyOn(wrapper.vm, 'setActiveIssueLabels').mockImplementation(() => { + throw new Error(['failed mutation']); + }); + findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]); + await wrapper.vm.$nextTick(); + }); + + it('collapses sidebar and renders former issue weight', () => { + expect(findCollapsed().isVisible()).toBe(true); + expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES); + expect(createFlash).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 5776332c499..50c0a85fc70 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -108,13 +108,19 @@ const assignees = [ }, ]; -const labels = [ +export const labels = [ { id: 'gid://gitlab/GroupLabel/5', title: 'Cosync', color: '#34ebec', description: null, }, + { + id: 'gid://gitlab/GroupLabel/6', + title: 'Brock', + color: '#e082b6', + description: null, + }, ]; export const rawIssue = { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index bdbcd435708..78e70161121 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -6,12 +6,14 @@ import { mockIssueWithModel, mockIssue2WithModel, rawIssue, + mockIssues, + labels, } 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, formatBoardLists } from '~/boards/boards_util'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -76,6 +78,80 @@ describe('setActiveId', () => { }); }); +describe('fetchLists', () => { + const state = { + endpoints: { + fullPath: 'gitlab-org', + boardId: 1, + }, + filterParams: {}, + boardType: 'group', + }; + + let queryResponse = { + data: { + group: { + board: { + hideBacklogList: true, + lists: { + nodes: [mockLists[1]], + }, + }, + }, + }, + }; + + const formattedLists = formatBoardLists(queryResponse.data.group.board.lists); + + it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', done => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + testAction( + actions.fetchLists, + {}, + state, + [ + { + type: types.RECEIVE_BOARD_LISTS_SUCCESS, + payload: formattedLists, + }, + ], + [{ type: 'showWelcomeList' }], + done, + ); + }); + + it('dispatch createList action when backlog list does not exist and is not hidden', done => { + queryResponse = { + data: { + group: { + board: { + hideBacklogList: false, + lists: { + nodes: [mockLists[1]], + }, + }, + }, + }, + }; + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + testAction( + actions.fetchLists, + {}, + state, + [ + { + type: types.RECEIVE_BOARD_LISTS_SUCCESS, + payload: formattedLists, + }, + ], + [{ type: 'createList', payload: { backlog: true } }, { type: 'showWelcomeList' }], + done, + ); + }); +}); + describe('showWelcomeList', () => { it('should dispatch addList action', done => { const state = { @@ -176,16 +252,26 @@ describe('createList', () => { describe('moveList', () => { it('should commit MOVE_LIST mutation and dispatch updateList action', done => { + const initialBoardListsState = { + 'gid://gitlab/List/1': mockListsWithModel[0], + 'gid://gitlab/List/2': mockListsWithModel[1], + }; + const state = { endpoints: { fullPath: 'gitlab-org', boardId: '1' }, boardType: 'group', disabled: false, - boardLists: mockListsWithModel, + boardLists: initialBoardListsState, }; testAction( actions.moveList, - { listId: 'gid://gitlab/List/1', newIndex: 1, adjustmentValue: 1 }, + { + listId: 'gid://gitlab/List/1', + replacedListId: 'gid://gitlab/List/2', + newIndex: 1, + adjustmentValue: 1, + }, state, [ { @@ -196,7 +282,11 @@ describe('moveList', () => { [ { type: 'updateList', - payload: { listId: 'gid://gitlab/List/1', position: 0, backupList: mockListsWithModel }, + payload: { + listId: 'gid://gitlab/List/1', + position: 0, + backupList: initialBoardListsState, + }, }, ], done, @@ -237,6 +327,99 @@ describe('deleteList', () => { expectNotImplemented(actions.deleteList); }); +describe('fetchIssuesForList', () => { + const listId = mockLists[0].id; + + const state = { + endpoints: { + fullPath: 'gitlab-org', + boardId: 1, + }, + filterParams: {}, + boardType: 'group', + }; + + const mockIssuesNodes = mockIssues.map(issue => ({ node: issue })); + + const pageInfo = { + endCursor: '', + hasNextPage: false, + }; + + const queryResponse = { + data: { + group: { + board: { + lists: { + nodes: [ + { + id: listId, + issues: { + edges: mockIssuesNodes, + pageInfo, + }, + }, + ], + }, + }, + }, + }, + }; + + const formattedIssues = formatListIssues(queryResponse.data.group.board.lists); + + const listPageInfo = { + [listId]: pageInfo, + }; + + it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_SUCCESS on success', done => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); + + testAction( + actions.fetchIssuesForList, + { listId }, + state, + [ + { + type: types.REQUEST_ISSUES_FOR_LIST, + payload: { listId, fetchNext: false }, + }, + { + type: types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, + payload: { listIssues: formattedIssues, listPageInfo, listId }, + }, + ], + [], + done, + ); + }); + + it('should commit mutations REQUEST_ISSUES_FOR_LIST and RECEIVE_ISSUES_FOR_LIST_FAILURE on failure', done => { + jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); + + testAction( + actions.fetchIssuesForList, + { listId }, + state, + [ + { + type: types.REQUEST_ISSUES_FOR_LIST, + payload: { listId, fetchNext: false }, + }, + { 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], @@ -418,6 +601,51 @@ describe('addListIssueFailure', () => { }); }); +describe('setActiveIssueLabels', () => { + const state = { issues: { [mockIssue.id]: mockIssue } }; + const getters = { getActiveIssue: mockIssue }; + const testLabelIds = labels.map(label => label.id); + const input = { + addLabelIds: testLabelIds, + removeLabelIds: [], + projectPath: 'h/b', + }; + + it('should assign labels on success', done => { + jest + .spyOn(gqlClient, 'mutate') + .mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } }); + + const payload = { + issueId: getters.getActiveIssue.id, + prop: 'labels', + value: labels, + }; + + testAction( + actions.setActiveIssueLabels, + input, + { ...state, ...getters }, + [ + { + type: types.UPDATE_ISSUE_BY_ID, + payload, + }, + ], + [], + done, + ); + }); + + it('throws error if fails', async () => { + jest + .spyOn(gqlClient, 'mutate') + .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } }); + + await expect(actions.setActiveIssueLabels({ getters }, input)).rejects.toThrow(Error); + }); +}); + describe('fetchBacklog', () => { expectNotImplemented(actions.fetchBacklog); }); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index 288143a0f21..b987080abab 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -1,6 +1,13 @@ import getters from '~/boards/stores/getters'; import { inactiveId } from '~/boards/constants'; -import { mockIssue, mockIssue2, mockIssues, mockIssuesByListId, issues } from '../mock_data'; +import { + mockIssue, + mockIssue2, + mockIssues, + mockIssuesByListId, + issues, + mockListsWithModel, +} from '../mock_data'; describe('Boards - Getters', () => { describe('getLabelToggleState', () => { @@ -130,4 +137,25 @@ describe('Boards - Getters', () => { ); }); }); + + const boardsState = { + boardLists: { + 'gid://gitlab/List/1': mockListsWithModel[0], + 'gid://gitlab/List/2': mockListsWithModel[1], + }, + }; + + describe('getListByLabelId', () => { + it('returns list for a given label id', () => { + expect(getters.getListByLabelId(boardsState)('gid://gitlab/GroupLabel/121')).toEqual( + mockListsWithModel[1], + ); + }); + }); + + describe('getListByTitle', () => { + it('returns list for a given list title', () => { + expect(getters.getListByTitle(boardsState)('To Do')).toEqual(mockListsWithModel[1]); + }); + }); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index a13a99a507e..6e53f184bb3 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -2,8 +2,6 @@ import mutations from '~/boards/stores/mutations'; import * as types from '~/boards/stores/mutation_types'; import defaultState from '~/boards/stores/state'; import { - listObj, - listObjDuplicate, mockListsWithModel, mockLists, rawIssue, @@ -22,6 +20,11 @@ const expectNotImplemented = action => { describe('Board Store Mutations', () => { let state; + const initialBoardListsState = { + 'gid://gitlab/List/1': mockListsWithModel[0], + 'gid://gitlab/List/2': mockListsWithModel[1], + }; + beforeEach(() => { state = defaultState(); }); @@ -56,11 +59,19 @@ describe('Board Store Mutations', () => { describe('RECEIVE_BOARD_LISTS_SUCCESS', () => { it('Should set boardLists to state', () => { - const lists = [listObj, listObjDuplicate]; + mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, initialBoardListsState); + + expect(state.boardLists).toEqual(initialBoardListsState); + }); + }); - mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, lists); + describe('RECEIVE_BOARD_LISTS_FAILURE', () => { + it('Should set error in state', () => { + mutations[types.RECEIVE_BOARD_LISTS_FAILURE](state); - expect(state.boardLists).toEqual(lists); + expect(state.error).toEqual( + 'An error occurred while fetching the board lists. Please reload the page.', + ); }); }); @@ -95,7 +106,13 @@ describe('Board Store Mutations', () => { }); describe('RECEIVE_ADD_LIST_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_ADD_LIST_SUCCESS); + it('adds list to boardLists state', () => { + mutations.RECEIVE_ADD_LIST_SUCCESS(state, mockListsWithModel[0]); + + expect(state.boardLists).toEqual({ + [mockListsWithModel[0].id]: mockListsWithModel[0], + }); + }); }); describe('RECEIVE_ADD_LIST_ERROR', () => { @@ -106,7 +123,7 @@ describe('Board Store Mutations', () => { it('updates boardLists state with reordered lists', () => { state = { ...state, - boardLists: mockListsWithModel, + boardLists: initialBoardListsState, }; mutations.MOVE_LIST(state, { @@ -114,7 +131,10 @@ describe('Board Store Mutations', () => { listAtNewIndex: mockListsWithModel[1], }); - expect(state.boardLists).toEqual([mockListsWithModel[1], mockListsWithModel[0]]); + expect(state.boardLists).toEqual({ + 'gid://gitlab/List/2': mockListsWithModel[1], + 'gid://gitlab/List/1': mockListsWithModel[0], + }); }); }); @@ -122,13 +142,16 @@ describe('Board Store Mutations', () => { it('updates boardLists state with previous order and sets error message', () => { state = { ...state, - boardLists: [mockListsWithModel[1], mockListsWithModel[0]], + boardLists: { + 'gid://gitlab/List/2': mockListsWithModel[1], + 'gid://gitlab/List/1': mockListsWithModel[0], + }, error: undefined, }; - mutations.UPDATE_LIST_FAILURE(state, mockListsWithModel); + mutations.UPDATE_LIST_FAILURE(state, initialBoardListsState); - expect(state.boardLists).toEqual(mockListsWithModel); + expect(state.boardLists).toEqual(initialBoardListsState); expect(state.error).toEqual('An error occurred while updating the list. Please try again.'); }); }); @@ -145,6 +168,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,14 +196,23 @@ describe('Board Store Mutations', () => { state = { ...state, - isLoadingIssues: true, - issuesByListId: {}, + issuesByListId: { + 'gid://gitlab/List/1': [], + }, issues: {}, - boardLists: mockListsWithModel, + boardLists: initialBoardListsState, + }; + + const listPageInfo = { + 'gid://gitlab/List/1': { + endCursor: '', + hasNextPage: false, + }, }; mutations.RECEIVE_ISSUES_FOR_LIST_SUCCESS(state, { listIssues: { listData: listIssues, issues }, + listPageInfo, listId: 'gid://gitlab/List/1', }); @@ -172,21 +221,11 @@ 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 = { ...state, - boardLists: mockListsWithModel, + boardLists: initialBoardListsState, error: undefined, }; @@ -200,51 +239,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 +252,6 @@ describe('Board Store Mutations', () => { beforeEach(() => { state = { ...state, - isLoadingIssues: true, error: undefined, issues: { ...issue, @@ -310,7 +307,7 @@ describe('Board Store Mutations', () => { state = { ...state, issuesByListId: listIssues, - boardLists: mockListsWithModel, + boardLists: initialBoardListsState, issues, }; @@ -358,6 +355,7 @@ describe('Board Store Mutations', () => { state = { ...state, issuesByListId: listIssues, + boardLists: initialBoardListsState, }; mutations.MOVE_ISSUE_FAILURE(state, { @@ -425,6 +423,7 @@ describe('Board Store Mutations', () => { ...state, issuesByListId: listIssues, issues, + boardLists: initialBoardListsState, }; mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 }); diff --git a/spec/frontend/ci_lint/components/ci_lint_results_spec.js b/spec/frontend/ci_lint/components/ci_lint_results_spec.js new file mode 100644 index 00000000000..37575a988c5 --- /dev/null +++ b/spec/frontend/ci_lint/components/ci_lint_results_spec.js @@ -0,0 +1,114 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import { GlTable } from '@gitlab/ui'; +import CiLintResults from '~/ci_lint/components/ci_lint_results.vue'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { mockJobs, mockErrors, mockWarnings } from '../mock_data'; + +describe('CI Lint Results', () => { + let wrapper; + + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(CiLintResults, { + propsData: { + valid: true, + jobs: mockJobs, + errors: [], + warnings: [], + dryRun: false, + ...props, + }, + }); + }; + + const findTable = () => wrapper.find(GlTable); + const findByTestId = selector => () => wrapper.find(`[data-testid="ci-lint-${selector}"]`); + const findAllByTestId = selector => () => wrapper.findAll(`[data-testid="ci-lint-${selector}"]`); + const findErrors = findByTestId('errors'); + const findWarnings = findByTestId('warnings'); + const findStatus = findByTestId('status'); + const findOnlyExcept = findByTestId('only-except'); + const findLintParameters = findAllByTestId('parameter'); + const findBeforeScripts = findAllByTestId('before-script'); + const findScripts = findAllByTestId('script'); + const findAfterScripts = findAllByTestId('after-script'); + const filterEmptyScripts = property => mockJobs.filter(job => job[property].length !== 0); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('Invalid results', () => { + beforeEach(() => { + createComponent({ valid: false, errors: mockErrors, warnings: mockWarnings }, mount); + }); + + it('does not display the table', () => { + expect(findTable().exists()).toBe(false); + }); + + it('displays the invalid status', () => { + expect(findStatus().text()).toBe(`Status: ${wrapper.vm.$options.incorrect.text}`); + expect(findStatus().props('variant')).toBe(wrapper.vm.$options.incorrect.variant); + }); + + it('displays the error message', () => { + const [expectedError] = mockErrors; + + expect(findErrors().text()).toBe(expectedError); + }); + + it('displays the warning message', () => { + const [expectedWarning] = mockWarnings; + + expect(findWarnings().exists()).toBe(true); + expect(findWarnings().text()).toContain(expectedWarning); + }); + }); + + describe('Valid results', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('displays the valid status', () => { + expect(findStatus().text()).toBe(wrapper.vm.$options.correct.text); + expect(findStatus().props('variant')).toBe(wrapper.vm.$options.correct.variant); + }); + + it('does not display only/expect values with dry run', () => { + expect(findOnlyExcept().exists()).toBe(false); + }); + }); + + describe('Lint results', () => { + beforeEach(() => { + createComponent({}, mount); + }); + + it('formats parameter value', () => { + findLintParameters().wrappers.forEach((job, index) => { + const { stage } = mockJobs[index]; + const { name } = mockJobs[index]; + + expect(job.text()).toBe(`${capitalizeFirstCharacter(stage)} Job - ${name}`); + }); + }); + + it('only shows before scripts when data is present', () => { + expect(findBeforeScripts()).toHaveLength(filterEmptyScripts('beforeScript').length); + }); + + it('only shows script when data is present', () => { + expect(findScripts()).toHaveLength(filterEmptyScripts('script').length); + }); + + it('only shows after script when data is present', () => { + expect(findAfterScripts()).toHaveLength(filterEmptyScripts('afterScript').length); + }); + }); +}); diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js new file mode 100644 index 00000000000..e617cca499d --- /dev/null +++ b/spec/frontend/ci_lint/components/ci_lint_spec.js @@ -0,0 +1,77 @@ +import { shallowMount } from '@vue/test-utils'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import CiLint from '~/ci_lint/components/ci_lint.vue'; +import lintCIMutation from '~/ci_lint/graphql/mutations/lint_ci.mutation.graphql'; + +describe('CI Lint', () => { + let wrapper; + + const endpoint = '/namespace/project/-/ci/lint'; + const content = + "test_job:\n stage: build\n script: echo 'Building'\n only:\n - web\n - chat\n - pushes\n allow_failure: true "; + + const createComponent = () => { + wrapper = shallowMount(CiLint, { + data() { + return { + content, + }; + }, + propsData: { + endpoint, + helpPagePath: '/help/ci/lint#pipeline-simulation', + }, + mocks: { + $apollo: { + mutate: jest.fn(), + }, + }, + }); + }; + + const findEditor = () => wrapper.find(EditorLite); + const findValidateBtn = () => wrapper.find('[data-testid="ci-lint-validate"]'); + const findClearBtn = () => wrapper.find('[data-testid="ci-lint-clear"]'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays the editor', () => { + expect(findEditor().exists()).toBe(true); + }); + + it('validate action calls mutation correctly', () => { + findValidateBtn().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: lintCIMutation, + variables: { content, dry: false, endpoint }, + }); + }); + + it('validate action calls mutation with dry run', async () => { + const dryRunEnabled = true; + + await wrapper.setData({ dryRun: dryRunEnabled }); + + findValidateBtn().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: lintCIMutation, + variables: { content, dry: dryRunEnabled, endpoint }, + }); + }); + + it('content is cleared on clear action', async () => { + expect(findEditor().props('value')).toBe(content); + + await findClearBtn().vm.$emit('click'); + + expect(findEditor().props('value')).toBe(''); + }); +}); diff --git a/spec/frontend/ci_lint/components/ci_lint_warnings_spec.js b/spec/frontend/ci_lint/components/ci_lint_warnings_spec.js new file mode 100644 index 00000000000..6e0a4881e14 --- /dev/null +++ b/spec/frontend/ci_lint/components/ci_lint_warnings_spec.js @@ -0,0 +1,54 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import CiLintWarnings from '~/ci_lint/components/ci_lint_warnings.vue'; + +const warnings = ['warning 1', 'warning 2', 'warning 3']; + +describe('CI lint warnings', () => { + let wrapper; + + const createComponent = (limit = 25) => { + wrapper = mount(CiLintWarnings, { + propsData: { + warnings, + maxWarnings: limit, + }, + }); + }; + + const findWarningAlert = () => wrapper.find(GlAlert); + const findWarnings = () => wrapper.findAll('[data-testid="ci-lint-warning"]'); + const findWarningMessage = () => trimText(wrapper.find(GlSprintf).text()); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays the warning alert', () => { + createComponent(); + + expect(findWarningAlert().exists()).toBe(true); + }); + + it('displays all the warnings', () => { + createComponent(); + + expect(findWarnings()).toHaveLength(warnings.length); + }); + + it('shows the correct message when the limit is not passed', () => { + createComponent(); + + expect(findWarningMessage()).toBe(`${warnings.length} warnings found:`); + }); + + it('shows the correct message when the limit is passed', () => { + const limit = 2; + + createComponent(limit); + + expect(findWarningMessage()).toBe(`${warnings.length} warnings found: showing first ${limit}`); + }); +}); diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci_lint/mock_data.js new file mode 100644 index 00000000000..cf7d69dcad3 --- /dev/null +++ b/spec/frontend/ci_lint/mock_data.js @@ -0,0 +1,49 @@ +export const mockJobs = [ + { + name: 'job_1', + stage: 'build', + beforeScript: [], + script: ["echo 'Building'"], + afterScript: [], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: true, + only: { refs: ['web', 'chat', 'pushes'] }, + except: null, + }, + { + name: 'multi_project_job', + stage: 'test', + beforeScript: [], + script: [], + afterScript: [], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: false, + only: { refs: ['branches', 'tags'] }, + except: null, + }, + { + name: 'job_2', + stage: 'test', + beforeScript: ["echo 'before script'"], + script: ["echo 'script'"], + afterScript: ["echo 'after script"], + tagList: [], + environment: null, + when: 'on_success', + allowFailure: false, + only: { refs: ['branches@gitlab-org/gitlab'] }, + except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] }, + }, +]; + +export const mockErrors = [ + '"job_1 job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post"', +]; + +export const mockWarnings = [ + '"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"', +]; 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_enviroments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js index 7785d436834..7bcd558c60f 100644 --- a/spec/frontend/ci_variable_list/components/ci_enviroments_dropdown_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_environments_dropdown_spec.js @@ -1,6 +1,6 @@ import Vuex from 'vuex'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; const localVue = createLocalVue(); @@ -17,7 +17,7 @@ describe('Ci environments dropdown', () => { }, }); - wrapper = shallowMount(CiEnvironmentsDropdown, { + wrapper = mount(CiEnvironmentsDropdown, { store, localVue, propsData: { @@ -26,21 +26,21 @@ describe('Ci environments dropdown', () => { }); }; - const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); - const findDropdownItemByIndex = index => wrapper.findAll(GlDeprecatedDropdownItem).at(index); - const findActiveIconByIndex = index => wrapper.findAll(GlIcon).at(index); + const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findDropdownItemByIndex = index => wrapper.findAll(GlDropdownItem).at(index); + const findActiveIconByIndex = index => findDropdownItemByIndex(index).find(GlIcon); afterEach(() => { wrapper.destroy(); wrapper = null; }); - describe('No enviroments found', () => { + describe('No environments found', () => { beforeEach(() => { createComponent('stable'); }); - it('renders create button with search term if enviroments do not contain search term', () => { + it('renders create button with search term if environments do not contain search term', () => { expect(findAllDropdownItems()).toHaveLength(2); expect(findDropdownItemByIndex(1).text()).toBe('Create wildcard: stable'); }); @@ -55,27 +55,31 @@ describe('Ci environments dropdown', () => { createComponent(''); }); - it('renders all enviroments when search term is empty', () => { + it('renders all environments when search term is empty', () => { expect(findAllDropdownItems()).toHaveLength(3); expect(findDropdownItemByIndex(0).text()).toBe('dev'); expect(findDropdownItemByIndex(1).text()).toBe('prod'); expect(findDropdownItemByIndex(2).text()).toBe('staging'); }); + + it('should not display active checkmark on the inactive stage', () => { + expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(true); + }); }); - describe('Enviroments found', () => { + describe('Environments found', () => { beforeEach(() => { createComponent('prod'); }); - it('renders only the enviroment searched for', () => { + it('renders only the environment searched for', () => { expect(findAllDropdownItems()).toHaveLength(1); expect(findDropdownItemByIndex(0).text()).toBe('prod'); }); it('should not display create button', () => { - const enviroments = findAllDropdownItems().filter(env => env.text().startsWith('Create')); - expect(enviroments).toHaveLength(0); + const environments = findAllDropdownItems().filter(env => env.text().startsWith('Create')); + expect(environments).toHaveLength(0); expect(findAllDropdownItems()).toHaveLength(1); }); @@ -84,7 +88,7 @@ describe('Ci environments dropdown', () => { }); it('should display active checkmark if active', () => { - expect(findActiveIconByIndex(0).classes('invisible')).toBe(false); + expect(findActiveIconByIndex(0).classes('gl-visibility-hidden')).toBe(false); }); describe('Custom events', () => { 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/ci_variable_list/store/getters_spec.js b/spec/frontend/ci_variable_list/store/getters_spec.js index 7ad96545652..92f22b18763 100644 --- a/spec/frontend/ci_variable_list/store/getters_spec.js +++ b/spec/frontend/ci_variable_list/store/getters_spec.js @@ -3,7 +3,7 @@ import mockData from '../services/mock_data'; describe('Ci variable getters', () => { describe('joinedEnvironments', () => { - it('should join fetched enviroments with variable environment scopes', () => { + it('should join fetched environments with variable environment scopes', () => { const state = { environments: ['All (default)', 'staging', 'deployment', 'prod'], variables: mockData.mockVariableScopes, diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js index 663b3486a17..a333fb7d8f9 100644 --- a/spec/frontend/ci_variable_list/store/mutations_spec.js +++ b/spec/frontend/ci_variable_list/store/mutations_spec.js @@ -73,7 +73,7 @@ describe('CI variable list mutations', () => { }); describe('ADD_WILD_CARD_SCOPE', () => { - it('should add wild card scope to enviroments array and sort', () => { + it('should add wild card scope to environments array and sort', () => { stateCopy.environments = ['dev', 'staging']; mutations[types.ADD_WILD_CARD_SCOPE](stateCopy, 'production'); diff --git a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap index 3328ec724fd..b6e89281fef 100644 --- a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap @@ -94,7 +94,7 @@ exports[`Applications Prometheus application shows the correct description 1`] = Prometheus is an open-source monitoring system with <a class="gl-link" - href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html" + href="https://docs.gitlab.com/ee/user/project/integrations/prometheus.html" rel="noopener noreferrer" target="_blank" > diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index 93b757e008a..15eeadcc8b8 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -5,14 +5,17 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ class="gl-display-flex gl-justify-content-end" > <div - class="dropdown b-dropdown gl-dropdown btn-group" + class="dropdown b-dropdown gl-new-dropdown btn-group" + menu-class="dropdown-menu-large" > <button - class="btn btn-danger" + class="btn btn-danger btn-md gl-button split-content-button" type="button" > + <!----> + <span - class="gl-dropdown-toggle-text" + class="gl-new-dropdown-button-text" > Remove integration and resources </span> @@ -22,7 +25,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ <button aria-expanded="false" aria-haspopup="true" - class="btn dropdown-toggle btn-danger dropdown-toggle-split" + class="btn dropdown-toggle btn-danger btn-md gl-button gl-dropdown-toggle dropdown-toggle-split" type="button" > <span @@ -32,29 +35,58 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ </span> </button> <ul - class="dropdown-menu dropdown-menu-selectable dropdown-menu-large" + class="dropdown-menu dropdown-menu-large" role="menu" tabindex="-1" > + <!----> + <li + class="gl-new-dropdown-item" role="presentation" > <button - class="dropdown-item is-active" + class="dropdown-item" role="menuitem" type="button" > - <strong> - Remove integration and resources - </strong> + <svg + class="gl-icon s16 gl-new-dropdown-item-check-icon" + data-testid="mobile-issue-close-icon" + > + <use + href="#mobile-issue-close" + /> + </svg> + + <!----> - <div> - Deletes all GitLab resources attached to this cluster during removal + <!----> + + <div + class="gl-new-dropdown-item-text-wrapper" + > + <p + class="gl-new-dropdown-item-text-primary" + > + <strong> + Remove integration and resources + </strong> + + <div> + Deletes all GitLab resources attached to this cluster during removal + </div> + </p> + + <!----> </div> + + <!----> </button> </li> <li + class="gl-new-dropdown-divider" role="presentation" > <hr @@ -64,6 +96,7 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ /> </li> <li + class="gl-new-dropdown-item" role="presentation" > <button @@ -71,13 +104,38 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ role="menuitem" type="button" > - <strong> - Remove integration - </strong> + <svg + class="gl-icon s16 gl-new-dropdown-item-check-icon gl-visibility-hidden" + data-testid="mobile-issue-close-icon" + > + <use + href="#mobile-issue-close" + /> + </svg> + + <!----> - <div> - Removes cluster from project but keeps associated resources + <!----> + + <div + class="gl-new-dropdown-item-text-wrapper" + > + <p + class="gl-new-dropdown-item-text-primary" + > + <strong> + Remove integration + </strong> + + <div> + Removes cluster from project but keeps associated resources + </div> + </p> + + <!----> </div> + + <!----> </button> </li> 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..d61f79071d5 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -6,7 +6,7 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlTable, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/wrapper'; import axios from '~/lib/utils/axios_utils'; import Clusters from '~/clusters_list/components/clusters.vue'; import ClusterStore from '~/clusters_list/store'; @@ -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/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 053128a179a..3d4e07d00eb 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/wrapper'; import Poll from '~/lib/utils/poll'; import { deprecatedCreateFlash as flashError } from '~/flash'; import axios from '~/lib/utils/axios_utils'; 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/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js index b1a304fabcd..86f4c450c05 100644 --- a/spec/frontend/collapsed_sidebar_todo_spec.js +++ b/spec/frontend/collapsed_sidebar_todo_spec.js @@ -59,7 +59,7 @@ describe('Issuable right sidebar collapsed todo toggle', () => { it('sets default tooltip title', () => { expect( document.querySelector('.js-issuable-todo.sidebar-collapsed-icon').getAttribute('title'), - ).toBe('Add a To Do'); + ).toBe('Add a to do'); }); it('toggle todo state', done => { @@ -125,7 +125,7 @@ describe('Issuable right sidebar collapsed todo toggle', () => { expect( document.querySelector('.issuable-sidebar-header .js-issuable-todo').textContent.trim(), - ).toBe('Add a To Do'); + ).toBe('Add a to do'); }) .then(done) .catch(done.fail); @@ -164,7 +164,7 @@ describe('Issuable right sidebar collapsed todo toggle', () => { document .querySelector('.js-issuable-todo.sidebar-collapsed-icon') .getAttribute('aria-label'), - ).toBe('Add a To Do'); + ).toBe('Add a to do'); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js index 1086985eec0..625024ee61f 100644 --- a/spec/frontend/commit/commit_pipeline_status_component_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js @@ -142,7 +142,7 @@ describe('Commit pipeline status component', () => { }); it('renders CI icon', () => { - expect(findCiIcon().attributes('data-original-title')).toEqual('Pipeline: pending'); + expect(findCiIcon().attributes('title')).toEqual('Pipeline: pending'); expect(findCiIcon().props('status')).toEqual(mockCiStatus); }); }); @@ -161,7 +161,7 @@ describe('Commit pipeline status component', () => { }); it('renders not found CI icon', () => { - expect(findCiIcon().attributes('data-original-title')).toEqual('Pipeline: not found'); + expect(findCiIcon().attributes('title')).toEqual('Pipeline: not found'); expect(findCiIcon().props('status')).toEqual({ text: 'not found', icon: 'status_notfound', 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/confidential_merge_request/components/dropdown_spec.js b/spec/frontend/confidential_merge_request/components/dropdown_spec.js index 3e95cd6c0d7..401948e24e4 100644 --- a/spec/frontend/confidential_merge_request/components/dropdown_spec.js +++ b/spec/frontend/confidential_merge_request/components/dropdown_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import Dropdown from '~/confidential_merge_request/components/dropdown.vue'; let vm; @@ -30,27 +30,18 @@ describe('Confidential merge request project dropdown component', () => { }, ]); - expect(vm.findAll(GlDeprecatedDropdownItem).length).toBe(2); + expect(vm.findAll(GlDropdownItem).length).toBe(2); }); - it('renders selected project icon', () => { - factory([ - { - id: 1, - name: 'test', - }, - { - id: 2, - name: 'test 2', - }, - ]); + it('shows lock icon', () => { + factory(); + + expect(vm.find(GlDropdown).props('icon')).toBe('lock'); + }); + + it('has dropdown text', () => { + factory(); - expect(vm.find('.js-active-project-check').classes()).not.toContain('icon'); - expect( - vm - .findAll('.js-active-project-check') - .at(1) - .classes(), - ).toContain('icon'); + expect(vm.find(GlDropdown).props('text')).toBe('Select private project'); }); }); 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/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js index 1fe80d3b1ce..d577d0b602a 100644 --- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js +++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js @@ -118,7 +118,7 @@ describe('StageNavItem', () => { expect(wrapper.find('.stage-median').text()).toBe('Not available'); }); it('does not render options menu', () => { - expect(wrapper.find('.more-actions-toggle').exists()).toBe(false); + expect(wrapper.find('[data-testid="more-actions-toggle"]').exists()).toBe(false); }); }); @@ -135,7 +135,7 @@ describe('StageNavItem', () => { }); it('does not render options menu', () => { - expect(wrapper.find('.more-actions-toggle').exists()).toBe(false); + expect(wrapper.find('[data-testid="more-actions-toggle"]').exists()).toBe(false); }); it('can not edit the stage', () => { diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js index 99cb864ce34..7c1a4ff1085 100644 --- a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js +++ b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js @@ -1,6 +1,6 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedDropdownItem, GlDropdown } from '@gitlab/ui'; +import { GlDropdownItem, GlDropdown } from '@gitlab/ui'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; import createStore from '~/deploy_freeze/store'; @@ -29,8 +29,8 @@ describe('Deploy freeze timezone dropdown', () => { wrapper.setData({ searchTerm }); }; - const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); - const findDropdownItemByIndex = index => wrapper.findAll(GlDeprecatedDropdownItem).at(index); + const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findDropdownItemByIndex = index => wrapper.findAll(GlDropdownItem).at(index); afterEach(() => { wrapper.destroy(); diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap index 62a0f675cff..ed8ed3254ba 100644 --- a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap +++ b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap @@ -1,23 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Design note pin component should match the snapshot of note when repositioning 1`] = ` -<button - aria-label="Comment form position" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 btn-transparent comment-indicator" - style="left: 10px; top: 10px; cursor: move;" - type="button" -> - <gl-icon-stub - name="image-comment-dark" - size="24" - /> -</button> -`; - exports[`Design note pin component should match the snapshot of note with index 1`] = ` <button aria-label="Comment '1' position" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 js-image-badge badge badge-pill" + class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0! js-image-badge badge badge-pill" style="left: 10px; top: 10px;" type="button" > @@ -30,7 +16,7 @@ exports[`Design note pin component should match the snapshot of note with index exports[`Design note pin component should match the snapshot of note without index 1`] = ` <button aria-label="Comment form position" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 btn-transparent comment-indicator" + class="gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-font-lg gl-outline-0! btn-transparent comment-indicator gl-p-0" style="left: 10px; top: 10px;" type="button" > diff --git a/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap index 189962c5b2e..560533891c9 100644 --- a/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap +++ b/spec/frontend/design_management/components/__snapshots__/design_presentation_spec.js.snap @@ -2,10 +2,10 @@ exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = ` <div - class="h-100 w-100 p-3 overflow-auto position-relative" + class="gl-h-full gl-w-full gl-p-5 overflow-auto gl-relative" > <div - class="h-100 w-100 d-flex align-items-center position-relative" + class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative" > <design-image-stub image="test.jpg" @@ -25,10 +25,10 @@ exports[`Design management design presentation component currentCommentForm is e exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = ` <div - class="h-100 w-100 p-3 overflow-auto position-relative" + class="gl-h-full gl-w-full gl-p-5 overflow-auto gl-relative" > <div - class="h-100 w-100 d-flex align-items-center position-relative" + class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative" > <design-image-stub image="test.jpg" @@ -47,10 +47,10 @@ exports[`Design management design presentation component currentCommentForm is n exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = ` <div - class="h-100 w-100 p-3 overflow-auto position-relative" + class="gl-h-full gl-w-full gl-p-5 overflow-auto gl-relative" > <div - class="h-100 w-100 d-flex align-items-center position-relative" + class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative" > <design-image-stub image="test.jpg" @@ -69,10 +69,10 @@ exports[`Design management design presentation component currentCommentForm is n exports[`Design management design presentation component renders empty state when no image provided 1`] = ` <div - class="h-100 w-100 p-3 overflow-auto position-relative" + class="gl-h-full gl-w-full gl-p-5 overflow-auto gl-relative" > <div - class="h-100 w-100 d-flex align-items-center position-relative" + class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative" > <!----> @@ -83,10 +83,10 @@ exports[`Design management design presentation component renders empty state whe exports[`Design management design presentation component renders image and overlay when image provided 1`] = ` <div - class="h-100 w-100 p-3 overflow-auto position-relative" + class="gl-h-full gl-w-full gl-p-5 overflow-auto gl-relative" > <div - class="h-100 w-100 d-flex align-items-center position-relative" + class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative" > <design-image-stub image="test.jpg" diff --git a/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap index cb4575cbd11..0679b485f77 100644 --- a/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap +++ b/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap @@ -10,7 +10,7 @@ exports[`Design management design scaler component minus and reset buttons are d disabled="disabled" > <span - class="d-flex-center gl-icon s16" + class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16" > – @@ -48,7 +48,7 @@ exports[`Design management design scaler component minus and reset buttons are e class="btn" > <span - class="d-flex-center gl-icon s16" + class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16" > – @@ -85,7 +85,7 @@ exports[`Design management design scaler component plus button is disabled when class="btn" > <span - class="d-flex-center gl-icon s16" + class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16" > – diff --git a/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap index acaa62b11eb..7cffd3cf3e8 100644 --- a/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap +++ b/spec/frontend/design_management/components/__snapshots__/image_spec.js.snap @@ -2,7 +2,7 @@ exports[`Design management large image component renders image 1`] = ` <div - class="m-auto js-design-image" + class="gl-mx-auto gl-my-auto js-design-image" > <!----> @@ -16,7 +16,7 @@ exports[`Design management large image component renders image 1`] = ` exports[`Design management large image component renders loading state 1`] = ` <div - class="m-auto js-design-image" + class="gl-mx-auto gl-my-auto js-design-image" isloading="true" > <!----> @@ -31,7 +31,7 @@ exports[`Design management large image component renders loading state 1`] = ` exports[`Design management large image component renders media broken icon on error 1`] = ` <gl-icon-stub - class="text-secondary-100" + class="gl-text-gray-200" name="media-broken" size="48" /> @@ -39,7 +39,7 @@ exports[`Design management large image component renders media broken icon on er exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = ` <div - class="m-auto js-design-image" + class="gl-mx-auto gl-my-auto js-design-image" > <!----> @@ -54,7 +54,7 @@ exports[`Design management large image component sets correct classes and styles exports[`Design management large image component zoom sets image style when zoomed 1`] = ` <div - class="m-auto js-design-image" + class="gl-mx-auto gl-my-auto js-design-image" > <!----> diff --git a/spec/frontend/design_management/components/design_note_pin_spec.js b/spec/frontend/design_management/components/design_note_pin_spec.js index 4e045b58a35..a6219923aca 100644 --- a/spec/frontend/design_management/components/design_note_pin_spec.js +++ b/spec/frontend/design_management/components/design_note_pin_spec.js @@ -29,21 +29,4 @@ describe('Design note pin component', () => { createComponent({ label: 1 }); expect(wrapper.element).toMatchSnapshot(); }); - - it('should match the snapshot of note when repositioning', () => { - createComponent({ repositioning: true }); - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('pinStyle', () => { - it('sets cursor to `move` when repositioning = true', () => { - createComponent({ repositioning: true }); - expect(wrapper.vm.pinStyle.cursor).toBe('move'); - }); - - it('does not set cursor when repositioning = false', () => { - createComponent(); - expect(wrapper.vm.pinStyle.cursor).toBe(undefined); - }); - }); }); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index 673a09320e5..4ef067e3f5e 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -202,7 +202,7 @@ describe('Design overlay component', () => { { x: position.x, y: position.y }, { x: 20, y: 20 }, ).then(() => { - expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;'); + expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px;'); }); }); @@ -300,9 +300,7 @@ describe('Design overlay component', () => { { x: position.x, y: position.y }, { x: 20, y: 20 }, ).then(() => { - expect(findCommentBadge().attributes().style).toBe( - 'left: 20px; top: 20px; cursor: move;', - ); + expect(findCommentBadge().attributes().style).toBe('left: 20px; top: 20px;'); }); }); diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js index 700faa8a70f..60266883fcd 100644 --- a/spec/frontend/design_management/components/design_sidebar_spec.js +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -43,7 +43,7 @@ describe('Design management design sidebar component', () => { const findNewDiscussionDisclaimer = () => wrapper.find('[data-testid="new-discussion-disclaimer"]'); - function createComponent(props = {}, { enableTodoButton } = {}) { + function createComponent(props = {}) { wrapper = shallowMount(DesignSidebar, { propsData: { design, @@ -58,9 +58,6 @@ describe('Design management design sidebar component', () => { }, }, stubs: { GlPopover }, - provide: { - glFeatures: { designManagementTodoButton: enableTodoButton }, - }, }); } @@ -80,6 +77,12 @@ describe('Design management design sidebar component', () => { expect(findParticipants().props('participants')).toHaveLength(1); }); + it('renders To-Do button', () => { + createComponent(); + + expect(wrapper.find(DesignTodoButton).exists()).toBe(true); + }); + describe('when has no discussions', () => { beforeEach(() => { createComponent({ @@ -245,23 +248,4 @@ describe('Design management design sidebar component', () => { expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 }); }); }); - - it('does not render To-Do button by default', () => { - createComponent(); - expect(wrapper.find(DesignTodoButton).exists()).toBe(false); - }); - - describe('when `design_management_todo_button` feature flag is enabled', () => { - beforeEach(() => { - createComponent({}, { enableTodoButton: true }); - }); - - it('renders sidebar root element with no top padding', () => { - expect(wrapper.classes()).toContain('gl-pt-0'); - }); - - it('renders To-Do button', () => { - expect(wrapper.find(DesignTodoButton).exists()).toBe(true); - }); - }); }); 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/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap index 9284099b40d..1ca5360fa59 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap @@ -2,10 +2,10 @@ exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = ` <div - class="w-100 position-relative" + class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -39,11 +39,11 @@ exports[`Design management dropzone component when dragging renders correct temp name="design-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="" > <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="display: none;" > <h3 @@ -58,7 +58,7 @@ exports[`Design management dropzone component when dragging renders correct temp </div> <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="" > <h3 @@ -78,10 +78,10 @@ exports[`Design management dropzone component when dragging renders correct temp exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = ` <div - class="w-100 position-relative" + class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -115,11 +115,11 @@ exports[`Design management dropzone component when dragging renders correct temp name="design-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="" > <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="display: none;" > <h3 @@ -134,7 +134,7 @@ exports[`Design management dropzone component when dragging renders correct temp </div> <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="" > <h3 @@ -154,10 +154,10 @@ exports[`Design management dropzone component when dragging renders correct temp exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = ` <div - class="w-100 position-relative" + class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -191,11 +191,11 @@ exports[`Design management dropzone component when dragging renders correct temp name="design-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="" > <div - class="mw-50 text-center" + class="mw-50 gl-text-center" > <h3 class="" @@ -209,7 +209,7 @@ exports[`Design management dropzone component when dragging renders correct temp </div> <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="display: none;" > <h3 @@ -229,10 +229,10 @@ exports[`Design management dropzone component when dragging renders correct temp exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = ` <div - class="w-100 position-relative" + class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -266,11 +266,11 @@ exports[`Design management dropzone component when dragging renders correct temp name="design-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="" > <div - class="mw-50 text-center" + class="mw-50 gl-text-center" > <h3 class="" @@ -284,7 +284,7 @@ exports[`Design management dropzone component when dragging renders correct temp </div> <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="display: none;" > <h3 @@ -304,10 +304,10 @@ exports[`Design management dropzone component when dragging renders correct temp exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = ` <div - class="w-100 position-relative" + class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -341,11 +341,11 @@ exports[`Design management dropzone component when dragging renders correct temp name="design-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="display: none;" > <div - class="mw-50 text-center" + class="mw-50 gl-text-center" > <h3 class="" @@ -359,7 +359,7 @@ exports[`Design management dropzone component when dragging renders correct temp </div> <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="display: none;" > <h3 @@ -379,10 +379,10 @@ exports[`Design management dropzone component when dragging renders correct temp exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = ` <div - class="w-100 position-relative" + class="gl-w-full gl-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" > <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" @@ -416,11 +416,11 @@ exports[`Design management dropzone component when no slot provided renders defa name="design-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="display: none;" > <div - class="mw-50 text-center" + class="mw-50 gl-text-center" > <h3 class="" @@ -434,7 +434,7 @@ exports[`Design management dropzone component when no slot provided renders defa </div> <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="display: none;" > <h3 @@ -454,7 +454,7 @@ exports[`Design management dropzone component when no slot provided renders defa exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = ` <div - class="w-100 position-relative" + class="gl-w-full gl-relative" > <div> dropzone slot @@ -464,11 +464,11 @@ exports[`Design management dropzone component when slot provided renders dropzon name="design-dropzone-fade" > <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" + class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" style="display: none;" > <div - class="mw-50 text-center" + class="mw-50 gl-text-center" > <h3 class="" @@ -482,7 +482,7 @@ exports[`Design management dropzone component when slot provided renders dropzon </div> <div - class="mw-50 text-center" + class="mw-50 gl-text-center" style="display: none;" > <h3 diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index 1c7806c292f..5e41210221b 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: [ { @@ -50,6 +51,34 @@ export const designListQueryResponse = { }, }; +export const designUploadMutationCreatedResponse = { + data: { + designManagementUpload: { + designs: [ + { + id: '1', + event: 'CREATION', + filename: 'fox_1.jpg', + }, + ], + }, + }, +}; + +export const designUploadMutationUpdatedResponse = { + data: { + designManagementUpload: { + designs: [ + { + id: '1', + event: 'MODIFICATION', + filename: 'fox_1.jpg', + }, + ], + }, + }, +}; + export const permissionsQueryResponse = { data: { project: { 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..2d29b79e31c 100644 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -8,7 +8,7 @@ exports[`Design management index page designs does not render toolbar when there <!----> <div - class="mt-4" + class="gl-mt-6" > <ol class="list-unstyled row" @@ -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> @@ -91,7 +92,7 @@ exports[`Design management index page designs renders designs list and header wi data-testid="designs-root" > <header - class="row-content-block border-top-0 p-2 d-flex" + class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex" > <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full" @@ -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 @@ -139,7 +142,7 @@ exports[`Design management index page designs renders designs list and header wi </header> <div - class="mt-4" + class="gl-mt-6" > <ol class="list-unstyled row" @@ -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> @@ -233,7 +243,7 @@ exports[`Design management index page designs renders error 1`] = ` <!----> <div - class="mt-4" + class="gl-mt-6" > <gl-alert-stub dismisslabel="Dismiss" @@ -264,7 +274,7 @@ exports[`Design management index page designs renders loading icon 1`] = ` <!----> <div - class="mt-4" + class="gl-mt-6" > <gl-loading-icon-stub color="orange" @@ -287,7 +297,7 @@ exports[`Design management index page when has no designs renders design dropzon <!----> <div - class="mt-4" + class="gl-mt-6" > <ol class="list-unstyled row" @@ -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..3d6c2561ff6 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 @@ -2,10 +2,10 @@ exports[`Design management design index page renders design index 1`] = ` <div - class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" + class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" > <div - class="d-flex overflow-hidden flex-grow-1 flex-column position-relative" + class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative" > <design-destroyer-stub filenames="test.jpg" @@ -23,16 +23,26 @@ exports[`Design management design index page renders design index 1`] = ` /> <div - class="design-scaler-wrapper position-absolute mb-4 d-flex-center" + class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" > <design-scaler-stub /> </div> </div> <div - class="image-notes" + class="image-notes gl-pt-0" > - <!----> + <div + class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > + <span> + To Do + </span> + + <design-todo-button-stub + design="[object Object]" + /> + </div> <h2 class="gl-font-weight-bold gl-mt-0" @@ -67,6 +77,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" @@ -121,10 +132,10 @@ exports[`Design management design index page renders design index 1`] = ` exports[`Design management design index page sets loading state 1`] = ` <div - class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" + class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" > <gl-loading-icon-stub - class="align-self-center" + class="gl-align-self-center" color="orange" label="Loading" size="xl" @@ -134,10 +145,10 @@ exports[`Design management design index page sets loading state 1`] = ` exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = ` <div - class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" + class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row" > <div - class="d-flex overflow-hidden flex-grow-1 flex-column position-relative" + class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative" > <design-destroyer-stub filenames="test.jpg" @@ -146,7 +157,7 @@ exports[`Design management design index page with error GlAlert is rendered in c /> <div - class="p-3" + class="gl-p-5" > <gl-alert-stub dismissible="true" @@ -172,16 +183,26 @@ exports[`Design management design index page with error GlAlert is rendered in c /> <div - class="design-scaler-wrapper position-absolute mb-4 d-flex-center" + class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" > <design-scaler-stub /> </div> </div> <div - class="image-notes" + class="image-notes gl-pt-0" > - <!----> + <div + class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > + <span> + To Do + </span> + + <design-todo-button-stub + design="[object Object]" + /> + </div> <h2 class="gl-font-weight-bold gl-mt-0" diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 661717d29a3..27a91b11448 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -4,6 +4,7 @@ import VueDraggable from 'vuedraggable'; import VueRouter from 'vue-router'; import { GlEmptyState } from '@gitlab/ui'; import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import Index from '~/design_management/pages/index.vue'; import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql'; import DesignDestroyer from '~/design_management/components/design_destroyer.vue'; @@ -21,6 +22,8 @@ import * as utils from '~/design_management/utils/design_management_utils'; import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants'; import { designListQueryResponse, + designUploadMutationCreatedResponse, + designUploadMutationUpdatedResponse, permissionsQueryResponse, moveDesignMutationResponse, reorderedDesigns, @@ -29,6 +32,7 @@ import { import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql'; import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql'; import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql'; +import { DESIGN_TRACKING_PAGE_NAME } from '~/design_management/utils/tracking'; jest.mock('~/flash.js'); const mockPageEl = { @@ -92,6 +96,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 +105,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 +122,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 +131,7 @@ describe('Design management index page', () => { mutate = mockMutate; const $apollo = { queries: { - designs: { + designCollection: { loading, }, permissions: { @@ -137,8 +144,8 @@ describe('Design management index page', () => { wrapper = shallowMount(Index, { data() { return { - designs, allVersions, + designCollection, permissions: { createDesign, }, @@ -200,13 +207,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 +243,7 @@ describe('Design management index page', () => { describe('when has no designs', () => { beforeEach(() => { - createComponent(); + createComponent({ designCollection: { designs: [], copyState: 'READY' } }); }); it('renders design dropzone', () => @@ -259,6 +266,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 +304,10 @@ describe('Design management index page', () => { { __typename: 'Design', id: expect.anything(), + currentUserTodos: { + __typename: 'TodoConnection', + nodes: [], + }, image: '', imageV432x230: '', filename: 'test', @@ -348,7 +374,7 @@ describe('Design management index page', () => { createComponent({ stubs: { GlEmptyState } }); wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); - wrapper.vm.onUploadDesignDone(); + wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.filesToBeSaved).toEqual([]); expect(wrapper.vm.isSaving).toBeFalsy(); @@ -460,6 +486,34 @@ describe('Design management index page', () => { expect(createFlash).toHaveBeenCalledWith(message); }); }); + + describe('tracking', () => { + let trackingSpy; + + beforeEach(() => { + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + + createComponent({ stubs: { GlEmptyState } }); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('tracks design creation', () => { + wrapper.vm.onUploadDesignDone(designUploadMutationCreatedResponse); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(DESIGN_TRACKING_PAGE_NAME, 'create_design'); + }); + + it('tracks design modification', () => { + wrapper.vm.onUploadDesignDone(designUploadMutationUpdatedResponse); + + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(DESIGN_TRACKING_PAGE_NAME, 'update_design'); + }); + }); }); describe('on latest version when has designs', () => { @@ -531,13 +585,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 () => { @@ -626,9 +683,8 @@ describe('Design management index page', () => { describe('when navigating', () => { it('ensures fullscreen layout is not applied', () => { - createComponent(true); + createComponent({ loading: true }); - wrapper.vm.$router.push('/'); expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1); expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); }); @@ -637,7 +693,7 @@ describe('Design management index page', () => { router.replace({ path: '/designs', }); - createComponent(true); + createComponent({ loading: true }); return wrapper.vm.$nextTick().then(() => { expect(scrollIntoViewMock).toHaveBeenCalled(); @@ -676,6 +732,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..a04486fc5c7 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'; @@ -22,7 +20,7 @@ const diffFile = Object.freeze( name: 'base.js', mode: '100644', readable_text: true, - icon: 'file-text-o', + icon: 'doc-text', }, }), ); @@ -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 4f1376e2c73..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', @@ -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/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js index 951b3f6258b..c65a39b9083 100644 --- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { TEST_HOST } from 'helpers/test_constants'; import { createStore } from '~/mr_notes/stores'; import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue'; import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; @@ -28,13 +27,6 @@ describe('InlineDiffTableRow', () => { }); }; - const setWindowLocation = value => { - Object.defineProperty(window, 'location', { - writable: true, - value, - }); - }; - beforeEach(() => { store = createStore(); store.state.notes.userData = TEST_USER; @@ -122,22 +114,15 @@ describe('InlineDiffTableRow', () => { const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButton' }); it.each` - userData | query | mergeRefHeadComments | expectation - ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true} - ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true} - ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false} - ${null} | ${''} | ${true} | ${false} - `( - 'exists is $expectation - with userData ($userData) query ($query)', - ({ userData, query, mergeRefHeadComments, expectation }) => { - store.state.notes.userData = userData; - gon.features = { mergeRefHeadComments }; - setWindowLocation({ href: `${TEST_HOST}?${query}` }); - createComponent({}, store); - - expect(findNoteButton().exists()).toBe(expectation); - }, - ); + userData | expectation + ${TEST_USER} | ${true} + ${null} | ${false} + `('exists is $expectation - with userData ($userData)', ({ userData, expectation }) => { + store.state.notes.userData = userData; + createComponent({}, store); + + expect(findNoteButton().exists()).toBe(expectation); + }); it.each` isHover | line | expectation diff --git a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js index 13c4ce06f18..13031bd8b66 100644 --- a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js @@ -1,7 +1,6 @@ import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { TEST_HOST } from 'helpers/test_constants'; import { createStore } from '~/mr_notes/stores'; import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; import diffFileMockData from '../mock_data/diff_file'; @@ -186,13 +185,6 @@ describe('ParallelDiffTableRow', () => { }); }; - const setWindowLocation = value => { - Object.defineProperty(window, 'location', { - writable: true, - value, - }); - }; - beforeEach(() => { // eslint-disable-next-line prefer-destructuring thisLine = diffFileMockData.parallel_diff_lines[2]; @@ -228,19 +220,15 @@ describe('ParallelDiffTableRow', () => { const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButtonLeft' }); it.each` - hover | line | userData | query | mergeRefHeadComments | expectation - ${true} | ${{}} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true} - ${true} | ${{ line: { left: null } }} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${false} - ${true} | ${{}} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true} - ${true} | ${{}} | ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false} - ${true} | ${{}} | ${null} | ${''} | ${true} | ${false} - ${false} | ${{}} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${false} + hover | line | userData | expectation + ${true} | ${{}} | ${TEST_USER} | ${true} + ${true} | ${{ line: { left: null } }} | ${TEST_USER} | ${false} + ${true} | ${{}} | ${null} | ${false} + ${false} | ${{}} | ${TEST_USER} | ${false} `( - 'exists is $expectation - with userData ($userData) query ($query)', - async ({ hover, line, userData, query, mergeRefHeadComments, expectation }) => { + 'exists is $expectation - with userData ($userData)', + async ({ hover, line, userData, expectation }) => { store.state.notes.userData = userData; - gon.features = { mergeRefHeadComments }; - setWindowLocation({ href: `${TEST_HOST}?${query}` }); createComponent(line, store); if (hover) await wrapper.find('.line_holder').trigger('mouseover'); diff --git a/spec/frontend/diffs/mock_data/diff_discussions.js b/spec/frontend/diffs/mock_data/diff_discussions.js index 711ab543411..eff949bfb0d 100644 --- a/spec/frontend/diffs/mock_data/diff_discussions.js +++ b/spec/frontend/diffs/mock_data/diff_discussions.js @@ -260,11 +260,10 @@ export default { name: 'CHANGELOG', mode: '100644', readable_text: true, - icon: 'file-text-o', + icon: 'doc-text', }, blob_path: 'CHANGELOG', blob_name: 'CHANGELOG', - blob_icon: '<i aria-hidden="true" data-hidden="true" class="fa fa-file-text-o fa-fw"></i>', file_hash: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a', file_path: 'CHANGELOG.rb', new_file: false, diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js index c2a4424ee95..d3886819a91 100644 --- a/spec/frontend/diffs/mock_data/diff_file.js +++ b/spec/frontend/diffs/mock_data/diff_file.js @@ -7,11 +7,10 @@ export default { name: 'CHANGELOG', mode: '100644', readable_text: true, - icon: 'file-text-o', + icon: 'doc-text', }, blob_path: 'CHANGELOG', blob_name: 'CHANGELOG', - blob_icon: '<i aria-hidden="true" data-hidden="true" class="fa fa-file-text-o fa-fw"></i>', file_hash: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a', file_identifier_hash: '928f8286952bda02d674b692addcbe077084663a', file_path: 'CHANGELOG', @@ -27,7 +26,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..f6cdca9950a 100644 --- a/spec/frontend/diffs/mock_data/diff_file_unreadable.js +++ b/spec/frontend/diffs/mock_data/diff_file_unreadable.js @@ -7,11 +7,10 @@ export default { name: 'CHANGELOG', mode: '100644', readable_text: false, - icon: 'file-text-o', + icon: 'doc-text', }, blob_path: 'CHANGELOG', blob_name: 'CHANGELOG', - blob_icon: '<i aria-hidden="true" data-hidden="true" class="fa fa-file-text-o fa-fw"></i>', file_hash: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a', file_path: 'CHANGELOG', new_file: false, @@ -26,7 +25,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/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js index e566d3a4b38..bc17435c6d4 100644 --- a/spec/frontend/editor/editor_lite_spec.js +++ b/spec/frontend/editor/editor_lite_spec.js @@ -1,4 +1,5 @@ import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; +import waitForPromises from 'helpers/wait_for_promises'; import Editor from '~/editor/editor_lite'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from '~/editor/constants'; @@ -253,55 +254,125 @@ describe('Base editor', () => { const MyExt3 = { foo: foo2, }; - beforeEach(() => { - instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); - }); - it('is extensible with the extensions', () => { - expect(instance.foo).toBeUndefined(); + describe('basic functionality', () => { + beforeEach(() => { + instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); + }); - editor.use(MyExt1); - expect(instance.foo).toEqual(foo1); - }); + it('is extensible with the extensions', () => { + expect(instance.foo).toBeUndefined(); - it('does not fail if no extensions supplied', () => { - const spy = jest.spyOn(global.console, 'error'); - editor.use(); + instance.use(MyExt1); + expect(instance.foo).toEqual(foo1); + }); - expect(spy).not.toHaveBeenCalled(); - }); + it('does not fail if no extensions supplied', () => { + const spy = jest.spyOn(global.console, 'error'); + instance.use(); - it('is extensible with multiple extensions', () => { - expect(instance.foo).toBeUndefined(); - expect(instance.bar).toBeUndefined(); + expect(spy).not.toHaveBeenCalled(); + }); - editor.use([MyExt1, MyExt2]); + it('is extensible with multiple extensions', () => { + expect(instance.foo).toBeUndefined(); + expect(instance.bar).toBeUndefined(); - expect(instance.foo).toEqual(foo1); - expect(instance.bar).toEqual(bar); - }); + instance.use([MyExt1, MyExt2]); - it('uses the last definition of a method in case of an overlap', () => { - editor.use([MyExt1, MyExt2, MyExt3]); - expect(instance).toEqual( - expect.objectContaining({ - foo: foo2, - bar, - }), - ); + expect(instance.foo).toEqual(foo1); + expect(instance.bar).toEqual(bar); + }); + + it('uses the last definition of a method in case of an overlap', () => { + instance.use([MyExt1, MyExt2, MyExt3]); + expect(instance).toEqual( + expect.objectContaining({ + foo: foo2, + bar, + }), + ); + }); + + it('correctly resolves references withing extensions', () => { + const FunctionExt = { + inst() { + return this; + }, + mod() { + return this.getModel(); + }, + }; + instance.use(FunctionExt); + expect(instance.inst()).toEqual(editor.instances[0]); + }); }); - it('correctly resolves references withing extensions', () => { - const FunctionExt = { - inst() { - return this; - }, - mod() { - return this.getModel(); - }, + describe('extensions as an instance parameter', () => { + let editorExtensionSpy; + const instanceConstructor = (extensions = []) => { + return editor.createInstance({ + el: editorEl, + blobPath, + blobContent, + blobGlobalId, + extensions, + }); }; - editor.use(FunctionExt); - expect(instance.inst()).toEqual(editor.instances[0]); + + beforeEach(() => { + editorExtensionSpy = jest.spyOn(Editor, 'pushToImportsArray').mockImplementation(arr => { + arr.push( + Promise.resolve({ + default: {}, + }), + ); + }); + }); + + it.each([undefined, [], [''], ''])( + 'does not fail and makes no fetch if extensions is %s', + () => { + instance = instanceConstructor(null); + expect(editorExtensionSpy).not.toHaveBeenCalled(); + }, + ); + + it.each` + type | value | callsCount + ${'simple string'} | ${'foo'} | ${1} + ${'combined string'} | ${'foo, bar'} | ${2} + ${'array of strings'} | ${['foo', 'bar']} | ${2} + `('accepts $type as an extension parameter', ({ value, callsCount }) => { + instance = instanceConstructor(value); + expect(editorExtensionSpy).toHaveBeenCalled(); + expect(editorExtensionSpy.mock.calls).toHaveLength(callsCount); + }); + + it.each` + desc | path | expectation + ${'~/editor'} | ${'foo'} | ${'~/editor/foo'} + ${'~/CUSTOM_PATH with leading slash'} | ${'/my_custom_path/bar'} | ${'~/my_custom_path/bar'} + ${'~/CUSTOM_PATH without leading slash'} | ${'my_custom_path/delta'} | ${'~/my_custom_path/delta'} + `('fetches extensions from $desc path', ({ path, expectation }) => { + instance = instanceConstructor(path); + expect(editorExtensionSpy).toHaveBeenCalledWith(expect.any(Array), expectation); + }); + + it('emits editor-ready event after all extensions were applied', async () => { + const calls = []; + const eventSpy = jest.fn().mockImplementation(() => { + calls.push('event'); + }); + const useSpy = jest.spyOn(editor, 'use').mockImplementation(() => { + calls.push('use'); + }); + editorEl.addEventListener('editor-ready', eventSpy); + instance = instanceConstructor('foo, bar'); + await waitForPromises(); + expect(useSpy.mock.calls).toHaveLength(2); + expect(calls).toEqual(['use', 'use', 'event']); + }); }); describe('multiple instances', () => { diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js index 53c6d0835bc..f528313ef02 100644 --- a/spec/frontend/emoji/emoji_spec.js +++ b/spec/frontend/emoji/emoji_spec.js @@ -1,7 +1,6 @@ -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 { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji'; +import { glEmojiTag, searchEmoji, getEmoji } from '~/emoji'; import isEmojiUnicodeSupported, { isFlagEmoji, isRainbowFlagEmoji, @@ -30,37 +29,11 @@ const emptySupportMap = { 1.1: false, }; -const emojiFixtureMap = { - bomb: { - name: 'bomb', - moji: '💣', - unicodeVersion: '6.0', - }, - construction_worker_tone5: { - name: 'construction_worker_tone5', - moji: '👷🏿', - unicodeVersion: '8.0', - }, - five: { - name: 'five', - moji: '5️⃣', - unicodeVersion: '3.0', - }, - grey_question: { - name: 'grey_question', - moji: '❔', - unicodeVersion: '6.0', - }, -}; - describe('gl_emoji', () => { let mock; - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200); - - return initEmojiMap().catch(() => {}); + beforeEach(async () => { + mock = await initEmojiMock(); }); afterEach(() => { @@ -378,4 +351,126 @@ describe('gl_emoji', () => { expect(isSupported).toBeFalsy(); }); }); + + describe('getEmoji', () => { + const { grey_question } = emojiFixtureMap; + + describe('when query is undefined', () => { + it('should return null by default', () => { + expect(getEmoji()).toBe(null); + }); + + it('should return fallback emoji when fallback is true', () => { + expect(getEmoji(undefined, true).name).toEqual(grey_question.name); + }); + }); + }); + + describe('searchEmoji', () => { + const { atom, grey_question } = emojiFixtureMap; + const search = (query, opts) => searchEmoji(query, opts).map(({ name }) => name); + const mangle = str => str.slice(0, 1) + str.slice(-1); + const partial = str => str.slice(0, 2); + + describe('with default options', () => { + const subject = query => search(query); + + describeEmojiFields('with $field', ({ accessor }) => { + it(`should match by lower case: ${accessor(atom)}`, () => { + expect(subject(accessor(atom))).toContain(atom.name); + }); + + it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => { + expect(subject(accessor(atom).toUpperCase())).toContain(atom.name); + }); + + it(`should not match by partial: ${mangle(accessor(atom))}`, () => { + expect(subject(mangle(accessor(atom)))).not.toContain(atom.name); + }); + }); + + it(`should match by unicode value: ${atom.moji}`, () => { + expect(subject(atom.moji)).toContain(atom.name); + }); + + it('should not return a fallback value', () => { + expect(subject('foo bar baz')).toHaveLength(0); + }); + + it('should not return a fallback value when query is falsey', () => { + expect(subject()).toHaveLength(0); + }); + }); + + describe('with fuzzy match', () => { + const subject = query => search(query, { match: 'fuzzy' }); + + describeEmojiFields('with $field', ({ accessor }) => { + it(`should match by lower case: ${accessor(atom)}`, () => { + expect(subject(accessor(atom))).toContain(atom.name); + }); + + it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => { + expect(subject(accessor(atom).toUpperCase())).toContain(atom.name); + }); + + it(`should match by partial: ${mangle(accessor(atom))}`, () => { + expect(subject(mangle(accessor(atom)))).toContain(atom.name); + }); + }); + }); + + describe('with contains match', () => { + const subject = query => search(query, { match: 'contains' }); + + describeEmojiFields('with $field', ({ accessor }) => { + it(`should match by lower case: ${accessor(atom)}`, () => { + expect(subject(accessor(atom))).toContain(atom.name); + }); + + it(`should match by upper case: ${accessor(atom).toUpperCase()}`, () => { + expect(subject(accessor(atom).toUpperCase())).toContain(atom.name); + }); + + it(`should match by partial: ${partial(accessor(atom))}`, () => { + expect(subject(partial(accessor(atom)))).toContain(atom.name); + }); + + it(`should not match by mangled: ${mangle(accessor(atom))}`, () => { + expect(subject(mangle(accessor(atom)))).not.toContain(atom.name); + }); + }); + }); + + describe('with fallback', () => { + const subject = query => search(query, { fallback: true }); + + it.each` + query + ${'foo bar baz'} | ${undefined} + `('should return a fallback value when given $query', ({ query }) => { + expect(subject(query)).toContain(grey_question.name); + }); + }); + + describe('with name and alias fields', () => { + const subject = query => search(query, { fields: ['name', 'alias'] }); + + it(`should match by name: ${atom.name}`, () => { + expect(subject(atom.name)).toContain(atom.name); + }); + + it(`should match by alias: ${atom.aliases[0]}`, () => { + expect(subject(atom.aliases[0])).toContain(atom.name); + }); + + it(`should not match by description: ${atom.description}`, () => { + expect(subject(atom.description)).not.toContain(atom.name); + }); + + it(`should not match by unicode value: ${atom.moji}`, () => { + expect(subject(atom.moji)).not.toContain(atom.name); + }); + }); + }); }); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 35ca323f5a9..733bf4378eb 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -1,8 +1,8 @@ -/* eslint-disable import/no-commonjs */ +/* eslint-disable import/no-commonjs, max-classes-per-file */ const path = require('path'); const { ErrorWithStack } = require('jest-util'); -const JSDOMEnvironment = require('jest-environment-jsdom-sixteen'); +const JSDOMEnvironment = require('jest-environment-jsdom'); const { TEST_HOST } = require('./helpers/test_constants'); const ROOT_PATH = path.resolve(__dirname, '../..'); @@ -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/enable_review_app_button_spec.js b/spec/frontend/environments/enable_review_app_modal_spec.js index 5549a1737fc..7ea49a6e1d0 100644 --- a/spec/frontend/environments/enable_review_app_button_spec.js +++ b/spec/frontend/environments/enable_review_app_modal_spec.js @@ -1,6 +1,6 @@ -import { shallowMount, mount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; -import EnableReviewAppButton from '~/environments/components/enable_review_app_button.vue'; +import EnableReviewAppButton from '~/environments/components/enable_review_app_modal.vue'; describe('Enable Review App Button', () => { let wrapper; @@ -9,19 +9,13 @@ describe('Enable Review App Button', () => { wrapper.destroy(); }); - describe('renders button with text', () => { - beforeEach(() => { - wrapper = mount(EnableReviewAppButton); - }); - - it('renders Enable Review text', () => { - expect(wrapper.text()).toBe('Enable review app'); - }); - }); - describe('renders the modal', () => { beforeEach(() => { - wrapper = shallowMount(EnableReviewAppButton); + wrapper = shallowMount(EnableReviewAppButton, { + propsData: { + modalId: 'fake-id', + }, + }); }); it('renders the copyToClipboard button', () => { diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js index ebdc4923045..d305f5e90bd 100644 --- a/spec/frontend/environments/environment_actions_spec.js +++ b/spec/frontend/environments/environment_actions_spec.js @@ -1,14 +1,22 @@ import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import eventHub from '~/environments/event_hub'; import EnvironmentActions from '~/environments/components/environment_actions.vue'; describe('EnvironmentActions Component', () => { let vm; + const findEnvironmentActionsButton = () => vm.find('[data-testid="environment-actions-button"]'); + beforeEach(() => { - vm = shallowMount(EnvironmentActions, { propsData: { actions: [] } }); + vm = shallowMount(EnvironmentActions, { + propsData: { actions: [] }, + directives: { + GlTooltip: createMockDirective(), + }, + }); }); afterEach(() => { @@ -23,6 +31,11 @@ describe('EnvironmentActions Component', () => { expect(vm.find('.dropdown-new').attributes('aria-label')).toEqual('Deploy to...'); }); + it('should render a tooltip', () => { + const tooltip = getBinding(findEnvironmentActionsButton().element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + }); + describe('is loading', () => { beforeEach(() => { vm.setData({ isLoading: true }); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index fe32bf918dd..bb114e31063 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -1,9 +1,11 @@ import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import EnableReviewAppModal from '~/environments/components/enable_review_app_modal.vue'; import Container from '~/environments/components/container.vue'; import EmptyState from '~/environments/components/empty_state.vue'; import EnvironmentsApp from '~/environments/components/environments_app.vue'; +import axios from '~/lib/utils/axios_utils'; import { environment, folder } from './mock_data'; describe('Environment', () => { @@ -34,12 +36,18 @@ describe('Environment', () => { }); }; - const createWrapper = (shallow = false) => { + const createWrapper = (shallow = false, props = {}) => { const fn = shallow ? shallowMount : mount; - wrapper = fn(EnvironmentsApp, { propsData: mockData }); + wrapper = extendedWrapper(fn(EnvironmentsApp, { propsData: { ...mockData, ...props } })); return axios.waitForAll(); }; + const findEnableReviewAppButton = () => wrapper.findByTestId('enable-review-app'); + const findEnableReviewAppModal = () => wrapper.findAll(EnableReviewAppModal); + const findNewEnvironmentButton = () => wrapper.findByTestId('new-environment'); + const findEnvironmentsTabAvailable = () => wrapper.find('.js-environments-tab-available > a'); + const findEnvironmentsTabStopped = () => wrapper.find('.js-environments-tab-stopped > a'); + beforeEach(() => { mock = new MockAdapter(axios); }); @@ -59,19 +67,6 @@ describe('Environment', () => { it('should render the empty state', () => { expect(wrapper.find(EmptyState).exists()).toBe(true); }); - - describe('when it is possible to enable a review app', () => { - beforeEach(() => { - mockRequest(200, { environments: [], review_app: { can_setup_review_app: true } }); - return createWrapper(); - }); - - it('should render the enable review app button', () => { - expect(wrapper.find('.js-enable-review-app-button').text()).toContain( - 'Enable review app', - ); - }); - }); }); describe('with paginated environments', () => { @@ -86,7 +81,7 @@ describe('Environment', () => { return createWrapper(); }); - it('should render a conatiner table with environments', () => { + it('should render a container table with environments', () => { const containerTable = wrapper.find(Container); expect(containerTable.exists()).toBe(true); @@ -108,9 +103,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); + }); }); }); }); @@ -165,4 +167,65 @@ describe('Environment', () => { expect(wrapper.find('.text-center > a.btn').text()).toContain('Show all'); }); }); + + describe('environment button', () => { + describe('when user can create environment', () => { + beforeEach(() => { + mockRequest(200, { environments: [] }); + return createWrapper(true); + }); + + it('should render', () => { + expect(findNewEnvironmentButton().exists()).toBe(true); + }); + }); + + describe('when user can not create environment', () => { + beforeEach(() => { + mockRequest(200, { environments: [] }); + return createWrapper(true, { ...mockData, canCreateEnvironment: false }); + }); + + it('should not render', () => { + expect(findNewEnvironmentButton().exists()).toBe(false); + }); + }); + }); + + describe('review app modal', () => { + describe('when it is not possible to enable a review app', () => { + beforeEach(() => { + mockRequest(200, { environments: [] }); + return createWrapper(true); + }); + + it('should not render the enable review app button', () => { + expect(findEnableReviewAppButton().exists()).toBe(false); + }); + + it('should not render a review app modal', () => { + const modal = findEnableReviewAppModal(); + expect(modal).toHaveLength(0); + expect(modal.exists()).toBe(false); + }); + }); + + describe('when it is possible to enable a review app', () => { + beforeEach(() => { + mockRequest(200, { environments: [], review_app: { can_setup_review_app: true } }); + return createWrapper(true); + }); + + it('should render the enable review app button', () => { + expect(findEnableReviewAppButton().exists()).toBe(true); + expect(findEnableReviewAppButton().text()).toContain('Enable review app'); + }); + + it('should render only one review app modal', () => { + const modal = findEnableReviewAppModal(); + expect(modal).toHaveLength(1); + expect(modal.at(0).exists()).toBe(true); + }); + }); + }); }); diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index f33c8de0094..f55cb851dde 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -1,10 +1,10 @@ +import { GlPagination } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { removeBreakLine, removeWhitespace } from 'helpers/text_helper'; -import { GlPagination } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import EnvironmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue'; import EnvironmentTable from '~/environments/components/environments_table.vue'; +import EnvironmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue'; +import axios from '~/lib/utils/axios_utils'; import { environmentsList } from '../mock_data'; describe('Environments Folder View', () => { @@ -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); @@ -88,9 +89,9 @@ describe('Environments Folder View', () => { }); it('should render parent folder name', () => { - expect(removeBreakLine(removeWhitespace(wrapper.find('.js-folder-name').text()))).toContain( - 'Environments / review', - ); + expect( + removeBreakLine(removeWhitespace(wrapper.find('[data-testid="folder-name"]').text())), + ).toContain('Environments / review'); }); describe('pagination', () => { 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..0e364c47f8d --- /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', + featureFlagsClientLibrariesHelpPagePath: '/help/path/#flags', + featureFlagsClientExampleHelpPagePath: '/feature-flags#clientexample', + unleashApiUrl: '/api/url', + }; + + const propsData = { + 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( + provide.featureFlagsClientLibrariesHelpPagePath, + ); + }); + + 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..6a394251060 --- /dev/null +++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js @@ -0,0 +1,183 @@ +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 createStore from '~/feature_flags/store/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 = createStore({ + path: '/feature_flags', + endpoint: `${TEST_HOST}/feature_flags.json`, + }); + + const factory = (opts = {}) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(EditFeatureFlag, { + localVue, + store, + provide: { + showUserCallout: true, + userCalloutId, + userCalloutsPath, + 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('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('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({ 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({ 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..917f5f5ccd3 --- /dev/null +++ b/spec/frontend/feature_flags/components/environments_dropdown_spec.js @@ -0,0 +1,147 @@ +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: { + ...props, + }, + provide: { + environmentsEndpoint: `${TEST_HOST}/environments.json'`, + }, + }); + }; + + 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..3c1234fea94 --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_spec.js @@ -0,0 +1,371 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import MockAdapter from 'axios-mock-adapter'; +import { GlAlert, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import Api from '~/api'; +import createStore from '~/feature_flags/store/index'; +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'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Feature flags', () => { + const mockData = { + canUserConfigure: true, + csrfToken: 'testToken', + featureFlagsClientExampleHelpPagePath: '/help/feature-flags#client-example', + featureFlagsClientLibrariesHelpPagePath: '/help/feature-flags#unleash-clients', + featureFlagsHelpPagePath: '/help/feature-flags', + featureFlagsLimit: '200', + featureFlagsLimitExceeded: false, + newFeatureFlagPath: 'feature-flags/new', + newUserListPath: '/user-list/new', + unleashApiUrl: `${TEST_HOST}/api/unleash`, + projectName: 'fakeProjectName', + errorStateSvgPath: '/assets/illustrations/feature_flag.svg', + }; + + const mockState = { + endpoint: `${TEST_HOST}/endpoint.json`, + projectId: '8', + unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F', + }; + + let wrapper; + let mock; + let store; + + const factory = (provide = mockData, fn = shallowMount) => { + store = createStore(mockState); + wrapper = fn(FeatureFlagsComponent, { + localVue, + store, + provide, + 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"]'); + const limitAlert = () => wrapper.find(GlAlert); + + 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('when limit exceeded', () => { + const provideData = { ...mockData, featureFlagsLimitExceeded: true }; + + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(provideData); + setImmediate(done); + }); + + it('makes the new feature flag button do nothing if clicked', () => { + expect(newButton().exists()).toBe(true); + expect(newButton().props('disabled')).toBe(false); + expect(newButton().props('href')).toBe(undefined); + }); + + it('shows a feature flags limit reached alert', () => { + expect(limitAlert().exists()).toBe(true); + expect( + limitAlert() + .find(GlSprintf) + .attributes('message'), + ).toContain('Feature flags limit reached'); + }); + + describe('when the alert is dismissed', () => { + beforeEach(async () => { + await limitAlert().vm.$emit('dismiss'); + }); + + it('hides the alert', async () => { + expect(limitAlert().exists()).toBe(false); + }); + + it('re-shows the alert if the new feature flag button is clicked', async () => { + await newButton().vm.$emit('click'); + + expect(limitAlert().exists()).toBe(true); + }); + }); + }); + + describe('without permissions', () => { + const provideData = { + ...mockData, + canUserConfigure: false, + canUserRotateToken: false, + newFeatureFlagPath: null, + newUserListPath: null, + }; + + beforeEach(done => { + mock + .onGet(`${TEST_HOST}/endpoint.json`, { params: { scope: FEATURE_FLAG_SCOPE, page: '1' } }) + .reply(200, getRequestData, {}); + factory(provideData); + 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(mockState.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 () => { + 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(mockState.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('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(mockState.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..a488662470e --- /dev/null +++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js @@ -0,0 +1,266 @@ +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, + }, + ], + }, + ], +}); + +describe('Feature flag table', () => { + let wrapper; + let props; + + const createWrapper = (propsData, opts = {}) => { + wrapper = shallowMount(FeatureFlagsTable, { + propsData, + provide: { + csrfToken: 'fakeToken', + }, + ...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: { csrfToken: 'fakeToken', 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..33c7eeb54b7 --- /dev/null +++ b/spec/frontend/feature_flags/components/form_spec.js @@ -0,0 +1,493 @@ +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', + }; + + const requiredInjections = { + environmentsEndpoint: '/environments.json', + projectId: '1', + glFeatures: { + featureFlagPermissions: true, + featureFlagsNewVersion: true, + }, + }; + + const factory = (props = {}, provide = {}) => { + wrapper = shallowMount(Form, { + propsData: { ...requiredProps, ...props }, + provide: { + ...requiredInjections, + ...provide, + }, + }); + }; + + 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( + {}, + { + ...requiredInjections, + 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..12dc98fbde8 --- /dev/null +++ b/spec/frontend/feature_flags/components/new_environments_dropdown_spec.js @@ -0,0 +1,105 @@ +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, { + provide: { environmentsEndpoint: 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..dbc6e03d922 --- /dev/null +++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js @@ -0,0 +1,136 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +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 createStore from '~/feature_flags/store/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`; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('New feature flag form', () => { + let wrapper; + + const store = createStore({ + endpoint: `${TEST_HOST}/feature_flags.json`, + path: '/feature_flags', + }); + + const factory = (opts = {}) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = shallowMount(NewFeatureFlag, { + localVue, + store, + provide: { + showUserCallout: true, + userCalloutId, + userCalloutsPath, + environmentsEndpoint: 'environments.json', + projectId: '8', + glFeatures: { + featureFlagsNewVersion: true, + }, + ...opts, + }, + }); + }; + + beforeEach(() => { + factory(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findAlert = () => wrapper.find(GlAlert); + + describe('with error', () => { + it('should render the error', () => { + store.dispatch('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('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('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({ 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({ 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/strategies/default_spec.js b/spec/frontend/feature_flags/components/strategies/default_spec.js new file mode 100644 index 00000000000..1315cd7d735 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/default_spec.js @@ -0,0 +1,10 @@ +import { shallowMount } from '@vue/test-utils'; +import Default from '~/feature_flags/components/strategies/default.vue'; + +describe('~/feature_flags/components/strategies/default.vue', () => { + it('should emit an empty parameter object on mount', () => { + const wrapper = shallowMount(Default); + + expect(wrapper.emitted('change')).toEqual([[{ parameters: {} }]]); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js new file mode 100644 index 00000000000..f3f70a325d0 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js @@ -0,0 +1,116 @@ +import { mount } from '@vue/test-utils'; +import { GlFormInput, GlFormSelect } from '@gitlab/ui'; +import FlexibleRollout from '~/feature_flags/components/strategies/flexible_rollout.vue'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; +import { PERCENT_ROLLOUT_GROUP_ID } from '~/feature_flags/constants'; +import { flexibleRolloutStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: flexibleRolloutStrategy, +}; + +describe('feature_flags/components/strategies/flexible_rollout.vue', () => { + let wrapper; + let percentageFormGroup; + let percentageInput; + let stickinessFormGroup; + let stickinessSelect; + + const factory = (props = {}) => + mount(FlexibleRollout, { propsData: { ...DEFAULT_PROPS, ...props } }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('with valid percentage', () => { + beforeEach(() => { + wrapper = factory(); + + percentageFormGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + percentageInput = percentageFormGroup.find(GlFormInput); + stickinessFormGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-stickiness"]') + .find(ParameterFormGroup); + stickinessSelect = stickinessFormGroup.find(GlFormSelect); + }); + + it('displays the current percentage value', () => { + expect(percentageInput.element.value).toBe(flexibleRolloutStrategy.parameters.rollout); + }); + + it('displays the current stickiness value', () => { + expect(stickinessSelect.element.value).toBe(flexibleRolloutStrategy.parameters.stickiness); + }); + + it('emits a change when the percentage value changes', async () => { + percentageInput.setValue('75'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [ + { + parameters: { + rollout: '75', + groupId: PERCENT_ROLLOUT_GROUP_ID, + stickiness: flexibleRolloutStrategy.parameters.stickiness, + }, + }, + ], + ]); + }); + + it('emits a change when the stickiness value changes', async () => { + stickinessSelect.setValue('USERID'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [ + { + parameters: { + rollout: flexibleRolloutStrategy.parameters.rollout, + groupId: PERCENT_ROLLOUT_GROUP_ID, + stickiness: 'USERID', + }, + }, + ], + ]); + }); + + it('does not show errors', () => { + expect(percentageFormGroup.attributes('state')).toBe('true'); + }); + }); + + describe('with percentage that is out of range', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { rollout: '101' } } }); + }); + + it('shows errors', () => { + const formGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); + + describe('with percentage that is not a whole number', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { rollout: '3.14' } } }); + }); + + it('shows errors', () => { + const formGroup = wrapper + .find('[data-testid="strategy-flexible-rollout-percentage"]') + .find(ParameterFormGroup); + + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js new file mode 100644 index 00000000000..014c6dd98b9 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js @@ -0,0 +1,51 @@ +import { mount } from '@vue/test-utils'; +import { GlFormSelect } from '@gitlab/ui'; +import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue'; +import { userListStrategy, userList } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: userListStrategy, + userLists: [userList], +}; + +describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => { + let wrapper; + + const factory = (props = {}) => + mount(GitlabUserList, { propsData: { ...DEFAULT_PROPS, ...props } }); + + describe('with user lists', () => { + beforeEach(() => { + wrapper = factory(); + }); + + it('should show the input for userListId with the correct value', () => { + const inputWrapper = wrapper.find(GlFormSelect); + expect(inputWrapper.exists()).toBe(true); + expect(inputWrapper.element.value).toBe('2'); + }); + + it('should emit a change event when altering the userListId', () => { + const inputWrapper = wrapper.find(GitlabUserList); + inputWrapper.vm.$emit('change', { + userListId: '3', + }); + expect(wrapper.emitted('change')).toEqual([ + [ + { + userListId: '3', + }, + ], + ]); + }); + }); + describe('without user lists', () => { + beforeEach(() => { + wrapper = factory({ userLists: [] }); + }); + + it('should display a message that there are no user lists', () => { + expect(wrapper.text()).toContain('There are no configured user lists'); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js b/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js new file mode 100644 index 00000000000..a0ffdb1fca0 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/parameter_form_group_spec.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; + +describe('~/feature_flags/strategies/parameter_form_group.vue', () => { + let wrapper; + let formGroup; + let slot; + + beforeEach(() => { + wrapper = mount(ParameterFormGroup, { + propsData: { inputId: 'test-id', label: 'test' }, + attrs: { description: 'test description' }, + scopedSlots: { + default(props) { + return this.$createElement(GlFormInput, { + attrs: { id: props.inputId, 'data-testid': 'slot' }, + }); + }, + }, + }); + + formGroup = wrapper.find(GlFormGroup); + slot = wrapper.find('[data-testid="slot"]'); + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + it('should display the default slot', () => { + expect(slot.exists()).toBe(true); + }); + + it('should bind the input id to the slot', () => { + expect(slot.attributes('id')).toBe('test-id'); + }); + + it('should bind the label-for to the input id', () => { + expect(formGroup.find('[for="test-id"]').exists()).toBe(true); + }); + + it('should bind extra attributes to the form group', () => { + expect(formGroup.attributes('description')).toBe('test description'); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js new file mode 100644 index 00000000000..de0b439f1c5 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js @@ -0,0 +1,78 @@ +import { mount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue'; +import ParameterFormGroup from '~/feature_flags/components/strategies/parameter_form_group.vue'; +import { PERCENT_ROLLOUT_GROUP_ID } from '~/feature_flags/constants'; +import { percentRolloutStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: percentRolloutStrategy, +}; + +describe('~/feature_flags/components/strategies/percent_rollout.vue', () => { + let wrapper; + let input; + let formGroup; + + const factory = (props = {}) => + mount(PercentRollout, { propsData: { ...DEFAULT_PROPS, ...props } }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('with valid percentage', () => { + beforeEach(() => { + wrapper = factory(); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('displays the current value', () => { + expect(input.element.value).toBe(percentRolloutStrategy.parameters.percentage); + }); + + it('emits a change when the value changes', async () => { + input.setValue('75'); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('change')).toEqual([ + [{ parameters: { percentage: '75', groupId: PERCENT_ROLLOUT_GROUP_ID } }], + ]); + }); + + it('does not show errors', () => { + expect(formGroup.attributes('state')).toBe('true'); + }); + }); + + describe('with percentage that is out of range', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { percentage: '101' } } }); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('shows errors', () => { + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); + + describe('with percentage that is not a whole number', () => { + beforeEach(() => { + wrapper = factory({ strategy: { parameters: { percentage: '3.14' } } }); + + input = wrapper.find(GlFormInput); + formGroup = wrapper.find(ParameterFormGroup); + }); + + it('shows errors', () => { + expect(formGroup.attributes('state')).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js b/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js new file mode 100644 index 00000000000..460df6ef2ec --- /dev/null +++ b/spec/frontend/feature_flags/components/strategies/users_with_id_spec.js @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils'; +import { GlFormTextarea } from '@gitlab/ui'; +import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue'; +import { usersWithIdStrategy } from '../../mock_data'; + +const DEFAULT_PROPS = { + strategy: usersWithIdStrategy, +}; + +describe('~/feature_flags/components/users_with_id.vue', () => { + let wrapper; + let textarea; + + const factory = (props = {}) => mount(UsersWithId, { propsData: { ...DEFAULT_PROPS, ...props } }); + + beforeEach(() => { + wrapper = factory(); + textarea = wrapper.find(GlFormTextarea); + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + it('should display the current value of the parameters', () => { + expect(textarea.element.value).toBe(usersWithIdStrategy.parameters.userIds); + }); + + it('should emit a change event when the IDs change', () => { + textarea.setValue('4,5,6'); + + expect(wrapper.emitted('change')).toEqual([[{ parameters: { userIds: '4,5,6' } }]]); + }); +}); diff --git a/spec/frontend/feature_flags/components/strategy_parameters_spec.js b/spec/frontend/feature_flags/components/strategy_parameters_spec.js new file mode 100644 index 00000000000..314fb0f21f4 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategy_parameters_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import { last } from 'lodash'; +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_USER_ID, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, +} from '~/feature_flags/constants'; +import Default from '~/feature_flags/components/strategies/default.vue'; +import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue'; +import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue'; +import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue'; +import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; +import { allUsersStrategy, userList } from '../mock_data'; + +const DEFAULT_PROPS = { + strategy: allUsersStrategy, + userLists: [userList], +}; + +describe('~/feature_flags/components/strategy_parameters.vue', () => { + let wrapper; + + const factory = (props = {}) => + shallowMount(StrategyParameters, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + }); + + afterEach(() => { + if (wrapper?.destroy) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe.each` + name | component + ${ROLLOUT_STRATEGY_ALL_USERS} | ${Default} + ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} | ${PercentRollout} + ${ROLLOUT_STRATEGY_USER_ID} | ${UsersWithId} + ${ROLLOUT_STRATEGY_GITLAB_USER_LIST} | ${GitlabUserList} + `('with $name', ({ name, component }) => { + let strategy; + + beforeEach(() => { + strategy = { name, parameters: {} }; + wrapper = factory({ strategy }); + }); + + it('should show the correct component', () => { + expect(wrapper.contains(component)).toBe(true); + }); + + it('should emit changes from the lower component', () => { + const strategyParameterWrapper = wrapper.find(component); + + strategyParameterWrapper.vm.$emit('change', { parameters: { foo: 'bar' } }); + + expect(last(wrapper.emitted('change'))).toEqual([ + { + name, + parameters: { foo: 'bar' }, + }, + ]); + }); + }); + + describe('pass through props', () => { + it('should pass through any extra props that might be needed', () => { + wrapper = factory({ + strategy: { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + }, + }); + + expect(wrapper.find(GitlabUserList).props('userLists')).toEqual([userList]); + }); + }); +}); 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..7d6700ba184 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategy_spec.js @@ -0,0 +1,264 @@ +import { mount } from '@vue/test-utils'; +import { last } from 'lodash'; +import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui'; +import { + PERCENT_ROLLOUT_GROUP_ID, + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_FLEXIBLE_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 StrategyParameters from '~/feature_flags/components/strategy_parameters.vue'; + +import { userList } from '../mock_data'; + +const provide = { + strategyTypeDocsPagePath: 'link-to-strategy-docs', + environmentsScopeDocsPath: 'link-scope-docs', + environmentsEndpoint: '', +}; + +describe('Feature flags strategy', () => { + let wrapper; + + const findStrategyParameters = () => wrapper.find(StrategyParameters); + const findDocsLinks = () => wrapper.findAll(GlLink); + + const factory = ( + opts = { + propsData: { + strategy: {}, + index: 0, + userLists: [userList], + }, + provide, + }, + ) => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + wrapper = mount(Strategy, opts); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('helper links', () => { + const propsData = { strategy: {}, index: 0, 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 + ${ROLLOUT_STRATEGY_ALL_USERS} + ${ROLLOUT_STRATEGY_PERCENT_ROLLOUT} + ${ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT} + ${ROLLOUT_STRATEGY_USER_ID} + ${ROLLOUT_STRATEGY_GITLAB_USER_LIST} + `('with strategy $name', ({ name }) => { + let propsData; + let strategy; + + beforeEach(() => { + strategy = { name, parameters: {}, scopes: [] }; + propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + return wrapper.vm.$nextTick(); + }); + + it('should set the select to match the strategy name', () => { + expect(wrapper.find(GlFormSelect).element.value).toBe(name); + }); + + it('should emit a change if the parameters component does', () => { + findStrategyParameters().vm.$emit('change', { name, parameters: { test: 'parameters' } }); + expect(last(wrapper.emitted('change'))).toEqual([ + { name, parameters: { test: 'parameters' } }, + ]); + }); + }); + + describe('with the gradualRolloutByUserId strategy', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [{ environmentScope: 'production' }], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('shows an alert asking users to consider using flexibleRollout instead', () => { + expect(wrapper.find(GlAlert).text()).toContain( + 'Consider using the more flexible "Percent rollout" strategy instead.', + ); + }); + }); + + describe('with a strategy', () => { + describe('with a single environment scope defined', () => { + let strategy; + + beforeEach(() => { + strategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [{ environmentScope: 'production' }], + }; + const propsData = { strategy, index: 0 }; + 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(last(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', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [{ environmentScope: '*' }], + }; + const propsData = { strategy, index: 0 }; + factory({ propsData, provide }); + }); + + it('should change the parameters if a different strategy is chosen', () => { + const select = wrapper.find(GlFormSelect); + select.setValue(ROLLOUT_STRATEGY_ALL_USERS); + return wrapper.vm.$nextTick().then(() => { + expect(last(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(last(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', groupId: PERCENT_ROLLOUT_GROUP_ID }, + scopes: [], + }; + const propsData = { strategy, index: 0 }; + 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(last(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..ed06ea059a7 --- /dev/null +++ b/spec/frontend/feature_flags/mock_data.js @@ -0,0 +1,155 @@ +import { + ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + ROLLOUT_STRATEGY_GITLAB_USER_LIST, + ROLLOUT_STRATEGY_USER_ID, +} 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', + }, + }, + ], + }, + { + id: 5, + active: true, + environment_scope: 'development', + can_update: true, + protected: false, + created_at: '2019-01-14T06:41:40.987Z', + updated_at: '2019-01-14T06:41:40.987Z', + strategies: [ + { + name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + parameters: { + rollout: '42', + stickiness: 'DEFAULT', + }, + }, + ], + }, + ], +}; + +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 userListStrategy = { + name: ROLLOUT_STRATEGY_GITLAB_USER_LIST, + parameters: {}, + scopes: [], + userListId: userList.id, +}; + +export const percentRolloutStrategy = { + name: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + parameters: { percentage: '50', groupId: 'default' }, + scopes: [], +}; + +export const flexibleRolloutStrategy = { + name: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + parameters: { rollout: '50', groupId: 'default', stickiness: 'DEFAULT' }, + scopes: [], +}; + +export const usersWithIdStrategy = { + name: ROLLOUT_STRATEGY_USER_ID, + parameters: { userIds: '1,2,3' }, + scopes: [], +}; + +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..9d764799d09 --- /dev/null +++ b/spec/frontend/feature_flags/store/edit/actions_spec.js @@ -0,0 +1,303 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import { + updateFeatureFlag, + requestUpdateFeatureFlag, + receiveUpdateFeatureFlagSuccess, + receiveUpdateFeatureFlagError, + fetchFeatureFlag, + requestFeatureFlag, + receiveFeatureFlagSuccess, + receiveFeatureFlagError, + toggleActive, +} from '~/feature_flags/store/edit/actions'; +import state from '~/feature_flags/store/edit/state'; +import { mapStrategiesToRails, mapFromScopesViewModel } from '~/feature_flags/store/helpers'; +import { + NEW_VERSION_FLAG, + LEGACY_FLAG, + ROLLOUT_STRATEGY_ALL_USERS, +} from '~/feature_flags/constants'; +import * as types from '~/feature_flags/store/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({ endpoint: 'feature_flags.json', path: '/feature_flags' }); + }); + + 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..1d817fb8004 --- /dev/null +++ b/spec/frontend/feature_flags/store/edit/mutations_spec.js @@ -0,0 +1,134 @@ +import state from '~/feature_flags/store/edit/state'; +import mutations from '~/feature_flags/store/edit/mutations'; +import * as types from '~/feature_flags/store/edit/mutation_types'; + +describe('Feature flags Edit Module Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state({ endpoint: 'feature_flags.json', path: '/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..301b1d09fcc --- /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/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..d223bb2c292 --- /dev/null +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -0,0 +1,563 @@ +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, + setFeatureFlagsOptions, + rotateInstanceId, + requestRotateInstanceId, + receiveRotateInstanceIdSuccess, + receiveRotateInstanceIdError, + toggleFeatureFlag, + updateFeatureFlag, + receiveUpdateFeatureFlagSuccess, + receiveUpdateFeatureFlagError, + requestUserLists, + receiveUserListsSuccess, + receiveUserListsError, + fetchUserLists, + deleteUserList, + receiveDeleteUserListError, + clearAlert, +} from '~/feature_flags/store/index/actions'; +import { mapToScopesViewModel } from '~/feature_flags/store/helpers'; +import state from '~/feature_flags/store/index/state'; +import * as types from '~/feature_flags/store/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('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('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..376c7b069fa --- /dev/null +++ b/spec/frontend/feature_flags/store/index/mutations_spec.js @@ -0,0 +1,307 @@ +import state from '~/feature_flags/store/index/state'; +import mutations from '~/feature_flags/store/index/mutations'; +import * as types from '~/feature_flags/store/index/mutation_types'; +import { mapToScopesViewModel } from '~/feature_flags/store/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_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('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..130c5235aa0 --- /dev/null +++ b/spec/frontend/feature_flags/store/new/actions_spec.js @@ -0,0 +1,192 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import { + createFeatureFlag, + requestCreateFeatureFlag, + receiveCreateFeatureFlagSuccess, + receiveCreateFeatureFlagError, +} from '~/feature_flags/store/new/actions'; +import state from '~/feature_flags/store/new/state'; +import * as types from '~/feature_flags/store/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/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({ endpoint: 'feature_flags.json', path: '/feature_flags' }); + }); + + describe('createFeatureFlag', () => { + let mock; + + const actionParams = { + name: 'name', + description: 'description', + active: true, + version: LEGACY_FLAG, + scopes: [ + { + id: 1, + environmentScope: 'environmentScope', + active: true, + canUpdate: true, + protected: true, + shouldBeDestroyed: false, + rolloutStrategy: ROLLOUT_STRATEGY_ALL_USERS, + rolloutPercentage: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, + }, + ], + }; + + beforeEach(() => { + mockedState.endpoint = `${TEST_HOST}/endpoint.json`; + mock = new MockAdapter(axios); + }); + + 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..e8609a6d116 --- /dev/null +++ b/spec/frontend/feature_flags/store/new/mutations_spec.js @@ -0,0 +1,49 @@ +import state from '~/feature_flags/store/new/state'; +import mutations from '~/feature_flags/store/new/mutations'; +import * as types from '~/feature_flags/store/new/mutation_types'; + +describe('Feature flags New Module Mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state({ endpoint: 'feature_flags.json', path: '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/blob.rb b/spec/frontend/fixtures/blob.rb index 712c3bd9b23..a365ee805af 100644 --- a/spec/frontend/fixtures/blob.rb +++ b/spec/frontend/fixtures/blob.rb @@ -33,4 +33,14 @@ RSpec.describe Projects::BlobController, '(JavaScript fixtures)', type: :control expect(response).to be_successful end + + it 'blob/show_readme.html' do + get(:show, params: { + namespace_id: project.namespace, + project_id: project, + id: 'master/README.md' + }) + + expect(response).to be_successful + end end diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb new file mode 100644 index 00000000000..dc282b49be5 --- /dev/null +++ b/spec/frontend/fixtures/releases.rb @@ -0,0 +1,146 @@ +# 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 + + describe GraphQL::Query, type: :request do + include GraphqlHelpers + + all_releases_query_path = 'releases/queries/all_releases.query.graphql' + one_release_query_path = 'releases/queries/one_release.query.graphql' + fragment_paths = ['releases/queries/release.fragment.graphql'] + + before(:all) do + clean_frontend_fixtures('graphql/releases/') + end + + it "graphql/#{all_releases_query_path}.json" do + query = get_graphql_query_as_string(all_releases_query_path, fragment_paths) + + post_graphql(query, current_user: admin, variables: { fullPath: project.full_path }) + + expect_graphql_errors_to_be_empty + end + + it "graphql/#{one_release_query_path}.json" do + query = get_graphql_query_as_string(one_release_query_path, fragment_paths) + + post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag }) + + expect_graphql_errors_to_be_empty + end + end +end diff --git a/spec/frontend/fixtures/snippet.rb b/spec/frontend/fixtures/snippet.rb index 26b088bbd88..2e67a2ecfe3 100644 --- a/spec/frontend/fixtures/snippet.rb +++ b/spec/frontend/fixtures/snippet.rb @@ -17,7 +17,6 @@ RSpec.describe SnippetsController, '(JavaScript fixtures)', type: :controller do end before do - stub_feature_flags(snippets_vue: false) sign_in(admin) allow(Discussion).to receive(:build_discussion_id).and_return(['discussionid:ceterumcenseo']) end diff --git a/spec/frontend/fixtures/static/issue_sidebar_label.html b/spec/frontend/fixtures/static/issue_sidebar_label.html deleted file mode 100644 index ec8fb30f219..00000000000 --- a/spec/frontend/fixtures/static/issue_sidebar_label.html +++ /dev/null @@ -1,26 +0,0 @@ -<div class="block labels"> -<div class="sidebar-collapsed-icon js-sidebar-labels-tooltip"></div> -<div class="title hide-collapsed"> -<a class="edit-link float-right" href="#"> -Edit -</a> -</div> -<div class="selectbox hide-collapsed" style="display: none;"> -<div class="dropdown"> -<button class="dropdown-menu-toggle js-label-select js-multiselect" data-ability-name="issue" data-field-name="issue[label_names][]" data-issue-update="/root/test/issues/2.json" data-labels="/root/test/labels.json" data-project-id="12" data-show-any="true" data-show-no="true" data-toggle="dropdown" type="button"> -<span class="dropdown-toggle-text"> -Label -</span> -<i class="fa fa-chevron-down"></i> -</button> -<div class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable"> -<div class="dropdown-page-one"> -<div class="dropdown-content"></div> -<div class="dropdown-loading"> -<i class="fa fa-spinner fa-spin"></i> -</div> -</div> -</div> -</div> -</div> -</div> diff --git a/spec/frontend/fixtures/static/pipeline_graph.html b/spec/frontend/fixtures/static/pipeline_graph.html index 422372bb7d5..d2c30ff9211 100644 --- a/spec/frontend/fixtures/static/pipeline_graph.html +++ b/spec/frontend/fixtures/static/pipeline_graph.html @@ -10,7 +10,7 @@ Test <div class="curve"></div> <a> <svg></svg> -<div class="ci-status-text"> +<div> stop_review </div> </a> diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 6c40b1ba3a7..8da4320d993 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -1,6 +1,7 @@ /* eslint no-param-reassign: "off" */ import $ from 'jquery'; +import { emojiFixtureMap, initEmojiMock, describeEmojiFields } from 'helpers/emoji'; import '~/lib/utils/jquery_at_who'; import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete'; @@ -119,7 +120,7 @@ describe('GfmAutoComplete', () => { const defaultMatcher = (context, flag, subtext) => gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext); - const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$', '+']; + const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$']; const otherFlags = ['/', ':']; const flags = flagsUseDefaultMatcher.concat(otherFlags); @@ -153,6 +154,7 @@ describe('GfmAutoComplete', () => { 'я', '.', "'", + '+', '-', '_', ]; @@ -416,8 +418,9 @@ describe('GfmAutoComplete', () => { let $textarea; beforeEach(() => { + setFixtures('<textarea></textarea>'); autocomplete = new GfmAutoComplete(dataSources); - $textarea = $('<textarea></textarea>'); + $textarea = $('textarea'); autocomplete.setup($textarea, { labels: true }); }); @@ -488,4 +491,114 @@ describe('GfmAutoComplete', () => { `('$input shows $output.length labels', expectLabels); }); }); + + describe('emoji', () => { + const { atom, heart, star } = emojiFixtureMap; + const assertInserted = ({ input, subject, emoji }) => + expect(subject).toBe(`:${emoji?.name || input}:`); + const assertTemplated = ({ input, subject, emoji, field }) => + expect(subject.replace(/\s+/g, ' ')).toBe( + `<li>${field || input} <gl-emoji data-name="${emoji?.name || input}"></gl-emoji> </li>`, + ); + + let mock; + + beforeEach(async () => { + mock = await initEmojiMock(); + + await new GfmAutoComplete({}).loadEmojiData({ atwho() {}, trigger() {} }, ':'); + if (!GfmAutoComplete.glEmojiTag) throw new Error('emoji not loaded'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe.each` + name | inputFormat | assert + ${'insertTemplateFunction'} | ${name => ({ name })} | ${assertInserted} + ${'templateFunction'} | ${name => name} | ${assertTemplated} + `('Emoji.$name', ({ name, inputFormat, assert }) => { + const execute = (accessor, input, emoji) => + assert({ + input, + emoji, + field: accessor && accessor(emoji), + subject: GfmAutoComplete.Emoji[name](inputFormat(input)), + }); + + describeEmojiFields('for $field', ({ accessor }) => { + it('should work with lowercase', () => { + execute(accessor, accessor(atom), atom); + }); + + it('should work with uppercase', () => { + execute(accessor, accessor(atom).toUpperCase(), atom); + }); + + it('should work with partial value', () => { + execute(accessor, accessor(atom).slice(1), atom); + }); + }); + + it('should work with unicode value', () => { + execute(null, atom.moji, atom); + }); + + it('should pass through unknown value', () => { + execute(null, 'foo bar baz'); + }); + }); + + const expectEmojiOrder = (first, second) => { + const keys = Object.keys(emojiFixtureMap); + const firstIndex = keys.indexOf(first); + const secondIndex = keys.indexOf(second); + expect(firstIndex).toBeGreaterThanOrEqual(0); + expect(secondIndex).toBeGreaterThanOrEqual(0); + expect(firstIndex).toBeLessThan(secondIndex); + }; + + describe('Emoji.insertTemplateFunction', () => { + it('should map ":heart" to :heart: [regression]', () => { + // the bug mapped heart to black_heart because the latter sorted first + expectEmojiOrder('black_heart', 'heart'); + + const item = GfmAutoComplete.Emoji.insertTemplateFunction({ name: 'heart' }); + expect(item).toEqual(`:${heart.name}:`); + }); + + it('should map ":star" to :star: [regression]', () => { + // the bug mapped star to custard because the latter sorted first + expectEmojiOrder('custard', 'star'); + + const item = GfmAutoComplete.Emoji.insertTemplateFunction({ name: 'star' }); + expect(item).toEqual(`:${star.name}:`); + }); + }); + + describe('Emoji.templateFunction', () => { + it('should map ":heart" to ❤ [regression]', () => { + // the bug mapped heart to black_heart because the latter sorted first + expectEmojiOrder('black_heart', 'heart'); + + const item = GfmAutoComplete.Emoji.templateFunction('heart') + .replace(/(<gl-emoji)\s+(data-name)/, '$1 $2') + .replace(/>\s+|\s+</g, s => s.trim()); + expect(item).toEqual( + `<li>${heart.name}<gl-emoji data-name="${heart.name}"></gl-emoji></li>`, + ); + }); + + it('should map ":star" to ⭐ [regression]', () => { + // the bug mapped star to custard because the latter sorted first + expectEmojiOrder('custard', 'star'); + + const item = GfmAutoComplete.Emoji.templateFunction('star') + .replace(/(<gl-emoji)\s+(data-name)/, '$1 $2') + .replace(/>\s+|\s+</g, s => s.trim()); + expect(item).toEqual(`<li>${star.name}<gl-emoji data-name="${star.name}"></gl-emoji></li>`); + }); + }); + }); }); 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/group_settings/components/shared_runners_form_spec.js b/spec/frontend/group_settings/components/shared_runners_form_spec.js new file mode 100644 index 00000000000..9e3ee8a2cb1 --- /dev/null +++ b/spec/frontend/group_settings/components/shared_runners_form_spec.js @@ -0,0 +1,169 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import MockAxiosAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import SharedRunnersForm from '~/group_settings/components/shared_runners_form.vue'; +import { ENABLED, DISABLED, ALLOW_OVERRIDE } from '~/group_settings/constants'; +import axios from '~/lib/utils/axios_utils'; + +const TEST_UPDATE_PATH = '/test/update'; +const DISABLED_PAYLOAD = { shared_runners_setting: DISABLED }; +const ENABLED_PAYLOAD = { shared_runners_setting: ENABLED }; +const OVERRIDE_PAYLOAD = { shared_runners_setting: ALLOW_OVERRIDE }; + +jest.mock('~/flash'); + +describe('group_settings/components/shared_runners_form', () => { + let wrapper; + let mock; + + const createComponent = (props = {}) => { + wrapper = shallowMount(SharedRunnersForm, { + propsData: { + updatePath: TEST_UPDATE_PATH, + sharedRunnersAvailability: ENABLED, + parentSharedRunnersAvailability: null, + ...props, + }, + }); + }; + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findErrorAlert = () => wrapper.find(GlAlert); + const findEnabledToggle = () => wrapper.find('[data-testid="enable-runners-toggle"]'); + const findOverrideToggle = () => wrapper.find('[data-testid="override-runners-toggle"]'); + const changeToggle = toggle => toggle.vm.$emit('change', !toggle.props('value')); + const getRequestPayload = () => JSON.parse(mock.history.put[0].data); + const isLoadingIconVisible = () => findLoadingIcon().exists(); + + beforeEach(() => { + mock = new MockAxiosAdapter(axios); + + mock.onPut(TEST_UPDATE_PATH).reply(200); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + mock.restore(); + }); + + describe('with default', () => { + beforeEach(() => { + createComponent(); + }); + + it('loading icon does not exist', () => { + expect(isLoadingIconVisible()).toBe(false); + }); + + it('enabled toggle exists', () => { + expect(findEnabledToggle().exists()).toBe(true); + }); + + it('override toggle does not exist', () => { + expect(findOverrideToggle().exists()).toBe(false); + }); + }); + + describe('loading icon', () => { + it('shows and hides the loading icon on request', async () => { + createComponent(); + + expect(isLoadingIconVisible()).toBe(false); + + findEnabledToggle().vm.$emit('change', true); + + await wrapper.vm.$nextTick(); + + expect(isLoadingIconVisible()).toBe(true); + + await waitForPromises(); + + expect(isLoadingIconVisible()).toBe(false); + }); + }); + + describe('enable toggle', () => { + beforeEach(() => { + createComponent(); + }); + + it('enabling the toggle sends correct payload', async () => { + findEnabledToggle().vm.$emit('change', true); + + await waitForPromises(); + + expect(getRequestPayload()).toEqual(ENABLED_PAYLOAD); + expect(findOverrideToggle().exists()).toBe(false); + }); + + it('disabling the toggle sends correct payload', async () => { + findEnabledToggle().vm.$emit('change', false); + + await waitForPromises(); + + expect(getRequestPayload()).toEqual(DISABLED_PAYLOAD); + expect(findOverrideToggle().exists()).toBe(true); + }); + }); + + describe('override toggle', () => { + beforeEach(() => { + createComponent({ sharedRunnersAvailability: ALLOW_OVERRIDE }); + }); + + it('enabling the override toggle sends correct payload', async () => { + findOverrideToggle().vm.$emit('change', true); + + await waitForPromises(); + + expect(getRequestPayload()).toEqual(OVERRIDE_PAYLOAD); + }); + + it('disabling the override toggle sends correct payload', async () => { + findOverrideToggle().vm.$emit('change', false); + + await waitForPromises(); + + expect(getRequestPayload()).toEqual(DISABLED_PAYLOAD); + }); + }); + + describe('toggle disabled state', () => { + it(`toggles are not disabled with setting ${DISABLED}`, () => { + createComponent({ sharedRunnersAvailability: DISABLED }); + expect(findEnabledToggle().props('disabled')).toBe(false); + expect(findOverrideToggle().props('disabled')).toBe(false); + }); + + it('toggles are disabled', () => { + createComponent({ + sharedRunnersAvailability: DISABLED, + parentSharedRunnersAvailability: DISABLED, + }); + expect(findEnabledToggle().props('disabled')).toBe(true); + expect(findOverrideToggle().props('disabled')).toBe(true); + }); + }); + + describe.each` + errorObj | message + ${{}} | ${'An error occurred while updating configuration. Refresh the page and try again.'} + ${{ error: 'Undefined error' }} | ${'Undefined error Refresh the page and try again.'} + `(`with error $errorObj`, ({ errorObj, message }) => { + beforeEach(async () => { + mock.onPut(TEST_UPDATE_PATH).reply(500, errorObj); + + createComponent(); + changeToggle(findEnabledToggle()); + + await waitForPromises(); + }); + + it('error should be shown', () => { + expect(findErrorAlert().text()).toBe(message); + }); + }); +}); diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 7eb1c54ddb2..83acbb152b5 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -203,7 +203,7 @@ describe('GroupItemComponent', () => { expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined(); expect(visibilityIconEl).not.toBe(null); - expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip); + expect(visibilityIconEl.title).toBe(vm.visibilityTooltip); expect(visibilityIconEl.querySelectorAll('svg').length).toBeGreaterThan(0); expect(vm.$el.querySelector('.access-type')).toBeDefined(); 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..bca233883af 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('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/components/app_spec.js b/spec/frontend/groups/members/components/app_spec.js new file mode 100644 index 00000000000..de9f30649e9 --- /dev/null +++ b/spec/frontend/groups/members/components/app_spec.js @@ -0,0 +1,89 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { GlAlert } from '@gitlab/ui'; +import App from '~/groups/members/components/app.vue'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { + RECEIVE_MEMBER_ROLE_ERROR, + HIDE_ERROR, +} from '~/vuex_shared/modules/members/mutation_types'; +import mutations from '~/vuex_shared/modules/members/mutations'; + +describe('GroupMembersApp', () => { + const localVue = createLocalVue(); + localVue.use(Vuex); + + let wrapper; + let store; + + const createComponent = (state = {}) => { + store = new Vuex.Store({ + state: { + showError: true, + errorMessage: 'Something went wrong, please try again.', + ...state, + }, + mutations, + }); + + wrapper = shallowMount(App, { + localVue, + store, + }); + }; + + const findAlert = () => wrapper.find(GlAlert); + + beforeEach(() => { + commonUtils.scrollToElement = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + store = null; + }); + + describe('when `showError` is changed to `true`', () => { + it('renders and scrolls to error alert', async () => { + createComponent({ showError: false, errorMessage: '' }); + + store.commit(RECEIVE_MEMBER_ROLE_ERROR); + + await nextTick(); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.text()).toBe( + "An error occurred while updating the member's role, please try again.", + ); + expect(commonUtils.scrollToElement).toHaveBeenCalledWith(alert.element); + }); + }); + + describe('when `showError` is changed to `false`', () => { + it('does not render and scroll to error alert', async () => { + createComponent(); + + store.commit(HIDE_ERROR); + + await nextTick(); + + expect(findAlert().exists()).toBe(false); + expect(commonUtils.scrollToElement).not.toHaveBeenCalled(); + }); + }); + + describe('when alert is dismissed', () => { + it('hides alert', async () => { + createComponent(); + + findAlert().vm.$emit('dismiss'); + + await nextTick(); + + expect(findAlert().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js index 70fce0d60fb..2fb7904bcfe 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); }; @@ -17,14 +17,12 @@ describe('initGroupMembersApp', () => { el = document.createElement('div'); el.setAttribute('data-members', membersJsonString); el.setAttribute('data-group-id', '234'); + el.setAttribute('data-member-path', '/groups/foo-bar/-/group_members/:id'); window.gon = { current_user_id: 123 }; - - document.body.appendChild(el); }); afterEach(() => { - document.body.innerHTML = ''; el = null; wrapper.destroy(); @@ -63,4 +61,22 @@ describe('initGroupMembersApp', () => { expect(vm.$store.state.members).toEqual(membersParsed); }); + + it('sets `tableFields` in Vuex store', () => { + setup(); + + expect(vm.$store.state.tableFields).toEqual(['account']); + }); + + it('sets `requestFormatter` in Vuex store', () => { + setup(); + + expect(vm.$store.state.requestFormatter()).toEqual({}); + }); + + it('sets `memberPath` in Vuex store', () => { + setup(); + + expect(vm.$store.state.memberPath).toBe('/groups/foo-bar/-/group_members/:id'); + }); }); diff --git a/spec/frontend/groups/members/utils_spec.js b/spec/frontend/groups/members/utils_spec.js new file mode 100644 index 00000000000..b0921c7642f --- /dev/null +++ b/spec/frontend/groups/members/utils_spec.js @@ -0,0 +1,51 @@ +import { membersJsonString, membersParsed } from './mock_data'; +import { + parseDataAttributes, + memberRequestFormatter, + groupLinkRequestFormatter, +} from '~/groups/members/utils'; + +describe('group member utils', () => { + describe('parseDataAttributes', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + el.setAttribute('data-members', membersJsonString); + el.setAttribute('data-group-id', '234'); + }); + + afterEach(() => { + el = null; + }); + + it('correctly parses the data attributes', () => { + expect(parseDataAttributes(el)).toEqual({ + members: membersParsed, + sourceId: 234, + }); + }); + }); + + describe('memberRequestFormatter', () => { + it('returns expected format', () => { + expect( + memberRequestFormatter({ + accessLevel: 50, + expires_at: '2020-10-16', + }), + ).toEqual({ group_member: { access_level: 50, expires_at: '2020-10-16' } }); + }); + }); + + describe('groupLinkRequestFormatter', () => { + it('returns expected format', () => { + expect( + groupLinkRequestFormatter({ + accessLevel: 50, + expires_at: '2020-10-16', + }), + ).toEqual({ group_link: { group_access: 50, expires_at: '2020-10-16' } }); + }); + }); +}); diff --git a/spec/frontend/helpers/dom_shims/create_object_url.js b/spec/frontend/helpers/dom_shims/create_object_url.js new file mode 100644 index 00000000000..94d060cab08 --- /dev/null +++ b/spec/frontend/helpers/dom_shims/create_object_url.js @@ -0,0 +1,3 @@ +URL.createObjectURL = function createObjectURL() { + return 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b'; +}; diff --git a/spec/frontend/helpers/dom_shims/index.js b/spec/frontend/helpers/dom_shims/index.js index 2ba5701fc77..9b70cb86b8b 100644 --- a/spec/frontend/helpers/dom_shims/index.js +++ b/spec/frontend/helpers/dom_shims/index.js @@ -1,3 +1,4 @@ +import './create_object_url'; import './element_scroll_into_view'; import './element_scroll_by'; import './element_scroll_to'; diff --git a/spec/frontend/helpers/emoji.js b/spec/frontend/helpers/emoji.js new file mode 100644 index 00000000000..e8a93e21818 --- /dev/null +++ b/spec/frontend/helpers/emoji.js @@ -0,0 +1,88 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { initEmojiMap, EMOJI_VERSION } from '~/emoji'; + +export const emojiFixtureMap = { + atom: { + moji: '⚛', + description: 'atom symbol', + unicodeVersion: '4.1', + aliases: ['atom_symbol'], + }, + bomb: { + moji: '💣', + unicodeVersion: '6.0', + description: 'bomb', + }, + construction_worker_tone5: { + moji: '👷🏿', + unicodeVersion: '8.0', + description: 'construction worker tone 5', + }, + five: { + moji: '5️⃣', + unicodeVersion: '3.0', + description: 'keycap digit five', + }, + grey_question: { + moji: '❔', + unicodeVersion: '6.0', + description: 'white question mark ornament', + }, + + // used for regression tests + // black_heart MUST come before heart + // custard MUST come before star + black_heart: { + moji: '🖤', + unicodeVersion: '1.1', + description: 'black heart', + }, + heart: { + moji: '❤', + unicodeVersion: '1.1', + description: 'heavy black heart', + }, + custard: { + moji: '🍮', + unicodeVersion: '6.0', + description: 'custard', + }, + star: { + moji: '⭐', + unicodeVersion: '5.1', + description: 'white medium star', + }, +}; + +Object.keys(emojiFixtureMap).forEach(k => { + emojiFixtureMap[k].name = k; + if (!emojiFixtureMap[k].aliases) { + emojiFixtureMap[k].aliases = []; + } +}); + +export async function initEmojiMock() { + 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 }]; + }), + ); + + const mock = new MockAdapter(axios); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, JSON.stringify(emojiData)); + + await initEmojiMap(); + + return mock; +} + +export function describeEmojiFields(label, tests) { + describe.each` + field | accessor + ${'name'} | ${e => e.name} + ${'alias'} | ${e => e.aliases[0]} + ${'description'} | ${e => e.description} + `(label, tests); +} 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/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js index 7b83f0aefca..1a88e80344e 100644 --- a/spec/frontend/helpers/startup_css_helper_spec.js +++ b/spec/frontend/helpers/startup_css_helper_spec.js @@ -1,4 +1,4 @@ -import { waitForCSSLoaded } from '../../../app/assets/javascripts/helpers/startup_css_helper'; +import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; describe('waitForCSSLoaded', () => { let mockedCallback; 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/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index 56667d6b03d..abd7e3bb8fc 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -7,7 +7,12 @@ import { createStore } from '~/ide/stores'; import consts from '~/ide/stores/modules/commit/constants'; import CommitForm from '~/ide/components/commit_sidebar/form.vue'; import { leftSidebarViews } from '~/ide/constants'; -import { createCodeownersCommitError, createUnexpectedCommitError } from '~/ide/lib/errors'; +import { + createCodeownersCommitError, + createUnexpectedCommitError, + createBranchChangedCommitError, + branchAlreadyExistsCommitError, +} from '~/ide/lib/errors'; describe('IDE commit form', () => { const Component = Vue.extend(CommitForm); @@ -290,20 +295,30 @@ describe('IDE commit form', () => { jest.spyOn(vm.$store, 'dispatch').mockReturnValue(Promise.resolve()); }); - it('updates commit action and commits', async () => { - store.state.commit.commitError = createCodeownersCommitError('test message'); + const commitActions = [ + ['commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH], + ['commit/commitChanges'], + ]; - await vm.$nextTick(); + it.each` + commitError | expectedActions + ${createCodeownersCommitError} | ${commitActions} + ${createBranchChangedCommitError} | ${commitActions} + ${branchAlreadyExistsCommitError} | ${[['commit/addSuffixToBranchName'], ...commitActions]} + `( + 'updates commit action and commits for error: $commitError', + async ({ commitError, expectedActions }) => { + store.state.commit.commitError = commitError('test message'); - getByText(document.body, 'Create new branch').click(); + await vm.$nextTick(); - await waitForPromises(); + getByText(document.body, 'Create new branch').click(); - expect(vm.$store.dispatch.mock.calls).toEqual([ - ['commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH], - ['commit/commitChanges', undefined], - ]); - }); + await waitForPromises(); + + expect(vm.$store.dispatch.mock.calls).toEqual(expectedActions); + }, + ); }); }); 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/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index a65d9e6f78b..faa70982fac 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -16,8 +16,6 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` <strong class="gl-ml-3 text-truncate" data-container="body" - data-original-title="" - title="" > build diff --git a/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js index 42526590ebb..57174181a3d 100644 --- a/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js +++ b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js @@ -31,7 +31,7 @@ describe('IDE job log scroll button', () => { }); it('returns proper title', () => { - expect(wrapper.attributes('data-original-title')).toBe(title); + expect(wrapper.attributes('title')).toBe(title); }); }); diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index ae497106f73..3f3784dbb3a 100644 --- a/spec/frontend/ide/components/new_dropdown/upload_spec.js +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -59,14 +59,11 @@ describe('new dropdown upload', () => { result: 'base64,cGxhaW4gdGV4dA==', }; const binaryTarget = { - result: 'base64,w4I=', + result: 'base64,8PDw8A==', // ðððð }; - const textFile = new File(['plain text'], 'textFile'); - const binaryFile = { - name: 'binaryFile', - type: 'image/png', - }; + const textFile = new File(['plain text'], 'textFile'); + const binaryFile = new File(['😺'], 'binaryFile'); beforeEach(() => { jest.spyOn(FileReader.prototype, 'readAsText'); @@ -92,16 +89,16 @@ describe('new dropdown upload', () => { .catch(done.fail); }); - it('splits content on base64 if binary', () => { + it('creates a blob URL for the content if binary', () => { vm.createFile(binaryTarget, binaryFile); - expect(FileReader.prototype.readAsText).not.toHaveBeenCalledWith(textFile); + expect(FileReader.prototype.readAsText).not.toHaveBeenCalled(); expect(vm.$emit).toHaveBeenCalledWith('create', { name: binaryFile.name, type: 'blob', - content: binaryTarget.result.split('base64,')[1], - rawPath: binaryTarget.result, + content: 'ðððð', + rawPath: 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b', }); }); }); 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/ide/lib/errors_spec.js b/spec/frontend/ide/lib/errors_spec.js index 8c3fb378302..733d5a5da3c 100644 --- a/spec/frontend/ide/lib/errors_spec.js +++ b/spec/frontend/ide/lib/errors_spec.js @@ -2,6 +2,7 @@ import { createUnexpectedCommitError, createCodeownersCommitError, createBranchChangedCommitError, + branchAlreadyExistsCommitError, parseCommitError, } from '~/ide/lib/errors'; @@ -21,35 +22,22 @@ describe('~/ide/lib/errors', () => { }, }); - describe('createCodeownersCommitError', () => { - it('uses given message', () => { - expect(createCodeownersCommitError(TEST_MESSAGE)).toEqual({ - title: 'CODEOWNERS rule violation', - messageHTML: TEST_MESSAGE, - canCreateBranch: true, - }); - }); + const NEW_BRANCH_SUFFIX = `<br/><br/>Would you like to create a new branch?`; + const AUTOGENERATE_SUFFIX = `<br/><br/>Would you like to try auto-generating a branch name?`; - it('escapes special chars', () => { - expect(createCodeownersCommitError(TEST_SPECIAL)).toEqual({ - title: 'CODEOWNERS rule violation', - messageHTML: TEST_SPECIAL_ESCAPED, - canCreateBranch: true, - }); - }); - }); - - describe('createBranchChangedCommitError', () => { - it.each` - message | expectedMessage - ${TEST_MESSAGE} | ${`${TEST_MESSAGE}<br/><br/>Would you like to create a new branch?`} - ${TEST_SPECIAL} | ${`${TEST_SPECIAL_ESCAPED}<br/><br/>Would you like to create a new branch?`} - `('uses given message="$message"', ({ message, expectedMessage }) => { - expect(createBranchChangedCommitError(message)).toEqual({ - title: 'Branch changed', - messageHTML: expectedMessage, - canCreateBranch: true, - }); + it.each` + fn | title | message | messageHTML + ${createCodeownersCommitError} | ${'CODEOWNERS rule violation'} | ${TEST_MESSAGE} | ${TEST_MESSAGE} + ${createCodeownersCommitError} | ${'CODEOWNERS rule violation'} | ${TEST_SPECIAL} | ${TEST_SPECIAL_ESCAPED} + ${branchAlreadyExistsCommitError} | ${'Branch already exists'} | ${TEST_MESSAGE} | ${`${TEST_MESSAGE}${AUTOGENERATE_SUFFIX}`} + ${branchAlreadyExistsCommitError} | ${'Branch already exists'} | ${TEST_SPECIAL} | ${`${TEST_SPECIAL_ESCAPED}${AUTOGENERATE_SUFFIX}`} + ${createBranchChangedCommitError} | ${'Branch changed'} | ${TEST_MESSAGE} | ${`${TEST_MESSAGE}${NEW_BRANCH_SUFFIX}`} + ${createBranchChangedCommitError} | ${'Branch changed'} | ${TEST_SPECIAL} | ${`${TEST_SPECIAL_ESCAPED}${NEW_BRANCH_SUFFIX}`} + `('$fn escapes and uses given message="$message"', ({ fn, title, message, messageHTML }) => { + expect(fn(message)).toEqual({ + title, + messageHTML, + primaryAction: { text: 'Create new branch', callback: expect.any(Function) }, }); }); @@ -60,7 +48,7 @@ describe('~/ide/lib/errors', () => { ${{}} | ${createUnexpectedCommitError()} ${{ response: {} }} | ${createUnexpectedCommitError()} ${{ response: { data: {} } }} | ${createUnexpectedCommitError()} - ${createResponseError('test')} | ${createUnexpectedCommitError()} + ${createResponseError(TEST_MESSAGE)} | ${createUnexpectedCommitError(TEST_MESSAGE)} ${createResponseError(CODEOWNERS_MESSAGE)} | ${createCodeownersCommitError(CODEOWNERS_MESSAGE)} ${createResponseError(CHANGED_MESSAGE)} | ${createBranchChangedCommitError(CHANGED_MESSAGE)} `('parses message into error object with "$message"', ({ message, expectation }) => { diff --git a/spec/frontend/ide/lib/languages/hcl_spec.js b/spec/frontend/ide/lib/languages/hcl_spec.js new file mode 100644 index 00000000000..a39673a3225 --- /dev/null +++ b/spec/frontend/ide/lib/languages/hcl_spec.js @@ -0,0 +1,290 @@ +import { editor } from 'monaco-editor'; +import { registerLanguages } from '~/ide/utils'; +import hcl from '~/ide/lib/languages/hcl'; + +describe('tokenization for .tf files', () => { + beforeEach(() => { + registerLanguages(hcl); + }); + + it.each([ + ['// Foo', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]], + ['/* Bar */', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]], + ['/*', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]], + [ + 'foo = "bar"', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'string.hcl' }, + ], + ], + ], + [ + 'variable "foo" {', + [ + [ + { language: 'hcl', offset: 0, type: 'type.hcl' }, + { language: 'hcl', offset: 8, type: '' }, + { language: 'hcl', offset: 9, type: 'string.hcl' }, + { language: 'hcl', offset: 14, type: '' }, + { language: 'hcl', offset: 15, type: 'delimiter.curly.hcl' }, + ], + ], + ], + [ + // eslint-disable-next-line no-template-curly-in-string + ' api_key = "${var.foo}"', + [ + [ + { language: 'hcl', offset: 0, type: '' }, + { language: 'hcl', offset: 2, type: 'variable.hcl' }, + { language: 'hcl', offset: 9, type: '' }, + { language: 'hcl', offset: 10, type: 'operator.hcl' }, + { language: 'hcl', offset: 11, type: '' }, + { language: 'hcl', offset: 12, type: 'string.hcl' }, + { language: 'hcl', offset: 13, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 15, type: 'keyword.var.hcl' }, + { language: 'hcl', offset: 18, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 19, type: 'variable.hcl' }, + { language: 'hcl', offset: 22, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 23, type: 'string.hcl' }, + ], + ], + ], + [ + 'resource "aws_security_group" "firewall" {', + [ + [ + { language: 'hcl', offset: 0, type: 'type.hcl' }, + { language: 'hcl', offset: 8, type: '' }, + { language: 'hcl', offset: 9, type: 'string.hcl' }, + { language: 'hcl', offset: 29, type: '' }, + { language: 'hcl', offset: 30, type: 'string.hcl' }, + { language: 'hcl', offset: 40, type: '' }, + { language: 'hcl', offset: 41, type: 'delimiter.curly.hcl' }, + ], + ], + ], + [ + ' network_interface {', + [ + [ + { language: 'hcl', offset: 0, type: '' }, + { language: 'hcl', offset: 2, type: 'identifier.hcl' }, + { language: 'hcl', offset: 20, type: 'delimiter.curly.hcl' }, + ], + ], + ], + [ + 'foo = [1, 2, "foo"]', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'delimiter.square.hcl' }, + { language: 'hcl', offset: 7, type: 'number.hcl' }, + { language: 'hcl', offset: 8, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 9, type: '' }, + { language: 'hcl', offset: 10, type: 'number.hcl' }, + { language: 'hcl', offset: 11, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 12, type: '' }, + { language: 'hcl', offset: 13, type: 'string.hcl' }, + { language: 'hcl', offset: 18, type: 'delimiter.square.hcl' }, + ], + ], + ], + [ + 'resource "foo" "bar" {}', + [ + [ + { language: 'hcl', offset: 0, type: 'type.hcl' }, + { language: 'hcl', offset: 8, type: '' }, + { language: 'hcl', offset: 9, type: 'string.hcl' }, + { language: 'hcl', offset: 14, type: '' }, + { language: 'hcl', offset: 15, type: 'string.hcl' }, + { language: 'hcl', offset: 20, type: '' }, + { language: 'hcl', offset: 21, type: 'delimiter.curly.hcl' }, + ], + ], + ], + [ + 'foo = "bar"', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'string.hcl' }, + ], + ], + ], + [ + 'bar = 7', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'number.hcl' }, + ], + ], + ], + [ + 'baz = [1,2,3]', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'delimiter.square.hcl' }, + { language: 'hcl', offset: 7, type: 'number.hcl' }, + { language: 'hcl', offset: 8, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 9, type: 'number.hcl' }, + { language: 'hcl', offset: 10, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 11, type: 'number.hcl' }, + { language: 'hcl', offset: 12, type: 'delimiter.square.hcl' }, + ], + ], + ], + [ + 'foo = -12', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'operator.hcl' }, + { language: 'hcl', offset: 7, type: 'number.hcl' }, + ], + ], + ], + [ + 'bar = 3.14159', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'foo = true', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'keyword.true.hcl' }, + ], + ], + ], + [ + 'foo = false', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'keyword.false.hcl' }, + ], + ], + ], + [ + // eslint-disable-next-line no-template-curly-in-string + 'bar = "${file("bing/bong.txt")}"', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'operator.hcl' }, + { language: 'hcl', offset: 5, type: '' }, + { language: 'hcl', offset: 6, type: 'string.hcl' }, + { language: 'hcl', offset: 7, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 9, type: 'type.hcl' }, + { language: 'hcl', offset: 13, type: 'delimiter.parenthesis.hcl' }, + { language: 'hcl', offset: 14, type: 'string.hcl' }, + { language: 'hcl', offset: 29, type: 'delimiter.parenthesis.hcl' }, + { language: 'hcl', offset: 30, type: 'delimiter.hcl' }, + { language: 'hcl', offset: 31, type: 'string.hcl' }, + ], + ], + ], + [ + 'a = 1e-10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'b = 1e+10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'c = 1e10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'd = 1.2e-10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + [ + 'e = 1.2e+10', + [ + [ + { language: 'hcl', offset: 0, type: 'variable.hcl' }, + { language: 'hcl', offset: 1, type: '' }, + { language: 'hcl', offset: 2, type: 'operator.hcl' }, + { language: 'hcl', offset: 3, type: '' }, + { language: 'hcl', offset: 4, type: 'number.float.hcl' }, + ], + ], + ], + ])('%s', (string, tokens) => { + expect(editor.tokenize(string, 'hcl')).toEqual(tokens); + }); +}); diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index 974c0715c06..8f7fcc25cf0 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -291,6 +291,20 @@ describe('IDE store file actions', () => { expect(store.state.openFiles[0].name).toBe(localFile.name); }); }); + + it('does not toggle loading if toggleLoading=false', () => { + expect(localFile.loading).toBe(false); + + return store + .dispatch('getFileData', { + path: localFile.path, + makeFileActive: false, + toggleLoading: false, + }) + .then(() => { + expect(localFile.loading).toBe(true); + }); + }); }); describe('Re-named success', () => { diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js index e24f08fa802..5ae87f5f9cd 100644 --- a/spec/frontend/ide/stores/getters_spec.js +++ b/spec/frontend/ide/stores/getters_spec.js @@ -449,16 +449,16 @@ describe('IDE store getters', () => { describe('getAvailableFileName', () => { it.each` path | newPath - ${'foo'} | ${'foo_1'} + ${'foo'} | ${'foo-1'} ${'foo__93.png'} | ${'foo__94.png'} - ${'foo/bar.png'} | ${'foo/bar_1.png'} + ${'foo/bar.png'} | ${'foo/bar-1.png'} ${'foo/bar--34.png'} | ${'foo/bar--35.png'} ${'foo/bar 2.png'} | ${'foo/bar 3.png'} ${'foo/bar-621.png'} | ${'foo/bar-622.png'} - ${'jquery.min.js'} | ${'jquery_1.min.js'} + ${'jquery.min.js'} | ${'jquery-1.min.js'} ${'my_spec_22.js.snap'} | ${'my_spec_23.js.snap'} - ${'subtitles5.mp4.srt'} | ${'subtitles_6.mp4.srt'} - ${'sample_file.mp3'} | ${'sample_file_1.mp3'} + ${'subtitles5.mp4.srt'} | ${'subtitles-6.mp4.srt'} + ${'sample-file.mp3'} | ${'sample-file-1.mp3'} ${'Screenshot 2020-05-26 at 10.53.08 PM.png'} | ${'Screenshot 2020-05-26 at 11.53.08 PM.png'} `('suffixes the path with a number if the path already exists', ({ path, newPath }) => { localState.entries[path] = file(); diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js index babc50e54f1..cfe2bddf76c 100644 --- a/spec/frontend/ide/stores/modules/commit/actions_spec.js +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -76,59 +76,38 @@ describe('IDE commit module actions', () => { .then(done) .catch(done.fail); }); + }); - it('sets shouldCreateMR to true if "Create new MR" option is visible', done => { - Object.assign(store.state, { - shouldHideNewMrOption: false, - }); + describe('updateBranchName', () => { + let originalGon; - testAction( - actions.updateCommitAction, - {}, - store.state, - [ - { - type: mutationTypes.UPDATE_COMMIT_ACTION, - payload: { commitAction: expect.anything() }, - }, - { type: mutationTypes.TOGGLE_SHOULD_CREATE_MR, payload: true }, - ], - [], - done, - ); + beforeEach(() => { + originalGon = window.gon; + window.gon = { current_username: 'johndoe' }; + + store.state.currentBranchId = 'master'; }); - it('sets shouldCreateMR to false if "Create new MR" option is hidden', done => { - Object.assign(store.state, { - shouldHideNewMrOption: true, - }); + afterEach(() => { + window.gon = originalGon; + }); - testAction( - actions.updateCommitAction, - {}, - store.state, - [ - { - type: mutationTypes.UPDATE_COMMIT_ACTION, - payload: { commitAction: expect.anything() }, - }, - { type: mutationTypes.TOGGLE_SHOULD_CREATE_MR, payload: false }, - ], - [], - done, - ); + it('updates store with new branch name', async () => { + await store.dispatch('commit/updateBranchName', 'branch-name'); + + expect(store.state.commit.newBranchName).toBe('branch-name'); }); }); - describe('updateBranchName', () => { - it('updates store with new branch name', done => { - store - .dispatch('commit/updateBranchName', 'branch-name') - .then(() => { - expect(store.state.commit.newBranchName).toBe('branch-name'); - }) - .then(done) - .catch(done.fail); + describe('addSuffixToBranchName', () => { + it('adds suffix to branchName', async () => { + jest.spyOn(Math, 'random').mockReturnValue(0.391352525); + + store.state.commit.newBranchName = 'branch-name'; + + await store.dispatch('commit/addSuffixToBranchName'); + + expect(store.state.commit.newBranchName).toBe('branch-name-39135'); }); }); @@ -318,13 +297,16 @@ describe('IDE commit module actions', () => { currentBranchId: 'master', projects: { abcproject: { + default_branch: 'master', web_url: 'webUrl', branches: { master: { + name: 'master', workingReference: '1', commit: { id: TEST_COMMIT_SHA, }, + can_push: true, }, }, userPermissions: { @@ -499,6 +481,16 @@ describe('IDE commit module actions', () => { .catch(done.fail); }); + it('does not redirect to merge request page if shouldCreateMR is checked, but branch is the default branch', async () => { + jest.spyOn(eventHub, '$on').mockImplementation(); + + store.state.commit.commitAction = consts.COMMIT_TO_CURRENT_BRANCH; + store.state.commit.shouldCreateMR = true; + + await store.dispatch('commit/commitChanges'); + expect(visitUrl).not.toHaveBeenCalled(); + }); + it('resets changed files before redirecting', () => { jest.spyOn(eventHub, '$on').mockImplementation(); diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js index b53e40be980..d303de6e9ef 100644 --- a/spec/frontend/ide/stores/mutations/file_spec.js +++ b/spec/frontend/ide/stores/mutations/file_spec.js @@ -39,20 +39,34 @@ describe('IDE store file mutations', () => { }); describe('TOGGLE_FILE_OPEN', () => { - beforeEach(() => { + it('adds into opened files', () => { mutations.TOGGLE_FILE_OPEN(localState, localFile.path); - }); - it('adds into opened files', () => { expect(localFile.opened).toBeTruthy(); expect(localState.openFiles.length).toBe(1); }); - it('removes from opened files', () => { + describe('if already open', () => { + it('removes from opened files', () => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + + expect(localFile.opened).toBeFalsy(); + expect(localState.openFiles.length).toBe(0); + }); + }); + + it.each` + entry | loading + ${{ opened: false }} | ${true} + ${{ opened: false, tempFile: true }} | ${false} + ${{ opened: true }} | ${false} + `('for state: $entry, sets loading=$loading', ({ entry, loading }) => { + Object.assign(localFile, entry); + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); - expect(localFile.opened).toBeFalsy(); - expect(localState.openFiles.length).toBe(0); + expect(localFile.loading).toBe(loading); }); }); diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js index d1eb4304c79..b185013050e 100644 --- a/spec/frontend/ide/stores/utils_spec.js +++ b/spec/frontend/ide/stores/utils_spec.js @@ -46,7 +46,7 @@ describe('Multi-file store utils', () => { path: 'added', tempFile: true, content: 'new file content', - rawPath: 'data:image/png;base64,abc', + rawPath: 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b', lastCommitSha: '123456789', }, { ...file('deletedFile'), path: 'deletedFile', deleted: true }, @@ -77,7 +77,8 @@ describe('Multi-file store utils', () => { { action: commitActionTypes.create, file_path: 'added', - content: 'new file content', + // atob("new file content") + content: 'bmV3IGZpbGUgY29udGVudA==', encoding: 'base64', last_commit_id: '123456789', previous_path: undefined, @@ -117,7 +118,7 @@ describe('Multi-file store utils', () => { path: 'added', tempFile: true, content: 'new file content', - rawPath: 'data:image/png;base64,abc', + rawPath: 'blob:https://gitlab.com/048c7ac1-98de-4a37-ab1b-0206d0ea7e1b', lastCommitSha: '123456789', }, ], @@ -148,7 +149,8 @@ describe('Multi-file store utils', () => { { action: commitActionTypes.create, file_path: 'added', - content: 'new file content', + // atob("new file content") + content: 'bmV3IGZpbGUgY29udGVudA==', encoding: 'base64', last_commit_id: '123456789', previous_path: undefined, diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index 97dc8217ecc..6cd2128d356 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -9,6 +9,7 @@ import { getPathParents, getPathParent, readFileAsDataURL, + addNumericSuffix, } from '~/ide/utils'; describe('WebIDE utils', () => { @@ -291,4 +292,43 @@ describe('WebIDE utils', () => { }); }); }); + + /* + * hello-2425 -> hello-2425 + * hello.md -> hello-1.md + * hello_2.md -> hello_3.md + * hello_ -> hello_1 + * master-patch-22432 -> master-patch-22433 + * patch_332 -> patch_333 + */ + + describe('addNumericSuffix', () => { + it.each` + input | output + ${'hello'} | ${'hello-1'} + ${'hello2'} | ${'hello-3'} + ${'hello.md'} | ${'hello-1.md'} + ${'hello_2.md'} | ${'hello_3.md'} + ${'hello_'} | ${'hello_1'} + ${'master-patch-22432'} | ${'master-patch-22433'} + ${'patch_332'} | ${'patch_333'} + `('adds a numeric suffix to a given filename/branch name: $input', ({ input, output }) => { + expect(addNumericSuffix(input)).toBe(output); + }); + + it.each` + input | output + ${'hello'} | ${'hello-39135'} + ${'hello2'} | ${'hello-39135'} + ${'hello.md'} | ${'hello-39135.md'} + ${'hello_2.md'} | ${'hello_39135.md'} + ${'hello_'} | ${'hello_39135'} + ${'master-patch-22432'} | ${'master-patch-39135'} + ${'patch_332'} | ${'patch_39135'} + `('adds a random suffix if randomize=true is passed for name: $input', ({ input, output }) => { + jest.spyOn(Math, 'random').mockReturnValue(0.391352525); + + expect(addNumericSuffix(input, true)).toBe(output); + }); + }); }); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 307806e0a8a..709f66bb352 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -1,28 +1,28 @@ import { mount } from '@vue/test-utils'; -import { - GlAlert, - GlLoadingIcon, - GlTable, - GlAvatar, - GlPagination, - GlSearchBoxByType, - GlTab, - GlTabs, - GlBadge, - GlEmptyState, -} from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTable, GlAvatar, GlEmptyState } from '@gitlab/ui'; +import Tracking from '~/tracking'; 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 { + I18N, + TH_CREATED_AT_TEST_ID, + TH_SEVERITY_TEST_ID, + TH_PUBLISHED_TEST_ID, + trackIncidentCreateNewOptions, + trackIncidentListViewsOptions, +} from '~/incidents/constants'; import mockIncidents from '../mocks/incidents.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(), })); +jest.mock('~/tracking'); describe('Incidents List', () => { let wrapper; @@ -41,23 +41,22 @@ 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 findAssingees = () => wrapper.findAll('[data-testid="incident-assignees"]'); + const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]'); + const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]'); const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']"); - const findPagination = () => wrapper.find(GlPagination); - const findStatusFilterTabs = () => wrapper.findAll(GlTab); - const findStatusFilterBadge = () => wrapper.findAll(GlBadge); - const findStatusTabs = () => wrapper.find(GlTabs); const findEmptyState = () => wrapper.find(GlEmptyState); const findSeverity = () => wrapper.findAll(SeverityToken); + const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']"); - function mountComponent({ data = { incidents: [], incidentsCount: {} }, loading = false }) { + function mountComponent({ data = {}, loading = false, provide = {} } = {}) { wrapper = mount(IncidentsList, { data() { - return data; + return { + incidents: [], + incidentsCount: {}, + ...data, + }; }, mocks: { $apollo: { @@ -73,14 +72,20 @@ describe('Incidents List', () => { newIssuePath, incidentTemplateName, incidentType, - issuePath: '/project/isssues', + issuePath: '/project/issues', publishedAvailable: true, emptyListSvgPath, + textQuery: '', + authorUsernameQuery: '', + assigneeUsernameQuery: '', + slaFeatureAvailable: true, + ...provide, }, stubs: { GlButton: true, GlAvatar: true, GlEmptyState: true, + ServiceLevelAgreementCell: true, }, }); } @@ -153,14 +158,14 @@ describe('Incidents List', () => { describe('Assignees', () => { it('shows Unassigned when there are no assignees', () => { expect( - findAssingees() + findAssignees() .at(0) .text(), ).toBe(I18N.unassigned); }); it('renders an avatar component when there is an assignee', () => { - const avatar = findAssingees() + const avatar = findAssignees() .at(1) .find(GlAvatar); const { src, label } = avatar.attributes(); @@ -171,13 +176,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 +186,44 @@ describe('Incidents List', () => { it('renders severity per row', () => { expect(findSeverity().length).toBe(mockIncidents.length); }); + + it('contains a link to the incident details page', async () => { + findTableRows() + .at(0) + .trigger('click'); + expect(visitUrl).toHaveBeenCalledWith( + joinPaths(`/project/issues/incident`, mockIncidents[0].iid), + ); + }); + + describe('Incident SLA field', () => { + it('displays the column when the feature is available', () => { + mountComponent({ + data: { incidents: { list: mockIncidents } }, + provide: { slaFeatureAvailable: true }, + }); + + expect(findIncidentSlaHeader().text()).toContain('Time to SLA'); + }); + + it('does not display the column when the feature is not available', () => { + mountComponent({ + data: { incidents: { list: mockIncidents } }, + provide: { slaFeatureAvailable: false }, + }); + + expect(findIncidentSlaHeader().exists()).toBe(false); + }); + + it('renders an SLA for each incident', () => { + mountComponent({ + data: { incidents: { list: mockIncidents } }, + provide: { slaFeatureAvailable: true }, + }); + + expect(findIncidentSla().length).toBe(mockIncidents.length); + }); + }); }); describe('Create Incident', () => { @@ -198,7 +234,7 @@ describe('Incidents List', () => { }); }); - it('shows the button linking to new incidents page with prefilled incident template when clicked', () => { + it('shows the button linking to new incidents page with pre-filled incident template when clicked', () => { expect(findCreateIncidentBtn().exists()).toBe(true); findCreateIncidentBtn().trigger('click'); expect(mergeUrlParams).toHaveBeenCalledWith( @@ -207,11 +243,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", () => { @@ -221,175 +256,62 @@ describe('Incidents List', () => { }); expect(findCreateIncidentBtn().exists()).toBe(false); }); + + it('should track create new incident button', async () => { + findCreateIncidentBtn().vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(Tracking.event).toHaveBeenCalled(); + }); }); - describe('Pagination', () => { + describe('sorting the incident list by column', () => { beforeEach(() => { mountComponent({ - data: { - incidents: { - list: mockIncidents, - pageInfo: { hasNextPage: true, hasPreviousPage: true }, - }, - incidentsCount, - errored: false, - }, + data: { incidents: { list: mockIncidents }, incidentsCount }, loading: false, }); }); - it('should render pagination', () => { - expect(wrapper.find(GlPagination).exists()).toBe(true); - }); - - describe('prevPage', () => { - it('returns prevPage button', () => { - findPagination().vm.$emit('input', 3); - - return wrapper.vm.$nextTick(() => { - expect( - findPagination() - .findAll('.page-item') - .at(0) - .text(), - ).toBe('Prev'); - }); - }); - - it('returns prevPage number', () => { - findPagination().vm.$emit('input', 3); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.prevPage).toBe(2); - }); - }); - - it('returns 0 when it is the first page', () => { - findPagination().vm.$emit('input', 1); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.prevPage).toBe(0); - }); - }); - }); - - describe('nextPage', () => { - it('returns nextPage button', () => { - findPagination().vm.$emit('input', 3); - - return wrapper.vm.$nextTick(() => { - expect( - findPagination() - .findAll('.page-item') - .at(1) - .text(), - ).toBe('Next'); - }); - }); - - it('returns nextPage number', () => { - mountComponent({ - data: { - incidents: { - list: [...mockIncidents, ...mockIncidents, ...mockIncidents], - pageInfo: { hasNextPage: true, hasPreviousPage: true }, - }, - incidentsCount, - errored: false, - }, - loading: false, - }); - findPagination().vm.$emit('input', 1); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.nextPage).toBe(2); - }); - }); - - it('returns `null` when currentPage is already last page', () => { - findStatusTabs().vm.$emit('input', 1); - findPagination().vm.$emit('input', 1); - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.nextPage).toBeNull(); - }); - }); - }); - - describe('Search', () => { - beforeEach(() => { - mountComponent({ - data: { - incidents: { - list: mockIncidents, - pageInfo: { hasNextPage: true, hasPreviousPage: true }, - }, - incidentsCount, - errored: false, - }, - loading: false, - }); - }); - - it('renders the search component for incidents', () => { - expect(findSearch().exists()).toBe(true); - }); - - it('sets the `searchTerm` graphql variable', () => { - const SEARCH_TERM = 'Simple Incident'; - - findSearch().vm.$emit('input', SEARCH_TERM); - - expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM); - }); - }); - - describe('Status Filter Tabs', () => { - beforeEach(() => { - mountComponent({ - data: { incidents: { list: mockIncidents }, incidentsCount }, - loading: false, - stubs: { - GlTab: true, - }, - }); - }); - - it('should display filter tabs', () => { - const tabs = findStatusFilterTabs().wrappers; - - tabs.forEach((tab, i) => { - expect(tab.attributes('data-testid')).toContain(INCIDENT_STATUS_TABS[i].status); - }); - }); - - it('should display filter tabs with alerts count badge for each status', () => { - const tabs = findStatusFilterTabs().wrappers; - const badges = findStatusFilterBadge(); + const descSort = 'descending'; + const ascSort = 'ascending'; + const noneSort = 'none'; - tabs.forEach((tab, i) => { - const status = INCIDENT_STATUS_TABS[i].status.toLowerCase(); - expect(tab.attributes('data-testid')).toContain(INCIDENT_STATUS_TABS[i].status); - expect(badges.at(i).text()).toContain(incidentsCount[status]); - }); - }); + 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); }); }); - describe('sorting the incident list by column', () => { + describe('Snowplow tracking', () => { beforeEach(() => { mountComponent({ - data: { incidents: { list: mockIncidents }, incidentsCount }, + data: { incidents: { list: mockIncidents }, incidentsCount: {} }, loading: false, }); }); - it('updates sort with new direction and column key', () => { - expect(findDateColumnHeader().attributes('aria-sort')).toBe('descending'); + it('should track incident list views', () => { + const { category, action } = trackIncidentListViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); - findDateColumnHeader().trigger('click'); - return wrapper.vm.$nextTick(() => { - expect(findDateColumnHeader().attributes('aria-sort')).toBe('ascending'); - }); + it('should track incident creation events', async () => { + findCreateIncidentBtn().vm.$emit('click'); + await wrapper.vm.$nextTick(); + const { category, action } = trackIncidentCreateNewOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); }); }); }); diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json index 42b3d6d3eb6..07c87a5d43d 100644 --- a/spec/frontend/incidents/mocks/incidents.json +++ b/spec/frontend/incidents/mocks/incidents.json @@ -5,7 +5,8 @@ "createdAt": "2020-06-03T15:46:08Z", "assignees": {}, "state": "opened", - "severity": "CRITICAL" + "severity": "CRITICAL", + "slaDueAt": "2020-06-04T12:46:08Z" }, { "iid": "14", @@ -22,7 +23,8 @@ ] }, "state": "opened", - "severity": "HIGH" + "severity": "HIGH", + "slaDueAt": null }, { "iid": "13", 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..072e611b9a4 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="" @@ -57,6 +58,8 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` /> </gl-tab-stub> <!----> + + <!----> </gl-tabs-stub> </div> </section> 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..273356151fc 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 @@ -23,7 +23,6 @@ exports[`Alert integration settings form should match the default snapshot 1`] = <gl-form-group-stub class="col-8 col-md-9 gl-p-0" label="Webhook URL" - label-class="label-bold" label-for="url" > <gl-form-input-group-stub @@ -42,24 +41,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 +72,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/incidents_settings/components/incidents_settings_tabs_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js index c56b9ed2a69..11b9eda2585 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js @@ -6,7 +6,12 @@ describe('IncidentsSettingTabs', () => { let wrapper; beforeEach(() => { - wrapper = shallowMount(IncidentsSettingTabs); + wrapper = shallowMount(IncidentsSettingTabs, { + provide: { + service: {}, + serviceLevelAgreementSettings: {}, + }, + }); }); afterEach(() => { 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_member/components/invite_member_modal_spec.js b/spec/frontend/invite_member/components/invite_member_modal_spec.js new file mode 100644 index 00000000000..1d0adb3ab4c --- /dev/null +++ b/spec/frontend/invite_member/components/invite_member_modal_spec.js @@ -0,0 +1,63 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; +import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; + +const memberPath = 'member_path'; + +const createComponent = () => { + return shallowMount(InviteMemberModal, { + provide: { + membersPath: memberPath, + }, + stubs: { + 'gl-emoji': '<img/>', + 'gl-modal': '<div><slot name="modal-title"></slot><slot></slot></div>', + }, + }); +}; + +describe('InviteMemberModal', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findLink = () => wrapper.find(GlLink); + + describe('rendering the modal', () => { + it('renders the modal with the correct title', () => { + expect(wrapper.text()).toContain("Oops, this feature isn't ready yet"); + }); + + describe('rendering the see who link', () => { + it('renders the correct link', () => { + expect(findLink().attributes('href')).toBe(memberPath); + }); + }); + }); + + describe('tracking', () => { + let trackingSpy; + + afterEach(() => { + unmockTracking(); + }); + + it('send an event when go to pipelines is clicked', () => { + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + + triggerEvent(findLink().element); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_who_can_invite_link', { + label: 'invite_members_message', + }); + }); + }); +}); diff --git a/spec/frontend/invite_member/components/invite_member_trigger_mock_data.js b/spec/frontend/invite_member/components/invite_member_trigger_mock_data.js new file mode 100644 index 00000000000..9b34a8027e9 --- /dev/null +++ b/spec/frontend/invite_member/components/invite_member_trigger_mock_data.js @@ -0,0 +1,7 @@ +const triggerProvides = { + displayText: 'Invite member', + event: 'click_invite_members_version_b', + label: 'edit_assignee', +}; + +export default triggerProvides; diff --git a/spec/frontend/invite_member/components/invite_member_trigger_spec.js b/spec/frontend/invite_member/components/invite_member_trigger_spec.js new file mode 100644 index 00000000000..57b8918e3da --- /dev/null +++ b/spec/frontend/invite_member/components/invite_member_trigger_spec.js @@ -0,0 +1,48 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; +import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; +import triggerProvides from './invite_member_trigger_mock_data'; + +const createComponent = () => { + return shallowMount(InviteMemberTrigger, { provide: triggerProvides }); +}; + +describe('InviteMemberTrigger', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findLink = () => wrapper.find(GlLink); + + describe('displayText', () => { + it('includes the correct displayText for the link', () => { + expect(findLink().text()).toBe(triggerProvides.displayText); + }); + }); + + describe('tracking', () => { + let trackingSpy; + + afterEach(() => { + unmockTracking(); + }); + + it('send an event when go to pipelines is clicked', () => { + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + + triggerEvent(findLink().element); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', triggerProvides.event, { + label: triggerProvides.label, + }); + }); + }); +}); 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/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js index f6f914a595d..8eab2ca6f94 100644 --- a/spec/frontend/issuable_list/mock_data.js +++ b/spec/frontend/issuable_list/mock_data.js @@ -30,13 +30,23 @@ export const mockScopedLabel = { export const mockLabels = [mockRegularLabel, mockScopedLabel]; +export const mockCurrentUserTodo = { + id: 'gid://gitlab/Todo/489', + state: 'done', +}; + export const mockIssuable = { iid: '30', title: 'Dismiss Cipher with no integrity', - description: null, + titleHtml: 'Dismiss Cipher with no integrity', + description: 'fortitudinis _fomentis_ dolor mitigari solet.', + descriptionHtml: 'fortitudinis <i>fomentis</i> dolor mitigari solet.', + state: 'opened', createdAt: '2020-06-29T13:52:56Z', updatedAt: '2020-09-10T11:41:13Z', webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/30', + blocked: false, + confidential: false, author: mockAuthor, labels: { nodes: mockLabels, diff --git a/spec/frontend/issuable_show/components/issuable_body_spec.js b/spec/frontend/issuable_show/components/issuable_body_spec.js new file mode 100644 index 00000000000..0e4475e8103 --- /dev/null +++ b/spec/frontend/issuable_show/components/issuable_body_spec.js @@ -0,0 +1,140 @@ +import { shallowMount } from '@vue/test-utils'; + +import IssuableBody from '~/issuable_show/components/issuable_body.vue'; + +import IssuableTitle from '~/issuable_show/components/issuable_title.vue'; +import IssuableDescription from '~/issuable_show/components/issuable_description.vue'; +import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { mockIssuableShowProps, mockIssuable } from '../mock_data'; + +jest.mock('~/autosave'); + +const issuableBodyProps = { + ...mockIssuableShowProps, + issuable: mockIssuable, +}; + +const createComponent = (propsData = issuableBodyProps) => + shallowMount(IssuableBody, { + propsData, + stubs: { + IssuableTitle, + IssuableDescription, + IssuableEditForm, + TimeAgoTooltip, + }, + slots: { + 'status-badge': 'Open', + 'edit-form-actions': ` + <button class="js-save">Save changes</button> + <button class="js-cancel">Cancel</button> + `, + }, + }); + +describe('IssuableBody', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('isUpdated', () => { + it.each` + updatedAt | returnValue + ${mockIssuable.updatedAt} | ${true} + ${null} | ${false} + ${''} | ${false} + `( + 'returns $returnValue when value of `updateAt` prop is `$updatedAt`', + async ({ updatedAt, returnValue }) => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + updatedAt, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isUpdated).toBe(returnValue); + }, + ); + }); + + describe('updatedBy', () => { + it('returns value of `issuable.updatedBy`', () => { + expect(wrapper.vm.updatedBy).toBe(mockIssuable.updatedBy); + }); + }); + }); + + describe('template', () => { + it('renders issuable-title component', () => { + const titleEl = wrapper.find(IssuableTitle); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.props()).toMatchObject({ + issuable: issuableBodyProps.issuable, + statusBadgeClass: issuableBodyProps.statusBadgeClass, + statusIcon: issuableBodyProps.statusIcon, + enableEdit: issuableBodyProps.enableEdit, + }); + }); + + it('renders issuable-description component', () => { + const descriptionEl = wrapper.find(IssuableDescription); + + expect(descriptionEl.exists()).toBe(true); + expect(descriptionEl.props('issuable')).toEqual(issuableBodyProps.issuable); + }); + + it('renders issuable edit info', () => { + const editedEl = wrapper.find('small'); + const sanitizedText = editedEl + .text() + .replace(/\n/g, ' ') + .replace(/\s+/g, ' '); + + expect(sanitizedText).toContain('Edited'); + expect(sanitizedText).toContain('ago'); + expect(sanitizedText).toContain(`by ${mockIssuable.updatedBy.name}`); + }); + + it('renders issuable-edit-form when `editFormVisible` prop is true', async () => { + wrapper.setProps({ + editFormVisible: true, + }); + + await wrapper.vm.$nextTick(); + + const editFormEl = wrapper.find(IssuableEditForm); + expect(editFormEl.exists()).toBe(true); + expect(editFormEl.props()).toMatchObject({ + issuable: issuableBodyProps.issuable, + enableAutocomplete: issuableBodyProps.enableAutocomplete, + descriptionPreviewPath: issuableBodyProps.descriptionPreviewPath, + descriptionHelpPath: issuableBodyProps.descriptionHelpPath, + }); + expect(editFormEl.find('button.js-save').exists()).toBe(true); + expect(editFormEl.find('button.js-cancel').exists()).toBe(true); + }); + + describe('events', () => { + it('component emits `edit-issuable` event bubbled via issuable-title', () => { + const issuableTitle = wrapper.find(IssuableTitle); + + issuableTitle.vm.$emit('edit-issuable'); + + expect(wrapper.emitted('edit-issuable')).toBeTruthy(); + }); + }); + }); +}); diff --git a/spec/frontend/issuable_show/components/issuable_description_spec.js b/spec/frontend/issuable_show/components/issuable_description_spec.js new file mode 100644 index 00000000000..1dd8348b098 --- /dev/null +++ b/spec/frontend/issuable_show/components/issuable_description_spec.js @@ -0,0 +1,41 @@ +import $ from 'jquery'; +import { shallowMount } from '@vue/test-utils'; + +import IssuableDescription from '~/issuable_show/components/issuable_description.vue'; + +import { mockIssuable } from '../mock_data'; + +const createComponent = (issuable = mockIssuable) => + shallowMount(IssuableDescription, { + propsData: { issuable }, + }); + +describe('IssuableDescription', () => { + let renderGFMSpy; + let wrapper; + + beforeEach(() => { + renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('mounted', () => { + it('calls `renderGFM`', () => { + expect(renderGFMSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('methods', () => { + describe('renderGFM', () => { + it('calls `renderGFM` on container element', () => { + wrapper.vm.renderGFM(); + + expect(renderGFMSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/issuable_show/components/issuable_edit_form_spec.js b/spec/frontend/issuable_show/components/issuable_edit_form_spec.js new file mode 100644 index 00000000000..352e66cdffe --- /dev/null +++ b/spec/frontend/issuable_show/components/issuable_edit_form_spec.js @@ -0,0 +1,122 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; + +import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue'; +import IssuableEventHub from '~/issuable_show/event_hub'; + +import { mockIssuableShowProps, mockIssuable } from '../mock_data'; + +const issuableEditFormProps = { + issuable: mockIssuable, + ...mockIssuableShowProps, +}; + +const createComponent = ({ propsData = issuableEditFormProps } = {}) => + shallowMount(IssuableEditForm, { + propsData, + stubs: { + MarkdownField, + }, + slots: { + 'edit-form-actions': ` + <button class="js-save">Save changes</button> + <button class="js-cancel">Cancel</button> + `, + }, + }); + +describe('IssuableEditForm', () => { + let wrapper; + const assertEvent = eventSpy => { + expect(eventSpy).toHaveBeenNthCalledWith(1, 'update.issuable', expect.any(Function)); + expect(eventSpy).toHaveBeenNthCalledWith(2, 'close.form', expect.any(Function)); + }; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('created', () => { + it('binds `update.issuable` and `close.form` event listeners', () => { + const eventOnSpy = jest.spyOn(IssuableEventHub, '$on'); + const wrapperTemp = createComponent(); + + assertEvent(eventOnSpy); + + wrapperTemp.destroy(); + }); + }); + + describe('beforeDestroy', () => { + it('unbinds `update.issuable` and `close.form` event listeners', () => { + const wrapperTemp = createComponent(); + const eventOffSpy = jest.spyOn(IssuableEventHub, '$off'); + + wrapperTemp.destroy(); + + assertEvent(eventOffSpy); + }); + }); + + describe('methods', () => { + describe('initAutosave', () => { + it('initializes `autosaveTitle` and `autosaveDescription` props', () => { + expect(wrapper.vm.autosaveTitle).toBeDefined(); + expect(wrapper.vm.autosaveDescription).toBeDefined(); + }); + }); + + describe('resetAutosave', () => { + it('calls `reset` on `autosaveTitle` and `autosaveDescription` props', () => { + jest.spyOn(wrapper.vm.autosaveTitle, 'reset').mockImplementation(jest.fn); + jest.spyOn(wrapper.vm.autosaveDescription, 'reset').mockImplementation(jest.fn); + + wrapper.vm.resetAutosave(); + + expect(wrapper.vm.autosaveTitle.reset).toHaveBeenCalled(); + expect(wrapper.vm.autosaveDescription.reset).toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + it('renders title input field', () => { + const titleInputEl = wrapper.find('[data-testid="title"]'); + + expect(titleInputEl.exists()).toBe(true); + expect(titleInputEl.find(GlFormInput).attributes()).toMatchObject({ + 'aria-label': 'Title', + placeholder: 'Title', + }); + }); + + it('renders description textarea field', () => { + const descriptionEl = wrapper.find('[data-testid="description"]'); + + expect(descriptionEl.exists()).toBe(true); + expect(descriptionEl.find(MarkdownField).props()).toMatchObject({ + markdownPreviewPath: issuableEditFormProps.descriptionPreviewPath, + markdownDocsPath: issuableEditFormProps.descriptionHelpPath, + enableAutocomplete: issuableEditFormProps.enableAutocomplete, + textareaValue: mockIssuable.description, + }); + expect(descriptionEl.find('textarea').attributes()).toMatchObject({ + 'data-supports-quick-actions': 'true', + 'aria-label': 'Description', + placeholder: 'Write a comment or drag your files here…', + }); + }); + + it('renders form actions', () => { + const actionsEl = wrapper.find('[data-testid="actions"]'); + + expect(actionsEl.find('button.js-save').exists()).toBe(true); + expect(actionsEl.find('button.js-cancel').exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/issuable_show/components/issuable_header_spec.js b/spec/frontend/issuable_show/components/issuable_header_spec.js new file mode 100644 index 00000000000..fad8ec8a891 --- /dev/null +++ b/spec/frontend/issuable_show/components/issuable_header_spec.js @@ -0,0 +1,132 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlAvatarLabeled } from '@gitlab/ui'; + +import IssuableHeader from '~/issuable_show/components/issuable_header.vue'; + +import { mockIssuableShowProps, mockIssuable } from '../mock_data'; + +const issuableHeaderProps = { + ...mockIssuable, + ...mockIssuableShowProps, +}; + +const createComponent = (propsData = issuableHeaderProps) => + shallowMount(IssuableHeader, { + propsData, + slots: { + 'status-badge': 'Open', + 'header-actions': ` + <button class="js-close">Close issuable</button> + <a class="js-new" href="/gitlab-org/gitlab-shell/-/issues/new">New issuable</a> + `, + }, + }); + +describe('IssuableHeader', () => { + let wrapper; + const findByTestId = testId => wrapper.find(`[data-testid="${testId}"]`); + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('authorId', () => { + it('returns numeric ID from GraphQL ID of `author` prop', () => { + expect(wrapper.vm.authorId).toBe(1); + }); + }); + }); + + describe('handleRightSidebarToggleClick', () => { + beforeEach(() => { + setFixtures('<button class="js-toggle-right-sidebar-button">Collapse sidebar</button>'); + }); + + it('dispatches `click` event on sidebar toggle button', () => { + wrapper.vm.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); + jest.spyOn(wrapper.vm.toggleSidebarButtonEl, 'dispatchEvent').mockImplementation(jest.fn); + + wrapper.vm.handleRightSidebarToggleClick(); + + expect(wrapper.vm.toggleSidebarButtonEl.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'click', + }), + ); + }); + }); + + describe('template', () => { + it('renders issuable status icon and text', () => { + const statusBoxEl = findByTestId('status'); + + expect(statusBoxEl.exists()).toBe(true); + expect(statusBoxEl.find(GlIcon).props('name')).toBe(mockIssuableShowProps.statusIcon); + expect(statusBoxEl.text()).toContain('Open'); + }); + + it('renders blocked icon when issuable is blocked', async () => { + wrapper.setProps({ + blocked: true, + }); + + await wrapper.vm.$nextTick(); + + const blockedEl = findByTestId('blocked'); + + expect(blockedEl.exists()).toBe(true); + expect(blockedEl.find(GlIcon).props('name')).toBe('lock'); + }); + + it('renders confidential icon when issuable is confidential', async () => { + wrapper.setProps({ + confidential: true, + }); + + await wrapper.vm.$nextTick(); + + const confidentialEl = findByTestId('confidential'); + + expect(confidentialEl.exists()).toBe(true); + expect(confidentialEl.find(GlIcon).props('name')).toBe('eye-slash'); + }); + + it('renders issuable author avatar', () => { + const { username, name, webUrl, avatarUrl } = mockIssuable.author; + const avatarElAttrs = { + 'data-user-id': '1', + 'data-username': username, + 'data-name': name, + href: webUrl, + target: '_blank', + }; + const avatarEl = findByTestId('avatar'); + expect(avatarEl.exists()).toBe(true); + expect(avatarEl.attributes()).toMatchObject(avatarElAttrs); + expect(avatarEl.find(GlAvatarLabeled).attributes()).toMatchObject({ + size: '24', + src: avatarUrl, + label: name, + }); + }); + + it('renders sidebar toggle button', () => { + const toggleButtonEl = findByTestId('sidebar-toggle'); + + expect(toggleButtonEl.exists()).toBe(true); + expect(toggleButtonEl.props('icon')).toBe('chevron-double-lg-left'); + }); + + it('renders header actions', () => { + const actionsEl = findByTestId('header-actions'); + + expect(actionsEl.find('button.js-close').exists()).toBe(true); + expect(actionsEl.find('a.js-new').exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/issuable_show/components/issuable_show_root_spec.js b/spec/frontend/issuable_show/components/issuable_show_root_spec.js new file mode 100644 index 00000000000..112e4ccd340 --- /dev/null +++ b/spec/frontend/issuable_show/components/issuable_show_root_spec.js @@ -0,0 +1,123 @@ +import { shallowMount } from '@vue/test-utils'; + +import IssuableShowRoot from '~/issuable_show/components/issuable_show_root.vue'; + +import IssuableHeader from '~/issuable_show/components/issuable_header.vue'; +import IssuableBody from '~/issuable_show/components/issuable_body.vue'; +import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; + +import { mockIssuableShowProps, mockIssuable } from '../mock_data'; + +const createComponent = (propsData = mockIssuableShowProps) => + shallowMount(IssuableShowRoot, { + propsData, + stubs: { + IssuableHeader, + IssuableBody, + IssuableSidebar, + }, + slots: { + 'status-badge': 'Open', + 'header-actions': ` + <button class="js-close">Close issuable</button> + <a class="js-new" href="/gitlab-org/gitlab-shell/-/issues/new">New issuable</a> + `, + 'edit-form-actions': ` + <button class="js-save">Save changes</button> + <button class="js-cancel">Cancel</button> + `, + 'right-sidebar-items': ` + <div class="js-todo"> + To Do <button class="js-add-todo">Add a To Do</button> + </div> + `, + }, + }); + +describe('IssuableShowRoot', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + const { + statusBadgeClass, + statusIcon, + enableEdit, + enableAutocomplete, + editFormVisible, + descriptionPreviewPath, + descriptionHelpPath, + } = mockIssuableShowProps; + const { blocked, confidential, createdAt, author } = mockIssuable; + + it('renders component container element with class `issuable-show-container`', () => { + expect(wrapper.classes()).toContain('issuable-show-container'); + }); + + it('renders issuable-header component', () => { + const issuableHeader = wrapper.find(IssuableHeader); + + expect(issuableHeader.exists()).toBe(true); + expect(issuableHeader.props()).toMatchObject({ + statusBadgeClass, + statusIcon, + blocked, + confidential, + createdAt, + author, + }); + expect(issuableHeader.find('.issuable-status-box').text()).toContain('Open'); + expect(issuableHeader.find('.detail-page-header-actions button.js-close').exists()).toBe( + true, + ); + expect(issuableHeader.find('.detail-page-header-actions a.js-new').exists()).toBe(true); + }); + + it('renders issuable-body component', () => { + const issuableBody = wrapper.find(IssuableBody); + + expect(issuableBody.exists()).toBe(true); + expect(issuableBody.props()).toMatchObject({ + issuable: mockIssuable, + statusBadgeClass, + statusIcon, + enableEdit, + enableAutocomplete, + editFormVisible, + descriptionPreviewPath, + descriptionHelpPath, + }); + }); + + it('renders issuable-sidebar component', () => { + const issuableSidebar = wrapper.find(IssuableSidebar); + + expect(issuableSidebar.exists()).toBe(true); + }); + + describe('events', () => { + it('component emits `edit-issuable` event bubbled via issuable-body', () => { + const issuableBody = wrapper.find(IssuableBody); + + issuableBody.vm.$emit('edit-issuable'); + + expect(wrapper.emitted('edit-issuable')).toBeTruthy(); + }); + + it('component emits `sidebar-toggle` event bubbled via issuable-sidebar', () => { + const issuableSidebar = wrapper.find(IssuableSidebar); + + issuableSidebar.vm.$emit('sidebar-toggle', true); + + expect(wrapper.emitted('sidebar-toggle')).toBeTruthy(); + }); + }); + }); +}); diff --git a/spec/frontend/issuable_show/components/issuable_title_spec.js b/spec/frontend/issuable_show/components/issuable_title_spec.js new file mode 100644 index 00000000000..e8621c763b3 --- /dev/null +++ b/spec/frontend/issuable_show/components/issuable_title_spec.js @@ -0,0 +1,100 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import IssuableTitle from '~/issuable_show/components/issuable_title.vue'; + +import { mockIssuableShowProps, mockIssuable } from '../mock_data'; + +const issuableTitleProps = { + issuable: mockIssuable, + ...mockIssuableShowProps, +}; + +const createComponent = (propsData = issuableTitleProps) => + shallowMount(IssuableTitle, { + propsData, + stubs: { + transition: true, + }, + slots: { + 'status-badge': 'Open', + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + +describe('IssuableTitle', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('methods', () => { + describe('handleTitleAppear', () => { + it('sets value of `stickyTitleVisible` prop to false', () => { + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + + expect(wrapper.vm.stickyTitleVisible).toBe(false); + }); + }); + + describe('handleTitleDisappear', () => { + it('sets value of `stickyTitleVisible` prop to true', () => { + wrapper.find(GlIntersectionObserver).vm.$emit('disappear'); + + expect(wrapper.vm.stickyTitleVisible).toBe(true); + }); + }); + }); + + describe('template', () => { + it('renders issuable title', async () => { + const wrapperWithTitle = createComponent({ + ...mockIssuableShowProps, + issuable: { + ...mockIssuable, + titleHtml: '<b>Sample</b> title', + }, + }); + + await wrapperWithTitle.vm.$nextTick(); + const titleEl = wrapperWithTitle.find('h2'); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.html()).toBe('<h2 dir="auto" class="title qa-title"><b>Sample</b> title</h2>'); + + wrapperWithTitle.destroy(); + }); + + it('renders edit button', () => { + const editButtonEl = wrapper.find(GlButton); + const tooltip = getBinding(editButtonEl.element, 'gl-tooltip'); + + expect(editButtonEl.exists()).toBe(true); + expect(editButtonEl.props('icon')).toBe('pencil'); + expect(editButtonEl.attributes('title')).toBe('Edit title and description'); + expect(tooltip).toBeDefined(); + }); + + it('renders sticky header when `stickyTitleVisible` prop is true', async () => { + wrapper.setData({ + stickyTitleVisible: true, + }); + + await wrapper.vm.$nextTick(); + const stickyHeaderEl = wrapper.find('[data-testid="header"]'); + + expect(stickyHeaderEl.exists()).toBe(true); + expect(stickyHeaderEl.find(GlIcon).props('name')).toBe(issuableTitleProps.statusIcon); + expect(stickyHeaderEl.text()).toContain('Open'); + expect(stickyHeaderEl.text()).toContain(issuableTitleProps.issuable.title); + }); + }); +}); diff --git a/spec/frontend/issuable_show/mock_data.js b/spec/frontend/issuable_show/mock_data.js new file mode 100644 index 00000000000..14e5febdc6b --- /dev/null +++ b/spec/frontend/issuable_show/mock_data.js @@ -0,0 +1,34 @@ +import { mockIssuable as issuable } from '../issuable_list/mock_data'; + +export const mockIssuable = { + ...issuable, + id: 'gid://gitlab/Issue/30', + title: 'Sample title', + titleHtml: 'Sample title', + description: '# Summary', + descriptionHtml: + '<h1 data-sourcepos="1:1-1:25" dir="auto">
<a id="user-content-magnoque-it-lurida-deus" class="anchor" href="#magnoque-it-lurida-deus" aria-hidden="true"></a>Summary</h1>', + state: 'opened', + blocked: false, + confidential: false, + updatedBy: issuable.author, + currentUserTodos: { + nodes: [ + { + id: 'gid://gitlab/Todo/489', + state: 'done', + }, + ], + }, +}; + +export const mockIssuableShowProps = { + issuable: mockIssuable, + descriptionHelpPath: '/help/user/markdown', + descriptionPreviewPath: '/gitlab-org/gitlab-shell/preview_markdown', + editFormVisible: false, + enableAutocomplete: true, + enableEdit: true, + statusBadgeClass: 'status-box-open', + statusIcon: 'issue-open-m', +}; diff --git a/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js b/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js new file mode 100644 index 00000000000..7686dad4644 --- /dev/null +++ b/spec/frontend/issuable_sidebar/components/issuable_sidebar_root_spec.js @@ -0,0 +1,199 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import Cookies from 'js-cookie'; + +import IssuableSidebarRoot from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; + +const createComponent = (expanded = true) => + shallowMount(IssuableSidebarRoot, { + propsData: { + expanded, + }, + slots: { + 'right-sidebar-items': ` + <button class="js-todo">Todo</button> + `, + }, + }); + +describe('IssuableSidebarRoot', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('watch', () => { + describe('isExpanded', () => { + it('emits `sidebar-toggle` event on component', async () => { + wrapper.setData({ + isExpanded: false, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('sidebar-toggle')).toBeTruthy(); + expect(wrapper.emitted('sidebar-toggle')[0]).toEqual([ + { + expanded: false, + }, + ]); + }); + }); + }); + + describe('methods', () => { + describe('updatePageContainerClass', () => { + beforeEach(() => { + setFixtures('<div class="layout-page"></div>'); + }); + + it.each` + isExpanded | layoutPageClass + ${true} | ${'right-sidebar-expanded'} + ${false} | ${'right-sidebar-collapsed'} + `( + 'set class $layoutPageClass to container element when `isExpanded` prop is $isExpanded', + async ({ isExpanded, layoutPageClass }) => { + wrapper.setData({ + isExpanded, + }); + + await wrapper.vm.$nextTick(); + + wrapper.vm.updatePageContainerClass(); + + expect(document.querySelector('.layout-page').classList.contains(layoutPageClass)).toBe( + true, + ); + }, + ); + }); + + describe('handleWindowResize', () => { + beforeEach(async () => { + wrapper.setData({ + userExpanded: true, + }); + + await wrapper.vm.$nextTick(); + }); + + it.each` + breakpoint | isExpandedValue + ${'xs'} | ${false} + ${'sm'} | ${false} + ${'md'} | ${false} + ${'lg'} | ${true} + ${'xl'} | ${true} + `( + 'sets `isExpanded` prop to $isExpandedValue only when current screen size is `lg` or `xl`', + async ({ breakpoint, isExpandedValue }) => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(breakpoint === 'lg' || breakpoint === 'xl'); + + wrapper.vm.handleWindowResize(); + + expect(wrapper.vm.isExpanded).toBe(isExpandedValue); + }, + ); + + it('calls `updatePageContainerClass` method', () => { + jest.spyOn(wrapper.vm, 'updatePageContainerClass'); + + wrapper.vm.handleWindowResize(); + + expect(wrapper.vm.updatePageContainerClass).toHaveBeenCalled(); + }); + }); + + describe('handleToggleSidebarClick', () => { + beforeEach(async () => { + jest.spyOn(Cookies, 'set').mockImplementation(jest.fn()); + wrapper.setData({ + isExpanded: true, + }); + + await wrapper.vm.$nextTick(); + }); + + it('flips value of `isExpanded`', () => { + wrapper.vm.handleToggleSidebarClick(); + + expect(wrapper.vm.isExpanded).toBe(false); + expect(wrapper.vm.userExpanded).toBe(false); + }); + + it('updates "collapsed_gutter" cookie value', () => { + wrapper.vm.handleToggleSidebarClick(); + + expect(Cookies.set).toHaveBeenCalledWith('collapsed_gutter', true); + }); + + it('calls `updatePageContainerClass` method', () => { + jest.spyOn(wrapper.vm, 'updatePageContainerClass'); + + wrapper.vm.handleWindowResize(); + + expect(wrapper.vm.updatePageContainerClass).toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + describe('sidebar expanded', () => { + beforeEach(async () => { + wrapper.setData({ + isExpanded: true, + }); + + await wrapper.vm.$nextTick(); + }); + + it('renders component container element with class `right-sidebar-expanded` when `isExpanded` prop is true', () => { + expect(wrapper.classes()).toContain('right-sidebar-expanded'); + }); + + it('renders sidebar toggle button with text and icon', () => { + const buttonEl = wrapper.find('button'); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.attributes('title')).toBe('Toggle sidebar'); + expect(buttonEl.find('span').text()).toBe('Collapse sidebar'); + expect(buttonEl.find('[data-testid="icon-collapse"]').isVisible()).toBe(true); + }); + }); + + describe('sidebar collapsed', () => { + beforeEach(async () => { + wrapper.setData({ + isExpanded: false, + }); + + await wrapper.vm.$nextTick(); + }); + + it('renders component container element with class `right-sidebar-collapsed` when `isExpanded` prop is false', () => { + expect(wrapper.classes()).toContain('right-sidebar-collapsed'); + }); + + it('renders sidebar toggle button with text and icon', () => { + const buttonEl = wrapper.find('button'); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.attributes('title')).toBe('Toggle sidebar'); + expect(buttonEl.find('[data-testid="icon-expand"]').isVisible()).toBe(true); + }); + }); + + it('renders sidebar items', () => { + const sidebarItemsEl = wrapper.find('[data-testid="sidebar-items"]'); + + expect(sidebarItemsEl.exists()).toBe(true); + expect(sidebarItemsEl.find('button.js-todo').exists()).toBe(true); + }); + }); +}); 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..c1ab4433761 100644 --- a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js +++ b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import merge from 'lodash/merge'; import { GlLink } from '@gitlab/ui'; import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue'; import { formatDate } from '~/lib/utils/datetime_utility'; @@ -9,18 +10,24 @@ 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, title: 'Alert 1', }; - const mountComponent = () => { - wrapper = shallowMount(HighlightBar, { - propsData: { - alert, - }, - }); + const mountComponent = options => { + wrapper = shallowMount( + HighlightBar, + merge( + { + propsData: { alert }, + provide: { fullPath: 'test', iid: 1, slaFeatureAvailable: true }, + }, + options, + ), + ); }; beforeEach(() => { @@ -36,21 +43,52 @@ describe('Highlight Bar', () => { const findLink = () => wrapper.find(GlLink); - 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); + describe('empty state', () => { + beforeEach(() => { + mountComponent({ propsData: { alert: null } }); + }); + + it('renders a empty component', () => { + expect(wrapper.isVisible()).toBe(false); + }); }); - it('renders formatted start time of the alert', () => { - const formattedDate = '2020-05-29 UTC'; - formatDate.mockReturnValueOnce(formattedDate); - mountComponent(); - expect(formatDate).toHaveBeenCalledWith(alert.startedAt, 'yyyy-mm-dd Z'); - expect(wrapper.text()).toContain(formattedDate); + describe('alert present', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders a link to the alert page', () => { + expect(findLink().exists()).toBe(true); + expect(findLink().attributes('href')).toBe(alert.detailsUrl); + expect(findLink().attributes('title')).toBe(alert.title); + expect(findLink().text()).toBe(`#${alert.iid}`); + }); + + it('renders formatted start time of the alert', () => { + const formattedDate = '2020-05-29 UTC'; + formatDate.mockReturnValueOnce(formattedDate); + mountComponent(); + expect(formatDate).toHaveBeenCalledWith(alert.startedAt, 'yyyy-mm-dd Z'); + expect(wrapper.text()).toContain(formattedDate); + }); + + it('renders a number of alert events', () => { + expect(wrapper.text()).toContain(alert.eventCount); + }); }); - it('renders a number of alert events', () => { - expect(wrapper.text()).toContain(alert.eventCount); + describe('when child data is present', () => { + beforeEach(() => { + mountComponent({ + data() { + return { hasChildData: true }; + }, + }); + }); + + it('renders the highlight bar component', () => { + expect(wrapper.isVisible()).toBe(true); + }); }); }); 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..c6200fd69bf 100644 --- a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js @@ -6,6 +6,8 @@ import { descriptionProps } from '../../mock_data'; import DescriptionComponent from '~/issue_show/components/description.vue'; import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import Tracking from '~/tracking'; +import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; const mockAlert = { __typename: 'AlertManagementAlert', @@ -57,7 +59,6 @@ describe('Incident Tabs component', () => { it('does not show the alert details tab', () => { expect(findAlertDetailsComponent().exists()).toBe(false); - expect(findHighlightBarComponent().exists()).toBe(false); }); }); @@ -79,7 +80,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); }); @@ -98,4 +99,16 @@ describe('Incident Tabs component', () => { expect(findDescriptionComponent().props()).toMatchObject(descriptionProps); }); }); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + it('should track incident details views', () => { + const { category, action } = trackIncidentDetailsViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + }); }); 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/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index eede5426f42..cd0266068aa 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -98,7 +98,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = </span> <svg - class="dropdown-chevron gl-icon s16" + class="gl-button-icon dropdown-chevron gl-icon s16" data-testid="chevron-down-icon" > <use @@ -114,7 +114,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> <div - class="gl-search-box-by-type gl-m-3" + class="gl-search-box-by-type" > <svg class="gl-search-box-by-type-search-icon gl-icon s16" @@ -209,7 +209,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = </span> <svg - class="dropdown-chevron gl-icon s16" + class="gl-button-icon dropdown-chevron gl-icon s16" data-testid="chevron-down-icon" > <use @@ -225,7 +225,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> <div - class="gl-search-box-by-type gl-m-3" + class="gl-search-box-by-type" > <svg class="gl-search-box-by-type-search-icon gl-icon s16" diff --git a/spec/frontend/jobs/components/job_container_item_spec.js b/spec/frontend/jobs/components/job_container_item_spec.js index 9019504d22d..41b399fa32b 100644 --- a/spec/frontend/jobs/components/job_container_item_spec.js +++ b/spec/frontend/jobs/components/job_container_item_spec.js @@ -90,7 +90,7 @@ describe('JobContainerItem', () => { Vue.nextTick() .then(() => { - expect(vm.$el.querySelector('.js-job-link').getAttribute('data-original-title')).toEqual( + expect(vm.$el.querySelector('.js-job-link').getAttribute('title')).toEqual( 'delayed job - delayed manual action (00:22:17)', ); }) diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js index c2412a807c3..1a30921fece 100644 --- a/spec/frontend/jobs/components/log/line_spec.js +++ b/spec/frontend/jobs/components/log/line_spec.js @@ -2,21 +2,25 @@ import { shallowMount } from '@vue/test-utils'; import Line from '~/jobs/components/log/line.vue'; import LineNumber from '~/jobs/components/log/line_number.vue'; +const httpUrl = 'http://example.com'; +const httpsUrl = 'https://example.com'; + +const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = {}) => ({ + line: { + content: [ + { + text, + style: 'term-fg-l-green', + }, + ], + lineNumber: 0, + }, + path: '/jashkenas/underscore/-/jobs/335', +}); + describe('Job Log Line', () => { let wrapper; - - const data = { - line: { - content: [ - { - text: 'Running with gitlab-runner 12.1.0 (de7731dd)', - style: 'term-fg-l-green', - }, - ], - lineNumber: 0, - }, - path: '/jashkenas/underscore/-/jobs/335', - }; + let data; const createComponent = (props = {}) => { wrapper = shallowMount(Line, { @@ -27,6 +31,7 @@ describe('Job Log Line', () => { }; beforeEach(() => { + data = mockProps(); createComponent(data); }); @@ -45,4 +50,38 @@ describe('Job Log Line', () => { it('renders the provided style as a class attribute', () => { expect(wrapper.find('span').classes()).toContain(data.line.content[0].style); }); + + describe('when the line contains a link', () => { + const findLink = () => wrapper.find('span a'); + + it('renders an http link', () => { + createComponent(mockProps({ text: httpUrl })); + + expect(findLink().text()).toBe(httpUrl); + expect(findLink().attributes().href).toEqual(httpUrl); + }); + + it('renders an https link', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().text()).toBe(httpsUrl); + expect(findLink().attributes().href).toEqual(httpsUrl); + }); + + it('renders a link with rel nofollow and noopener', () => { + createComponent(mockProps({ text: httpsUrl })); + + expect(findLink().attributes().rel).toBe('nofollow noopener'); + }); + + test.each` + type | text + ${'ftp'} | ${'ftp://example.com/file'} + ${'email'} | ${'email@example.com'} + ${'no scheme'} | ${'example.com/page'} + `('does not render a $type link', ({ text }) => { + createComponent(mockProps({ text })); + expect(findLink().exists()).toBe(false); + }); + }); }); 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/labels_issue_sidebar_spec.js b/spec/frontend/labels_issue_sidebar_spec.js deleted file mode 100644 index f74547c0554..00000000000 --- a/spec/frontend/labels_issue_sidebar_spec.js +++ /dev/null @@ -1,98 +0,0 @@ -/* eslint-disable no-new */ - -import $ from 'jquery'; -import MockAdapter from 'axios-mock-adapter'; -import { shuffle } from 'lodash'; -import axios from '~/lib/utils/axios_utils'; -import IssuableContext from '~/issuable_context'; -import LabelsSelect from '~/labels_select'; - -import 'select2'; -import '~/api'; -import '~/create_label'; -import '~/users_select'; - -let saveLabelCount = 0; -let mock; - -function testLabelClicks(labelOrder, done) { - $('.edit-link') - .get(0) - .click(); - - jest.runOnlyPendingTimers(); - - setImmediate(() => { - const labelsInDropdown = $('.dropdown-content a'); - - expect(labelsInDropdown.length).toBe(10); - - const arrayOfLabels = labelsInDropdown.get(); - const randomArrayOfLabels = shuffle(arrayOfLabels); - randomArrayOfLabels.forEach((label, i) => { - if (i < saveLabelCount) { - $(label).click(); - } - }); - - $('.edit-link') - .get(0) - .click(); - - setImmediate(() => { - expect($('.sidebar-collapsed-icon').attr('data-original-title')).toBe(labelOrder); - done(); - }); - }); -} - -describe('Issue dropdown sidebar', () => { - preloadFixtures('static/issue_sidebar_label.html'); - - beforeEach(() => { - loadFixtures('static/issue_sidebar_label.html'); - - mock = new MockAdapter(axios); - - new IssuableContext('{"id":1,"name":"Administrator","username":"root"}'); - new LabelsSelect(); - - mock.onGet('/root/test/labels.json').reply(() => { - const labels = Array(10) - .fill() - .map((_val, i) => ({ - id: i, - title: `test ${i}`, - color: '#5CB85C', - })); - - return [200, labels]; - }); - - mock.onPut('/root/test/issues/2.json').reply(() => { - const labels = Array(saveLabelCount) - .fill() - .map((_val, i) => ({ - id: i, - title: `test ${i}`, - color: '#5CB85C', - })); - - return [200, { labels }]; - }); - }); - - afterEach(() => { - mock.restore(); - }); - - it('changes collapsed tooltip when changing labels when less than 5', done => { - saveLabelCount = 5; - testLabelClicks('test 0, test 1, test 2, test 3, test 4', done); - }); - - it('changes collapsed tooltip when changing labels when more than 5', done => { - saveLabelCount = 6; - testLabelClicks('test 0, test 1, test 2, test 3, test 4, and 1 more', done); - }); -}); 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..b0b0b028761 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -69,6 +69,34 @@ describe('Date time utils', () => { }); }); + describe('formatDateAsMonth', () => { + it('should format dash cased date properly', () => { + const formattedMonth = datetimeUtility.formatDateAsMonth(new Date('2020-06-28')); + + expect(formattedMonth).toBe('Jun'); + }); + + it('should format return the non-abbreviated month', () => { + const formattedMonth = datetimeUtility.formatDateAsMonth(new Date('2020-07-28'), { + abbreviated: false, + }); + + expect(formattedMonth).toBe('July'); + }); + + it('should format date with slashes properly', () => { + const formattedMonth = datetimeUtility.formatDateAsMonth(new Date('07/23/2016')); + + expect(formattedMonth).toBe('Jul'); + }); + + it('should format ISO date properly', () => { + const formattedMonth = datetimeUtility.formatDateAsMonth('2016-07-23T00:00:00.559Z'); + + expect(formattedMonth).toBe('Jul'); + }); + }); + describe('formatDate', () => { it('should format date properly', () => { const formattedDate = datetimeUtility.formatDate(new Date('07/23/2016')); @@ -654,6 +682,20 @@ describe('differenceInSeconds', () => { }); }); +describe('differenceInMonths', () => { + const startDateTime = new Date('2019-07-17T00:00:00.000Z'); + + it.each` + startDate | endDate | expected + ${startDateTime} | ${startDateTime} | ${0} + ${startDateTime} | ${new Date('2019-12-17T12:00:00.000Z')} | ${5} + ${startDateTime} | ${new Date('2021-02-18T00:00:00.000Z')} | ${19} + ${new Date('2021-02-18T00:00:00.000Z')} | ${startDateTime} | ${-19} + `('returns $expected for $endDate - $startDate', ({ startDate, endDate, expected }) => { + expect(datetimeUtility.differenceInMonths(startDate, endDate)).toBe(expected); + }); +}); + describe('differenceInMilliseconds', () => { const startDateTime = new Date('2019-07-17T00:00:00.000Z'); @@ -667,3 +709,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/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index 2f8f1092612..f600f2bcd55 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -1,5 +1,6 @@ import { formatRelevantDigits, + bytesToKB, bytesToKiB, bytesToMiB, bytesToGiB, @@ -54,6 +55,16 @@ describe('Number Utils', () => { }); }); + describe('bytesToKB', () => { + it.each` + input | output + ${1000} | ${1} + ${1024} | ${1.024} + `('returns $output KB for $input bytes', ({ input, output }) => { + expect(bytesToKB(input)).toBe(output); + }); + }); + describe('bytesToKiB', () => { it('calculates KiB for the given bytes', () => { expect(bytesToKiB(1024)).toEqual(1); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index 1aaae80dcdf..43de195c702 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -13,6 +13,23 @@ describe('init markdown', () => { textArea.parentNode.removeChild(textArea); }); + describe('insertMarkdownText', () => { + it('will not error if selected text is a number', () => { + const selected = 2; + + insertMarkdownText({ + textArea, + text: '', + tag: '', + blockTag: null, + selected, + wrap: false, + }); + + expect(textArea.value).toBe(selected.toString()); + }); + }); + describe('textArea', () => { describe('without selection', () => { it('inserts the tag on an empty line', () => { @@ -251,88 +268,10 @@ describe('init markdown', () => { }); }); - describe('Ace Editor', () => { - let editor; - - beforeEach(() => { - editor = { - getSelectionRange: jest.fn().mockReturnValue({ - start: 0, - end: 0, - }), - getValue: jest.fn().mockReturnValue('this is text \n in two lines'), - insert: jest.fn(), - navigateLeft: jest.fn(), - }; - }); - - it('uses ace editor insert text when editor is passed in', () => { - insertMarkdownText({ - text: editor.getValue, - tag: '*', - blockTag: null, - selected: '', - wrap: false, - editor, - }); - - expect(editor.insert).toHaveBeenCalled(); - }); - - it('adds block tags on line above and below selection', () => { - const selected = 'this text \n is multiple \n lines'; - const text = `before \n ${selected} \n after`; - - insertMarkdownText({ - text, - tag: '', - blockTag: '***', - selected, - wrap: true, - editor, - }); - - expect(editor.insert).toHaveBeenCalledWith(`***\n${selected}\n***`); - }); - - it('uses ace editor to navigate back tag length when nothing is selected', () => { - insertMarkdownText({ - text: editor.getValue, - tag: '*', - blockTag: null, - selected: '', - wrap: true, - editor, - }); - - expect(editor.navigateLeft).toHaveBeenCalledWith(1); - }); - - it('ace editor does not navigate back when there is selected text', () => { - insertMarkdownText({ - text: editor.getValue, - tag: '*', - blockTag: null, - selected: 'foobar', - wrap: true, - editor, - }); - - expect(editor.navigateLeft).not.toHaveBeenCalled(); - }); - }); - describe('Editor Lite', () => { let editor; - let origGon; beforeEach(() => { - origGon = window.gon; - window.gon = { - features: { - monacoBlobs: true, - }, - }; editor = { getSelection: jest.fn().mockReturnValue({ startLineNumber: 1, @@ -347,10 +286,6 @@ describe('init markdown', () => { }; }); - afterEach(() => { - window.gon = origGon; - }); - it('replaces selected text', () => { insertMarkdownText({ text: editor.getValue, diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 869ae274a3f..0f9290e36b5 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -509,6 +509,20 @@ describe('URL utility', () => { }); }); + describe('isBlobUrl', () => { + it.each` + url | valid + ${undefined} | ${false} + ${'blob:http://gitlab.com/abcd'} | ${true} + ${'data:image/png;base64,abcdef'} | ${false} + ${'notaurl'} | ${false} + ${'../relative_url'} | ${false} + ${'<a></a>'} | ${false} + `('returns $valid for $url', ({ url, valid }) => { + expect(urlUtils.isBlobUrl(url)).toBe(valid); + }); + }); + describe('relativePathToAbsolute', () => { it.each` path | base | result @@ -664,6 +678,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 +715,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 +826,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/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js index 559ce4f9414..e32deaea993 100644 --- a/spec/frontend/logs/components/environment_logs_spec.js +++ b/spec/frontend/logs/components/environment_logs_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf, GlIcon, GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlSprintf, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import EnvironmentLogs from '~/logs/components/environment_logs.vue'; @@ -121,7 +121,7 @@ describe('EnvironmentLogs', () => { it('displays UI elements', () => { initWrapper(); - expect(findEnvironmentsDropdown().is(GlDeprecatedDropdown)).toBe(true); + expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true); expect(findSimpleFilters().exists()).toBe(true); expect(findLogControlButtons().exists()).toBe(true); @@ -164,7 +164,7 @@ describe('EnvironmentLogs', () => { it('displays a disabled environments dropdown', () => { expect(findEnvironmentsDropdown().attributes('disabled')).toBe('true'); - expect(findEnvironmentsDropdown().findAll(GlDeprecatedDropdownItem).length).toBe(0); + expect(findEnvironmentsDropdown().findAll(GlDropdownItem).length).toBe(0); }); it('does not update buttons state', () => { @@ -241,7 +241,7 @@ describe('EnvironmentLogs', () => { }); it('populates environments dropdown', () => { - const items = findEnvironmentsDropdown().findAll(GlDeprecatedDropdownItem); + const items = findEnvironmentsDropdown().findAll(GlDropdownItem); expect(findEnvironmentsDropdown().props('text')).toBe(mockEnvName); expect(items.length).toBe(mockEnvironments.length); mockEnvironments.forEach((env, i) => { @@ -251,14 +251,14 @@ describe('EnvironmentLogs', () => { }); it('dropdown has one environment selected', () => { - const items = findEnvironmentsDropdown().findAll(GlDeprecatedDropdownItem); + const items = findEnvironmentsDropdown().findAll(GlDropdownItem); mockEnvironments.forEach((env, i) => { const item = items.at(i); if (item.text() !== mockEnvName) { - expect(item.find(GlIcon).classes('invisible')).toBe(true); + expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy(); } else { - expect(item.find(GlIcon).classes('invisible')).toBe(false); + expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy(); } }); }); @@ -286,7 +286,7 @@ describe('EnvironmentLogs', () => { describe('when user clicks', () => { it('environment name, trace is refreshed', () => { - const items = findEnvironmentsDropdown().findAll(GlDeprecatedDropdownItem); + const items = findEnvironmentsDropdown().findAll(GlDropdownItem); const index = 1; // any env expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything()); diff --git a/spec/frontend/logs/components/log_simple_filters_spec.js b/spec/frontend/logs/components/log_simple_filters_spec.js index 1e30a7df559..b819f0d25a8 100644 --- a/spec/frontend/logs/components/log_simple_filters_spec.js +++ b/spec/frontend/logs/components/log_simple_filters_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createStore } from '~/logs/stores'; import { mockPods, mockPodName } from '../mock_data'; @@ -17,7 +17,7 @@ describe('LogSimpleFilters', () => { const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' }); const findPodsDropdownItems = () => findPodsDropdown() - .findAll(GlDeprecatedDropdownItem) + .findAll(GlDropdownItem) .filter(item => !('disabled' in item.attributes())); const mockPodsLoading = () => { @@ -114,9 +114,9 @@ describe('LogSimpleFilters', () => { mockPods.forEach((pod, i) => { const item = items.at(i); if (item.text() !== mockPodName) { - expect(item.find(GlIcon).classes('invisible')).toBe(true); + expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy(); } else { - expect(item.find(GlIcon).classes('invisible')).toBe(false); + expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy(); } }); }); 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/milestones/stores/actions_spec.js b/spec/frontend/milestones/stores/actions_spec.js new file mode 100644 index 00000000000..ad73d0e4238 --- /dev/null +++ b/spec/frontend/milestones/stores/actions_spec.js @@ -0,0 +1,140 @@ +import testAction from 'helpers/vuex_action_helper'; +import createState from '~/milestones/stores/state'; +import * as actions from '~/milestones/stores/actions'; +import * as types from '~/milestones/stores/mutation_types'; + +let mockProjectMilestonesReturnValue; +let mockProjectSearchReturnValue; + +jest.mock('~/api', () => ({ + // `__esModule: true` is required when mocking modules with default exports: + // https://jestjs.io/docs/en/jest-object#jestmockmodulename-factory-options + __esModule: true, + default: { + projectMilestones: () => mockProjectMilestonesReturnValue, + projectSearch: () => mockProjectSearchReturnValue, + }, +})); + +describe('Milestone combobox Vuex store actions', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe('setProjectId', () => { + it(`commits ${types.SET_PROJECT_ID} with the new project ID`, () => { + const projectId = '4'; + testAction(actions.setProjectId, projectId, state, [ + { type: types.SET_PROJECT_ID, payload: projectId }, + ]); + }); + }); + + describe('setSelectedMilestones', () => { + it(`commits ${types.SET_SELECTED_MILESTONES} with the new selected milestones name`, () => { + const selectedMilestones = ['v1.2.3']; + testAction(actions.setSelectedMilestones, selectedMilestones, state, [ + { type: types.SET_SELECTED_MILESTONES, payload: selectedMilestones }, + ]); + }); + }); + + describe('toggleMilestones', () => { + const selectedMilestone = 'v1.2.3'; + it(`commits ${types.ADD_SELECTED_MILESTONE} with the new selected milestone name`, () => { + testAction(actions.toggleMilestones, selectedMilestone, state, [ + { type: types.ADD_SELECTED_MILESTONE, payload: selectedMilestone }, + ]); + }); + + it(`commits ${types.REMOVE_SELECTED_MILESTONE} with the new selected milestone name`, () => { + state.selectedMilestones = [selectedMilestone]; + testAction(actions.toggleMilestones, selectedMilestone, state, [ + { type: types.REMOVE_SELECTED_MILESTONE, payload: selectedMilestone }, + ]); + }); + }); + + describe('search', () => { + it(`commits ${types.SET_QUERY} with the new search query`, () => { + const query = 'v1.0'; + testAction( + actions.search, + query, + state, + [{ type: types.SET_QUERY, payload: query }], + [{ type: 'searchMilestones' }], + ); + }); + }); + + describe('searchMilestones', () => { + describe('when the search is successful', () => { + const projectSearchApiResponse = { data: [{ title: 'v1.0' }] }; + + beforeEach(() => { + mockProjectSearchReturnValue = Promise.resolve(projectSearchApiResponse); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchMilestones, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectSearchApiResponse }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + + describe('when the search fails', () => { + const error = new Error('Something went wrong!'); + + beforeEach(() => { + mockProjectSearchReturnValue = Promise.reject(error); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.searchMilestones, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + }); + + describe('fetchMilestones', () => { + describe('when the fetch is successful', () => { + const projectMilestonesApiResponse = { data: [{ title: 'v1.0' }] }; + + beforeEach(() => { + mockProjectMilestonesReturnValue = Promise.resolve(projectMilestonesApiResponse); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.fetchMilestones, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectMilestonesApiResponse }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + + describe('when the fetch fails', () => { + const error = new Error('Something went wrong!'); + + beforeEach(() => { + mockProjectMilestonesReturnValue = Promise.reject(error); + }); + + it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => { + return testAction(actions.fetchMilestones, undefined, state, [ + { type: types.REQUEST_START }, + { type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error }, + { type: types.REQUEST_FINISH }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/milestones/stores/getter_spec.js b/spec/frontend/milestones/stores/getter_spec.js new file mode 100644 index 00000000000..df7c3d28e67 --- /dev/null +++ b/spec/frontend/milestones/stores/getter_spec.js @@ -0,0 +1,15 @@ +import * as getters from '~/milestones/stores/getters'; + +describe('Milestone comboxbox Vuex store getters', () => { + describe('isLoading', () => { + it.each` + requestCount | isLoading + ${2} | ${true} + ${1} | ${true} + ${0} | ${false} + ${-1} | ${false} + `('returns true when at least one request is in progress', ({ requestCount, isLoading }) => { + expect(getters.isLoading({ requestCount })).toBe(isLoading); + }); + }); +}); diff --git a/spec/frontend/milestones/stores/mutations_spec.js b/spec/frontend/milestones/stores/mutations_spec.js new file mode 100644 index 00000000000..8f8ce3c87ad --- /dev/null +++ b/spec/frontend/milestones/stores/mutations_spec.js @@ -0,0 +1,159 @@ +import createState from '~/milestones/stores/state'; +import mutations from '~/milestones/stores/mutations'; +import * as types from '~/milestones/stores/mutation_types'; + +describe('Milestones combobox Vuex store mutations', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe('initial state', () => { + it('is created with the correct structure and initial values', () => { + expect(state).toEqual({ + projectId: null, + groupId: null, + query: '', + matches: { + projectMilestones: { + list: [], + totalCount: 0, + error: null, + }, + }, + selectedMilestones: [], + requestCount: 0, + }); + }); + }); + + describe(`${types.SET_PROJECT_ID}`, () => { + it('updates the project ID', () => { + const newProjectId = '4'; + mutations[types.SET_PROJECT_ID](state, newProjectId); + + expect(state.projectId).toBe(newProjectId); + }); + }); + + describe(`${types.SET_SELECTED_MILESTONES}`, () => { + it('sets the selected milestones', () => { + const selectedMilestones = ['v1.2.3']; + mutations[types.SET_SELECTED_MILESTONES](state, selectedMilestones); + + expect(state.selectedMilestones).toEqual(['v1.2.3']); + }); + }); + + describe(`${types.ADD_SELECTED_MILESTONESs}`, () => { + it('adds the selected milestones', () => { + const selectedMilestone = 'v1.2.3'; + mutations[types.ADD_SELECTED_MILESTONE](state, selectedMilestone); + + expect(state.selectedMilestones).toEqual(['v1.2.3']); + }); + }); + + describe(`${types.REMOVE_SELECTED_MILESTONES}`, () => { + it('removes the selected milestones', () => { + const selectedMilestone = 'v1.2.3'; + + mutations[types.SET_SELECTED_MILESTONES](state, [selectedMilestone]); + expect(state.selectedMilestones).toEqual(['v1.2.3']); + + mutations[types.REMOVE_SELECTED_MILESTONE](state, selectedMilestone); + expect(state.selectedMilestones).toEqual([]); + }); + }); + + describe(`${types.SET_QUERY}`, () => { + it('updates the search query', () => { + const newQuery = 'hello'; + mutations[types.SET_QUERY](state, newQuery); + + expect(state.query).toBe(newQuery); + }); + }); + + describe(`${types.REQUEST_START}`, () => { + it('increments requestCount by 1', () => { + mutations[types.REQUEST_START](state); + expect(state.requestCount).toBe(1); + + mutations[types.REQUEST_START](state); + expect(state.requestCount).toBe(2); + + mutations[types.REQUEST_START](state); + expect(state.requestCount).toBe(3); + }); + }); + + describe(`${types.REQUEST_FINISH}`, () => { + it('decrements requestCount by 1', () => { + state.requestCount = 3; + + mutations[types.REQUEST_FINISH](state); + expect(state.requestCount).toBe(2); + + mutations[types.REQUEST_FINISH](state); + expect(state.requestCount).toBe(1); + + mutations[types.REQUEST_FINISH](state); + expect(state.requestCount).toBe(0); + }); + }); + + describe(`${types.RECEIVE_PROJECT_MILESTONES_SUCCESS}`, () => { + it('updates state.matches.projectMilestones based on the provided API response', () => { + const response = { + data: [ + { + title: 'v0.1', + }, + { + title: 'v0.2', + }, + ], + headers: { + 'x-total': 2, + }, + }; + + mutations[types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response); + + expect(state.matches.projectMilestones).toEqual({ + list: [ + { + title: 'v0.1', + }, + { + title: 'v0.2', + }, + ], + error: null, + totalCount: 2, + }); + }); + + describe(`${types.RECEIVE_PROJECT_MILESTONES_ERROR}`, () => { + it('updates state.matches.projectMilestones to an empty state with the error object', () => { + const error = new Error('Something went wrong!'); + + state.matches.projectMilestones = { + list: [{ title: 'v0.1' }], + totalCount: 1, + error: null, + }; + + mutations[types.RECEIVE_PROJECT_MILESTONES_ERROR](state, error); + + expect(state.matches.projectMilestones).toEqual({ + list: [], + totalCount: 0, + error, + }); + }); + }); + }); +}); diff --git a/spec/frontend/mini_pipeline_graph_dropdown_spec.js b/spec/frontend/mini_pipeline_graph_dropdown_spec.js index 506290834c8..1ecf01894af 100644 --- a/spec/frontend/mini_pipeline_graph_dropdown_spec.js +++ b/spec/frontend/mini_pipeline_graph_dropdown_spec.js @@ -69,7 +69,7 @@ describe('Mini Pipeline Graph Dropdown', () => { html: `<li> <a class="mini-pipeline-graph-dropdown-item" href="#"> <span class="ci-status-icon ci-status-icon-failed"></span> - <span class="ci-build-text">build</span> + <span>build</span> </a> <a class="ci-action-icon-wrapper js-ci-action-icon" href="#"></a> </li>`, diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index a28ecac00fd..645aca0b157 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -52,7 +52,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` </gl-dropdown-section-header-stub> <gl-search-box-by-type-stub - class="gl-m-3" clearbuttontitle="Clear" value="" /> 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/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 8947a6c1570..ee0e1fd3176 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -38,8 +38,6 @@ import MonitorStackedColumnChart from '~/monitoring/components/charts/stacked_co import { createStore, monitoringDashboard } from '~/monitoring/stores'; import { createStore as createEmbedGroupStore } from '~/monitoring/stores/embed_group'; -global.URL.createObjectURL = jest.fn(); - const mocks = { $toast: { show: jest.fn(), @@ -94,6 +92,8 @@ describe('Dashboard Panel', () => { state = store.state.monitoringDashboard; axiosMock = new AxiosMockAdapter(axios); + + jest.spyOn(URL, 'createObjectURL'); }); afterEach(() => { 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/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js index 8b97c8ed125..2bf2065b178 100644 --- a/spec/frontend/monitoring/router_spec.js +++ b/spec/frontend/monitoring/router_spec.js @@ -105,8 +105,7 @@ describe('Monitoring router', () => { path | currentDashboard ${'/panel/new'} | ${undefined} ${'/dashboard.yml/panel/new'} | ${'dashboard.yml'} - ${'/config/prometheus/common_metrics.yml/panel/new'} | ${'config/prometheus/common_metrics.yml'} - ${'/config%2Fprometheus%2Fcommon_metrics.yml/panel/new'} | ${'config/prometheus/common_metrics.yml'} + ${'/config%2Fprometheus%2Fcommon_metrics.yml/panel/new'} | ${'config%2Fprometheus%2Fcommon_metrics.yml'} `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { const wrapper = createWrapper(BASE_PATH, path); 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..9f3655c53b9 100644 --- a/spec/frontend/notes/components/discussion_filter_spec.js +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -25,6 +25,8 @@ describe('DiscussionFilter component', () => { const filterDiscussion = jest.fn(); + const findFilter = filterType => wrapper.find(`.dropdown-item[data-filter-type="${filterType}"]`); + const mountComponent = () => { const discussions = [ { @@ -74,22 +76,22 @@ 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); }); it('updates to the selected item', () => { - const filterItem = wrapper.find( - `.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.HISTORY}"] button`, - ); + const filterItem = findFilter(DISCUSSION_FILTER_TYPES.ALL); filterItem.trigger('click'); @@ -97,37 +99,37 @@ describe('DiscussionFilter component', () => { }); it('only updates when selected filter changes', () => { - wrapper - .find(`.dropdown-menu li[data-filter-type="${DISCUSSION_FILTER_TYPES.ALL}"] button`) - .trigger('click'); + findFilter(DISCUSSION_FILTER_TYPES.ALL).trigger('click'); expect(filterDiscussion).not.toHaveBeenCalled(); }); + it('disables timeline view if it was enabled', () => { + store.state.isTimelineEnabled = true; + + findFilter(DISCUSSION_FILTER_TYPES.HISTORY).trigger('click'); + + expect(wrapper.vm.$store.state.isTimelineEnabled).toBe(false); + }); + 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`, - ); - filterItem.trigger('click'); + findFilter(DISCUSSION_FILTER_TYPES.HISTORY).trigger('click'); expect(wrapper.vm.$store.state.commentsDisabled).toBe(true); }); 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`, - ); - filterItem.trigger('click'); + findFilter(DISCUSSION_FILTER_TYPES.ALL).trigger('click'); expect(wrapper.vm.$store.state.commentsDisabled).toBe(false); }); 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/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index c6034639a4a..e905a12919e 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -174,6 +174,23 @@ describe('note_app', () => { }); }); + describe('timeline view', () => { + beforeEach(() => { + setFixtures('<div class="js-discussions-count"></div>'); + + axiosMock.onAny().reply(mockData.getIndividualNoteResponse); + store.state.commentsDisabled = false; + store.state.isTimelineEnabled = true; + + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + + it('should not render comments form', () => { + expect(wrapper.find('.js-main-target-form').exists()).toBe(false); + }); + }); + describe('while fetching data', () => { beforeEach(() => { setFixtures('<div class="js-discussions-count"></div>'); diff --git a/spec/frontend/notes/components/sort_discussion_spec.js b/spec/frontend/notes/components/sort_discussion_spec.js index 575f1057db2..739e247735d 100644 --- a/spec/frontend/notes/components/sort_discussion_spec.js +++ b/spec/frontend/notes/components/sort_discussion_spec.js @@ -46,7 +46,7 @@ describe('Sort Discussion component', () => { it('calls setDiscussionSortDirection when update is emitted', () => { findLocalStorageSync().vm.$emit('input', ASC); - expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', ASC); + expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', { direction: ASC }); }); }); @@ -55,9 +55,11 @@ 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(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', { + direction: DESC, + }); expect(Tracking.event).toHaveBeenCalledWith(undefined, 'change_discussion_sort_direction', { property: DESC, }); @@ -67,7 +69,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,21 +81,23 @@ 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(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', { + direction: ASC, + }); expect(Tracking.event).toHaveBeenCalledWith(undefined, 'change_discussion_sort_direction', { property: ASC, }); }); - 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/notes/components/timeline_toggle_spec.js b/spec/frontend/notes/components/timeline_toggle_spec.js new file mode 100644 index 00000000000..b8df6fc7996 --- /dev/null +++ b/spec/frontend/notes/components/timeline_toggle_spec.js @@ -0,0 +1,117 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import Vuex from 'vuex'; +import TimelineToggle, { + timelineEnabledTooltip, + timelineDisabledTooltip, +} from '~/notes/components/timeline_toggle.vue'; +import createStore from '~/notes/stores'; +import { ASC, DESC } from '~/notes/constants'; +import { trackToggleTimelineView } from '~/notes/utils'; +import Tracking from '~/tracking'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Timeline toggle', () => { + let wrapper; + let store; + const mockEvent = { currentTarget: { blur: jest.fn() } }; + + const createComponent = () => { + jest.spyOn(store, 'dispatch').mockImplementation(); + jest.spyOn(Tracking, 'event').mockImplementation(); + + wrapper = shallowMount(TimelineToggle, { + localVue, + store, + }); + }; + + const findGlButton = () => wrapper.find(GlButton); + + beforeEach(() => { + store = createStore(); + createComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + store.dispatch.mockReset(); + mockEvent.currentTarget.blur.mockReset(); + Tracking.event.mockReset(); + }); + + describe('ON state', () => { + it('should update timeline flag in the store', () => { + store.state.isTimelineEnabled = false; + findGlButton().vm.$emit('click', mockEvent); + expect(store.dispatch).toHaveBeenCalledWith('setTimelineView', true); + }); + + it('should set sort direction to DESC if not set', () => { + store.state.isTimelineEnabled = true; + store.state.sortDirection = ASC; + findGlButton().vm.$emit('click', mockEvent); + expect(store.dispatch).toHaveBeenCalledWith('setDiscussionSortDirection', { + direction: DESC, + persist: false, + }); + }); + + it('should set correct UI state', async () => { + store.state.isTimelineEnabled = true; + findGlButton().vm.$emit('click', mockEvent); + await wrapper.vm.$nextTick(); + expect(findGlButton().attributes('title')).toBe(timelineEnabledTooltip); + expect(findGlButton().attributes('selected')).toBe('true'); + expect(mockEvent.currentTarget.blur).toHaveBeenCalled(); + }); + + it('should track Snowplow event', async () => { + store.state.isTimelineEnabled = true; + await wrapper.vm.$nextTick(); + + findGlButton().trigger('click'); + + const { category, action, label, property, value } = trackToggleTimelineView(true); + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property, value }); + }); + }); + + describe('OFF state', () => { + it('should update timeline flag in the store', () => { + store.state.isTimelineEnabled = true; + findGlButton().vm.$emit('click', mockEvent); + expect(store.dispatch).toHaveBeenCalledWith('setTimelineView', false); + }); + + it('should NOT update sort direction', () => { + store.state.isTimelineEnabled = false; + findGlButton().vm.$emit('click', mockEvent); + expect(store.dispatch).not.toHaveBeenCalledWith(); + }); + + it('should set correct UI state', async () => { + store.state.isTimelineEnabled = false; + findGlButton().vm.$emit('click', mockEvent); + await wrapper.vm.$nextTick(); + expect(findGlButton().attributes('title')).toBe(timelineDisabledTooltip); + expect(findGlButton().attributes('selected')).toBe(undefined); + expect(mockEvent.currentTarget.blur).toHaveBeenCalled(); + }); + + it('should track Snowplow event', async () => { + store.state.isTimelineEnabled = false; + await wrapper.vm.$nextTick(); + + findGlButton().trigger('click'); + + const { category, action, label, property, value } = trackToggleTimelineView(false); + expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property, value }); + }); + }); +}); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 4681f3aa429..920959f41e7 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -1144,9 +1144,14 @@ describe('Actions Notes Store', () => { it('calls the correct mutation with the correct args', done => { testAction( actions.setDiscussionSortDirection, - notesConstants.DESC, + { direction: notesConstants.DESC, persist: false }, {}, - [{ type: mutationTypes.SET_DISCUSSIONS_SORT, payload: notesConstants.DESC }], + [ + { + type: mutationTypes.SET_DISCUSSIONS_SORT, + payload: { direction: notesConstants.DESC, persist: false }, + }, + ], [], done, ); diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js index a07aa45d812..1a369caee49 100644 --- a/spec/frontend/notes/stores/getters_spec.js +++ b/spec/frontend/notes/stores/getters_spec.js @@ -6,6 +6,7 @@ import { noteableDataMock, individualNote, collapseNotesMock, + discussionMock, discussion1, discussion2, discussion3, @@ -65,6 +66,18 @@ describe('Getters Notes Store', () => { it('should return all discussions in the store', () => { expect(getters.discussions(state)).toEqual([individualNote]); }); + + it('should transform discussion to individual notes in timeline view', () => { + state.discussions = [discussionMock]; + state.isTimelineEnabled = true; + + expect(getters.discussions(state).length).toEqual(discussionMock.notes.length); + getters.discussions(state).forEach(discussion => { + expect(discussion.individual_note).toBe(true); + expect(discussion.id).toBe(discussion.notes[0].id); + expect(discussion.created_at).toBe(discussion.notes[0].created_at); + }); + }); }); describe('resolvedDiscussionsById', () => { diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index b953bffc4fe..2618c3a53b8 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -680,9 +680,10 @@ describe('Notes Store mutations', () => { }); it('sets sort order', () => { - mutations.SET_DISCUSSIONS_SORT(state, DESC); + mutations.SET_DISCUSSIONS_SORT(state, { direction: DESC, persist: false }); expect(state.discussionSortOrder).toBe(DESC); + expect(state.persistSortOrder).toBe(false); }); }); 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/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js index c13981fbb87..b44609e8ae7 100644 --- a/spec/frontend/packages/details/components/composer_installation_spec.js +++ b/spec/frontend/packages/details/components/composer_installation_spec.js @@ -12,21 +12,23 @@ localVue.use(Vuex); describe('ComposerInstallation', () => { let wrapper; + let store; const composerRegistryIncludeStr = 'foo/registry'; const composerPackageIncludeStr = 'foo/package'; - const store = new Vuex.Store({ - state: { - packageEntity, - composerHelpPath, - }, - getters: { - composerRegistryInclude: () => composerRegistryIncludeStr, - composerPackageInclude: () => composerPackageIncludeStr, - }, - }); + const createStore = (groupExists = true) => { + store = new Vuex.Store({ + state: { packageEntity, composerHelpPath }, + getters: { + composerRegistryInclude: () => composerRegistryIncludeStr, + composerPackageInclude: () => composerPackageIncludeStr, + groupExists: () => groupExists, + }, + }); + }; + const findRootNode = () => wrapper.find('[data-testid="root-node"]'); const findRegistryInclude = () => wrapper.find('[data-testid="registry-include"]'); const findPackageInclude = () => wrapper.find('[data-testid="package-include"]'); const findHelpText = () => wrapper.find('[data-testid="help-text"]'); @@ -42,15 +44,16 @@ describe('ComposerInstallation', () => { }); } - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); describe('registry include command', () => { + beforeEach(() => { + createStore(); + createComponent(); + }); + it('uses code_instructions', () => { const registryIncludeCommand = findRegistryInclude(); expect(registryIncludeCommand.exists()).toBe(true); @@ -62,11 +65,16 @@ describe('ComposerInstallation', () => { }); it('has the correct title', () => { - expect(findRegistryInclude().props('label')).toBe('composer.json registry include'); + expect(findRegistryInclude().props('label')).toBe('Add composer registry'); }); }); describe('package include command', () => { + beforeEach(() => { + createStore(); + createComponent(); + }); + it('uses code_instructions', () => { const registryIncludeCommand = findPackageInclude(); expect(registryIncludeCommand.exists()).toBe(true); @@ -78,7 +86,7 @@ describe('ComposerInstallation', () => { }); it('has the correct title', () => { - expect(findPackageInclude().props('label')).toBe('composer.json require package include'); + expect(findPackageInclude().props('label')).toBe('Install package version'); }); it('has the correct help text', () => { @@ -91,4 +99,20 @@ describe('ComposerInstallation', () => { }); }); }); + + describe('root node', () => { + it('is normally rendered', () => { + createStore(); + createComponent(); + + expect(findRootNode().exists()).toBe(true); + }); + + it('is not rendered when the group does not exist', () => { + createStore(false); + createComponent(); + + expect(findRootNode().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js index 0e95ee4cfd3..b8c2138e7f5 100644 --- a/spec/frontend/packages/details/store/getters_spec.js +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -15,6 +15,7 @@ import { pypiSetupCommand, composerRegistryInclude, composerPackageInclude, + groupExists, } from '~/packages/details/store/getters'; import { conanPackage, @@ -31,7 +32,6 @@ import { registryUrl, pypiSetupCommandStr, } from '../mock_data'; -import { generateConanRecipe } from '~/packages/details/utils'; import { NpmManager } from '~/packages/details/constants'; describe('Getters PackageDetails Store', () => { @@ -53,8 +53,7 @@ describe('Getters PackageDetails Store', () => { }; }; - const recipe = generateConanRecipe(conanPackage); - const conanInstallationCommandStr = `conan install ${recipe} --remote=gitlab`; + const conanInstallationCommandStr = `conan install ${conanPackage.name} --remote=gitlab`; const conanSetupCommandStr = `conan remote add gitlab ${registryUrl}`; const mavenCommandStr = generateMavenCommand(packageWithoutBuildInfo.maven_metadatum); @@ -69,11 +68,12 @@ 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 composerRegistryIncludeStr = '{"type":"composer","url":"foo"}'; - const composerPackageIncludeStr = JSON.stringify({ - [packageWithoutBuildInfo.name]: packageWithoutBuildInfo.version, - }); + const pypiPipCommandStr = `pip install ${pypiPackage.name} --extra-index-url ${registryUrl}`; + const composerRegistryIncludeStr = + 'composer config repositories.gitlab.com/123 \'{"type": "composer", "url": "foo"}\''; + const composerPackageIncludeStr = `composer req ${[packageWithoutBuildInfo.name]}:${ + packageWithoutBuildInfo.version + }`; describe('packagePipeline', () => { it('should return the pipeline info when pipeline exists', () => { @@ -101,7 +101,7 @@ describe('Getters PackageDetails Store', () => { ${packageWithoutBuildInfo} | ${'Maven'} ${npmPackage} | ${'NPM'} ${nugetPackage} | ${'NuGet'} - ${pypiPackage} | ${'PyPi'} + ${pypiPackage} | ${'PyPI'} `(`package type`, ({ packageEntity, expectedResult }) => { beforeEach(() => setupState({ packageEntity })); @@ -223,7 +223,7 @@ describe('Getters PackageDetails Store', () => { describe('composer string getters', () => { it('gets the correct composerRegistryInclude command', () => { - setupState({ composerPath: 'foo' }); + setupState({ composerPath: 'foo', composerConfigRepositoryName: 'gitlab.com/123' }); expect(composerRegistryInclude(state)).toBe(composerRegistryIncludeStr); }); @@ -234,4 +234,18 @@ describe('Getters PackageDetails Store', () => { expect(composerPackageInclude(state)).toBe(composerPackageIncludeStr); }); }); + + describe('check if group', () => { + it('is set', () => { + setupState({ groupListUrl: '/groups/composer/-/packages' }); + + expect(groupExists(state)).toBe(true); + }); + + it('is not set', () => { + setupState({ groupListUrl: '' }); + + expect(groupExists(state)).toBe(false); + }); + }); }); diff --git a/spec/frontend/packages/details/utils_spec.js b/spec/frontend/packages/details/utils_spec.js deleted file mode 100644 index 087888016ee..00000000000 --- a/spec/frontend/packages/details/utils_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { generateConanRecipe } from '~/packages/details/utils'; -import { conanPackage } from '../mock_data'; - -describe('Package detail utils', () => { - describe('generateConanRecipe', () => { - it('correctly generates the conan recipe', () => { - const recipe = generateConanRecipe(conanPackage); - - expect(recipe).toEqual(conanPackage.recipe); - }); - - it('returns an empty recipe when no information is supplied', () => { - const recipe = generateConanRecipe({}); - - expect(recipe).toEqual('/@/'); - }); - - it('recipe returns empty strings for missing metadata', () => { - const recipe = generateConanRecipe({ name: 'foo', version: '0.0.1' }); - - expect(recipe).toBe('foo/0.0.1@/'); - }); - }); -}); diff --git a/spec/frontend/packages/list/coming_soon/helpers_spec.js b/spec/frontend/packages/list/coming_soon/helpers_spec.js deleted file mode 100644 index 4a996bfad76..00000000000 --- a/spec/frontend/packages/list/coming_soon/helpers_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import * as comingSoon from '~/packages/list/coming_soon/helpers'; -import { fakeIssues, asGraphQLResponse, asViewModel } from './mock_data'; - -jest.mock('~/api.js'); - -describe('Coming Soon Helpers', () => { - const [noLabels, acceptingMergeRequestLabel, workflowLabel] = fakeIssues; - - describe('toViewModel', () => { - it('formats a GraphQL response correctly', () => { - expect(comingSoon.toViewModel(asGraphQLResponse)).toEqual(asViewModel); - }); - }); - - describe('findWorkflowLabel', () => { - it('finds a workflow label', () => { - expect(comingSoon.findWorkflowLabel(workflowLabel.labels)).toEqual(workflowLabel.labels[0]); - }); - - it("returns undefined when there isn't one", () => { - expect(comingSoon.findWorkflowLabel(noLabels.labels)).toBeUndefined(); - }); - }); - - describe('findAcceptingContributionsLabel', () => { - it('finds the correct label when it exists', () => { - expect(comingSoon.findAcceptingContributionsLabel(acceptingMergeRequestLabel.labels)).toEqual( - acceptingMergeRequestLabel.labels[0], - ); - }); - - it("returns undefined when there isn't one", () => { - expect(comingSoon.findAcceptingContributionsLabel(noLabels.labels)).toBeUndefined(); - }); - }); -}); diff --git a/spec/frontend/packages/list/coming_soon/mock_data.js b/spec/frontend/packages/list/coming_soon/mock_data.js deleted file mode 100644 index bb4568e4bd5..00000000000 --- a/spec/frontend/packages/list/coming_soon/mock_data.js +++ /dev/null @@ -1,90 +0,0 @@ -export const fakeIssues = [ - { - id: 1, - iid: 1, - title: 'issue one', - webUrl: 'foo', - }, - { - id: 2, - iid: 2, - title: 'issue two', - labels: [{ title: 'Accepting merge requests', color: '#69d100' }], - milestone: { - title: '12.10', - }, - webUrl: 'foo', - }, - { - id: 3, - iid: 3, - title: 'issue three', - labels: [{ title: 'workflow::In dev', color: '#428bca' }], - webUrl: 'foo', - }, - { - id: 4, - iid: 4, - title: 'issue four', - labels: [ - { title: 'Accepting merge requests', color: '#69d100' }, - { title: 'workflow::In dev', color: '#428bca' }, - ], - webUrl: 'foo', - }, -]; - -export const asGraphQLResponse = { - project: { - issues: { - nodes: fakeIssues.map(x => ({ - ...x, - labels: { - nodes: x.labels, - }, - })), - }, - }, -}; - -export const asViewModel = [ - { - ...fakeIssues[0], - labels: [], - }, - { - ...fakeIssues[1], - labels: [ - { - title: 'Accepting merge requests', - color: '#69d100', - scoped: false, - }, - ], - }, - { - ...fakeIssues[2], - labels: [ - { - title: 'workflow::In dev', - color: '#428bca', - scoped: true, - }, - ], - }, - { - ...fakeIssues[3], - labels: [ - { - title: 'workflow::In dev', - color: '#428bca', - scoped: true, - }, - { - title: 'Accepting merge requests', - color: '#69d100', - scoped: false, - }, - ], - }, -]; diff --git a/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js deleted file mode 100644 index c4cdadc45e6..00000000000 --- a/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js +++ /dev/null @@ -1,138 +0,0 @@ -import { GlEmptyState, GlSkeletonLoader, GlLabel } from '@gitlab/ui'; -import { mount, createLocalVue } from '@vue/test-utils'; -import VueApollo, { ApolloQuery } from 'vue-apollo'; -import ComingSoon from '~/packages/list/coming_soon/packages_coming_soon.vue'; -import { TrackingActions } from '~/packages/shared/constants'; -import { asViewModel } from './mock_data'; -import Tracking from '~/tracking'; - -jest.mock('~/packages/list/coming_soon/helpers.js'); - -const localVue = createLocalVue(); -localVue.use(VueApollo); - -describe('packages_coming_soon', () => { - let wrapper; - - const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); - const findAllIssues = () => wrapper.findAll('[data-testid="issue-row"]'); - const findIssuesData = () => - findAllIssues().wrappers.map(x => { - const titleLink = x.find('[data-testid="issue-title-link"]'); - const milestone = x.find('[data-testid="milestone"]'); - const issueIdLink = x.find('[data-testid="issue-id-link"]'); - const labels = x.findAll(GlLabel); - - const issueId = Number(issueIdLink.text().substr(1)); - - return { - id: issueId, - iid: issueId, - title: titleLink.text(), - webUrl: titleLink.attributes('href'), - labels: labels.wrappers.map(label => ({ - color: label.props('backgroundColor'), - title: label.props('title'), - scoped: label.props('scoped'), - })), - ...(milestone.exists() ? { milestone: { title: milestone.text() } } : {}), - }; - }); - const findIssueTitleLink = () => wrapper.find('[data-testid="issue-title-link"]'); - const findIssueIdLink = () => wrapper.find('[data-testid="issue-id-link"]'); - const findEmptyState = () => wrapper.find(GlEmptyState); - - const mountComponent = (testParams = {}) => { - const $apolloData = { - loading: testParams.isLoading || false, - }; - - wrapper = mount(ComingSoon, { - localVue, - propsData: { - illustration: 'foo', - projectPath: 'foo', - suggestedContributionsPath: 'foo', - }, - stubs: { - ApolloQuery, - GlLink: true, - }, - mocks: { - $apolloData, - }, - }); - - // Mock the GraphQL query result - wrapper.find(ApolloQuery).setData({ - result: { - data: testParams.issues || asViewModel, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when loading', () => { - beforeEach(() => mountComponent({ isLoading: true })); - - it('renders the skeleton loader', () => { - expect(findSkeletonLoader().exists()).toBe(true); - }); - }); - - describe('when there are no issues', () => { - beforeEach(() => mountComponent({ issues: [] })); - - it('renders the empty state', () => { - expect(findEmptyState().exists()).toBe(true); - }); - }); - - describe('when there are issues', () => { - beforeEach(() => mountComponent()); - - it('renders each issue', () => { - expect(findIssuesData()).toEqual(asViewModel); - }); - }); - - describe('tracking', () => { - const firstIssue = asViewModel[0]; - let eventSpy; - - beforeEach(() => { - eventSpy = jest.spyOn(Tracking, 'event'); - mountComponent(); - }); - - it('tracks when mounted', () => { - expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_REQUESTED, {}); - }); - - it('tracks when an issue title link is clicked', () => { - eventSpy.mockClear(); - - findIssueTitleLink().vm.$emit('click'); - - expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, { - label: firstIssue.title, - value: firstIssue.iid, - }); - }); - - it('tracks when an issue id link is clicked', () => { - eventSpy.mockClear(); - - findIssueIdLink().vm.$emit('click'); - - expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, { - label: firstIssue.title, - value: firstIssue.iid, - }); - }); - }); -}); 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..ce3a58c856d 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,461 @@ // 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" - /> - - <package-sort-stub /> - </div> - </template> -</b-tabs-stub> + </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="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/list/stores/mutations_spec.js b/spec/frontend/packages/list/stores/mutations_spec.js index 563a3dabbb3..0d424a0c011 100644 --- a/spec/frontend/packages/list/stores/mutations_spec.js +++ b/spec/frontend/packages/list/stores/mutations_spec.js @@ -18,7 +18,6 @@ describe('Mutations Registry Store', () => { userCanDelete: '', emptyListIllustration: 'foo', emptyListHelpUrl: 'baz', - comingSoonJson: '{ "project_path": "gitlab-org/gitlab-test" }', }; const expectedState = { diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js index b95d06428ff..d7494bf85d0 100644 --- a/spec/frontend/packages/mock_data.js +++ b/spec/frontend/packages/mock_data.js @@ -84,15 +84,15 @@ export const conanPackage = { package_channel: 'stable', package_username: 'conan+conan-package', }, + conan_package_name: 'conan-package', created_at: '2015-12-10', id: 3, - name: 'conan-package', + name: 'conan-package/1.0.0@conan+conan-package/stable', project_path: 'foo/bar/baz', projectPathName: 'foo/bar/baz', package_files: [], package_type: 'conan', project_id: 1, - recipe: 'conan-package/1.0.0@conan+conan-package/stable', updated_at: '2015-12-10', version: '1.0.0', _links, 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/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js index 1fe90a4827f..3e4ce8eb323 100644 --- a/spec/frontend/packages/shared/utils_spec.js +++ b/spec/frontend/packages/shared/utils_spec.js @@ -37,7 +37,7 @@ describe('Packages shared utils', () => { ${'maven'} | ${'Maven'} ${'npm'} | ${'NPM'} ${'nuget'} | ${'NuGet'} - ${'pypi'} | ${'PyPi'} + ${'pypi'} | ${'PyPI'} ${'composer'} | ${'Composer'} ${'foo'} | ${null} `(`package type`, ({ packageType, expectedResult }) => { 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/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index 211f4ea20f5..8ccad7d5c22 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -9,65 +9,54 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <!----> - <gl-deprecated-dropdown-stub + <gl-dropdown-stub + category="tertiary" + headertext="" + size="medium" text="rspec" + variant="default" > - <gl-deprecated-dropdown-item-stub + <gl-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischecked="true" + ischeckitem="true" + secondarytext="" value="rspec" > - <div - class="gl-display-flex" - > - <gl-icon-stub - class="gl-absolute" - name="mobile-issue-close" - size="16" - /> - - <span - class="gl-display-flex align-items-center ml-4" - > - - rspec - - </span> - </div> - </gl-deprecated-dropdown-item-stub> - <gl-deprecated-dropdown-item-stub + + rspec + + </gl-dropdown-item-stub> + <gl-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischeckitem="true" + secondarytext="" value="cypress" > - <div - class="gl-display-flex" - > - <!----> - - <span - class="gl-display-flex align-items-center ml-4" - > - - cypress - - </span> - </div> - </gl-deprecated-dropdown-item-stub> - <gl-deprecated-dropdown-item-stub + + cypress + + </gl-dropdown-item-stub> + <gl-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischeckitem="true" + secondarytext="" value="karma" > - <div - class="gl-display-flex" - > - <!----> - - <span - class="gl-display-flex align-items-center ml-4" - > - - karma - - </span> - </div> - </gl-deprecated-dropdown-item-stub> - </gl-deprecated-dropdown-stub> + + karma + + </gl-dropdown-item-stub> + </gl-dropdown-stub> </div> <gl-area-chart-stub diff --git a/spec/frontend/pages/projects/graphs/code_coverage_spec.js b/spec/frontend/pages/projects/graphs/code_coverage_spec.js index 8884f7815ab..4a60c7fd509 100644 --- a/spec/frontend/pages/projects/graphs/code_coverage_spec.js +++ b/spec/frontend/pages/projects/graphs/code_coverage_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { shallowMount } from '@vue/test-utils'; -import { GlAlert, GlIcon, GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlAlert, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import waitForPromises from 'helpers/wait_for_promises'; @@ -17,7 +17,7 @@ describe('Code Coverage', () => { const findAlert = () => wrapper.find(GlAlert); const findAreaChart = () => wrapper.find(GlAreaChart); - const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); + const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); const findFirstDropdownItem = () => findAllDropdownItems().at(0); const findSecondDropdownItem = () => findAllDropdownItems().at(1); @@ -124,7 +124,7 @@ describe('Code Coverage', () => { }); it('renders the dropdown with all custom names as options', () => { - expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeDefined(); + expect(wrapper.find(GlDropdown).exists()).toBeDefined(); expect(findAllDropdownItems()).toHaveLength(codeCoverageMockData.length); expect(findFirstDropdownItem().text()).toBe(codeCoverageMockData[0].group_name); }); @@ -145,16 +145,8 @@ describe('Code Coverage', () => { await wrapper.vm.$nextTick(); - expect( - findFirstDropdownItem() - .find(GlIcon) - .exists(), - ).toBe(false); - expect( - findSecondDropdownItem() - .find(GlIcon) - .exists(), - ).toBe(true); + expect(findFirstDropdownItem().attributes('ischecked')).toBeFalsy(); + expect(findSecondDropdownItem().attributes('ischecked')).toBeTruthy(); }); it('updates the graph data when selecting a different option in dropdown', async () => { 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/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index 1517142c21e..bcd2cbbd530 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -9,7 +9,6 @@ describe('performance bar wrapper', () => { let vm; beforeEach(() => { - URL.createObjectURL = jest.fn(); performance.getEntriesByType = jest.fn().mockReturnValue([]); // clear html so that elements from previous tests don't mess with this test diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 97a92778f1a..040c0fbecc5 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem, GlForm, GlSprintf } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; +import httpStatusCodes from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; import { mockRefs, mockParams, mockPostParams, mockProjectId, mockError } from '../mock_data'; @@ -11,7 +12,8 @@ jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); -const pipelinesPath = '/root/project/-/pipleines'; +const pipelinesPath = '/root/project/-/pipelines'; +const configVariablesPath = '/root/project/-/pipelines/config_variables'; const postResponse = { id: 1 }; describe('Pipeline New Form', () => { @@ -28,6 +30,7 @@ describe('Pipeline New Form', () => { const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]'); + const findValueInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-value"]'); const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]'); const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); @@ -39,6 +42,7 @@ describe('Pipeline New Form', () => { propsData: { projectId: mockProjectId, pipelinesPath, + configVariablesPath, refs: mockRefs, defaultBranch: 'master', settingsLink: '', @@ -55,6 +59,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock = new MockAdapter(axios); + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); }); afterEach(() => { @@ -66,7 +71,7 @@ describe('Pipeline New Form', () => { describe('Dropdown with branches and tags', () => { beforeEach(() => { - mock.onPost(pipelinesPath).reply(200, postResponse); + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); }); it('displays dropdown with all branches and tags', () => { @@ -87,17 +92,27 @@ describe('Pipeline New Form', () => { }); describe('Form', () => { - beforeEach(() => { + beforeEach(async () => { createComponent('', mockParams, mount); - mock.onPost(pipelinesPath).reply(200, postResponse); + mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse); + + await waitForPromises(); }); + it('displays the correct values for the provided query params', async () => { expect(findDropdown().props('text')).toBe('tag-1'); + expect(findVariableRows()).toHaveLength(3); + }); - await wrapper.vm.$nextTick(); + it('displays a variable from provided query params', () => { + expect(findKeyInputs().at(0).element.value).toBe('test_var'); + expect(findValueInputs().at(0).element.value).toBe('test_var_val'); + }); - expect(findVariableRows()).toHaveLength(3); + it('displays an empty variable for the user to fill out', async () => { + expect(findKeyInputs().at(2).element.value).toBe(''); + expect(findValueInputs().at(2).element.value).toBe(''); }); it('does not display remove icon for last row', () => { @@ -124,13 +139,143 @@ describe('Pipeline New Form', () => { }); it('creates blank variable on input change event', async () => { - findKeyInputs() - .at(2) - .trigger('change'); + const input = findKeyInputs().at(2); + input.element.value = 'test_var_2'; + input.trigger('change'); await wrapper.vm.$nextTick(); expect(findVariableRows()).toHaveLength(4); + expect(findKeyInputs().at(3).element.value).toBe(''); + expect(findValueInputs().at(3).element.value).toBe(''); + }); + + describe('when the form has been modified', () => { + const selectRef = i => + findDropdownItems() + .at(i) + .vm.$emit('click'); + + beforeEach(async () => { + const input = findKeyInputs().at(0); + input.element.value = 'test_var_2'; + input.trigger('change'); + + findRemoveIcons() + .at(1) + .trigger('click'); + + await wrapper.vm.$nextTick(); + }); + + it('form values are restored when the ref changes', async () => { + expect(findVariableRows()).toHaveLength(2); + + selectRef(1); + await waitForPromises(); + + expect(findVariableRows()).toHaveLength(3); + expect(findKeyInputs().at(0).element.value).toBe('test_var'); + }); + + it('form values are restored again when the ref is reverted', async () => { + selectRef(1); + await waitForPromises(); + + selectRef(2); + await waitForPromises(); + + expect(findVariableRows()).toHaveLength(2); + expect(findKeyInputs().at(0).element.value).toBe('test_var_2'); + }); + }); + }); + + describe('when feature flag new_pipeline_form_prefilled_vars is enabled', () => { + let origGon; + + const mockYmlKey = 'yml_var'; + const mockYmlValue = 'yml_var_val'; + const mockYmlDesc = 'A var from yml.'; + + beforeAll(() => { + origGon = window.gon; + window.gon = { features: { newPipelineFormPrefilledVars: true } }; + }); + + afterAll(() => { + window.gon = origGon; + }); + + describe('when yml defines a variable with description', () => { + beforeEach(async () => { + createComponent('', mockParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlValue, + description: mockYmlDesc, + }, + }); + + await waitForPromises(); + }); + + it('displays all the variables', async () => { + expect(findVariableRows()).toHaveLength(4); + }); + + it('displays a variable from yml', () => { + expect(findKeyInputs().at(0).element.value).toBe(mockYmlKey); + expect(findValueInputs().at(0).element.value).toBe(mockYmlValue); + }); + + it('displays a variable from provided query params', () => { + expect(findKeyInputs().at(1).element.value).toBe('test_var'); + expect(findValueInputs().at(1).element.value).toBe('test_var_val'); + }); + + it('adds a description to the first variable from yml', () => { + expect( + findVariableRows() + .at(0) + .text(), + ).toContain(mockYmlDesc); + }); + + it('removes the description when a variable key changes', async () => { + findKeyInputs().at(0).element.value = 'yml_var_modified'; + findKeyInputs() + .at(0) + .trigger('change'); + + await wrapper.vm.$nextTick(); + + expect( + findVariableRows() + .at(0) + .text(), + ).not.toContain(mockYmlDesc); + }); + }); + + describe('when yml defines a variable without description', () => { + beforeEach(async () => { + createComponent('', mockParams, mount); + + mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { + [mockYmlKey]: { + value: mockYmlValue, + description: null, + }, + }); + + await waitForPromises(); + }); + + it('displays all the variables', async () => { + expect(findVariableRows()).toHaveLength(3); + }); }); }); @@ -138,7 +283,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { createComponent(); - mock.onPost(pipelinesPath).reply(400, mockError); + mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError); findForm().vm.$emit('submit', dummySubmitEvent); 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_graph_spec.js b/spec/frontend/pipelines/components/dag/dag_graph_spec.js index e312791b01f..7786212cb69 100644 --- a/spec/frontend/pipelines/components/dag/dag_graph_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_graph_spec.js @@ -3,7 +3,7 @@ import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants'; import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions'; import { createSankey } from '~/pipelines/components/dag/drawing_utils'; -import { removeOrphanNodes } from '~/pipelines/components/dag/parsing_utils'; +import { removeOrphanNodes } from '~/pipelines/components/parsing_utils'; import { parsedData } from './mock_data'; describe('The DAG graph', () => { 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/components/dag/drawing_utils_spec.js b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js index 37a7d07485b..095ded01298 100644 --- a/spec/frontend/pipelines/components/dag/drawing_utils_spec.js +++ b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js @@ -1,5 +1,5 @@ import { createSankey } from '~/pipelines/components/dag/drawing_utils'; -import { parseData } from '~/pipelines/components/dag/parsing_utils'; +import { parseData } from '~/pipelines/components/parsing_utils'; import { mockParsedGraphQLNodes } from './mock_data'; describe('DAG visualization drawing utilities', () => { diff --git a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js index e93fa8e6760..ceb6b64d4ad 100644 --- a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js +++ b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js @@ -5,7 +5,7 @@ import { parseData, removeOrphanNodes, getMaxNodes, -} from '~/pipelines/components/dag/parsing_utils'; +} from '~/pipelines/components/parsing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { mockParsedGraphQLNodes } from './mock_data'; 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/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index e844cbc5bf8..8aabb2f9cdd 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -1,5 +1,4 @@ import { mount } from '@vue/test-utils'; -import { trimText } from 'helpers/text_helper'; import JobItem from '~/pipelines/components/graph/job_item.vue'; describe('pipeline graph job item', () => { @@ -65,7 +64,7 @@ describe('pipeline graph job item', () => { expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); - expect(trimText(wrapper.find('.ci-status-text').text())).toBe(mockJob.name); + expect(wrapper.text()).toBe(mockJob.name); done(); }); @@ -85,7 +84,7 @@ describe('pipeline graph job item', () => { expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); expect(wrapper.find('a').exists()).toBe(false); - expect(trimText(wrapper.find('.ci-status-text').text())).toBe(mockJobWithoutDetails.name); + expect(wrapper.text()).toBe(mockJobWithoutDetails.name); }); it('should apply hover class and provided class name', () => { diff --git a/spec/frontend/pipelines/graph/job_name_component_spec.js b/spec/frontend/pipelines/graph/job_name_component_spec.js index 3574b66403e..f0aa646b8d7 100644 --- a/spec/frontend/pipelines/graph/job_name_component_spec.js +++ b/spec/frontend/pipelines/graph/job_name_component_spec.js @@ -21,12 +21,7 @@ describe('job name component', () => { }); it('should render the provided name', () => { - expect( - wrapper - .find('.ci-status-text') - .text() - .trim(), - ).toBe(propsData.name); + expect(wrapper.text()).toBe(propsData.name); }); it('should render an icon with the provided status', () => { 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/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js index 5a5d6c021a6..b50932deec6 100644 --- a/spec/frontend/pipelines/pipeline_graph/mock_data.js +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -1,3 +1,5 @@ +import { createUniqueJobId } from '~/pipelines/utils'; + export const yamlString = `stages: - empty - build @@ -39,18 +41,20 @@ deploy_a: script: echo hello `; +const jobId1 = createUniqueJobId('build', 'build_1'); +const jobId2 = createUniqueJobId('test', 'test_1'); +const jobId3 = createUniqueJobId('test', 'test_2'); +const jobId4 = createUniqueJobId('deploy', 'deploy_1'); + export const pipelineData = { stages: [ { name: 'build', - groups: [], - }, - { - name: 'build', groups: [ { name: 'build_1', jobs: [{ script: 'echo hello', stage: 'build' }], + id: jobId1, }, ], }, @@ -60,10 +64,12 @@ export const pipelineData = { { name: 'test_1', jobs: [{ script: 'yarn test', stage: 'test' }], + id: jobId2, }, { name: 'test_2', jobs: [{ script: 'yarn karma', stage: 'test' }], + id: jobId3, }, ], }, @@ -73,8 +79,15 @@ export const pipelineData = { { name: 'deploy_1', jobs: [{ script: 'yarn magick', stage: 'deploy' }], + id: jobId4, }, ], }, ], + jobs: { + [jobId1]: {}, + [jobId2]: {}, + [jobId3]: {}, + [jobId4]: {}, + }, }; diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js index dd85c8c2bd0..ade026c7053 100644 --- a/spec/frontend/pipelines/pipeline_graph/utils_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js @@ -1,150 +1,211 @@ -import { preparePipelineGraphData } from '~/pipelines/utils'; +import { + preparePipelineGraphData, + createUniqueJobId, + generateJobNeedsDict, +} from '~/pipelines/utils'; -describe('preparePipelineGraphData', () => { - const emptyResponse = { stages: [] }; +describe('utils functions', () => { + const emptyResponse = { stages: [], jobs: {} }; const jobName1 = 'build_1'; const jobName2 = 'build_2'; const jobName3 = 'test_1'; const jobName4 = 'deploy_1'; - const job1 = { [jobName1]: { script: 'echo hello', stage: 'build' } }; - const job2 = { [jobName2]: { script: 'echo build', stage: 'build' } }; - const job3 = { [jobName3]: { script: 'echo test', stage: 'test' } }; - const job4 = { [jobName4]: { script: 'echo deploy', stage: 'deploy' } }; - - describe('returns an object with an empty array of stages if', () => { - it('no data is passed', () => { - expect(preparePipelineGraphData({})).toEqual(emptyResponse); - }); + const job1 = { script: 'echo hello', stage: 'build' }; + const job2 = { script: 'echo build', stage: 'build' }; + const job3 = { script: 'echo test', stage: 'test', needs: [jobName1, jobName2] }; + const job4 = { script: 'echo deploy', stage: 'deploy', needs: [jobName3] }; + const userDefinedStage = 'myStage'; - it('no stages are found', () => { - expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual( - emptyResponse, - ); - }); - }); - - describe('returns the correct array of stages', () => { - it('when multiple jobs are in the same stage', () => { - const expectedData = { - stages: [ + const pipelineGraphData = { + stages: [ + { + name: userDefinedStage, + groups: [], + }, + { + name: job4.stage, + groups: [ { - name: job1[jobName1].stage, - groups: [ - { - name: jobName1, - jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], - }, - { - name: jobName2, - jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }], - }, - ], + name: jobName4, + jobs: [{ ...job4 }], + id: createUniqueJobId(job4.stage, jobName4), }, ], - }; - - expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData); - }); - - it('when stages are defined by the user', () => { - const userDefinedStage = 'myStage'; - const userDefinedStage2 = 'myStage2'; - - const expectedData = { - stages: [ + }, + { + name: job1.stage, + groups: [ { - name: userDefinedStage, - groups: [], + name: jobName1, + jobs: [{ ...job1 }], + id: createUniqueJobId(job1.stage, jobName1), }, { - name: userDefinedStage2, - groups: [], + name: jobName2, + jobs: [{ ...job2 }], + id: createUniqueJobId(job2.stage, jobName2), }, ], - }; + }, + { + name: job3.stage, + groups: [ + { + name: jobName3, + jobs: [{ ...job3 }], + id: createUniqueJobId(job3.stage, jobName3), + }, + ], + }, + ], + jobs: { + [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, + [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, + [jobName3]: { ...job3, id: createUniqueJobId(job3.stage, jobName3) }, + [jobName4]: { ...job4, id: createUniqueJobId(job4.stage, jobName4) }, + }, + }; - expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual( - expectedData, - ); - }); + describe('preparePipelineGraphData', () => { + describe('returns an empty array of stages and empty job objects if', () => { + it('no data is passed', () => { + expect(preparePipelineGraphData({})).toEqual(emptyResponse); + }); - it('by combining user defined stage and job stages, it preserves user defined order', () => { - const userDefinedStage = 'myStage'; - const userDefinedStageThatOverlaps = 'deploy'; + it('no stages are found', () => { + expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual( + emptyResponse, + ); + }); + }); - const expectedData = { - stages: [ - { - name: userDefinedStage, - groups: [], - }, - { - name: job4[jobName4].stage, - groups: [ - { - name: jobName4, - jobs: [{ script: job4[jobName4].script, stage: job4[jobName4].stage }], - }, - ], - }, - { - name: job1[jobName1].stage, - groups: [ - { - name: jobName1, - jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], - }, - { - name: jobName2, - jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }], - }, - ], + describe('returns the correct array of stages and object of jobs', () => { + it('when multiple jobs are in the same stage', () => { + const expectedData = { + stages: [ + { + name: job1.stage, + groups: [ + { + name: jobName1, + jobs: [{ ...job1 }], + id: createUniqueJobId(job1.stage, jobName1), + }, + { + name: jobName2, + jobs: [{ ...job2 }], + id: createUniqueJobId(job2.stage, jobName2), + }, + ], + }, + ], + jobs: { + [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, + [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, }, - { - name: job3[jobName3].stage, - groups: [ - { - name: jobName3, - jobs: [{ script: job3[jobName3].script, stage: job3[jobName3].stage }], - }, - ], + }; + expect( + preparePipelineGraphData({ [jobName1]: { ...job1 }, [jobName2]: { ...job2 } }), + ).toEqual(expectedData); + }); + + it('when stages are defined by the user', () => { + const userDefinedStage2 = 'myStage2'; + + const expectedData = { + stages: [ + { + name: userDefinedStage, + groups: [], + }, + { + name: userDefinedStage2, + groups: [], + }, + ], + jobs: {}, + }; + + expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual( + expectedData, + ); + }); + + it('by combining user defined stage and job stages, it preserves user defined order', () => { + const userDefinedStageThatOverlaps = 'deploy'; + + expect( + preparePipelineGraphData({ + stages: [userDefinedStage, userDefinedStageThatOverlaps], + [jobName1]: { ...job1 }, + [jobName2]: { ...job2 }, + [jobName3]: { ...job3 }, + [jobName4]: { ...job4 }, + }), + ).toEqual(pipelineGraphData); + }); + + it('with only unique values', () => { + const expectedData = { + stages: [ + { + name: job1.stage, + groups: [ + { + name: jobName1, + jobs: [{ ...job1 }], + id: createUniqueJobId(job1.stage, jobName1), + }, + ], + }, + ], + jobs: { + [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, }, - ], - }; + }; - expect( - preparePipelineGraphData({ - stages: [userDefinedStage, userDefinedStageThatOverlaps], - ...job1, - ...job2, - ...job3, - ...job4, - }), - ).toEqual(expectedData); + expect( + preparePipelineGraphData({ + stages: ['build'], + [jobName1]: { ...job1 }, + [jobName1]: { ...job1 }, + }), + ).toEqual(expectedData); + }); }); + }); - it('with only unique values', () => { - const expectedData = { - stages: [ - { - name: job1[jobName1].stage, - groups: [ - { - name: jobName1, - jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], - }, - ], - }, - ], + describe('generateJobNeedsDict', () => { + it('generates an empty object if it receives no jobs', () => { + expect(generateJobNeedsDict({ jobs: {} })).toEqual({}); + }); + + it('generates a dict with empty needs if there are no dependencies', () => { + const smallGraph = { + jobs: { + [jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) }, + [jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) }, + }, }; - expect( - preparePipelineGraphData({ - stages: ['build'], - ...job1, - ...job1, - }), - ).toEqual(expectedData); + expect(generateJobNeedsDict(smallGraph)).toEqual({ + [pipelineGraphData.jobs[jobName1].id]: [], + [pipelineGraphData.jobs[jobName2].id]: [], + }); + }); + + it('generates a dict where key is the a job and its value is an array of all its needs', () => { + const uniqueJobName1 = pipelineGraphData.jobs[jobName1].id; + const uniqueJobName2 = pipelineGraphData.jobs[jobName2].id; + const uniqueJobName3 = pipelineGraphData.jobs[jobName3].id; + const uniqueJobName4 = pipelineGraphData.jobs[jobName4].id; + + expect(generateJobNeedsDict(pipelineGraphData)).toEqual({ + [uniqueJobName1]: [], + [uniqueJobName2]: [], + [uniqueJobName3]: [uniqueJobName1, uniqueJobName2], + [uniqueJobName4]: [uniqueJobName3, uniqueJobName1, uniqueJobName2], + }); }); }); }); 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/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js index 9901f476f1b..32d53c0f1f8 100644 --- a/spec/frontend/pipelines/pipelines_table_row_spec.js +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -181,7 +181,9 @@ describe('Pipelines Table Row', () => { it('should render the provided actions', () => { expect(wrapper.find('.js-pipelines-retry-button').exists()).toBe(true); + expect(wrapper.find('.js-pipelines-retry-button').attributes('title')).toMatch('Retry'); expect(wrapper.find('.js-pipelines-cancel-button').exists()).toBe(true); + expect(wrapper.find('.js-pipelines-cancel-button').attributes('title')).toMatch('Cancel'); const dropdownMenu = wrapper.find('.dropdown-menu'); expect(dropdownMenu.text()).toContain(scheduledJobAction.name); diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js index 1d03f0b655f..c3ca1429842 100644 --- a/spec/frontend/pipelines/test_reports/mock_data.js +++ b/spec/frontend/pipelines/test_reports/mock_data.js @@ -3,10 +3,29 @@ import { TestStatus } from '~/pipelines/constants'; export default [ { classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', execution_time: 0, name: 'Test#skipped text', stack_trace: null, status: TestStatus.SKIPPED, system_output: null, }, + { + classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', + execution_time: 0, + name: 'Test#error text', + stack_trace: null, + status: TestStatus.ERROR, + system_output: null, + }, + { + classname: 'spec.test_spec', + file: 'spec/trace_spec.rb', + 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..838e0606375 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -1,6 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { getJSONFixture } from 'helpers/fixtures'; +import { GlButton } from '@gitlab/ui'; import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; import * as getters from '~/pipelines/stores/test_reports/getters'; import { TestStatus } from '~/pipelines/constants'; @@ -61,18 +62,27 @@ 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(row, status).exists()).toBe(true); + }); + + it('renders the file name for the test with a copy button', () => { + const { file } = testCases[0]; + const row = findCaseRowAtIndex(0); + const button = row.find(GlButton); - 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(row.text()).toContain(file); + expect(button.exists()).toBe(true); + expect(button.attributes('data-clipboard-text')).toBe(file); }); }); }); 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/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 00b1d5cfbe2..5eee22f479e 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -313,7 +313,7 @@ describe('Ref selector component', () => { findBranchesSection() .find('[data-testid="section-header"]') .text(), - ).toBe('Branches 123'); + ).toMatchInterpolatedText('Branches 123'); }); it("does not render an error message in the branches section's body", () => { @@ -392,7 +392,7 @@ describe('Ref selector component', () => { findTagsSection() .find('[data-testid="section-header"]') .text(), - ).toBe('Tags 456'); + ).toMatchInterpolatedText('Tags 456'); }); it("does not render an error message in the tags section's body", () => { @@ -460,7 +460,7 @@ describe('Ref selector component', () => { findCommitsSection() .find('[data-testid="section-header"]') .text(), - ).toBe('Commits 1'); + ).toMatchInterpolatedText('Commits 1'); }); it("does not render an error message in the comits section's body", () => { 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/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index c5b4b3fa5d8..ce446e6d93e 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -9,6 +9,8 @@ import { ROW_SCHEDULED_FOR_DELETION, LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CLEANUP_TIMED_OUT_ERROR_MESSAGE, } from '~/registry/explorer/constants'; import { RouterLink } from '../../stubs'; import { imagesListResponse } from '../../mock_data'; @@ -21,6 +23,7 @@ describe('Image List Row', () => { const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]'); const findDeleteBtn = () => wrapper.find(DeleteButton); const findClipboardButton = () => wrapper.find(ClipboardButton); + const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]'); const mountComponent = props => { wrapper = shallowMount(Component, { @@ -74,6 +77,26 @@ describe('Image List Row', () => { expect(button.props('text')).toBe(item.location); expect(button.props('title')).toBe(item.location); }); + + describe('warning icon', () => { + it.each` + failedDelete | cleanup_policy_started_at | shown | title + ${true} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE} + ${false} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE} + ${false} | ${false} | ${false} | ${''} + `( + 'when failedDelete is $failedDelete and cleanup_policy_started_at is $cleanup_policy_started_at', + ({ cleanup_policy_started_at, failedDelete, shown, title }) => { + mountComponent({ item: { ...item, failedDelete, cleanup_policy_started_at } }); + const icon = findWarningIcon(); + expect(icon.exists()).toBe(shown); + if (shown) { + const tooltip = getBinding(icon.element, 'gl-tooltip'); + expect(tooltip.value.title).toBe(title); + } + }, + ); + }); }); describe('delete button', () => { 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..a784396f47a 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, emptyExpirationPolicyPayload } 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', () => { @@ -107,4 +115,23 @@ describe('Registry Settings App', () => { expect(findAlert().html()).toContain(FETCH_SETTINGS_ERROR_MESSAGE); }); }); + + describe('empty API response', () => { + it.each` + enableHistoricEntries | isShown + ${true} | ${true} + ${false} | ${false} + `('is $isShown that the form is shown', async ({ enableHistoricEntries, isShown }) => { + const requests = mountComponentWithApollo({ + provide: { + ...defaultProvidedValues, + enableHistoricEntries, + }, + resolver: jest.fn().mockResolvedValue(emptyExpirationPolicyPayload()), + }); + await Promise.all(requests); + + expect(findSettingsComponent().exists()).toBe(isShown); + }); + }); }); diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 6f9518808db..4346cfadcc8 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, + }, + ...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, }, - store, + ...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,32 +113,36 @@ 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({}); }); - }); - describe('form', () => { - let form; - beforeEach(() => { - mountComponent(); - form = findForm(); - dispatchSpy.mockReturnValue(); + it('shows the default option when none are selected', () => { + mountComponent({ props: { value: {} } }); + expect(findFields().props('value')).toEqual({ + cadence: 'EVERY_DAY', + keepN: 'TEN_TAGS', + olderThan: 'NINETY_DAYS', + }); }); + }); + describe('form', () => { 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 +153,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()), + }); - it('parses the error messages', async () => { - dispatchSpy.mockRejectedValue({ - response: { - data: { - message: { - foo: 'bar', - 'container_expiration_policy.name': ['baz'], + findForm().trigger('submit'); + await Promise.all(handlers); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + 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 +250,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..7f3772ce7fe --- /dev/null +++ b/spec/frontend/registry/settings/mock_data.js @@ -0,0 +1,40 @@ +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 emptyExpirationPolicyPayload = () => ({ + data: { + project: { + containerExpirationPolicy: {}, + }, + }, +}); + +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..25c108e45bc 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -1,113 +1,245 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`releases/util.js convertGraphQLResponse matches snapshot 1`] = ` +exports[`releases/util.js convertAllReleasesGraphQLResponse matches snapshot 1`] = ` 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": "eyJpZCI6IjEifQ", + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "eyJpZCI6IjEifQ", + }, +} +`; + +exports[`releases/util.js convertOneReleaseGraphQLResponse matches snapshot 1`] = ` +Object { + "data": Object { + "_links": Object { + "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": 8, + "links": Array [ + Object { + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-3", + "external": true, + "id": "gid://gitlab/Releases::Link/13", + "linkType": "image", + "name": "Image", + "url": "https://example.com/image", + }, + Object { + "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-2", + "external": true, + "id": "gid://gitlab/Releases::Link/12", + "linkType": "package", + "name": "Package", + "url": "https://example.com/package", + }, + Object { + "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/10", + "linkType": "other", + "name": "linux-amd64 binaries", + "url": "https://downloads.example.com/bin/gitlab-linux-amd64", + }, + ], + "sources": Array [ + Object { + "format": "zip", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.zip", + }, + Object { + "format": "tar.gz", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.gz", + }, + Object { + "format": "tar.bz2", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar.bz2", + }, + Object { + "format": "tar", + "url": "http://localhost/releases-namespace/releases-project/-/archive/v1.1/releases-project-v1.1.tar", + }, + ], + }, + "author": Object { + "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "username": "administrator", + "webUrl": "http://localhost/administrator", + }, + "commit": Object { + "shortId": "b83d6e39", + "title": "Merge branch 'branch-merged' into 'master'", + }, + "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": "2018-12-03T00:00:00Z", + "filepath": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/evidences/1.json", + "sha": "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d", + }, + ], + "milestones": Array [ + Object { + "description": "The 12.4 milestone", + "id": "gid://gitlab/Milestone/124", + "issueStats": Object { + "closed": 1, + "total": 4, + }, + "stats": undefined, + "title": "12.4", + "webPath": undefined, + "webUrl": "/releases-namespace/releases-project/-/milestones/2", + }, + Object { + "description": "The 12.3 milestone", + "id": "gid://gitlab/Milestone/123", + "issueStats": Object { + "closed": 3, + "total": 5, + }, + "stats": undefined, + "title": "12.3", + "webPath": undefined, + "webUrl": "/releases-namespace/releases-project/-/milestones/1", + }, + ], + "name": "The first release", + "releasedAt": "2018-12-10T00:00:00Z", + "tagName": "v1.1", + "tagPath": "/releases-namespace/releases-project/-/tags/v1.1", + "upcomingRelease": true, + }, } `; diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index e9727801c1a..d92bdc3b99a 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; @@ -17,7 +20,7 @@ describe('Release edit/new component', () => { let state; let mock; - const factory = ({ featureFlags = {}, store: storeUpdates = {} } = {}) => { + const factory = async ({ featureFlags = {}, store: storeUpdates = {} } = {}) => { state = { release, markdownDocsPath: 'path/to/markdown/docs', @@ -65,6 +68,8 @@ describe('Release edit/new component', () => { }, }); + await wrapper.vm.$nextTick(); + wrapper.element.querySelectorAll('input').forEach(input => jest.spyOn(input, 'focus')); }; @@ -86,7 +91,9 @@ describe('Release edit/new component', () => { const findForm = () => wrapper.find('form'); describe(`basic functionality tests: all tests unrelated to the "${BACK_URL_PARAM}" parameter`, () => { - beforeEach(factory); + beforeEach(async () => { + await factory(); + }); it('calls initializeRelease when the component is created', () => { expect(actions.initializeRelease).toHaveBeenCalledTimes(1); @@ -128,7 +135,9 @@ describe('Release edit/new component', () => { }); describe(`when the URL does not contain a "${BACK_URL_PARAM}" parameter`, () => { - beforeEach(factory); + beforeEach(async () => { + await factory(); + }); it(`renders a "Cancel" button with an href pointing to "${BACK_URL_PARAM}"`, () => { const cancelButton = wrapper.find('.js-cancel-button'); @@ -139,12 +148,12 @@ describe('Release edit/new component', () => { describe(`when the URL contains a "${BACK_URL_PARAM}" parameter`, () => { const backUrl = 'https://example.gitlab.com/back/url'; - beforeEach(() => { + beforeEach(async () => { commonUtils.getParameterByName = jest .fn() .mockImplementation(paramToGet => ({ [BACK_URL_PARAM]: backUrl }[paramToGet])); - factory(); + await factory(); }); it('renders a "Cancel" button with an href pointing to the main Releases page', () => { @@ -154,8 +163,8 @@ describe('Release edit/new component', () => { }); describe('when creating a new release', () => { - beforeEach(() => { - factory({ + beforeEach(async () => { + await factory({ store: { modules: { detail: { @@ -174,7 +183,9 @@ describe('Release edit/new component', () => { }); describe('when editing an existing release', () => { - beforeEach(factory); + beforeEach(async () => { + await factory(); + }); it('renders the submit button with the text "Save changes"', () => { expect(findSubmitButton().text()).toBe('Save changes'); @@ -182,33 +193,17 @@ describe('Release edit/new component', () => { }); describe('asset links form', () => { - const findAssetLinksForm = () => wrapper.find(AssetLinksForm); - - describe('when the release_asset_link_editing feature flag is disabled', () => { - beforeEach(() => { - factory({ featureFlags: { releaseAssetLinkEditing: false } }); - }); - - it('does not render the asset links portion of the form', () => { - expect(findAssetLinksForm().exists()).toBe(false); - }); - }); - - describe('when the release_asset_link_editing feature flag is enabled', () => { - beforeEach(() => { - factory({ featureFlags: { releaseAssetLinkEditing: true } }); - }); + beforeEach(factory); - it('renders the asset links portion of the form', () => { - expect(findAssetLinksForm().exists()).toBe(true); - }); + it('renders the asset links portion of the form', () => { + expect(wrapper.find(AssetLinksForm).exists()).toBe(true); }); }); describe('validation', () => { describe('when the form is valid', () => { - beforeEach(() => { - factory({ + beforeEach(async () => { + await factory({ store: { modules: { detail: { @@ -227,8 +222,8 @@ describe('Release edit/new component', () => { }); describe('when the form is invalid', () => { - beforeEach(() => { - factory({ + beforeEach(async () => { + await factory({ store: { modules: { detail: { 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..6794a56debc 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; @@ -54,11 +56,6 @@ describe('Release edit component', () => { wrapper = mount(AssetLinksForm, { localVue, store, - provide: { - glFeatures: { - releaseAssetLinkType: true, - }, - }, }); }; @@ -223,10 +220,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..126ca27e8a6 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; @@ -20,9 +22,6 @@ describe('Release block assets', () => { const createComponent = (propsData = defaultProps) => { wrapper = mount(ReleaseBlockAssets, { - provide: { - glFeatures: { releaseAssetLinkType: true }, - }, propsData, }); }; @@ -31,7 +30,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 +42,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 deleted file mode 100644 index 6f184e45600..00000000000 --- a/spec/frontend/releases/components/release_block_metadata_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { trimText } from 'helpers/text_helper'; -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 mockFutureDate = new Date(9999, 0, 0).toISOString(); -let mockIsFutureRelease = false; - -jest.mock('~/vue_shared/mixins/timeago', () => ({ - methods: { - timeFormatted() { - return mockIsFutureRelease ? 'in 1 month' : '7 fortnights ago'; - }, - tooltipTitle() { - return 'February 30, 2401'; - }, - }, -})); - -describe('Release block metadata', () => { - let wrapper; - let release; - - const factory = (releaseUpdates = {}) => { - wrapper = mount(ReleaseBlockMetadata, { - propsData: { - release: { - ...convertObjectPropsToCamelCase(release, { deep: true }), - ...releaseUpdates, - }, - }, - }); - }; - - beforeEach(() => { - release = cloneDeep(originalRelease); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - mockIsFutureRelease = false; - }); - - const findReleaseDateInfo = () => wrapper.find('.js-release-date-info'); - - describe('with all props provided', () => { - beforeEach(() => factory()); - - it('renders the release time info', () => { - expect(trimText(findReleaseDateInfo().text())).toBe(`released 7 fortnights ago`); - }); - }); - - describe('with a future release date', () => { - beforeEach(() => { - mockIsFutureRelease = true; - factory({ releasedAt: mockFutureDate }); - }); - - it('renders the release date without the author name', () => { - expect(trimText(findReleaseDateInfo().text())).toBe(`will be released in 1 month`); - }); - }); -}); 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..633c6690529 100644 --- a/spec/frontend/releases/components/release_block_spec.js +++ b/spec/frontend/releases/components/release_block_spec.js @@ -1,15 +1,16 @@ 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; @@ -21,7 +22,6 @@ describe('Release block', () => { }, provide: { glFeatures: { - releaseIssueSummary: true, ...featureFlags, }, }, @@ -46,7 +46,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`, () => { @@ -69,50 +69,10 @@ describe('Release block', () => { expect(wrapper.text()).toContain(timeagoMixin.methods.timeFormatted(release.releasedAt)); }); - it('renders number of assets provided', () => { - expect(wrapper.find('.js-assets-count').text()).toContain(release.assets.count); - }); - - it('renders dropdown with the sources', () => { - expect(wrapper.findAll('.js-sources-dropdown li').length).toEqual( - release.assets.sources.length, - ); - - expect(wrapper.find('.js-sources-dropdown li a').attributes().href).toEqual( - release.assets.sources[0].url, - ); - - expect(wrapper.find('.js-sources-dropdown li a').text()).toContain( - release.assets.sources[0].format, - ); - }); - - it('renders list with the links provided', () => { - expect(wrapper.findAll('.js-assets-list li').length).toEqual(release.assets.links.length); - - expect(wrapper.find('.js-assets-list li a').attributes().href).toEqual( - release.assets.links[0].directAssetUrl, - ); - - expect(wrapper.find('.js-assets-list li a').text()).toContain(release.assets.links[0].name); - }); - it('renders author avatar', () => { expect(wrapper.find('.user-avatar-link').exists()).toBe(true); }); - describe('external label', () => { - it('renders external label when link is external', () => { - expect(wrapper.find('.js-assets-list li a').text()).toContain('external source'); - }); - - 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( - 'external source', - ); - }); - }); - it('renders the footer', () => { expect(wrapper.find(ReleaseBlockFooter).exists()).toBe(true); }); @@ -171,18 +131,14 @@ describe('Release block', () => { }); describe('evidence block', () => { - it('renders the evidence block when the evidence is available and the feature flag is true', () => - factory(release, { releaseEvidenceCollection: true }).then(() => - expect(wrapper.find(EvidenceBlock).exists()).toBe(true), - )); - - it('does not render the evidence block when the evidence is available but the feature flag is false', () => - factory(release, { releaseEvidenceCollection: true }).then(() => - expect(wrapper.find(EvidenceBlock).exists()).toBe(true), - )); + it('renders the evidence block when the evidence is available', () => { + return factory(release).then(() => { + expect(wrapper.find(EvidenceBlock).exists()).toBe(true); + }); + }); it('does not render the evidence block when there is no evidence', () => { - release.evidenceSha = null; + release.evidences = []; return factory(release).then(() => { expect(wrapper.find(EvidenceBlock).exists()).toBe(false); @@ -239,51 +195,4 @@ describe('Release block', () => { }); }); }); - - describe('when the releaseIssueSummary feature flag is disabled', () => { - describe('with default props', () => { - beforeEach(() => factory(release, { releaseIssueSummary: false })); - - it('renders the milestone icon', () => { - expect( - milestoneListLabel() - .find(GlIcon) - .exists(), - ).toBe(true); - }); - - it('renders the label as "Milestones" if more than one milestone is passed in', () => { - expect( - milestoneListLabel() - .find('.js-label-text') - .text(), - ).toEqual('Milestones'); - }); - - it('renders a link to the milestone with a tooltip', () => { - const milestone = release.milestones[0]; - const milestoneLink = wrapper.find('.js-milestone-link'); - - expect(milestoneLink.exists()).toBe(true); - - expect(milestoneLink.text()).toBe(milestone.title); - - expect(milestoneLink.attributes('href')).toBe(milestone.webUrl); - - expect(milestoneLink.attributes('title')).toBe(milestone.description); - }); - }); - - it('renders the label as "Milestone" if only a single milestone is passed in', () => { - release.milestones = release.milestones.slice(0, 1); - - return factory(release, { releaseIssueSummary: false }).then(() => { - expect( - milestoneListLabel() - .find('.js-label-text') - .text(), - ).toEqual('Milestone'); - }); - }); - }); }); 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..d38f6766d4e 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; @@ -32,6 +34,12 @@ describe('Release detail actions', () => { isExistingRelease: true, }; + const rootState = { + featureFlags: { + graphqlIndividualReleasePage: false, + }, + }; + state = { ...createState({ projectId: '18', @@ -42,6 +50,7 @@ describe('Release detail actions', () => { updateReleaseApiDocsPath: 'path/to/api/docs', }), ...getters, + ...rootState, ...updates, }; }; @@ -152,7 +161,7 @@ describe('Release detail actions', () => { }); it(`shows a flash message`, () => { - return actions.fetchRelease({ commit: jest.fn(), state }).then(() => { + return actions.fetchRelease({ commit: jest.fn(), state, rootState: state }).then(() => { expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledWith( 'Something went wrong while getting the release details', @@ -207,6 +216,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, [ @@ -265,32 +283,14 @@ describe('Release detail actions', () => { describe('receiveSaveReleaseSuccess', () => { it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => - testAction(actions.receiveSaveReleaseSuccess, undefined, { ...state, featureFlags: {} }, [ + testAction(actions.receiveSaveReleaseSuccess, release, state, [ { type: types.RECEIVE_SAVE_RELEASE_SUCCESS }, ])); - describe('when the releaseShowPage feature flag is enabled', () => { - beforeEach(() => { - const rootState = { featureFlags: { releaseShowPage: true } }; - actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release); - }); - - it("redirects to the release's dedicated page", () => { - expect(redirectTo).toHaveBeenCalledTimes(1); - expect(redirectTo).toHaveBeenCalledWith(release._links.self); - }); - }); - - describe('when the releaseShowPage feature flag is disabled', () => { - beforeEach(() => { - const rootState = { featureFlags: { releaseShowPage: false } }; - actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release); - }); - - it("redirects to the project's main Releases page", () => { - expect(redirectTo).toHaveBeenCalledTimes(1); - expect(redirectTo).toHaveBeenCalledWith(state.releasesPagePath); - }); + it("redirects to the release's dedicated page", () => { + actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state }, release); + expect(redirectTo).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith(release._links.self); }); }); 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..4e235e1d00f 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 { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; 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 = convertAllReleasesGraphQLResponse(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..521418cbddb 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 { convertAllReleasesGraphQLResponse } 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 = convertAllReleasesGraphQLResponse(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..e7b7766c0d0 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -1,6 +1,19 @@ import { cloneDeep } from 'lodash'; -import { releaseToApiJson, apiJsonToRelease, convertGraphQLResponse } from '~/releases/util'; -import { graphqlReleasesResponse as originalGraphqlReleasesResponse } from './mock_data'; +import { getJSONFixture } from 'helpers/fixtures'; +import { + releaseToApiJson, + apiJsonToRelease, + convertGraphQLRelease, + convertAllReleasesGraphQLResponse, + convertOneReleaseGraphQLResponse, +} from '~/releases/util'; + +const originalAllReleasesQueryResponse = getJSONFixture( + 'graphql/releases/queries/all_releases.query.graphql.json', +); +const originalOneReleaseQueryResponse = getJSONFixture( + 'graphql/releases/queries/one_release.query.graphql.json', +); describe('releases/util.js', () => { describe('releaseToApiJson', () => { @@ -103,54 +116,61 @@ describe('releases/util.js', () => { }); }); - describe('convertGraphQLResponse', () => { - let graphqlReleasesResponse; - let converted; + describe('convertGraphQLRelease', () => { + let releaseFromResponse; + let convertedRelease; beforeEach(() => { - graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); - converted = convertGraphQLResponse(graphqlReleasesResponse); - }); - - it('matches snapshot', () => { - expect(converted).toMatchSnapshot(); + releaseFromResponse = cloneDeep(originalOneReleaseQueryResponse).data.project.release; + convertedRelease = convertGraphQLRelease(releaseFromResponse); }); describe('assets', () => { it("handles asset links that don't have a linkType", () => { - expect(converted.data[0].assets.links[0].linkType).not.toBeUndefined(); + expect(convertedRelease.assets.links[0].linkType).not.toBeUndefined(); - delete graphqlReleasesResponse.data.project.releases.nodes[0].assets.links.nodes[0] - .linkType; + delete releaseFromResponse.assets.links.nodes[0].linkType; - converted = convertGraphQLResponse(graphqlReleasesResponse); + convertedRelease = convertGraphQLRelease(releaseFromResponse); - expect(converted.data[0].assets.links[0].linkType).toBeUndefined(); + expect(convertedRelease.assets.links[0].linkType).toBeUndefined(); }); }); describe('_links', () => { it("handles releases that don't have any links", () => { - expect(converted.data[0]._links.selfUrl).not.toBeUndefined(); + expect(convertedRelease._links.selfUrl).not.toBeUndefined(); - delete graphqlReleasesResponse.data.project.releases.nodes[0].links; + delete releaseFromResponse.links; - converted = convertGraphQLResponse(graphqlReleasesResponse); + convertedRelease = convertGraphQLRelease(releaseFromResponse); - expect(converted.data[0]._links.selfUrl).toBeUndefined(); + expect(convertedRelease._links.selfUrl).toBeUndefined(); }); }); describe('commit', () => { it("handles releases that don't have any commit info", () => { - expect(converted.data[0].commit).not.toBeUndefined(); + expect(convertedRelease.commit).not.toBeUndefined(); - delete graphqlReleasesResponse.data.project.releases.nodes[0].commit; + delete releaseFromResponse.commit; - converted = convertGraphQLResponse(graphqlReleasesResponse); + convertedRelease = convertGraphQLRelease(releaseFromResponse); - expect(converted.data[0].commit).toBeUndefined(); + expect(convertedRelease.commit).toBeUndefined(); }); }); }); + + describe('convertAllReleasesGraphQLResponse', () => { + it('matches snapshot', () => { + expect(convertAllReleasesGraphQLResponse(originalAllReleasesQueryResponse)).toMatchSnapshot(); + }); + }); + + describe('convertOneReleaseGraphQLResponse', () => { + it('matches snapshot', () => { + expect(convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse)).toMatchSnapshot(); + }); + }); }); 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/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index ca4120576f5..38e5c9aaca5 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -1,5 +1,5 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils'; -import { GlDeprecatedDropdown } from '@gitlab/ui'; +import { GlDropdown } from '@gitlab/ui'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; let vm; @@ -61,7 +61,7 @@ describe('Repository breadcrumbs component', () => { vm.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); return vm.vm.$nextTick(() => { - expect(vm.find(GlDeprecatedDropdown).exists()).toBe(false); + expect(vm.find(GlDropdown).exists()).toBe(false); }); }); @@ -71,7 +71,7 @@ describe('Repository breadcrumbs component', () => { vm.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); return vm.vm.$nextTick(() => { - expect(vm.find(GlDeprecatedDropdown).exists()).toBe(true); + expect(vm.find(GlDropdown).exists()).toBe(true); }); }); }); 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/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap index 69b7a3931f8..23c06dc5e68 100644 --- a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap +++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap @@ -10,9 +10,10 @@ exports[`Repository file preview component renders file HTML 1`] = ` <div class="file-header-content" > - <i + <gl-icon-stub aria-hidden="true" - class="fa fa-file-text-o fa-fw" + name="doc-text" + size="16" /> <gl-link-stub 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/repository/utils/icon_spec.js b/spec/frontend/repository/utils/icon_spec.js deleted file mode 100644 index 3d84705f7ea..00000000000 --- a/spec/frontend/repository/utils/icon_spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import { getIconName } from '~/repository/utils/icon'; - -describe('getIconName', () => { - // Tests the returning font awesome icon name - // We only test one for each file type to save testing a lot of different - // file types - it.each` - type | path | icon - ${'tree'} | ${''} | ${'folder'} - ${'commit'} | ${''} | ${'archive'} - ${'file'} | ${'test.pdf'} | ${'file-pdf-o'} - ${'file'} | ${'test.jpg'} | ${'file-image-o'} - ${'file'} | ${'test.zip'} | ${'file-archive-o'} - ${'file'} | ${'test.mp3'} | ${'file-audio-o'} - ${'file'} | ${'test.flv'} | ${'file-video-o'} - ${'file'} | ${'test.dotx'} | ${'file-word-o'} - ${'file'} | ${'test.xlsb'} | ${'file-excel-o'} - ${'file'} | ${'test.ppam'} | ${'file-powerpoint-o'} - ${'file'} | ${'test.js'} | ${'file-text-o'} - `('returns $icon for $type with path $path', ({ type, path, icon }) => { - expect(getIconName(type, path)).toEqual(icon); - }); -}); 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/state_filter_spec.js deleted file mode 100644 index 26344f2b592..00000000000 --- a/spec/frontend/search/components/state_filter_spec.js +++ /dev/null @@ -1,104 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import StateFilter from '~/search/state_filter/components/state_filter.vue'; -import { - FILTER_STATES, - SCOPES, - FILTER_STATES_BY_SCOPE, - FILTER_TEXT, -} from '~/search/state_filter/constants'; -import * as urlUtils from '~/lib/utils/url_utility'; - -jest.mock('~/lib/utils/url_utility', () => ({ - visitUrl: jest.fn(), - setUrlParams: jest.fn(), -})); - -function createComponent(props = { scope: 'issues' }) { - return shallowMount(StateFilter, { - propsData: { - ...props, - }, - }); -} - -describe('StateFilter', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findGlDropdown = () => wrapper.find(GlDropdown); - const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); - const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text()); - const firstDropDownItem = () => findGlDropdownItems().at(0); - - describe('template', () => { - describe.each` - scope | showStateDropdown - ${'issues'} | ${true} - ${'merge_requests'} | ${true} - ${'projects'} | ${false} - ${'milestones'} | ${false} - ${'users'} | ${false} - ${'notes'} | ${false} - ${'wiki_blobs'} | ${false} - ${'blobs'} | ${false} - `(`state dropdown`, ({ scope, showStateDropdown }) => { - beforeEach(() => { - wrapper = createComponent({ scope }); - }); - - it(`does${showStateDropdown ? '' : ' not'} render when scope is ${scope}`, () => { - expect(findGlDropdown().exists()).toBe(showStateDropdown); - }); - }); - - describe.each` - state | label - ${FILTER_STATES.ANY.value} | ${FILTER_TEXT} - ${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}`, () => { - beforeEach(() => { - wrapper = createComponent({ scope: 'issues', state }); - }); - - it(`sets dropdown label to ${label}`, () => { - expect(findGlDropdown().attributes('text')).toBe(label); - }); - }); - }); - - describe('Filter options', () => { - it('renders a dropdown item for each filterOption', () => { - expect(findDropdownItemsText()).toStrictEqual( - FILTER_STATES_BY_SCOPE[SCOPES.ISSUES].map(v => { - return v.label; - }), - ); - }); - - it('clicking a dropdown item calls setUrlParams', () => { - const state = FILTER_STATES[Object.keys(FILTER_STATES)[0]].value; - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ state }); - }); - - it('clicking a dropdown item calls visitUrl', () => { - firstDropDownItem().vm.$emit('click'); - - expect(urlUtils.visitUrl).toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js b/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js new file mode 100644 index 00000000000..4a6b5cebe1c --- /dev/null +++ b/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js @@ -0,0 +1,196 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import * as urlUtils from '~/lib/utils/url_utility'; +import initStore from '~/search/store'; +import DropdownFilter from '~/search/dropdown_filter/components/dropdown_filter.vue'; +import stateFilterData from '~/search/dropdown_filter/constants/state_filter_data'; +import confidentialFilterData from '~/search/dropdown_filter/constants/confidential_filter_data'; +import { MOCK_QUERY } from '../mock_data'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + setUrlParams: jest.fn(), +})); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('DropdownFilter', () => { + let wrapper; + let store; + + const createStore = options => { + store = initStore({ query: MOCK_QUERY, ...options }); + }; + + const createComponent = (props = { filterData: stateFilterData }) => { + wrapper = shallowMount(DropdownFilter, { + localVue, + store, + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + store = null; + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); + const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text()); + const firstDropDownItem = () => findGlDropdownItems().at(0); + + describe('StatusFilter', () => { + describe('template', () => { + describe.each` + scope | showDropdown + ${'issues'} | ${true} + ${'merge_requests'} | ${true} + ${'projects'} | ${false} + ${'milestones'} | ${false} + ${'users'} | ${false} + ${'notes'} | ${false} + ${'wiki_blobs'} | ${false} + ${'blobs'} | ${false} + `(`dropdown`, ({ scope, showDropdown }) => { + beforeEach(() => { + createStore({ query: { ...MOCK_QUERY, scope } }); + createComponent(); + }); + + it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findGlDropdown().exists()).toBe(showDropdown); + }); + }); + + describe.each` + initialFilter | label + ${stateFilterData.filters.ANY.value} | ${`Any ${stateFilterData.header}`} + ${stateFilterData.filters.OPEN.value} | ${stateFilterData.filters.OPEN.label} + ${stateFilterData.filters.CLOSED.value} | ${stateFilterData.filters.CLOSED.label} + `(`filter text`, ({ initialFilter, label }) => { + describe(`when initialFilter is ${initialFilter}`, () => { + beforeEach(() => { + createStore({ query: { ...MOCK_QUERY, [stateFilterData.filterParam]: initialFilter } }); + createComponent(); + }); + + it(`sets dropdown label to ${label}`, () => { + expect(findGlDropdown().attributes('text')).toBe(label); + }); + }); + }); + }); + + describe('Filter options', () => { + beforeEach(() => { + createStore(); + createComponent(); + }); + + it('renders a dropdown item for each filterOption', () => { + expect(findDropdownItemsText()).toStrictEqual( + stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(v => { + return v.label; + }), + ); + }); + + it('clicking a dropdown item calls setUrlParams', () => { + const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value; + firstDropDownItem().vm.$emit('click'); + + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ + [stateFilterData.filterParam]: filter, + }); + }); + + it('clicking a dropdown item calls visitUrl', () => { + firstDropDownItem().vm.$emit('click'); + + expect(urlUtils.visitUrl).toHaveBeenCalled(); + }); + }); + }); + + describe('ConfidentialFilter', () => { + describe('template', () => { + describe.each` + scope | showDropdown + ${'issues'} | ${true} + ${'merge_requests'} | ${false} + ${'projects'} | ${false} + ${'milestones'} | ${false} + ${'users'} | ${false} + ${'notes'} | ${false} + ${'wiki_blobs'} | ${false} + ${'blobs'} | ${false} + `(`dropdown`, ({ scope, showDropdown }) => { + beforeEach(() => { + createStore({ query: { ...MOCK_QUERY, scope } }); + createComponent({ filterData: confidentialFilterData }); + }); + + it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findGlDropdown().exists()).toBe(showDropdown); + }); + }); + + describe.each` + initialFilter | label + ${confidentialFilterData.filters.ANY.value} | ${`Any ${confidentialFilterData.header}`} + ${confidentialFilterData.filters.CONFIDENTIAL.value} | ${confidentialFilterData.filters.CONFIDENTIAL.label} + ${confidentialFilterData.filters.NOT_CONFIDENTIAL.value} | ${confidentialFilterData.filters.NOT_CONFIDENTIAL.label} + `(`filter text`, ({ initialFilter, label }) => { + describe(`when initialFilter is ${initialFilter}`, () => { + beforeEach(() => { + createStore({ + query: { ...MOCK_QUERY, [confidentialFilterData.filterParam]: initialFilter }, + }); + createComponent({ filterData: confidentialFilterData }); + }); + + it(`sets dropdown label to ${label}`, () => { + expect(findGlDropdown().attributes('text')).toBe(label); + }); + }); + }); + }); + }); + + describe('Filter options', () => { + beforeEach(() => { + createStore(); + createComponent({ filterData: confidentialFilterData }); + }); + + it('renders a dropdown item for each filterOption', () => { + expect(findDropdownItemsText()).toStrictEqual( + confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(v => { + return v.label; + }), + ); + }); + + it('clicking a dropdown item calls setUrlParams', () => { + const filter = + confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value; + firstDropDownItem().vm.$emit('click'); + + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ + [confidentialFilterData.filterParam]: filter, + }); + }); + + it('clicking a dropdown item calls visitUrl', () => { + firstDropDownItem().vm.$emit('click'); + + expect(urlUtils.visitUrl).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/search/dropdown_filter/mock_data.js b/spec/frontend/search/dropdown_filter/mock_data.js new file mode 100644 index 00000000000..f11ab3d9951 --- /dev/null +++ b/spec/frontend/search/dropdown_filter/mock_data.js @@ -0,0 +1,5 @@ +export const MOCK_QUERY = { + scope: 'issues', + state: 'all', + confidential: null, +}; 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/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js index bcc7f29b98d..ed30e4774d9 100644 --- a/spec/frontend/sentry/sentry_config_spec.js +++ b/spec/frontend/sentry/sentry_config_spec.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/wrapper'; import SentryConfig from '~/sentry/sentry_config'; describe('SentryConfig', () => { 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/serverless/components/url_spec.js b/spec/frontend/serverless/components/url_spec.js index 36dc9e73c74..92e4938c2cd 100644 --- a/spec/frontend/serverless/components/url_spec.js +++ b/spec/frontend/serverless/components/url_spec.js @@ -19,7 +19,7 @@ describe('urlComponent', () => { expect(vm.$el.classList.contains('clipboard-group')).toBe(true); expect(wrapper.find(ClipboardButton).attributes('text')).toEqual(uri); - expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri); + expect(vm.$el.querySelector('[data-testid="url-text-field"]').innerHTML).toContain(uri); vm.$destroy(); }); diff --git a/spec/frontend/sidebar/assignee_title_spec.js b/spec/frontend/sidebar/assignee_title_spec.js index 92fabaa664e..b5d1e5216f8 100644 --- a/spec/frontend/sidebar/assignee_title_spec.js +++ b/spec/frontend/sidebar/assignee_title_spec.js @@ -11,6 +11,7 @@ describe('AssigneeTitle component', () => { propsData: { numberOfAssignees: 0, editable: false, + changing: false, ...props, }, }); @@ -62,6 +63,22 @@ describe('AssigneeTitle component', () => { }); }); + describe('when changing is false', () => { + it('renders "Edit"', () => { + wrapper = createComponent({ editable: true }); + + expect(wrapper.find('[data-test-id="edit-link"]').text()).toEqual('Edit'); + }); + }); + + describe('when changing is true', () => { + it('renders "Edit"', () => { + wrapper = createComponent({ editable: true, changing: true }); + + expect(wrapper.find('[data-test-id="edit-link"]').text()).toEqual('Apply'); + }); + }); + it('does not render spinner by default', () => { wrapper = createComponent({ numberOfAssignees: 0, diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index 1f028f74423..5307be0bf58 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -155,8 +155,7 @@ describe('Issuable Time Tracker', () => { it('should show the correct tooltip text', done => { Vue.nextTick(() => { expect(vm.showComparisonState).toBe(true); - const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').dataset - .originalTitle; + const $title = vm.$el.querySelector('.time-tracking-content .compare-meter').title; expect($title).toBe('Time remaining: 26h 23m'); done(); 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/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js index ab1423a9bbb..e8091dcb51d 100644 --- a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js +++ b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue'; import EditForm from '~/sidebar/components/lock/edit_form.vue'; import createStore from '~/notes/stores'; @@ -19,6 +20,8 @@ describe('IssuableLockForm', () => { const findLockStatus = () => wrapper.find('[data-testid="lock-status"]'); const findEditLink = () => wrapper.find('[data-testid="edit-link"]'); const findEditForm = () => wrapper.find(EditForm); + const findSidebarLockStatusTooltip = () => + getBinding(findSidebarCollapseIcon().element, 'gl-tooltip'); const initStore = isLocked => { if (issuableType === ISSUABLE_TYPE_ISSUE) { @@ -37,6 +40,9 @@ describe('IssuableLockForm', () => { isEditable: true, ...props, }, + directives: { + GlTooltip: createMockDirective(), + }, }); }; @@ -125,6 +131,13 @@ describe('IssuableLockForm', () => { expect(findEditForm().exists()).toBe(true); }); }); + + it('renders a tooltip with the lock status text', () => { + const tooltip = findSidebarLockStatusTooltip(); + + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(isLocked ? 'Locked' : 'Unlocked'); + }); }); }); }); 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_assignees_spec.js b/spec/frontend/sidebar/sidebar_assignees_spec.js index 88e2d2c9514..dc4560d2ae8 100644 --- a/spec/frontend/sidebar/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/sidebar_assignees_spec.js @@ -20,6 +20,7 @@ describe('sidebar assignees', () => { mediator, field: '', projectPath: 'projectPath', + changing: false, ...props, }, provide: { diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js index 29333a344e1..7a687ffa761 100644 --- a/spec/frontend/sidebar/sidebar_labels_spec.js +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -1,6 +1,5 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; -import Vuex from 'vuex'; import { mockLabels, mockRegularLabel, @@ -9,17 +8,11 @@ import axios from '~/lib/utils/axios_utils'; import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; - -const localVue = createLocalVue(); -localVue.use(Vuex); describe('sidebar labels', () => { let axiosMock; let wrapper; - const store = new Vuex.Store(labelsSelectModule()); - const defaultProps = { allowLabelCreate: true, allowLabelEdit: true, @@ -39,11 +32,9 @@ describe('sidebar labels', () => { const mountComponent = () => { wrapper = shallowMount(SidebarLabels, { - localVue, provide: { ...defaultProps, }, - store, }); }; @@ -81,7 +72,7 @@ describe('sidebar labels', () => { }); }); - describe('when labels are changed', () => { + describe('when labels are updated', () => { beforeEach(() => { mountComponent(); }); @@ -114,7 +105,27 @@ describe('sidebar labels', () => { const expected = { [defaultProps.issuableType]: { - label_ids: [27, 28, 40], + label_ids: [27, 28, 29, 40], + }, + }; + + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected)); + }); + }); + + describe('when label `x` is clicked', () => { + beforeEach(() => { + mountComponent(); + }); + + it('makes an API call to update labels', async () => { + findLabelsSelect().vm.$emit('onLabelRemove', 27); + + await axios.waitForAll(); + + const expected = { + [defaultProps.issuableType]: { + label_ids: [26, 28, 29], }, }; diff --git a/spec/frontend/sidebar/sidebar_store_spec.js b/spec/frontend/sidebar/sidebar_store_spec.js index 6d063a7cfcf..7c18222f300 100644 --- a/spec/frontend/sidebar/sidebar_store_spec.js +++ b/spec/frontend/sidebar/sidebar_store_spec.js @@ -57,16 +57,40 @@ describe('Sidebar store', () => { expect(testContext.store.isFetching.assignees).toBe(true); }); - it('adds a new assignee', () => { - testContext.store.addAssignee(ASSIGNEE); + it('resets changing when resetChanging is called', () => { + testContext.store.changing = true; + + testContext.store.resetChanging(); - expect(testContext.store.assignees.length).toEqual(1); + expect(testContext.store.changing).toBe(false); }); - it('removes an assignee', () => { - testContext.store.removeAssignee(ASSIGNEE); + describe('when it adds a new assignee', () => { + beforeEach(() => { + testContext.store.addAssignee(ASSIGNEE); + }); - expect(testContext.store.assignees.length).toEqual(0); + it('adds a new assignee', () => { + expect(testContext.store.assignees).toHaveLength(1); + }); + + it('sets changing to true', () => { + expect(testContext.store.changing).toBe(true); + }); + }); + + describe('when it removes an assignee', () => { + beforeEach(() => { + testContext.store.removeAssignee(ASSIGNEE); + }); + + it('removes an assignee', () => { + expect(testContext.store.assignees).toHaveLength(0); + }); + + it('sets changing to true', () => { + expect(testContext.store.changing).toBe(true); + }); }); it('finds an existent assignee', () => { @@ -86,6 +110,7 @@ describe('Sidebar store', () => { testContext.store.removeAllAssignees(); expect(testContext.store.assignees.length).toEqual(0); + expect(testContext.store.changing).toBe(true); }); it('sets participants data', () => { diff --git a/spec/frontend/snippet/snippet_bundle_spec.js b/spec/frontend/snippet/snippet_bundle_spec.js deleted file mode 100644 index 208d2fea804..00000000000 --- a/spec/frontend/snippet/snippet_bundle_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { setHTMLFixture } from 'helpers/fixtures'; -import Editor from '~/editor/editor_lite'; -import initEditor from '~/snippet/snippet_bundle'; - -jest.mock('~/editor/editor_lite', () => jest.fn()); - -describe('Snippet editor', () => { - let editorEl; - let contentEl; - let fileNameEl; - let form; - - const mockName = 'foo.bar'; - const mockContent = 'Foo Bar'; - const updatedMockContent = 'New Foo Bar'; - - const mockEditor = { - updateModelLanguage: jest.fn(), - getValue: jest.fn().mockReturnValueOnce(updatedMockContent), - }; - const createInstance = jest.fn().mockImplementation(() => ({ ...mockEditor })); - Editor.mockImplementation(() => ({ - createInstance, - })); - - function setUpFixture(name, content) { - setHTMLFixture(` - <div class="snippet-form-holder"> - <form> - <input class="js-snippet-file-name" type="text" value="${name}"> - <input class="snippet-file-content" type="hidden" value="${content}"> - <pre id="editor"></pre> - </form> - </div> - `); - } - - function bootstrap(name = '', content = '') { - setUpFixture(name, content); - editorEl = document.getElementById('editor'); - contentEl = document.querySelector('.snippet-file-content'); - fileNameEl = document.querySelector('.js-snippet-file-name'); - form = document.querySelector('.snippet-form-holder form'); - - initEditor(); - } - - function createEvent(name) { - return new Event(name, { - view: window, - bubbles: true, - cancelable: true, - }); - } - - beforeEach(() => { - bootstrap(mockName, mockContent); - }); - - it('correctly initializes Editor', () => { - expect(createInstance).toHaveBeenCalledWith({ - el: editorEl, - blobPath: mockName, - blobContent: mockContent, - }); - }); - - it('listens to file name changes and updates syntax highlighting of code', () => { - expect(mockEditor.updateModelLanguage).not.toHaveBeenCalled(); - - const event = createEvent('change'); - - fileNameEl.value = updatedMockContent; - fileNameEl.dispatchEvent(event); - - expect(mockEditor.updateModelLanguage).toHaveBeenCalledWith(updatedMockContent); - }); - - it('listens to form submit event and populates the hidden field with most recent version of the content', () => { - expect(contentEl.value).toBe(mockContent); - - const event = createEvent('submit'); - - form.dispatchEvent(event); - expect(contentEl.value).toBe(updatedMockContent); - }); -}); diff --git a/spec/frontend/snippet/snippet_edit_spec.js b/spec/frontend/snippet/snippet_edit_spec.js deleted file mode 100644 index 7c12c0cac03..00000000000 --- a/spec/frontend/snippet/snippet_edit_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import '~/snippet/snippet_edit'; -import { triggerDOMEvent } from 'jest/helpers/dom_events_helper'; -import { SnippetEditInit } from '~/snippets'; -import initSnippet from '~/snippet/snippet_bundle'; - -jest.mock('~/snippet/snippet_bundle'); -jest.mock('~/snippets'); - -describe('Snippet edit form initialization', () => { - const setFF = flag => { - gon.features = { snippetsEditVue: flag }; - }; - let features; - - beforeEach(() => { - features = gon.features; - setFixtures('<div class="snippet-form"></div>'); - }); - - afterEach(() => { - gon.features = features; - }); - - it.each` - name | flag | isVue - ${'Regular'} | ${false} | ${false} - ${'Vue'} | ${true} | ${true} - `('correctly initializes $name Snippet Edit form', ({ flag, isVue }) => { - initSnippet.mockClear(); - SnippetEditInit.mockClear(); - - setFF(flag); - - triggerDOMEvent('DOMContentLoaded'); - - if (isVue) { - expect(initSnippet).not.toHaveBeenCalled(); - expect(SnippetEditInit).toHaveBeenCalled(); - } else { - expect(initSnippet).toHaveBeenCalled(); - expect(SnippetEditInit).not.toHaveBeenCalled(); - } - }); -}); 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..b0c253bca65 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,15 +3,18 @@ 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" data-qa-selector="file_name_field" id="blob_local_7_file_path" + showdelete="true" value="foo/bar/test.md" /> - <blob-content-edit-stub + <editor-lite-stub + editoroptions="[object Object]" fileglobalid="blob_local_7" filename="foo/bar/test.md" value="Lorem ipsum dolar sit amet, diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 3b101e9e815..93684ed48ee 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -24,7 +24,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = </div> <div - class="js-vue-markdown-field md-area position-relative js-expanded gfm-form" + class="js-vue-markdown-field md-area position-relative gfm-form js-expanded" > <markdown-header-stub linecontent="" 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_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js index 8b2051008d7..055168a1711 100644 --- a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js @@ -19,17 +19,12 @@ const TEST_BLOBS_UNLOADED = TEST_BLOBS.map(blob => ({ ...blob, content: '', isLo describe('snippets/components/snippet_blob_actions_edit', () => { let wrapper; - const createComponent = (props = {}, snippetMultipleFiles = true) => { + const createComponent = (props = {}) => { wrapper = shallowMount(SnippetBlobActionsEdit, { propsData: { initBlobs: TEST_BLOBS, ...props, }, - provide: { - glFeatures: { - snippetMultipleFiles, - }, - }, }); }; @@ -69,28 +64,24 @@ describe('snippets/components/snippet_blob_actions_edit', () => { wrapper = null; }); - describe.each` - featureFlag | label | showDelete | showAdd - ${true} | ${'Files'} | ${true} | ${true} - ${false} | ${'File'} | ${false} | ${false} - `('with feature flag = $featureFlag', ({ featureFlag, label, showDelete, showAdd }) => { + describe('multi-file snippets rendering', () => { beforeEach(() => { - createComponent({}, featureFlag); + createComponent(); }); it('renders label', () => { - expect(findLabel().text()).toBe(label); + expect(findLabel().text()).toBe('Files'); }); - it(`renders delete button (show=${showDelete})`, () => { + it(`renders delete button (show=true)`, () => { expect(findFirstBlobEdit().props()).toMatchObject({ - showDelete, + showDelete: true, canDelete: true, }); }); - it(`renders add button (show=${showAdd})`, () => { - expect(findAddButton().exists()).toBe(showAdd); + it(`renders add button (show=true)`, () => { + expect(findAddButton().exists()).toBe(true); }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index fc4da46d722..9d0311fd682 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -5,7 +5,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'helpers/test_constants'; import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as createFlash } from '~/flash'; @@ -48,7 +48,7 @@ describe('Snippet Blob Edit component', () => { const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findHeader = () => wrapper.find(BlobHeaderEdit); - const findContent = () => wrapper.find(BlobContentEdit); + const findContent = () => wrapper.find(EditorLite); const getLastUpdatedArgs = () => { const event = wrapper.emitted()['blob-updated']; @@ -156,7 +156,7 @@ describe('Snippet Blob Edit component', () => { }); it('shows blob header', () => { - const { canDelete = true, showDelete = false } = props; + const { canDelete = true, showDelete = true } = props; expect(findHeader().props()).toMatchObject({ canDelete, @@ -172,11 +172,13 @@ describe('Snippet Blob Edit component', () => { expect(findContent().exists()).toBe(showContent); if (showContent) { - expect(findContent().props()).toEqual({ - value: TEST_BLOB_LOADED.content, - fileGlobalId: TEST_BLOB_LOADED.id, - fileName: TEST_BLOB_LOADED.path, - }); + expect(findContent().props()).toEqual( + expect.objectContaining({ + value: TEST_BLOB_LOADED.content, + fileGlobalId: TEST_BLOB_LOADED.id, + fileName: TEST_BLOB_LOADED.path, + }), + ); } }); }); 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/snippets_spec.js b/spec/frontend/snippets_spec.js deleted file mode 100644 index 6c39ff0da27..00000000000 --- a/spec/frontend/snippets_spec.js +++ /dev/null @@ -1,70 +0,0 @@ -import snippetEmbed from '~/snippet/snippet_embed'; -import { loadHTMLFixture } from './helpers/fixtures'; - -describe('Snippets', () => { - let embedBtn; - let snippetUrlArea; - let shareBtn; - let scriptTag; - - const snippetUrl = 'http://test.host/-/snippets/1'; - - beforeEach(() => { - loadHTMLFixture('snippets/show.html'); - - embedBtn = document.querySelector('.js-embed-btn'); - snippetUrlArea = document.querySelector('.js-snippet-url-area'); - shareBtn = document.querySelector('.js-share-btn'); - }); - - it('selects the fields content when it is clicked', () => { - jest.spyOn(snippetUrlArea, 'select'); - snippetEmbed(); - - expect(snippetUrlArea.select).not.toHaveBeenCalled(); - snippetUrlArea.dispatchEvent(new Event('click')); - expect(snippetUrlArea.select).toHaveBeenCalled(); - }); - - describe('when the snippet url does not include params', () => { - beforeEach(() => { - snippetEmbed(); - - scriptTag = `<script src="${snippetUrl}.js"></script>`; - }); - - it('shows the script tag as default', () => { - expect(snippetUrlArea.value).toEqual(scriptTag); - }); - - it('sets the proper url depending on the button clicked', () => { - shareBtn.dispatchEvent(new Event('click')); - expect(snippetUrlArea.value).toEqual(snippetUrl); - - embedBtn.dispatchEvent(new Event('click')); - expect(snippetUrlArea.value).toEqual(scriptTag); - }); - }); - - describe('when the snippet url includes params', () => { - beforeEach(() => { - scriptTag = `<script src="${snippetUrl}.js?foo=bar"></script>`; - snippetUrlArea.value = scriptTag; - snippetUrlArea.dataset.url = `${snippetUrl}?foo=bar`; - - snippetEmbed(); - }); - - it('shows the script tag as default', () => { - expect(snippetUrlArea.value).toEqual(scriptTag); - }); - - it('sets the proper url depending on the button clicked', () => { - shareBtn.dispatchEvent(new Event('click')); - expect(snippetUrlArea.value).toEqual(`${snippetUrl}?foo=bar`); - - embedBtn.dispatchEvent(new Event('click')); - expect(snippetUrlArea.value).toEqual(scriptTag); - }); - }); -}); diff --git a/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js new file mode 100644 index 00000000000..191f91be076 --- /dev/null +++ b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js @@ -0,0 +1,99 @@ +import { shallowMount } from '@vue/test-utils'; + +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { GlFormInput, GlFormTextarea } from '@gitlab/ui'; + +import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue'; + +import { mergeRequestMeta } from '../mock_data'; + +describe('~/static_site_editor/components/edit_meta_controls.vue', () => { + useLocalStorageSpy(); + + let wrapper; + let mockSelect; + let mockGlFormInputTitleInstance; + const { title, description } = mergeRequestMeta; + const newTitle = 'New title'; + const newDescription = 'New description'; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(EditMetaControls, { + propsData: { + title, + description, + ...propsData, + }, + }); + }; + + const buildMocks = () => { + mockSelect = jest.fn(); + mockGlFormInputTitleInstance = { $el: { select: mockSelect } }; + wrapper.vm.$refs.title = mockGlFormInputTitleInstance; + }; + + const findGlFormInputTitle = () => wrapper.find(GlFormInput); + const findGlFormTextAreaDescription = () => wrapper.find(GlFormTextarea); + + beforeEach(() => { + buildWrapper(); + buildMocks(); + + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the title input', () => { + expect(findGlFormInputTitle().exists()).toBe(true); + }); + + it('renders the description input', () => { + expect(findGlFormTextAreaDescription().exists()).toBe(true); + }); + + it('forwards the title prop to the title input', () => { + expect(findGlFormInputTitle().attributes().value).toBe(title); + }); + + it('forwards the description prop to the description input', () => { + expect(findGlFormTextAreaDescription().attributes().value).toBe(description); + }); + + it('calls select on the title input when mounted', () => { + expect(mockGlFormInputTitleInstance.$el.select).toHaveBeenCalled(); + }); + + describe('when inputs change', () => { + const storageKey = 'sse-merge-request-meta-local-storage-editable'; + + afterEach(() => { + localStorage.removeItem(storageKey); + }); + + it.each` + findFn | key | value + ${findGlFormInputTitle} | ${'title'} | ${newTitle} + ${findGlFormTextAreaDescription} | ${'description'} | ${newDescription} + `('emits updated settings when $findFn input updates', ({ key, value, findFn }) => { + findFn().vm.$emit('input', value); + + const newSettings = { ...mergeRequestMeta, [key]: value }; + + expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings); + }); + + it('should remember the input changes', () => { + findGlFormInputTitle().vm.$emit('input', newTitle); + findGlFormTextAreaDescription().vm.$emit('input', newDescription); + + const newSettings = { title: newTitle, description: newDescription }; + + expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, JSON.stringify(newSettings)); + }); + }); +}); diff --git a/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js new file mode 100644 index 00000000000..7a5685033f3 --- /dev/null +++ b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js @@ -0,0 +1,80 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlModal } from '@gitlab/ui'; + +import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.vue'; +import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue'; + +import { sourcePath, mergeRequestMeta } from '../mock_data'; + +describe('~/static_site_editor/components/edit_meta_modal.vue', () => { + let wrapper; + let resetCachedEditable; + let mockEditMetaControlsInstance; + const { title, description } = mergeRequestMeta; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(EditMetaModal, { + propsData: { + sourcePath, + ...propsData, + }, + }); + }; + + const buildMocks = () => { + resetCachedEditable = jest.fn(); + mockEditMetaControlsInstance = { resetCachedEditable }; + wrapper.vm.$refs.editMetaControls = mockEditMetaControlsInstance; + }; + + const findGlModal = () => wrapper.find(GlModal); + const findEditMetaControls = () => wrapper.find(EditMetaControls); + + beforeEach(() => { + buildWrapper(); + buildMocks(); + + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the modal', () => { + expect(findGlModal().exists()).toBe(true); + }); + + it('renders the edit meta controls', () => { + expect(findEditMetaControls().exists()).toBe(true); + }); + + it('contains the sourcePath in the title', () => { + expect(findEditMetaControls().props('title')).toContain(sourcePath); + }); + + it('forwards the title prop', () => { + expect(findEditMetaControls().props('title')).toBe(title); + }); + + it('forwards the description prop', () => { + expect(findEditMetaControls().props('description')).toBe(description); + }); + + it('emits the primary event with mergeRequestMeta', () => { + findGlModal().vm.$emit('primary', mergeRequestMeta); + expect(wrapper.emitted('primary')).toEqual([[mergeRequestMeta]]); + }); + + it('calls resetCachedEditable on EditMetaControls when primary emits', () => { + findGlModal().vm.$emit('primary', mergeRequestMeta); + expect(mockEditMetaControlsInstance.resetCachedEditable).toHaveBeenCalled(); + }); + + it('emits the hide event', () => { + findGlModal().vm.$emit('hide'); + expect(wrapper.emitted('hide')).toEqual([[]]); + }); +}); diff --git a/spec/frontend/static_site_editor/components/front_matter_controls_spec.js b/spec/frontend/static_site_editor/components/front_matter_controls_spec.js index 82e8fad643e..8001f2fbd29 100644 --- a/spec/frontend/static_site_editor/components/front_matter_controls_spec.js +++ b/spec/frontend/static_site_editor/components/front_matter_controls_spec.js @@ -5,18 +5,11 @@ import { humanize } from '~/lib/utils/text_utility'; import FrontMatterControls from '~/static_site_editor/components/front_matter_controls.vue'; +import { sourceContentHeaderObjYAML as settings } from '../mock_data'; + describe('~/static_site_editor/components/front_matter_controls.vue', () => { let wrapper; - // TODO Refactor and update `sourceContentHeaderObjYAML` in mock_data when !41230 lands - const settings = { - layout: 'handbook-page-toc', - title: 'Handbook', - twitter_image: '/images/tweets/handbook-gitlab.png', - suppress_header: true, - extra_css: ['sales-and-free-trial-common.css', 'form-to-resource.css'], - }; - const buildWrapper = (propsData = {}) => { wrapper = shallowMount(FrontMatterControls, { propsData: { diff --git a/spec/frontend/static_site_editor/graphql/resolvers/has_submitted_changes_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/has_submitted_changes_spec.js new file mode 100644 index 00000000000..0670b240a3f --- /dev/null +++ b/spec/frontend/static_site_editor/graphql/resolvers/has_submitted_changes_spec.js @@ -0,0 +1,27 @@ +import appDataQuery from '~/static_site_editor/graphql/queries/app_data.query.graphql'; +import hasSubmittedChanges from '~/static_site_editor/graphql/resolvers/has_submitted_changes'; + +describe('static_site_editor/graphql/resolvers/has_submitted_changes', () => { + it('updates the cache with the data passed in input', () => { + const cachedData = { appData: { original: 'foo' } }; + const newValue = { input: { hasSubmittedChanges: true } }; + + const cache = { + readQuery: jest.fn().mockReturnValue(cachedData), + writeQuery: jest.fn(), + }; + hasSubmittedChanges(null, newValue, { cache }); + + expect(cache.readQuery).toHaveBeenCalledWith({ query: appDataQuery }); + expect(cache.writeQuery).toHaveBeenCalledWith({ + query: appDataQuery, + data: { + appData: { + __typename: 'AppData', + original: 'foo', + hasSubmittedChanges: true, + }, + }, + }); + }); +}); diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js index d861f6c9cd7..0b08e290227 100644 --- a/spec/frontend/static_site_editor/mock_data.js +++ b/spec/frontend/static_site_editor/mock_data.js @@ -2,11 +2,17 @@ export const sourceContentHeaderYAML = `--- layout: handbook-page-toc title: Handbook twitter_image: /images/tweets/handbook-gitlab.png +suppress_header: true +extra_css: + - sales-and-free-trial-common.css + - form-to-resource.css ---`; export const sourceContentHeaderObjYAML = { layout: 'handbook-page-toc', title: 'Handbook', twitter_image: '/images/tweets/handbook-gitlab.png', + suppress_header: true, + extra_css: ['sales-and-free-trial-common.css', 'form-to-resource.css'], }; export const sourceContentSpacing = `\n`; export const sourceContentBody = `## On this page @@ -23,7 +29,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..2c69e884005 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -1,12 +1,13 @@ -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'; import SkeletonLoader from '~/static_site_editor/components/skeleton_loader.vue'; import EditArea from '~/static_site_editor/components/edit_area.vue'; +import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.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,15 +18,15 @@ import { sourceContentTitle as title, sourcePath, username, + mergeRequestMeta, savedContentMeta, submitChangesError, trackingCategory, + images, } from '../mock_data'; const localVue = createLocalVue(); -localVue.use(Vuex); - describe('static_site_editor/pages/home', () => { let wrapper; let store; @@ -33,6 +34,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 +78,7 @@ describe('static_site_editor/pages/home', () => { }, data() { return { - appData: { isSupportedContent: true, returnUrl, project, username, sourcePath }, + appData: { ...defaultAppData }, sourceContent: { title, content }, ...data, }; @@ -73,6 +87,7 @@ describe('static_site_editor/pages/home', () => { }; const findEditArea = () => wrapper.find(EditArea); + const findEditMetaModal = () => wrapper.find(EditMetaModal); const findInvalidContentMessage = () => wrapper.find(InvalidContentMessage); const findSkeletonLoader = () => wrapper.find(SkeletonLoader); const findSubmitChangesError = () => wrapper.find(SubmitChangesError); @@ -140,24 +155,51 @@ describe('static_site_editor/pages/home', () => { }); it('displays invalid content message when content is not supported', () => { - buildWrapper({ appData: { isSupportedContent: false } }); + buildWrapper({ appData: { ...defaultAppData, isSupportedContent: false } }); expect(findInvalidContentMessage().exists()).toBe(true); }); it('does not display invalid content message when content is supported', () => { - buildWrapper({ appData: { isSupportedContent: true } }); + buildWrapper(); expect(findInvalidContentMessage().exists()).toBe(false); }); - describe('when submitting changes fails', () => { - beforeEach(() => { - mutateMock.mockRejectedValue(new Error(submitChangesError)); + it('renders an EditMetaModal component', () => { + buildWrapper(); + + expect(findEditMetaModal().exists()).toBe(true); + }); + describe('when preparing submission', () => { + it('calls the show method when the edit-area submit event is emitted', () => { buildWrapper(); + + const mockInstance = { show: jest.fn() }; + wrapper.vm.$refs.editMetaModal = mockInstance; + findEditArea().vm.$emit('submit', { content }); + return wrapper.vm.$nextTick().then(() => { + expect(mockInstance.show).toHaveBeenCalled(); + }); + }); + }); + + describe('when submitting changes fails', () => { + const setupMutateMock = () => { + mutateMock + .mockResolvedValueOnce(hasSubmittedChangesMutationPayload) + .mockRejectedValueOnce(new Error(submitChangesError)); + }; + + beforeEach(() => { + setupMutateMock(); + + buildWrapper({ content }); + findEditMetaModal().vm.$emit('primary', mergeRequestMeta); + return wrapper.vm.$nextTick(); }); @@ -166,6 +208,8 @@ describe('static_site_editor/pages/home', () => { }); it('retries submitting changes when retry button is clicked', () => { + setupMutateMock(); + findSubmitChangesError().vm.$emit('retry'); expect(mutateMock).toHaveBeenCalled(); @@ -180,26 +224,35 @@ describe('static_site_editor/pages/home', () => { }); }); - it('does not display submit changes error when an error does not exist', () => { - buildWrapper(); - - expect(findSubmitChangesError().exists()).toBe(false); - }); - describe('when submitting changes succeeds', () => { const newContent = `new ${content}`; beforeEach(() => { - mutateMock.mockResolvedValueOnce({ data: { submitContentChanges: savedContentMeta } }); + mutateMock.mockResolvedValueOnce(hasSubmittedChangesMutationPayload).mockResolvedValueOnce({ + data: { + submitContentChanges: savedContentMeta, + }, + }); - buildWrapper(); - findEditArea().vm.$emit('submit', { content: newContent }); + buildWrapper({ content: newContent, images }); + findEditMetaModal().vm.$emit('primary', mergeRequestMeta); 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 +260,8 @@ describe('static_site_editor/pages/home', () => { project, sourcePath, username, + images, + mergeRequestMeta, }, }, }); @@ -217,6 +272,12 @@ describe('static_site_editor/pages/home', () => { }); }); + it('does not display submit changes error when an error does not exist', () => { + buildWrapper(); + + expect(findSubmitChangesError().exists()).toBe(false); + }); + it('tracks when editor is initialized on the mounted lifecycle hook', () => { buildWrapper(); expect(trackingSpy).toHaveBeenCalledWith( 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/tracking_spec.js b/spec/frontend/tracking_spec.js index e2d39ffeaf0..8c2bef60e74 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -28,7 +28,7 @@ describe('Tracking', () => { respectDoNotTrack: true, forceSecureTracker: true, eventMethod: 'post', - contexts: { webPage: true }, + contexts: { webPage: true, performanceTiming: true }, formTracking: false, linkClickTracking: false, }); 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_author_time_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_author_time_spec.js index 58ed92298bf..78efcb6e695 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_author_time_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_author_time_spec.js @@ -35,9 +35,7 @@ describe('MrWidgetAuthorTime', () => { }); it('renders provided time', () => { - expect(vm.$el.querySelector('time').getAttribute('data-original-title')).toEqual( - '2017-03-23T23:02:00.807Z', - ); + expect(vm.$el.querySelector('time').getAttribute('title')).toEqual('2017-03-23T23:02:00.807Z'); expect(vm.$el.querySelector('time').textContent.trim()).toEqual('12 hours ago'); }); 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_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 1921599ae95..9b51e8583ba 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -212,8 +212,6 @@ describe('MRWidgetMerged', () => { }); it('should use mergedEvent mergedAt as tooltip title', () => { - expect(vm.$el.querySelector('time').getAttribute('data-original-title')).toBe( - 'Jan 24, 2018 1:02pm GMT+0000', - ); + expect(vm.$el.querySelector('time').getAttribute('title')).toBe('Jan 24, 2018 1:02pm GMT+0000'); }); }); 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/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js index 4c213899dbd..5326d63cb8a 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -1,5 +1,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue'; +import { SQUASH_BEFORE_MERGE } from '~/vue_merge_request_widget/i18n'; const localVue = createLocalVue(); @@ -85,7 +86,7 @@ describe('Squash before merge component', () => { }); describe('tooltip', () => { - const tooltipTitle = () => findLabel().element.dataset.title; + const tooltipTitle = () => findLabel().attributes('title'); it('does not render when isDisabled is false', () => { createComponent({ @@ -101,7 +102,7 @@ describe('Squash before merge component', () => { isDisabled: true, }); - expect(tooltipTitle()).toBe('Required in this project.'); + expect(tooltipTitle()).toBe(SQUASH_BEFORE_MERGE.tooltipTitle); }); }); 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/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index 4688af30269..144283dc507 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -262,6 +262,7 @@ export default { merge_trains_enabled: true, merge_trains_count: 3, merge_train_index: 1, + security_reports_docs_path: 'security-reports-docs-path', }; export const mockStore = { 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..25c967996e3 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,8 @@ 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 Api from '~/api'; 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'; @@ -50,13 +52,13 @@ describe('mrWidgetOptions', () => { gon.features = {}; }); - const createComponent = () => { + const createComponent = (mrData = mockData) => { if (vm) { vm.$destroy(); } vm = mountComponent(MrWidgetOptions, { - mrData: { ...mockData }, + mrData: { ...mrData }, }); return axios.waitForAll(); @@ -64,6 +66,7 @@ describe('mrWidgetOptions', () => { const findSuggestPipeline = () => vm.$el.querySelector('[data-testid="mr-suggest-pipeline"]'); const findSuggestPipelineButton = () => findSuggestPipeline().querySelector('button'); + const findSecurityMrWidget = () => vm.$el.querySelector('[data-testid="security-mr-widget"]'); describe('default', () => { beforeEach(() => { @@ -533,7 +536,7 @@ describe('mrWidgetOptions', () => { const tooltip = vm.$el.querySelector('[data-testid="question-o-icon"]'); expect(vm.$el.textContent).toContain('Deletes source branch'); - expect(tooltip.getAttribute('data-original-title')).toBe( + expect(tooltip.getAttribute('title')).toBe( 'A user with write access to the source branch selected this option', ); @@ -812,43 +815,96 @@ describe('mrWidgetOptions', () => { }); }); - describe('given suggestPipeline feature flag is enabled', () => { + describe('security widget', () => { + describe.each` + context | hasPipeline | reportType | isFlagEnabled | shouldRender + ${'security report and flag enabled'} | ${true} | ${'sast'} | ${true} | ${true} + ${'security report and flag disabled'} | ${true} | ${'sast'} | ${false} | ${false} + ${'no security report and flag enabled'} | ${true} | ${'foo'} | ${true} | ${false} + ${'no pipeline and flag enabled'} | ${false} | ${'sast'} | ${true} | ${false} + `('given $context', ({ hasPipeline, reportType, isFlagEnabled, shouldRender }) => { + beforeEach(() => { + gon.features.coreSecurityMrWidget = isFlagEnabled; + + if (hasPipeline) { + jest.spyOn(Api, 'pipelineJobs').mockResolvedValue({ + data: [{ artifacts: [{ file_type: reportType }] }], + }); + } + + return createComponent({ + ...mockData, + ...(hasPipeline ? {} : { pipeline: undefined }), + }); + }); + + if (shouldRender) { + it('renders', () => { + expect(findSecurityMrWidget()).toEqual(expect.any(HTMLElement)); + }); + } else { + it('does not render', () => { + expect(findSecurityMrWidget()).toBeNull(); + }); + } + }); + }); + + 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_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index b691a366a0f..f73f78d6f6e 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -118,27 +118,33 @@ describe('MergeRequestStore', () => { describe('setPaths', () => { it('should set the add ci config path', () => { - store.setData({ ...mockData }); + store.setPaths({ ...mockData }); expect(store.mergeRequestAddCiConfigPath).toBe('/group2/project2/new/pipeline'); }); it('should set humanAccess=Maintainer when user has that role', () => { - store.setData({ ...mockData }); + store.setPaths({ ...mockData }); expect(store.humanAccess).toBe('Maintainer'); }); it('should set pipelinesEmptySvgPath', () => { - store.setData({ ...mockData }); + store.setPaths({ ...mockData }); expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg'); }); it('should set newPipelinePath', () => { - store.setData({ ...mockData }); + store.setPaths({ ...mockData }); expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new'); }); + + it('should set securityReportsDocsPath', () => { + store.setPaths({ ...mockData }); + + expect(store.securityReportsDocsPath).toBe('security-reports-docs-path'); + }); }); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 19671d425a9..82503e5a025 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -228,9 +228,11 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` /> </span> - <i - aria-hidden="true" - class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" + <gl-loading-icon-stub + class="award-control-icon-loading" + color="dark" + label="Loading" + size="md" /> </button> </div> 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/__snapshots__/editor_lite_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap new file mode 100644 index 00000000000..26785855369 --- /dev/null +++ b/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Editor Lite component rendering matches the snapshot 1`] = ` +<div + data-editor-loading="" + id="editor-lite-snippet_777" +> + <pre + class="editor-loading-content" + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + </pre> +</div> +`; diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap index c2b97f1e7f9..19a649089e0 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap @@ -11,7 +11,7 @@ exports[`Expand button on click when short text is provided renders button after <!----> <svg - class="gl-icon s16" + class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > <use @@ -39,7 +39,7 @@ exports[`Expand button on click when short text is provided renders button after <!----> <svg - class="gl-icon s16" + class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > <use @@ -62,7 +62,7 @@ exports[`Expand button when short text is provided renders button before text 1` <!----> <svg - class="gl-icon s16" + class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > <use @@ -90,7 +90,7 @@ exports[`Expand button when short text is provided renders button before text 1` <!----> <svg - class="gl-icon s16" + class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" > <use diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap index fcb9c4b8b02..8eb0e8f9550 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -1,15 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SplitButton renders actionItems 1`] = ` -<gl-deprecated-dropdown-stub - menu-class="dropdown-menu-selectable " +<gl-dropdown-stub + category="tertiary" + headertext="" + menu-class="" + size="medium" split="true" text="professor" - variant="secondary" + variant="default" > - <gl-deprecated-dropdown-item-stub - active="true" - active-class="is-active" + <gl-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischecked="true" + ischeckitem="true" + secondarytext="" > <strong> professor @@ -18,11 +26,16 @@ exports[`SplitButton renders actionItems 1`] = ` <div> very symphonic </div> - </gl-deprecated-dropdown-item-stub> + </gl-dropdown-item-stub> - <gl-deprecated-dropdown-divider-stub /> - <gl-deprecated-dropdown-item-stub - active-class="is-active" + <gl-dropdown-divider-stub /> + <gl-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischeckitem="true" + secondarytext="" > <strong> captain @@ -31,8 +44,8 @@ exports[`SplitButton renders actionItems 1`] = ` <div> warp drive </div> - </gl-deprecated-dropdown-item-stub> + </gl-dropdown-item-stub> <!----> -</gl-deprecated-dropdown-stub> +</gl-dropdown-stub> `; diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js index 4dde9d726d1..6e7ed9d612b 100644 --- a/spec/frontend/vue_shared/components/actions_button_spec.js +++ b/spec/frontend/vue_shared/components/actions_button_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlLink } from '@gitlab/ui'; +import { GlDropdown, GlButton } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; @@ -9,7 +9,12 @@ const TEST_ACTION = { secondaryText: 'Lorem ipsum.', tooltip: '', href: '/sample', - attrs: { 'data-test': '123' }, + attrs: { + 'data-test': '123', + category: 'secondary', + href: '/sample', + variant: 'default', + }, }; const TEST_ACTION_2 = { key: 'action2', @@ -40,8 +45,8 @@ describe('Actions button component', () => { return directiveBinding.value; }; - const findLink = () => wrapper.find(GlLink); - const findLinkTooltip = () => getTooltip(findLink()); + const findButton = () => wrapper.find(GlButton); + const findButtonTooltip = () => getTooltip(findButton()); const findDropdown = () => wrapper.find(GlDropdown); const findDropdownTooltip = () => getTooltip(findDropdown()); const parseDropdownItems = () => @@ -63,7 +68,7 @@ describe('Actions button component', () => { }; }); const clickOn = (child, evt = new Event('click')) => child.vm.$emit('click', evt); - const clickLink = (...args) => clickOn(findLink(), ...args); + const clickLink = (...args) => clickOn(findButton(), ...args); const clickDropdown = (...args) => clickOn(findDropdown(), ...args); describe('with 1 action', () => { @@ -76,22 +81,19 @@ describe('Actions button component', () => { }); it('should render single button', () => { - const link = findLink(); - - expect(link.attributes()).toEqual({ - class: expect.any(String), + expect(findButton().attributes()).toMatchObject({ href: TEST_ACTION.href, ...TEST_ACTION.attrs, }); - expect(link.text()).toBe(TEST_ACTION.text); + expect(findButton().text()).toBe(TEST_ACTION.text); }); it('should have tooltip', () => { - expect(findLinkTooltip()).toBe(TEST_ACTION.tooltip); + expect(findButtonTooltip()).toBe(TEST_ACTION.tooltip); }); it('should have attrs', () => { - expect(findLink().attributes()).toMatchObject(TEST_ACTION.attrs); + expect(findButton().attributes()).toMatchObject(TEST_ACTION.attrs); }); it('can click', () => { @@ -103,7 +105,7 @@ describe('Actions button component', () => { it('should have tooltip', () => { createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] }); - expect(findLinkTooltip()).toBe(TEST_TOOLTIP); + expect(findButtonTooltip()).toBe(TEST_TOOLTIP); }); }); diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_detail_table_spec.js deleted file mode 100644 index 9c38ccad8a7..00000000000 --- a/spec/frontend/vue_shared/components/alert_detail_table_spec.js +++ /dev/null @@ -1,74 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlTable, GlLoadingIcon } from '@gitlab/ui'; -import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; - -const mockAlert = { - iid: '1527542', - title: 'SyntaxError: Invalid or unexpected token', - severity: 'CRITICAL', - eventCount: 7, - createdAt: '2020-04-17T23:18:14.996Z', - startedAt: '2020-04-17T23:18:14.996Z', - endedAt: '2020-04-17T23:18:14.996Z', - status: 'TRIGGERED', - assignees: { nodes: [] }, - notes: { nodes: [] }, - todos: { nodes: [] }, -}; - -describe('AlertDetails', () => { - let wrapper; - - function mountComponent(propsData = {}) { - wrapper = mount(AlertDetailsTable, { - propsData: { - alert: mockAlert, - loading: false, - ...propsData, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - const findTableComponent = () => wrapper.find(GlTable); - - describe('Alert details', () => { - describe('empty state', () => { - beforeEach(() => { - mountComponent({ alert: null }); - }); - - it('shows an empty state when no alert is provided', () => { - expect(wrapper.text()).toContain('No alert data to display.'); - }); - }); - - describe('loading state', () => { - beforeEach(() => { - mountComponent({ loading: true }); - }); - - it('displays a loading state when loading', () => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - }); - }); - - describe('with table data', () => { - beforeEach(() => { - mountComponent(); - }); - - it('renders a table', () => { - expect(findTableComponent().exists()).toBe(true); - }); - - it('renders a cell based on alert data', () => { - expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token'); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js new file mode 100644 index 00000000000..dff307e92c2 --- /dev/null +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -0,0 +1,139 @@ +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; + +const mockAlert = { + iid: '1527542', + title: 'SyntaxError: Invalid or unexpected token', + severity: 'CRITICAL', + eventCount: 7, + createdAt: '2020-04-17T23:18:14.996Z', + startedAt: '2020-04-17T23:18:14.996Z', + endedAt: '2020-04-17T23:18:14.996Z', + status: 'TRIGGERED', + assignees: { nodes: [] }, + notes: { nodes: [] }, + todos: { nodes: [] }, + hosts: ['host1', 'host2'], + __typename: 'AlertManagementAlert', +}; + +const environmentName = 'Production'; +const environmentPath = '/fake/path'; + +describe('AlertDetails', () => { + let environmentData = { name: environmentName, path: environmentPath }; + let glFeatures = { exposeEnvironmentPathInAlertDetails: false }; + let wrapper; + + function mountComponent(propsData = {}) { + wrapper = mount(AlertDetailsTable, { + provide: { + glFeatures, + }, + propsData: { + alert: { + ...mockAlert, + environment: environmentData, + }, + loading: false, + ...propsData, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTableComponent = () => wrapper.find(GlTable); + const findTableKeys = () => findTableComponent().findAll('tbody td:first-child'); + const findTableFieldValueByKey = fieldKey => + findTableComponent() + .findAll('tbody tr') + .filter(row => row.text().includes(fieldKey)) + .at(0) + .find('td:nth-child(2)'); + const findTableField = (fields, fieldName) => fields.filter(row => row.text() === fieldName); + + describe('Alert details', () => { + describe('empty state', () => { + beforeEach(() => { + mountComponent({ alert: null }); + }); + + it('shows an empty state when no alert is provided', () => { + expect(wrapper.text()).toContain('No alert data to display.'); + }); + }); + + describe('loading state', () => { + beforeEach(() => { + mountComponent({ loading: true }); + }); + + it('displays a loading state when loading', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('with table data', () => { + beforeEach(mountComponent); + + it('renders a table', () => { + expect(findTableComponent().exists()).toBe(true); + }); + + 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, 'Hosts').exists()).toBe(true); + expect(findTableField(fields, 'Environment').exists()).toBe(false); + }); + + it('should not show disallowed and flaggedAllowed 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, 'Environment').exists()).toBe(false); + }); + }); + + describe('when exposeEnvironmentPathInAlertDetails is enabled', () => { + beforeEach(() => { + glFeatures = { exposeEnvironmentPathInAlertDetails: true }; + mountComponent(); + }); + + it('should show flaggedAllowed alert fields', () => { + const fields = findTableKeys(); + + expect(findTableField(fields, 'Environment').exists()).toBe(true); + }); + + it('should display only the name for the environment', () => { + expect(findTableFieldValueByKey('Iid').text()).toBe('1527542'); + expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName); + }); + + it('should not display the environment row if there is not data', () => { + environmentData = { name: null, path: null }; + mountComponent(); + + expect(findTableFieldValueByKey('Environment').text()).toBeFalsy(); + }); + }); + }); +}); 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/deprecated_modal_2_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js index b201a9acdd4..c37a44df6f8 100644 --- a/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js +++ b/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js @@ -78,7 +78,7 @@ describe('DeprecatedModal2', () => { }); it('sets the primary button text', () => { - const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type'); + const primaryButton = vm.$el.querySelector('.js-modal-primary-action .gl-button-text'); expect(primaryButton.innerHTML.trim()).toBe(props.footerPrimaryButtonText); }); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js index efa30bf6605..ec553c52236 100644 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js @@ -29,7 +29,7 @@ describe('DropdownSearchInputComponent', () => { }); it('renders search icon element', () => { - expect(wrapper.find('.fa-search.dropdown-input-search').exists()).toBe(true); + expect(wrapper.find('.dropdown-input-search[data-testid="search-icon"]').exists()).toBe(true); }); it('displays custom placeholder text', () => { diff --git a/spec/frontend/vue_shared/components/editor_lite_spec.js b/spec/frontend/vue_shared/components/editor_lite_spec.js new file mode 100644 index 00000000000..52502fcf64f --- /dev/null +++ b/spec/frontend/vue_shared/components/editor_lite_spec.js @@ -0,0 +1,144 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import EditorLite from '~/vue_shared/components/editor_lite.vue'; +import Editor from '~/editor/editor_lite'; + +jest.mock('~/editor/editor_lite'); + +describe('Editor Lite component', () => { + let wrapper; + const onDidChangeModelContent = jest.fn(); + const updateModelLanguage = jest.fn(); + const getValue = jest.fn(); + const setValue = jest.fn(); + const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; + const fileName = 'lorem.txt'; + const fileGlobalId = 'snippet_777'; + const createInstanceMock = jest.fn().mockImplementation(() => ({ + onDidChangeModelContent, + updateModelLanguage, + getValue, + setValue, + dispose: jest.fn(), + })); + Editor.mockImplementation(() => { + return { + createInstance: createInstanceMock, + }; + }); + function createComponent(props = {}) { + wrapper = shallowMount(EditorLite, { + propsData: { + value, + fileName, + fileGlobalId, + ...props, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const triggerChangeContent = val => { + getValue.mockReturnValue(val); + const [cb] = onDidChangeModelContent.mock.calls[0]; + + cb(); + + jest.runOnlyPendingTimers(); + }; + + describe('rendering', () => { + it('matches the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders content', () => { + expect(wrapper.text()).toContain(value); + }); + }); + + describe('functionality', () => { + it('does not fail without content', () => { + const spy = jest.spyOn(global.console, 'error'); + createComponent({ value: undefined }); + + expect(spy).not.toHaveBeenCalled(); + expect(wrapper.find('[id^="editor-lite-"]').exists()).toBe(true); + }); + + it('initialises Editor Lite instance', () => { + const el = wrapper.find({ ref: 'editor' }).element; + expect(createInstanceMock).toHaveBeenCalledWith({ + el, + blobPath: fileName, + blobGlobalId: fileGlobalId, + blobContent: value, + extensions: null, + }); + }); + + it('reacts to the changes in fileName', () => { + const newFileName = 'ipsum.txt'; + + wrapper.setProps({ + fileName: newFileName, + }); + + return nextTick().then(() => { + expect(updateModelLanguage).toHaveBeenCalledWith(newFileName); + }); + }); + + it('registers callback with editor onChangeContent', () => { + expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('emits input event when the blob content is changed', () => { + expect(wrapper.emitted().input).toBeUndefined(); + + triggerChangeContent(value); + + expect(wrapper.emitted().input).toEqual([[value]]); + }); + + it('emits editor-ready event when the Editor Lite is ready', async () => { + const el = wrapper.find({ ref: 'editor' }).element; + expect(wrapper.emitted()['editor-ready']).toBeUndefined(); + + await el.dispatchEvent(new Event('editor-ready')); + + expect(wrapper.emitted()['editor-ready']).toBeDefined(); + }); + + describe('reaction to the value update', () => { + it('reacts to the changes in the passed value', async () => { + const newValue = 'New Value'; + + wrapper.setProps({ + value: newValue, + }); + + await nextTick(); + expect(setValue).toHaveBeenCalledWith(newValue); + }); + + it("does not update value if the passed one is exactly the same as the editor's content", async () => { + const newValue = `${value}`; // to make sure we're creating a new String with the same content and not just a reference + + wrapper.setProps({ + value: newValue, + }); + + await nextTick(); + expect(setValue).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js new file mode 100644 index 00000000000..1dd5f08e76a --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js @@ -0,0 +1,448 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; +import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions'; +import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; +import initialState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import Api from '~/api'; +import { filterMilestones, filterUsers, filterLabels } from './mock_data'; + +const milestonesEndpoint = 'fake_milestones_endpoint'; +const labelsEndpoint = 'fake_labels_endpoint'; +const groupEndpoint = 'fake_group_endpoint'; +const projectEndpoint = 'fake_project_endpoint'; + +jest.mock('~/flash'); + +describe('Filters actions', () => { + let state; + let mock; + let mockDispatch; + let mockCommit; + + beforeEach(() => { + state = initialState(); + mock = new MockAdapter(axios); + + mockDispatch = jest.fn().mockResolvedValue(); + mockCommit = jest.fn(); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('initialize', () => { + const initialData = { + milestonesEndpoint, + labelsEndpoint, + groupEndpoint, + projectEndpoint, + selectedAuthor: 'Mr cool', + selectedMilestone: 'NEXT', + }; + + it('does not dispatch', () => { + const result = actions.initialize( + { + state, + dispatch: mockDispatch, + commit: mockCommit, + }, + initialData, + ); + expect(result).toBeUndefined(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it(`commits the ${types.SET_SELECTED_FILTERS}`, () => { + actions.initialize( + { + state, + dispatch: mockDispatch, + commit: mockCommit, + }, + initialData, + ); + expect(mockCommit).toHaveBeenCalledWith(types.SET_SELECTED_FILTERS, initialData); + }); + }); + + describe('setFilters', () => { + const nextFilters = { + selectedAuthor: 'Mr cool', + selectedMilestone: 'NEXT', + }; + + it('dispatches the root/setFilters action', () => { + return testAction( + actions.setFilters, + nextFilters, + state, + [ + { + payload: nextFilters, + type: types.SET_SELECTED_FILTERS, + }, + ], + [ + { + type: 'setFilters', + payload: nextFilters, + }, + ], + ); + }); + }); + + describe('setEndpoints', () => { + it('sets the api paths', () => { + return testAction( + actions.setEndpoints, + { milestonesEndpoint, labelsEndpoint, groupEndpoint, projectEndpoint }, + state, + [ + { payload: 'fake_milestones_endpoint', type: types.SET_MILESTONES_ENDPOINT }, + { payload: 'fake_labels_endpoint', type: types.SET_LABELS_ENDPOINT }, + { payload: 'fake_group_endpoint', type: types.SET_GROUP_ENDPOINT }, + { payload: 'fake_project_endpoint', type: types.SET_PROJECT_ENDPOINT }, + ], + [], + ); + }); + }); + + describe('fetchBranches', () => { + describe('success', () => { + beforeEach(() => { + const url = Api.buildUrl(Api.createBranchPath).replace( + ':id', + encodeURIComponent(projectEndpoint), + ); + mock.onGet(url).replyOnce(httpStatusCodes.OK, mockBranches); + }); + + it('dispatches RECEIVE_BRANCHES_SUCCESS with received data', () => { + return testAction( + actions.fetchBranches, + null, + { ...state, projectEndpoint }, + [ + { type: types.REQUEST_BRANCHES }, + { type: types.RECEIVE_BRANCHES_SUCCESS, payload: mockBranches }, + ], + [], + ).then(({ data }) => { + expect(data).toBe(mockBranches); + }); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + }); + + it('dispatches RECEIVE_BRANCHES_ERROR', () => { + return testAction( + actions.fetchBranches, + null, + state, + [ + { type: types.REQUEST_BRANCHES }, + { + type: types.RECEIVE_BRANCHES_ERROR, + payload: httpStatusCodes.SERVICE_UNAVAILABLE, + }, + ], + [], + ).then(() => expect(createFlash).toHaveBeenCalled()); + }); + }); + }); + + describe('fetchAuthors', () => { + let restoreVersion; + beforeEach(() => { + restoreVersion = gon.api_version; + gon.api_version = 'v1'; + }); + + afterEach(() => { + gon.api_version = restoreVersion; + }); + + describe('success', () => { + beforeEach(() => { + mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers); + }); + + it('dispatches RECEIVE_AUTHORS_SUCCESS with received data and groupEndpoint set', () => { + return testAction( + actions.fetchAuthors, + null, + { ...state, groupEndpoint }, + [ + { type: types.REQUEST_AUTHORS }, + { type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers }, + ], + [], + ).then(({ data }) => { + expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); + expect(data).toBe(filterUsers); + }); + }); + + it('dispatches RECEIVE_AUTHORS_SUCCESS with received data and projectEndpoint set', () => { + return testAction( + actions.fetchAuthors, + null, + { ...state, projectEndpoint }, + [ + { type: types.REQUEST_AUTHORS }, + { type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers }, + ], + [], + ).then(({ data }) => { + expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); + expect(data).toBe(filterUsers); + }); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + }); + + it('dispatches RECEIVE_AUTHORS_ERROR and groupEndpoint set', () => { + return testAction( + actions.fetchAuthors, + null, + { ...state, groupEndpoint }, + [ + { type: types.REQUEST_AUTHORS }, + { + type: types.RECEIVE_AUTHORS_ERROR, + payload: httpStatusCodes.SERVICE_UNAVAILABLE, + }, + ], + [], + ).then(() => { + expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + it('dispatches RECEIVE_AUTHORS_ERROR and projectEndpoint set', () => { + return testAction( + actions.fetchAuthors, + null, + { ...state, projectEndpoint }, + [ + { type: types.REQUEST_AUTHORS }, + { + type: types.RECEIVE_AUTHORS_ERROR, + payload: httpStatusCodes.SERVICE_UNAVAILABLE, + }, + ], + [], + ).then(() => { + expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); + expect(createFlash).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('fetchMilestones', () => { + describe('success', () => { + beforeEach(() => { + mock.onGet(milestonesEndpoint).replyOnce(httpStatusCodes.OK, filterMilestones); + }); + + it('dispatches RECEIVE_MILESTONES_SUCCESS with received data', () => { + return testAction( + actions.fetchMilestones, + null, + { ...state, milestonesEndpoint }, + [ + { type: types.REQUEST_MILESTONES }, + { type: types.RECEIVE_MILESTONES_SUCCESS, payload: filterMilestones }, + ], + [], + ).then(({ data }) => { + expect(data).toBe(filterMilestones); + }); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + }); + + it('dispatches RECEIVE_MILESTONES_ERROR', () => { + return testAction( + actions.fetchMilestones, + null, + state, + [ + { type: types.REQUEST_MILESTONES }, + { + type: types.RECEIVE_MILESTONES_ERROR, + payload: httpStatusCodes.SERVICE_UNAVAILABLE, + }, + ], + [], + ).then(() => expect(createFlash).toHaveBeenCalled()); + }); + }); + }); + + describe('fetchAssignees', () => { + describe('success', () => { + let restoreVersion; + beforeEach(() => { + mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers); + restoreVersion = gon.api_version; + gon.api_version = 'v1'; + }); + + afterEach(() => { + gon.api_version = restoreVersion; + }); + + it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data and groupEndpoint set', () => { + return testAction( + actions.fetchAssignees, + null, + { ...state, milestonesEndpoint, groupEndpoint }, + [ + { type: types.REQUEST_ASSIGNEES }, + { type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers }, + ], + [], + ).then(({ data }) => { + expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); + expect(data).toBe(filterUsers); + }); + }); + + it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data and projectEndpoint set', () => { + return testAction( + actions.fetchAssignees, + null, + { ...state, milestonesEndpoint, projectEndpoint }, + [ + { type: types.REQUEST_ASSIGNEES }, + { type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers }, + ], + [], + ).then(({ data }) => { + expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); + expect(data).toBe(filterUsers); + }); + }); + }); + + describe('error', () => { + let restoreVersion; + beforeEach(() => { + mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + restoreVersion = gon.api_version; + gon.api_version = 'v1'; + }); + + afterEach(() => { + gon.api_version = restoreVersion; + }); + + it('dispatches RECEIVE_ASSIGNEES_ERROR and groupEndpoint set', () => { + return testAction( + actions.fetchAssignees, + null, + { ...state, groupEndpoint }, + [ + { type: types.REQUEST_ASSIGNEES }, + { + type: types.RECEIVE_ASSIGNEES_ERROR, + payload: httpStatusCodes.SERVICE_UNAVAILABLE, + }, + ], + [], + ).then(() => { + expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + it('dispatches RECEIVE_ASSIGNEES_ERROR and projectEndpoint set', () => { + return testAction( + actions.fetchAssignees, + null, + { ...state, projectEndpoint }, + [ + { type: types.REQUEST_ASSIGNEES }, + { + type: types.RECEIVE_ASSIGNEES_ERROR, + payload: httpStatusCodes.SERVICE_UNAVAILABLE, + }, + ], + [], + ).then(() => { + expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); + expect(createFlash).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('fetchLabels', () => { + describe('success', () => { + beforeEach(() => { + mock.onGet(labelsEndpoint).replyOnce(httpStatusCodes.OK, filterLabels); + }); + + it('dispatches RECEIVE_LABELS_SUCCESS with received data', () => { + return testAction( + actions.fetchLabels, + null, + { ...state, labelsEndpoint }, + [ + { type: types.REQUEST_LABELS }, + { type: types.RECEIVE_LABELS_SUCCESS, payload: filterLabels }, + ], + [], + ).then(({ data }) => { + expect(data).toBe(filterLabels); + }); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); + }); + + it('dispatches RECEIVE_LABELS_ERROR', () => { + return testAction( + actions.fetchLabels, + null, + state, + [ + { type: types.REQUEST_LABELS }, + { + type: types.RECEIVE_LABELS_ERROR, + payload: httpStatusCodes.SERVICE_UNAVAILABLE, + }, + ], + [], + ).then(() => expect(createFlash).toHaveBeenCalled()); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data.js new file mode 100644 index 00000000000..6afac9f752a --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data.js @@ -0,0 +1,50 @@ +export const filterMilestones = [ + { id: 1, title: 'None', name: 'Any' }, + { id: 101, title: 'Any', name: 'None' }, + { id: 1001, title: 'v1.0', name: 'v1.0' }, + { id: 10101, title: 'v0.0', name: 'v0.0' }, +]; + +export const filterUsers = [ + { + id: 31, + name: 'VSM User2', + username: 'vsm-user-2-1589776313', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/762398957a8c6e04eed16da88098899d?s=80\u0026d=identicon', + web_url: 'http://127.0.0.1:3001/vsm-user-2-1589776313', + access_level: 30, + expires_at: null, + }, + { + id: 32, + name: 'VSM User3', + username: 'vsm-user-3-1589776313', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/f78932237e8a5c5376b65a709824802f?s=80\u0026d=identicon', + web_url: 'http://127.0.0.1:3001/vsm-user-3-1589776313', + access_level: 30, + expires_at: null, + }, + { + id: 33, + name: 'VSM User4', + username: 'vsm-user-4-1589776313', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/ab506dc600d1a941e4d77d5ceeeba73f?s=80\u0026d=identicon', + web_url: 'http://127.0.0.1:3001/vsm-user-4-1589776313', + access_level: 30, + expires_at: null, + }, +]; + +export const filterLabels = [ + { id: 194, title: 'Afterfunc-Phureforge-781', color: '#990000', text_color: '#FFFFFF' }, + { id: 10, title: 'Afternix', color: '#16ecf2', text_color: '#FFFFFF' }, + { id: 176, title: 'Panasync-Pens-266', color: '#990000', text_color: '#FFFFFF' }, + { id: 79, title: 'Passat', color: '#f1a3d4', text_color: '#333333' }, + { id: 197, title: 'Phast-Onesync-395', color: '#990000', text_color: '#FFFFFF' }, +]; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js new file mode 100644 index 00000000000..263a4ee178f --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js @@ -0,0 +1,116 @@ +import { get } from 'lodash'; +import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; +import initialState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state'; +import mutations from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutations'; +import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { filterMilestones, filterUsers, filterLabels } from './mock_data'; + +let state = null; + +const branches = mockBranches.map(convertObjectPropsToCamelCase); +const milestones = filterMilestones.map(convertObjectPropsToCamelCase); +const users = filterUsers.map(convertObjectPropsToCamelCase); +const labels = filterLabels.map(convertObjectPropsToCamelCase); + +const filterValue = { value: 'foo' }; + +describe('Filters mutations', () => { + const errorCode = 500; + beforeEach(() => { + state = initialState(); + }); + + afterEach(() => { + state = null; + }); + + it.each` + mutation | stateKey | value + ${types.SET_MILESTONES_ENDPOINT} | ${'milestonesEndpoint'} | ${'new-milestone-endpoint'} + ${types.SET_LABELS_ENDPOINT} | ${'labelsEndpoint'} | ${'new-label-endpoint'} + ${types.SET_GROUP_ENDPOINT} | ${'groupEndpoint'} | ${'new-group-endpoint'} + `('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => { + mutations[mutation](state, value); + + expect(state[stateKey]).toEqual(value); + }); + + it.each` + mutation | stateKey | filterName | value + ${types.SET_SELECTED_FILTERS} | ${'branches.source.selected'} | ${'selectedSourceBranch'} | ${null} + ${types.SET_SELECTED_FILTERS} | ${'branches.source.selected'} | ${'selectedSourceBranch'} | ${filterValue} + ${types.SET_SELECTED_FILTERS} | ${'branches.source.selectedList'} | ${'selectedSourceBranchList'} | ${[]} + ${types.SET_SELECTED_FILTERS} | ${'branches.source.selectedList'} | ${'selectedSourceBranchList'} | ${[filterValue]} + ${types.SET_SELECTED_FILTERS} | ${'branches.target.selected'} | ${'selectedTargetBranch'} | ${null} + ${types.SET_SELECTED_FILTERS} | ${'branches.target.selected'} | ${'selectedTargetBranch'} | ${filterValue} + ${types.SET_SELECTED_FILTERS} | ${'branches.target.selectedList'} | ${'selectedTargetBranchList'} | ${[]} + ${types.SET_SELECTED_FILTERS} | ${'branches.target.selectedList'} | ${'selectedTargetBranchList'} | ${[filterValue]} + ${types.SET_SELECTED_FILTERS} | ${'authors.selected'} | ${'selectedAuthor'} | ${null} + ${types.SET_SELECTED_FILTERS} | ${'authors.selected'} | ${'selectedAuthor'} | ${filterValue} + ${types.SET_SELECTED_FILTERS} | ${'authors.selectedList'} | ${'selectedAuthorList'} | ${[]} + ${types.SET_SELECTED_FILTERS} | ${'authors.selectedList'} | ${'selectedAuthorList'} | ${[filterValue]} + ${types.SET_SELECTED_FILTERS} | ${'milestones.selected'} | ${'selectedMilestone'} | ${null} + ${types.SET_SELECTED_FILTERS} | ${'milestones.selected'} | ${'selectedMilestone'} | ${filterValue} + ${types.SET_SELECTED_FILTERS} | ${'milestones.selectedList'} | ${'selectedMilestoneList'} | ${[]} + ${types.SET_SELECTED_FILTERS} | ${'milestones.selectedList'} | ${'selectedMilestoneList'} | ${[filterValue]} + ${types.SET_SELECTED_FILTERS} | ${'assignees.selected'} | ${'selectedAssignee'} | ${null} + ${types.SET_SELECTED_FILTERS} | ${'assignees.selected'} | ${'selectedAssignee'} | ${filterValue} + ${types.SET_SELECTED_FILTERS} | ${'assignees.selectedList'} | ${'selectedAssigneeList'} | ${[]} + ${types.SET_SELECTED_FILTERS} | ${'assignees.selectedList'} | ${'selectedAssigneeList'} | ${[filterValue]} + ${types.SET_SELECTED_FILTERS} | ${'labels.selected'} | ${'selectedLabel'} | ${null} + ${types.SET_SELECTED_FILTERS} | ${'labels.selected'} | ${'selectedLabel'} | ${filterValue} + ${types.SET_SELECTED_FILTERS} | ${'labels.selectedList'} | ${'selectedLabelList'} | ${[]} + ${types.SET_SELECTED_FILTERS} | ${'labels.selectedList'} | ${'selectedLabelList'} | ${[filterValue]} + `( + '$mutation will set $stateKey with a given value', + ({ mutation, stateKey, filterName, value }) => { + mutations[mutation](state, { [filterName]: value }); + + expect(get(state, stateKey)).toEqual(value); + }, + ); + + it.each` + mutation | rootKey | stateKey | value + ${types.REQUEST_BRANCHES} | ${'branches'} | ${'isLoading'} | ${true} + ${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'isLoading'} | ${false} + ${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'data'} | ${branches} + ${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'errorCode'} | ${null} + ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'isLoading'} | ${false} + ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'data'} | ${[]} + ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'errorCode'} | ${errorCode} + ${types.REQUEST_MILESTONES} | ${'milestones'} | ${'isLoading'} | ${true} + ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'isLoading'} | ${false} + ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'data'} | ${milestones} + ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'errorCode'} | ${null} + ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'isLoading'} | ${false} + ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'data'} | ${[]} + ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'errorCode'} | ${errorCode} + ${types.REQUEST_AUTHORS} | ${'authors'} | ${'isLoading'} | ${true} + ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'isLoading'} | ${false} + ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'data'} | ${users} + ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'errorCode'} | ${null} + ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'isLoading'} | ${false} + ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'data'} | ${[]} + ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'errorCode'} | ${errorCode} + ${types.REQUEST_LABELS} | ${'labels'} | ${'isLoading'} | ${true} + ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'isLoading'} | ${false} + ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'data'} | ${labels} + ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'errorCode'} | ${null} + ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'isLoading'} | ${false} + ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'data'} | ${[]} + ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'errorCode'} | ${errorCode} + ${types.REQUEST_ASSIGNEES} | ${'assignees'} | ${'isLoading'} | ${true} + ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'isLoading'} | ${false} + ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'data'} | ${users} + ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'errorCode'} | ${null} + ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'isLoading'} | ${false} + ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'data'} | ${[]} + ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'errorCode'} | ${errorCode} + `('$mutation will set $stateKey with a given value', ({ mutation, rootKey, stateKey, value }) => { + mutations[mutation](state, value); + + expect(state[rootKey][stateKey]).toEqual(value); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/test_helper.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/test_helper.js new file mode 100644 index 00000000000..1b7c80a5252 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/test_helper.js @@ -0,0 +1,11 @@ +export function getFilterParams(tokens, options = {}) { + const { key = 'value', operator = '=', prop = 'title' } = options; + return tokens.map(token => { + return { [key]: token[prop], operator }; + }); +} + +export function getFilterValues(tokens, options = {}) { + const { prop = 'title' } = options; + return tokens.map(token => token[prop]); +} 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..efa9b5796fb 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'; @@ -124,5 +126,117 @@ describe('Local Storage Sync', () => { expect(localStorage.getItem(storageKey)).toBe(newValue); }); }); + + it('persists the value by default', async () => { + const persistedValue = 'persisted'; + + createComponent({ + props: { + storageKey, + }, + }); + + wrapper.setProps({ value: persistedValue }); + await wrapper.vm.$nextTick(); + expect(localStorage.getItem(storageKey)).toBe(persistedValue); + }); + + it('does not save a value if persist is set to false', async () => { + const notPersistedValue = 'notPersisted'; + + createComponent({ + props: { + storageKey, + }, + }); + + wrapper.setProps({ persist: false, value: notPersistedValue }); + await wrapper.vm.$nextTick(); + expect(localStorage.getItem(storageKey)).not.toBe(notPersistedValue); + }); + }); + + 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..a2ce6f40193 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -2,11 +2,13 @@ import { mount } from '@vue/test-utils'; import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants'; import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; -import fieldComponent from '~/vue_shared/components/markdown/field.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import axios from '~/lib/utils/axios_utils'; const markdownPreviewPath = `${TEST_HOST}/preview`; const markdownDocsPath = `${TEST_HOST}/docs`; +const textareaValue = 'testing\n123'; +const uploadsPath = 'test/uploads'; function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) { expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite); @@ -14,66 +16,81 @@ function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) { expect(wrapper.find('.md-preview-holder').element.style.display).toBe(isWrite ? 'none' : ''); } -function createComponent() { - const wrapper = mount(fieldComponent, { - propsData: { - markdownDocsPath, - markdownPreviewPath, - isSubmitting: false, - }, - slots: { - textarea: '<textarea>testing\n123</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; -} - -const getPreviewLink = wrapper => wrapper.find('.nav-links .js-preview-link'); -const getWriteLink = wrapper => wrapper.find('.nav-links .js-write-link'); -const getMarkdownButton = wrapper => wrapper.find('.js-md'); -const getAllMarkdownButtons = wrapper => wrapper.findAll('.js-md'); -const getVideo = wrapper => wrapper.find('video'); - describe('Markdown field component', () => { let axiosMock; + let subject; beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); + // window.uploads_path is needed for dropzone to initialize + window.uploads_path = uploadsPath; }); afterEach(() => { + subject.destroy(); + subject = null; axiosMock.restore(); }); + function createSubject() { + // We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression + // caused by mixing Vanilla JS and Vue. + subject = mount( + { + components: { + MarkdownField, + }, + props: { + wrapperClasses: { + type: String, + required: false, + default: '', + }, + }, + template: ` +<markdown-field :class="wrapperClasses" v-bind="$attrs"> + <template #textarea> + <textarea class="js-gfm-input" :value="$attrs.textareaValue"></textarea> + </template> +</markdown-field>`, + }, + { + propsData: { + markdownDocsPath, + markdownPreviewPath, + isSubmitting: false, + textareaValue, + }, + }, + ); + } + + const getPreviewLink = () => subject.find('.nav-links .js-preview-link'); + const getWriteLink = () => subject.find('.nav-links .js-write-link'); + const getMarkdownButton = () => subject.find('.js-md'); + const getAllMarkdownButtons = () => subject.findAll('.js-md'); + const getVideo = () => subject.find('video'); + const getAttachButton = () => subject.find('.button-attach-file'); + const clickAttachButton = () => getAttachButton().trigger('click'); + const findDropzone = () => subject.find('.div-dropzone'); + describe('mounted', () => { - let wrapper; const previewHTML = ` <p>markdown preview</p> <video src="${FIXTURES_PATH}/static/mock-video.mp4" muted="muted"></video> `; let previewLink; let writeLink; + let dropzoneSpy; - afterEach(() => { - wrapper.destroy(); + beforeEach(() => { + dropzoneSpy = jest.fn(); + createSubject(); + findDropzone().element.addEventListener('click', dropzoneSpy); }); it('renders textarea inside backdrop', () => { - wrapper = createComponent(); - expect(wrapper.find('.zen-backdrop textarea').element).not.toBeNull(); + expect(subject.find('.zen-backdrop textarea').element).not.toBeNull(); }); describe('markdown preview', () => { @@ -82,44 +99,40 @@ describe('Markdown field component', () => { }); it('sets preview link as active', () => { - wrapper = createComponent(); - previewLink = getPreviewLink(wrapper); + previewLink = getPreviewLink(); previewLink.trigger('click'); - return wrapper.vm.$nextTick().then(() => { + return subject.vm.$nextTick().then(() => { expect(previewLink.element.parentNode.classList.contains('active')).toBeTruthy(); }); }); it('shows preview loading text', () => { - wrapper = createComponent(); - previewLink = getPreviewLink(wrapper); + previewLink = getPreviewLink(); previewLink.trigger('click'); - return wrapper.vm.$nextTick(() => { - expect(wrapper.find('.md-preview-holder').element.textContent.trim()).toContain( + return subject.vm.$nextTick(() => { + expect(subject.find('.md-preview-holder').element.textContent.trim()).toContain( 'Loading…', ); }); }); it('renders markdown preview and GFM', () => { - wrapper = createComponent(); const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); - previewLink = getPreviewLink(wrapper); + previewLink = getPreviewLink(); previewLink.trigger('click'); return axios.waitFor(markdownPreviewPath).then(() => { - expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML); + expect(subject.find('.md-preview-holder').element.innerHTML).toContain(previewHTML); expect(renderGFMSpy).toHaveBeenCalled(); }); }); it('calls video.pause() on comment input when isSubmitting is changed to true', () => { - wrapper = createComponent(); - previewLink = getPreviewLink(wrapper); + previewLink = getPreviewLink(); previewLink.trigger('click'); let callPause; @@ -127,79 +140,107 @@ describe('Markdown field component', () => { return axios .waitFor(markdownPreviewPath) .then(() => { - const video = getVideo(wrapper); + const video = getVideo(); callPause = jest.spyOn(video.element, 'pause').mockImplementation(() => true); - wrapper.setProps({ - isSubmitting: true, - markdownPreviewPath, - markdownDocsPath, - }); + subject.setProps({ isSubmitting: true }); - return wrapper.vm.$nextTick(); + return subject.vm.$nextTick(); }) .then(() => { expect(callPause).toHaveBeenCalled(); }); }); - it('clicking already active write or preview link does nothing', () => { - wrapper = createComponent(); - writeLink = getWriteLink(wrapper); - previewLink = getPreviewLink(wrapper); + it('clicking already active write or preview link does nothing', async () => { + writeLink = getWriteLink(); + previewLink = getPreviewLink(); + + writeLink.trigger('click'); + await subject.vm.$nextTick(); + assertMarkdownTabs(true, writeLink, previewLink, subject); writeLink.trigger('click'); - return wrapper.vm - .$nextTick() - .then(() => assertMarkdownTabs(true, writeLink, previewLink, wrapper)) - .then(() => writeLink.trigger('click')) - .then(() => wrapper.vm.$nextTick()) - .then(() => assertMarkdownTabs(true, writeLink, previewLink, wrapper)) - .then(() => previewLink.trigger('click')) - .then(() => wrapper.vm.$nextTick()) - .then(() => assertMarkdownTabs(false, writeLink, previewLink, wrapper)) - .then(() => previewLink.trigger('click')) - .then(() => wrapper.vm.$nextTick()) - .then(() => assertMarkdownTabs(false, writeLink, previewLink, wrapper)); + await subject.vm.$nextTick(); + + assertMarkdownTabs(true, writeLink, previewLink, subject); + previewLink.trigger('click'); + await subject.vm.$nextTick(); + + assertMarkdownTabs(false, writeLink, previewLink, subject); + previewLink.trigger('click'); + await subject.vm.$nextTick(); + + assertMarkdownTabs(false, writeLink, previewLink, subject); }); }); describe('markdown buttons', () => { it('converts single words', () => { - wrapper = createComponent(); - const textarea = wrapper.find('textarea').element; + const textarea = subject.find('textarea').element; textarea.setSelectionRange(0, 7); - const markdownButton = getMarkdownButton(wrapper); + const markdownButton = getMarkdownButton(); markdownButton.trigger('click'); - return wrapper.vm.$nextTick(() => { + return subject.vm.$nextTick(() => { expect(textarea.value).toContain('**testing**'); }); }); it('converts a line', () => { - wrapper = createComponent(); - const textarea = wrapper.find('textarea').element; + const textarea = subject.find('textarea').element; textarea.setSelectionRange(0, 0); - const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5]; + const markdownButton = getAllMarkdownButtons().wrappers[5]; markdownButton.trigger('click'); - return wrapper.vm.$nextTick(() => { + return subject.vm.$nextTick(() => { expect(textarea.value).toContain('- testing'); }); }); it('converts multiple lines', () => { - wrapper = createComponent(); - const textarea = wrapper.find('textarea').element; + const textarea = subject.find('textarea').element; textarea.setSelectionRange(0, 50); - const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5]; + const markdownButton = getAllMarkdownButtons().wrappers[5]; markdownButton.trigger('click'); - return wrapper.vm.$nextTick(() => { + return subject.vm.$nextTick(() => { expect(textarea.value).toContain('- testing\n- 123'); }); }); }); + + it('should render attach a file button', () => { + expect(getAttachButton().text()).toBe('Attach a file'); + }); + + it('should trigger dropzone when attach button is clicked', () => { + expect(dropzoneSpy).not.toHaveBeenCalled(); + + clickAttachButton(); + + expect(dropzoneSpy).toHaveBeenCalled(); + }); + + describe('when textarea has changed', () => { + beforeEach(async () => { + // Do something to trigger rerendering the class + subject.setProps({ wrapperClasses: 'foo' }); + + await subject.vm.$nextTick(); + }); + + it('should have rerendered classes and kept gfm-form', () => { + expect(subject.classes()).toEqual(expect.arrayContaining(['gfm-form', 'foo'])); + }); + + it('should trigger dropzone when attach button is clicked', () => { + expect(dropzoneSpy).not.toHaveBeenCalled(); + + clickAttachButton(); + + expect(dropzoneSpy).toHaveBeenCalled(); + }); + }); }); }); 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/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js new file mode 100644 index 00000000000..58cb8ef61d1 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js @@ -0,0 +1,108 @@ +import { shallowMount } from '@vue/test-utils'; +import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue'; +import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; +import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue'; +import { accessRequest as member } from '../mock_data'; + +describe('AccessRequestActionButtons', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(AccessRequestActionButtons, { + propsData: { + member, + isCurrentUser: true, + ...propsData, + }, + }); + }; + + const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); + const findApproveButton = () => wrapper.find(ApproveAccessRequestButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when user has `canRemove` permissions', () => { + beforeEach(() => { + createComponent({ + permissions: { + canRemove: true, + }, + }); + }); + + it('renders remove member button', () => { + expect(findRemoveMemberButton().exists()).toBe(true); + }); + + it('sets props correctly', () => { + expect(findRemoveMemberButton().props()).toMatchObject({ + memberId: member.id, + title: 'Deny access', + isAccessRequest: true, + icon: 'close', + }); + }); + + describe('when member is the current user', () => { + it('sets `message` prop correctly', () => { + expect(findRemoveMemberButton().props('message')).toBe( + `Are you sure you want to withdraw your access request for "${member.source.name}"`, + ); + }); + }); + + describe('when member is not the current user', () => { + it('sets `message` prop correctly', () => { + createComponent({ + isCurrentUser: false, + permissions: { + canRemove: true, + }, + }); + + expect(findRemoveMemberButton().props('message')).toBe( + `Are you sure you want to deny ${member.user.name}'s request to join "${member.source.name}"`, + ); + }); + }); + }); + + describe('when user does not have `canRemove` permissions', () => { + it('does not render remove member button', () => { + createComponent({ + permissions: { + canRemove: false, + }, + }); + + expect(findRemoveMemberButton().exists()).toBe(false); + }); + }); + + describe('when user has `canUpdate` permissions', () => { + it('renders the approve button', () => { + createComponent({ + permissions: { + canUpdate: true, + }, + }); + + expect(findApproveButton().exists()).toBe(true); + }); + }); + + describe('when user does not have `canUpdate` permissions', () => { + it('does not render the approve button', () => { + createComponent({ + permissions: { + canUpdate: false, + }, + }); + + expect(findApproveButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js new file mode 100644 index 00000000000..93edaaa400d --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js @@ -0,0 +1,74 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlButton, GlForm } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ApproveAccessRequestButton', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(ApproveAccessRequestButton, { + localVue, + store: createStore(state), + propsData: { + memberId: 1, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findForm = () => wrapper.find(GlForm); + const findButton = () => findForm().find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a tooltip', () => { + const button = findButton(); + + expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); + expect(button.attributes('title')).toBe('Grant access'); + }); + + it('sets `aria-label` attribute', () => { + expect(findButton().attributes('aria-label')).toBe('Grant access'); + }); + + it('submits the form when button is clicked', () => { + expect(findButton().attributes('type')).toBe('submit'); + }); + + it('displays form with correct action and inputs', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe( + '/groups/foo-bar/-/group_members/1/approve_access_request', + ); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js new file mode 100644 index 00000000000..1374cdc6aef --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js @@ -0,0 +1,85 @@ +import { shallowMount } from '@vue/test-utils'; +import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; +import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; +import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue'; +import { invite as member } from '../mock_data'; + +describe('InviteActionButtons', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(InviteActionButtons, { + propsData: { + member, + ...propsData, + }, + }); + }; + + const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); + const findResendInviteButton = () => wrapper.find(ResendInviteButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when user has `canRemove` permissions', () => { + beforeEach(() => { + createComponent({ + permissions: { + canRemove: true, + }, + }); + }); + + it('renders remove member button', () => { + expect(findRemoveMemberButton().exists()).toBe(true); + }); + + it('sets props correctly', () => { + expect(findRemoveMemberButton().props()).toEqual({ + memberId: member.id, + message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.name}"`, + title: 'Revoke invite', + isAccessRequest: false, + icon: 'remove', + }); + }); + }); + + describe('when user does not have `canRemove` permissions', () => { + it('does not render remove member button', () => { + createComponent({ + permissions: { + canRemove: false, + }, + }); + + expect(findRemoveMemberButton().exists()).toBe(false); + }); + }); + + describe('when user has `canResend` permissions', () => { + it('renders resend invite button', () => { + createComponent({ + permissions: { + canResend: true, + }, + }); + + expect(findResendInviteButton().exists()).toBe(true); + }); + }); + + describe('when user does not have `canResend` permissions', () => { + it('does not render resend invite button', () => { + createComponent({ + permissions: { + canResend: false, + }, + }); + + expect(findResendInviteButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js new file mode 100644 index 00000000000..00896b23b95 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue'; +import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants'; +import { member } from '../mock_data'; + +describe('LeaveButton', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(LeaveButton, { + propsData: { + member, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + GlModal: createMockDirective(), + }, + }); + }; + + const findButton = () => wrapper.find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a tooltip', () => { + const button = findButton(); + + expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); + expect(button.attributes('title')).toBe('Leave'); + }); + + it('sets `aria-label` attribute', () => { + expect(findButton().attributes('aria-label')).toBe('Leave'); + }); + + it('renders leave modal', () => { + const leaveModal = wrapper.find(LeaveModal); + + expect(leaveModal.exists()).toBe(true); + expect(leaveModal.props('member')).toEqual(member); + }); + + it('triggers leave modal', () => { + const binding = getBinding(findButton().element, 'gl-modal'); + + expect(binding).not.toBeUndefined(); + expect(binding.value).toBe(LEAVE_MODAL_ID); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js new file mode 100644 index 00000000000..84fe1c51773 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js @@ -0,0 +1,64 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import RemoveGroupLinkButton from '~/vue_shared/components/members/action_buttons/remove_group_link_button.vue'; +import { group } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RemoveGroupLinkButton', () => { + let wrapper; + + const actions = { + showRemoveGroupLinkModal: jest.fn(), + }; + + const createStore = () => { + return new Vuex.Store({ + actions, + }); + }; + + const createComponent = () => { + wrapper = mount(RemoveGroupLinkButton, { + localVue, + store: createStore(), + propsData: { + groupLink: group, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findButton = () => wrapper.find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays a tooltip', () => { + const button = findButton(); + + expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); + expect(button.attributes('title')).toBe('Remove group'); + }); + + it('sets `aria-label` attribute', () => { + expect(findButton().attributes('aria-label')).toBe('Remove group'); + }); + + it('calls Vuex action to open remove group link modal when clicked', () => { + findButton().trigger('click'); + + expect(actions.showRemoveGroupLinkModal).toHaveBeenCalledWith(expect.any(Object), group); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js new file mode 100644 index 00000000000..7aa30494234 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js @@ -0,0 +1,66 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RemoveMemberButton', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(RemoveMemberButton, { + localVue, + store: createStore(state), + propsData: { + memberId: 1, + message: 'Are you sure you want to remove John Smith?', + title: 'Remove member', + isAccessRequest: true, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('sets attributes on button', () => { + createComponent(); + + expect(wrapper.attributes()).toMatchObject({ + 'data-member-path': '/groups/foo-bar/-/group_members/1', + 'data-message': 'Are you sure you want to remove John Smith?', + 'data-is-access-request': 'true', + 'aria-label': 'Remove member', + title: 'Remove member', + icon: 'remove', + }); + }); + + it('displays `title` prop as a tooltip', () => { + createComponent(); + + expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined(); + }); + + it('has CSS class used by `remove_member_modal.vue`', () => { + createComponent(); + + expect(wrapper.classes()).toContain('js-remove-member-button'); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js new file mode 100644 index 00000000000..859fdd01043 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js @@ -0,0 +1,66 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ResendInviteButton', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(ResendInviteButton, { + localVue, + store: createStore(state), + propsData: { + memberId: 1, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findForm = () => wrapper.find('form'); + const findButton = () => findForm().find(GlButton); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('displays a tooltip', () => { + expect(getBinding(findButton().element, 'gl-tooltip')).not.toBeUndefined(); + expect(findButton().attributes('title')).toBe('Resend invite'); + }); + + it('submits the form when button is clicked', () => { + expect(findButton().attributes('type')).toBe('submit'); + }); + + it('displays form with correct action and inputs', () => { + expect(findForm().attributes('action')).toBe('/groups/foo-bar/-/group_members/1/resend_invite'); + expect( + findForm() + .find('input[name="authenticity_token"]') + .attributes('value'), + ).toBe('mock-csrf-token'); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js new file mode 100644 index 00000000000..f766ad5b0d1 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js @@ -0,0 +1,89 @@ +import { shallowMount } from '@vue/test-utils'; +import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue'; +import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue'; +import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue'; +import { member, orphanedMember } from '../mock_data'; + +describe('UserActionButtons', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(UserActionButtons, { + propsData: { + member, + isCurrentUser: false, + ...propsData, + }, + }); + }; + + const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when user has `canRemove` permissions', () => { + beforeEach(() => { + createComponent({ + permissions: { + canRemove: true, + }, + }); + }); + + it('renders remove member button', () => { + expect(findRemoveMemberButton().exists()).toBe(true); + }); + + it('sets props correctly', () => { + expect(findRemoveMemberButton().props()).toEqual({ + memberId: member.id, + message: `Are you sure you want to remove ${member.user.name} from "${member.source.name}"`, + title: 'Remove member', + isAccessRequest: false, + icon: 'remove', + }); + }); + + describe('when member is orphaned', () => { + it('sets `message` prop correctly', () => { + createComponent({ + member: orphanedMember, + permissions: { + canRemove: true, + }, + }); + + expect(findRemoveMemberButton().props('message')).toBe( + `Are you sure you want to remove this orphaned member from "${orphanedMember.source.name}"`, + ); + }); + }); + + describe('when member is the current user', () => { + it('renders leave button', () => { + createComponent({ + isCurrentUser: true, + permissions: { + canRemove: true, + }, + }); + + expect(wrapper.find(LeaveButton).exists()).toBe(true); + }); + }); + }); + + describe('when user does not have `canRemove` permissions', () => { + it('does not render remove member button', () => { + createComponent({ + permissions: { + canRemove: false, + }, + }); + + expect(findRemoveMemberButton().exists()).toBe(false); + }); + }); +}); 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..93d8e640968 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js @@ -0,0 +1,115 @@ +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)); + + const findStatusEmoji = emoji => wrapper.find(`gl-emoji[data-name="${emoji}"]`); + + 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); + }); + }); + + describe('user status', () => { + const emoji = 'island'; + + describe('when set', () => { + it('displays the status emoji', () => { + createComponent({ + member: { + ...memberMock, + user: { + ...memberMock.user, + status: { emoji, messageHtml: 'On vacation' }, + }, + }, + }); + + expect(findStatusEmoji(emoji).exists()).toBe(true); + }); + }); + + describe('when not set', () => { + it('does not display status emoji', () => { + createComponent(); + + expect(findStatusEmoji(emoji).exists()).toBe(false); + }); + }); + }); +}); 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..d7bb8c0d142 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/mock_data.js @@ -0,0 +1,70 @@ +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, + validRoles: { + Guest: 10, + Reporter: 20, + Developer: 30, + Maintainer: 40, + Owner: 50, + 'Minimal Access': 5, + }, +}; + +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, + validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, +}; + +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/modals/leave_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js new file mode 100644 index 00000000000..63de355a3c8 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js @@ -0,0 +1,91 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { GlModal, GlForm } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import Vuex from 'vuex'; +import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants'; +import { member } from '../mock_data'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('LeaveModal', () => { + let wrapper; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = mount(LeaveModal, { + localVue, + store: createStore(state), + propsData: { + member, + ...propsData, + }, + attrs: { + static: true, + visible: true, + }, + }); + }; + + const findModal = () => wrapper.find(GlModal); + + const findForm = () => findModal().find(GlForm); + + const getByText = (text, options) => + createWrapper(within(findModal().element).getByText(text, options)); + + beforeEach(async () => { + createComponent(); + await nextTick(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('sets modal ID', () => { + expect(findModal().props('modalId')).toBe(LEAVE_MODAL_ID); + }); + + it('displays modal title', () => { + expect(getByText(`Leave "${member.source.name}"`).exists()).toBe(true); + }); + + it('displays modal body', () => { + expect(getByText(`Are you sure you want to leave "${member.source.name}"?`).exists()).toBe( + true, + ); + }); + + it('displays form with correct action and inputs', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe('/groups/foo-bar/-/group_members/leave'); + expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('submits the form when "Leave" button is clicked', () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + getByText('Leave').trigger('click'); + + expect(submitSpy).toHaveBeenCalled(); + + submitSpy.mockRestore(); + }); +}); diff --git a/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js new file mode 100644 index 00000000000..84da051792d --- /dev/null +++ b/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js @@ -0,0 +1,106 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import { GlModal, GlForm } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import Vuex from 'vuex'; +import RemoveGroupLinkModal from '~/vue_shared/components/members/modals/remove_group_link_modal.vue'; +import { REMOVE_GROUP_LINK_MODAL_ID } from '~/vue_shared/components/members/constants'; +import { group } from '../mock_data'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RemoveGroupLinkModal', () => { + let wrapper; + + const actions = { + hideRemoveGroupLinkModal: jest.fn(), + }; + + const createStore = (state = {}) => { + return new Vuex.Store({ + state: { + memberPath: '/groups/foo-bar/-/group_links/:id', + groupLinkToRemove: group, + removeGroupLinkModalVisible: true, + ...state, + }, + actions, + }); + }; + + const createComponent = state => { + wrapper = mount(RemoveGroupLinkModal, { + localVue, + store: createStore(state), + attrs: { + static: true, + }, + }); + }; + + const findModal = () => wrapper.find(GlModal); + const findForm = () => findModal().find(GlForm); + const getByText = (text, options) => + createWrapper(within(findModal().element).getByText(text, options)); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when modal is open', () => { + beforeEach(async () => { + createComponent(); + await nextTick(); + }); + + it('sets modal ID', () => { + expect(findModal().props('modalId')).toBe(REMOVE_GROUP_LINK_MODAL_ID); + }); + + it('displays modal title', () => { + expect(getByText(`Remove "${group.sharedWithGroup.fullName}"`).exists()).toBe(true); + }); + + it('displays modal body', () => { + expect( + getByText(`Are you sure you want to remove "${group.sharedWithGroup.fullName}"?`).exists(), + ).toBe(true); + }); + + it('displays form with correct action and inputs', () => { + const form = findForm(); + + expect(form.attributes('action')).toBe(`/groups/foo-bar/-/group_links/${group.id}`); + expect(form.find('input[name="_method"]').attributes('value')).toBe('delete'); + expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe( + 'mock-csrf-token', + ); + }); + + it('submits the form when "Remove group" button is clicked', () => { + const submitSpy = jest.spyOn(findForm().element, 'submit'); + + getByText('Remove group').trigger('click'); + + expect(submitSpy).toHaveBeenCalled(); + + submitSpy.mockRestore(); + }); + + it('calls `hideRemoveGroupLinkModal` action when modal is closed', () => { + getByText('Cancel').trigger('click'); + + expect(actions.hideRemoveGroupLinkModal).toHaveBeenCalled(); + }); + }); + + it('modal does not show when `removeGroupLinkModalVisible` is `false`', () => { + createComponent({ removeGroupLinkModalVisible: false }); + + expect(findModal().vm.$attrs.visible).toBe(false); + }); +}); 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_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js new file mode 100644 index 00000000000..e55d9b6be2a --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js @@ -0,0 +1,43 @@ +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 MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue'; +import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue'; +import GroupActionButtons from '~/vue_shared/components/members/action_buttons/group_action_buttons.vue'; +import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue'; +import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue'; + +describe('MemberActionButtons', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(MemberActionButtons, { + propsData: { + isCurrentUser: false, + permissions: { + canRemove: true, + }, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + test.each` + memberType | member | expectedComponent | expectedComponentName + ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'} + ${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'} + ${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'} + ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'} + `( + '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_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..ba693975a88 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js @@ -0,0 +1,251 @@ +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, + }, + permissions: { + type: Object, + 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" + :permissions="props.permissions" + /> + `, + }, + }); + }; + + const findWrappedComponent = () => wrapper.find(WrappedComponent); + + const memberCurrentUser = { + ...memberMock, + user: { + ...memberMock.user, + id: 1, + }, + }; + + const createComponentWithDirectMember = (member = {}) => { + createComponent({ + member: { + ...memberMock, + source: { + ...memberMock.source, + id: 1, + }, + ...member, + }, + }); + }; + const createComponentWithInheritedMember = (member = {}) => { + createComponent({ + member: { ...memberMock, ...member }, + }); + }; + + 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`', () => { + createComponentWithDirectMember(); + + expect(findWrappedComponent().props('isDirectMember')).toBe(true); + }); + + it('returns `false` when member is inherited', () => { + createComponentWithInheritedMember(); + + expect(findWrappedComponent().props('isDirectMember')).toBe(false); + }); + + it('returns `true` for linked groups', () => { + createComponent({ + member: group, + }); + + expect(findWrappedComponent().props('isDirectMember')).toBe(true); + }); + }); + + describe('isCurrentUser', () => { + it('returns `true` when `member.user` has the same ID as `currentUserId`', () => { + createComponent({ + member: memberCurrentUser, + }); + + 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); + }); + }); + + describe('permissions', () => { + describe('canRemove', () => { + describe('for a direct member', () => { + it('returns `true` when `canRemove` is `true`', () => { + createComponentWithDirectMember({ + canRemove: true, + }); + + expect(findWrappedComponent().props('permissions').canRemove).toBe(true); + }); + + it('returns `false` when `canRemove` is `false`', () => { + createComponentWithDirectMember({ + canRemove: false, + }); + + expect(findWrappedComponent().props('permissions').canRemove).toBe(false); + }); + }); + + describe('for an inherited member', () => { + it('returns `false`', () => { + createComponentWithInheritedMember(); + + expect(findWrappedComponent().props('permissions').canRemove).toBe(false); + }); + }); + }); + + describe('canResend', () => { + describe('when member type is `invite`', () => { + it('returns `true` when `canResend` is `true`', () => { + createComponent({ + member: invite, + }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(true); + }); + + it('returns `false` when `canResend` is `false`', () => { + createComponent({ + member: { + ...invite, + invite: { + ...invite, + canResend: false, + }, + }, + }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(false); + }); + }); + + describe('when member type is not `invite`', () => { + it('returns `false`', () => { + createComponent({ member: memberMock }); + + expect(findWrappedComponent().props('permissions').canResend).toBe(false); + }); + }); + }); + + describe('canUpdate', () => { + describe('for a direct member', () => { + it('returns `true` when `canUpdate` is `true`', () => { + createComponentWithDirectMember({ + canUpdate: true, + }); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(true); + }); + + it('returns `false` when `canUpdate` is `false`', () => { + createComponentWithDirectMember({ + canUpdate: false, + }); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); + }); + + it('returns `false` for current user', () => { + createComponentWithDirectMember(memberCurrentUser); + + expect(findWrappedComponent().props('permissions').canUpdate).toBe(false); + }); + }); + + describe('for an inherited member', () => { + it('returns `false`', () => { + createComponentWithInheritedMember(); + + expect(findWrappedComponent().props('permissions').canUpdate).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..20c1c26d2ee --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js @@ -0,0 +1,141 @@ +import { mount, createLocalVue, createWrapper } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { + getByText as getByTextHelper, + getByTestId as getByTestIdHelper, +} from '@testing-library/dom'; +import { GlBadge } from '@gitlab/ui'; +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 RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; +import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.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: [], + sourceId: 1, + ...state, + }, + }); + }; + + const createComponent = state => { + wrapper = mount(MembersTable, { + localVue, + store: createStore(state), + stubs: [ + 'member-avatar', + 'member-source', + 'expires-at', + 'created-at', + 'member-action-buttons', + 'role-dropdown', + 'remove-group-link-modal', + ], + }); + }; + + 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', () => { + const memberCanUpdate = { + ...memberMock, + canUpdate: true, + source: { ...memberMock.source, id: 1 }, + }; + + 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'} | ${memberCanUpdate} | ${RoleDropdown} + ${'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({ members: [memberMock], tableFields: ['actions'] }); + + const actionField = getByTestId('col-actions'); + + expect(actionField.exists()).toBe(true); + expect(actionField.classes('gl-sr-only')).toBe(true); + expect( + wrapper + .find(`[data-label="Actions"][role="cell"]`) + .find(MemberActionButtons) + .exists(), + ).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); + }); + }); + + describe('when member can not be updated', () => { + it('renders badge in "Max role" field', () => { + createComponent({ members: [memberMock], tableFields: ['maxRole'] }); + + expect( + wrapper + .find(`[data-label="Max role"][role="cell"]`) + .find(GlBadge) + .text(), + ).toBe(memberMock.accessLevel.stringValue); + }); + }); + + 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/table/role_dropdown_spec.js b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js new file mode 100644 index 00000000000..1e47953a510 --- /dev/null +++ b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js @@ -0,0 +1,150 @@ +import { mount, createWrapper, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { nextTick } from 'vue'; +import { within } from '@testing-library/dom'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue'; +import { member } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('RoleDropdown', () => { + let wrapper; + let actions; + const $toast = { + show: jest.fn(), + }; + + const createStore = () => { + actions = { + updateMemberRole: jest.fn(() => Promise.resolve()), + }; + + return new Vuex.Store({ actions }); + }; + + const createComponent = (propsData = {}) => { + wrapper = mount(RoleDropdown, { + propsData: { + member, + ...propsData, + }, + localVue, + store: createStore(), + mocks: { + $toast, + }, + }); + }; + + const getDropdownMenu = () => within(wrapper.element).getByRole('menu'); + const getByTextInDropdownMenu = (text, options = {}) => + createWrapper(within(getDropdownMenu()).getByText(text, options)); + const getDropdownItemByText = text => + createWrapper( + within(getDropdownMenu()) + .getByText(text, { selector: '[role="menuitem"] p' }) + .closest('[role="menuitem"]'), + ); + const getCheckedDropdownItem = () => + wrapper + .findAll(GlDropdownItem) + .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.props('isChecked')); + + const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); + const findDropdown = () => wrapper.find(GlDropdown); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when dropdown is open', () => { + beforeEach(done => { + createComponent(); + + findDropdownToggle().trigger('click'); + wrapper.vm.$root.$on('bv::dropdown::shown', () => { + done(); + }); + }); + + it('renders all valid roles', () => { + Object.keys(member.validRoles).forEach(role => { + expect(getDropdownItemByText(role).exists()).toBe(true); + }); + }); + + it('renders dropdown header', () => { + expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true); + }); + + it('sets dropdown toggle and checks selected role', () => { + expect(findDropdownToggle().text()).toBe('Owner'); + expect(getCheckedDropdownItem().text()).toBe('Owner'); + }); + + describe('when dropdown item is selected', () => { + it('does nothing if the item selected was already selected', () => { + getDropdownItemByText('Owner').trigger('click'); + + expect(actions.updateMemberRole).not.toHaveBeenCalled(); + }); + + it('calls `updateMemberRole` Vuex action', () => { + getDropdownItemByText('Developer').trigger('click'); + + expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), { + memberId: member.id, + accessLevel: { integerValue: 30, stringValue: 'Developer' }, + }); + }); + + it('displays toast when successful', async () => { + getDropdownItemByText('Developer').trigger('click'); + + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); + }); + + it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => { + getDropdownItemByText('Developer').trigger('click'); + + await nextTick(); + + expect(findDropdown().attributes('disabled')).toBe('disabled'); + + await waitForPromises(); + + expect(findDropdown().attributes('disabled')).toBeUndefined(); + }); + }); + }); + + it("sets initial dropdown toggle value to member's role", () => { + createComponent(); + + expect(findDropdownToggle().text()).toBe('Owner'); + }); + + it('sets the dropdown alignment to right on mobile', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(false); + createComponent(); + + await nextTick(); + + expect(findDropdown().attributes('right')).toBe('true'); + }); + + it('sets the dropdown alignment to left on desktop', async () => { + jest.spyOn(bp, 'isDesktop').mockReturnValue(true); + createComponent(); + + await nextTick(); + + expect(findDropdown().attributes('right')).toBeUndefined(); + }); +}); 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/paginated_table_with_search_and_tabs/mocks/items.json b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json new file mode 100644 index 00000000000..0d85b2bc68a --- /dev/null +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json @@ -0,0 +1,15 @@ +[ + { + "iid": "1527542", + "title": "SyntaxError: Invalid or unexpected token", + "createdAt": "2020-04-17T23:18:14.996Z", + "assignees": { "nodes": [] } + }, + { + "iid": "1527543", + "title": "SyntaxError: Invalid or unexpected token by root", + "createdAt": "2020-04-17T23:19:14.996Z", + "assignees": { "nodes": [] } + } + ] +
\ No newline at end of file diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json new file mode 100644 index 00000000000..b42ec42d8b8 --- /dev/null +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.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/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js new file mode 100644 index 00000000000..d943aaf3e5f --- /dev/null +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -0,0 +1,350 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlBadge, GlPagination, GlTabs, GlTab } from '@gitlab/ui'; +import PageWrapper from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; +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 Tracking from '~/tracking'; +import mockItems from './mocks/items.json'; +import mockFilters from './mocks/items_filters.json'; + +const EmptyStateSlot = { + template: '<div class="empty-state">Empty State</div>', +}; + +const HeaderActionsSlot = { + template: '<div class="header-actions"><button>Action Button</button></div>', +}; + +const TitleSlot = { + template: '<div>Page Wrapper Title</div>', +}; + +const TableSlot = { + template: '<table class="gl-table"></table>', +}; + +const itemsCount = { + opened: 24, + closed: 10, + all: 34, +}; + +const ITEMS_STATUS_TABS = [ + { + title: 'Opened items', + status: 'OPENED', + filters: ['opened'], + }, + { + title: 'Closed items', + status: 'CLOSED', + filters: ['closed'], + }, + { + title: 'All items', + status: 'ALL', + filters: ['all'], + }, +]; + +describe('AlertManagementEmptyState', () => { + let wrapper; + + function mountComponent({ props = {} } = {}) { + wrapper = mount(PageWrapper, { + provide: { + projectPath: '/link', + }, + propsData: { + items: [], + itemsCount: {}, + pageInfo: {}, + statusTabs: [], + loading: false, + showItems: false, + showErrorMsg: false, + trackViewsOptions: {}, + i18n: {}, + serverErrorMessage: '', + filterSearchKey: '', + ...props, + }, + slots: { + 'emtpy-state': EmptyStateSlot, + 'header-actions': HeaderActionsSlot, + title: TitleSlot, + table: TableSlot, + }, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + const EmptyState = () => wrapper.find('.empty-state'); + const ItemsTable = () => wrapper.find('.gl-table'); + const ErrorAlert = () => wrapper.find(GlAlert); + const Pagination = () => wrapper.find(GlPagination); + const Tabs = () => wrapper.find(GlTabs); + const ActionButton = () => wrapper.find('.header-actions > button'); + const Filters = () => wrapper.find(FilteredSearchBar); + const findPagination = () => wrapper.find(GlPagination); + const findStatusFilterTabs = () => wrapper.findAll(GlTab); + const findStatusTabs = () => wrapper.find(GlTabs); + const findStatusFilterBadge = () => wrapper.findAll(GlBadge); + + describe('Snowplow tracking', () => { + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + mountComponent({ + props: { trackViewsOptions: { category: 'category', action: 'action' } }, + }); + }); + + it('should track the items list page views', () => { + const { category, action } = wrapper.vm.trackViewsOptions; + expect(Tracking.event).toHaveBeenCalledWith(category, action); + }); + }); + + describe('Page wrapper with no items', () => { + it('renders the empty state if there are no items present', () => { + expect(EmptyState().exists()).toBe(true); + }); + }); + + describe('Page wrapper with items', () => { + it('renders the tabs selection with valid tabs', () => { + mountComponent({ + props: { + statusTabs: [{ status: 'opened', title: 'Open' }, { status: 'closed', title: 'Closed' }], + }, + }); + + expect(Tabs().exists()).toBe(true); + }); + + it('renders the header action buttons if present', () => { + expect(ActionButton().exists()).toBe(true); + }); + + it('renders a error alert if there are errors', () => { + mountComponent({ + props: { showErrorMsg: true }, + }); + + expect(ErrorAlert().exists()).toBe(true); + }); + + it('renders a table of items if items are present', () => { + mountComponent({ + props: { showItems: true, items: mockItems }, + }); + + expect(ItemsTable().exists()).toBe(true); + }); + + it('renders pagination if there the pagination info object has a next or previous page', () => { + mountComponent({ + props: { pageInfo: { hasNextPage: true } }, + }); + + expect(Pagination().exists()).toBe(true); + }); + + it('renders the filter set with the tokens according to the prop filterSearchTokens', () => { + mountComponent({ + props: { filterSearchTokens: ['assignee_username'] }, + }); + + expect(Filters().exists()).toBe(true); + }); + }); + + describe('Status Filter Tabs', () => { + beforeEach(() => { + mountComponent({ + props: { items: mockItems, itemsCount, statusTabs: ITEMS_STATUS_TABS }, + }); + }); + + it('should display filter tabs', () => { + const tabs = findStatusFilterTabs().wrappers; + + tabs.forEach((tab, i) => { + expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status); + }); + }); + + it('should display filter tabs with items count badge for each status', () => { + const tabs = findStatusFilterTabs().wrappers; + const badges = findStatusFilterBadge(); + + tabs.forEach((tab, i) => { + const status = ITEMS_STATUS_TABS[i].status.toLowerCase(); + expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status); + expect(badges.at(i).text()).toContain(itemsCount[status]); + }); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mountComponent({ + props: { + items: mockItems, + itemsCount, + statusTabs: ITEMS_STATUS_TABS, + pageInfo: { hasNextPage: true }, + }, + }); + }); + + it('should render pagination', () => { + expect(wrapper.find(GlPagination).exists()).toBe(true); + }); + + describe('prevPage', () => { + it('returns prevPage button', async () => { + findPagination().vm.$emit('input', 3); + + await wrapper.vm.$nextTick(); + expect( + findPagination() + .findAll('.page-item') + .at(0) + .text(), + ).toBe('Prev'); + }); + + it('returns prevPage number', async () => { + findPagination().vm.$emit('input', 3); + + await wrapper.vm.$nextTick(); + expect(wrapper.vm.previousPage).toBe(2); + }); + + it('returns 0 when it is the first page', async () => { + findPagination().vm.$emit('input', 1); + + await wrapper.vm.$nextTick(); + expect(wrapper.vm.previousPage).toBe(0); + }); + }); + + describe('nextPage', () => { + it('returns nextPage button', async () => { + findPagination().vm.$emit('input', 3); + + await wrapper.vm.$nextTick(); + expect( + findPagination() + .findAll('.page-item') + .at(1) + .text(), + ).toBe('Next'); + }); + + it('returns nextPage number', async () => { + mountComponent({ + props: { + items: mockItems, + itemsCount, + statusTabs: ITEMS_STATUS_TABS, + pageInfo: { hasNextPage: true }, + }, + }); + findPagination().vm.$emit('input', 1); + + await wrapper.vm.$nextTick(); + expect(wrapper.vm.nextPage).toBe(2); + }); + + it('returns `null` when currentPage is already last page', async () => { + findStatusTabs().vm.$emit('input', 1); + findPagination().vm.$emit('input', 1); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.nextPage).toBeNull(); + }); + }); + }); + + describe('Filtered search component', () => { + beforeEach(() => { + mountComponent({ + props: { + items: mockItems, + itemsCount, + statusTabs: ITEMS_STATUS_TABS, + filterSearchKey: 'items', + }, + }); + }); + + it('renders the search component for incidents', () => { + expect(Filters().props('searchInputPlaceholder')).toBe('Search or filter results…'); + expect(Filters().props('tokens')).toEqual([ + { + type: 'author_username', + icon: 'user', + title: 'Author', + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchPath: '/link', + fetchAuthors: expect.any(Function), + }, + { + type: 'assignee_username', + icon: 'user', + title: 'Assignee', + unique: true, + symbol: '@', + token: AuthorToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchPath: '/link', + fetchAuthors: expect.any(Function), + }, + ]); + expect(Filters().props('recentSearchesStorageKey')).toBe('items'); + }); + + 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('updates props tied to getIncidents GraphQL query', () => { + wrapper.vm.handleFilterItems(mockFilters); + + expect(wrapper.vm.authorUsername).toBe('root'); + expect(wrapper.vm.assigneeUsername).toEqual('root2'); + expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data); + }); + + it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => { + wrapper.setData({ + authorUsername: 'foo', + searchTerm: 'bar', + }); + + wrapper.vm.handleFilterItems([]); + + expect(wrapper.vm.authorUsername).toBe(''); + expect(wrapper.vm.searchTerm).toBe(''); + }); + }); +}); 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..ecea151fc8a 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" @@ -46,13 +47,15 @@ exports[`Package code instruction single line to match the default snapshot 1`] <!----> <svg - class="gl-icon s16" + class="gl-button-icon gl-icon s16" data-testid="copy-to-clipboard-icon" > <use href="#copy-to-clipboard" /> </svg> + + <!----> </button> </span> </div> diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js index e2cfdedb4bf..2a48bf4f2d6 100644 --- a/spec/frontend/vue_shared/components/registry/list_item_spec.js +++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js @@ -58,9 +58,9 @@ describe('list item', () => { describe.each` slotNames - ${['details_foo']} - ${['details_foo', 'details_bar']} - ${['details_foo', 'details_bar', 'details_baz']} + ${['details-foo']} + ${['details-foo', 'details-bar']} + ${['details-foo', 'details-bar', 'details-baz']} `('$slotNames details slots', ({ slotNames }) => { const slotMocks = slotNames.reduce((acc, current) => { acc[current] = `<div data-testid="${current}" />`; @@ -89,7 +89,7 @@ describe('list item', () => { describe('details toggle button', () => { it('is visible when at least one details slot exists', async () => { - mountComponent({}, { details_foo: '<span></span>' }); + mountComponent({}, { 'details-foo': '<span></span>' }); await wrapper.vm.$nextTick(); expect(findToggleDetailsButton().exists()).toBe(true); }); 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..5cb606b58d9 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" />', @@ -77,9 +79,9 @@ describe('title area', () => { describe.each` slotNames - ${['metadata_foo']} - ${['metadata_foo', 'metadata_bar']} - ${['metadata_foo', 'metadata_bar', 'metadata_baz']} + ${['metadata-foo']} + ${['metadata-foo', 'metadata-bar']} + ${['metadata-foo', 'metadata-bar', 'metadata-baz']} `('$slotNames metadata slots', ({ slotNames }) => { const slotMocks = slotNames.reduce((acc, current) => { acc[current] = `<div data-testid="${current}" />`; @@ -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..0f2f263a776 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 @@ -4,24 +4,37 @@ import { removeCustomEventListener, registerHTMLToMarkdownRenderer, addImage, + insertVideo, getMarkdown, getEditorOptions, } 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; let event; let handler; + const parseHtml = str => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = str; + return wrapper.firstChild; + }; beforeEach(() => { mockInstance = { eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, - editor: { exec: jest.fn() }, + editor: { + exec: jest.fn(), + isWysiwygMode: jest.fn(), + getSquire: jest.fn(), + insertText: jest.fn(), + }, invoke: jest.fn(), toMarkOptions: { renderer: { @@ -87,6 +100,38 @@ describe('Editor Service', () => { }); }); + describe('insertVideo', () => { + const mockUrl = 'some/url'; + const htmlString = `<figure contenteditable="false" class="gl-relative gl-h-0 video_container"><iframe class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full" width="560" height="315" frameborder="0" src="some/url"></iframe></figure>`; + const mockInsertElement = jest.fn(); + + beforeEach(() => + mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }), + ); + + describe('WYSIWYG mode', () => { + it('calls the insertElement method on the squire instance with an iFrame element', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(true); + + insertVideo(mockInstance, mockUrl); + + expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith( + parseHtml(htmlString), + ); + }); + }); + + describe('Markdown mode', () => { + it('calls the insertText method on the editor instance with the iFrame element HTML', () => { + mockInstance.editor.isWysiwygMode.mockReturnValue(false); + + insertVideo(mockInstance, mockUrl); + + expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString); + }); + }); + }); + describe('getMarkdown', () => { it('calls the invoke method on the instance', () => { getMarkdown(mockInstance); @@ -143,5 +188,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/modals/insert_video_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js new file mode 100644 index 00000000000..be3a4030b1d --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js @@ -0,0 +1,44 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; + +describe('Insert Video Modal', () => { + let wrapper; + + const findModal = () => wrapper.find(GlModal); + const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); + + const triggerInsertVideo = url => { + const preventDefault = jest.fn(); + findUrlInput().vm.$emit('input', url); + findModal().vm.$emit('primary', { preventDefault }); + }; + + beforeEach(() => { + wrapper = shallowMount(InsertVideoModal); + }); + + afterEach(() => wrapper.destroy()); + + describe('when content is loaded', () => { + it('renders a modal component', () => { + expect(findModal().exists()).toBe(true); + }); + + it('renders an input to add a URL', () => { + expect(findUrlInput().exists()).toBe(true); + }); + }); + + describe('insert video', () => { + it.each` + url | emitted + ${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]} + ${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]} + ${'::youtube.com/invalid/url'} | ${undefined} + `('formats the url correctly', ({ url, emitted }) => { + triggerInsertVideo(url); + expect(wrapper.emitted('insertVideo')).toEqual(emitted); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index 3d54db7fe5c..8c2c0413819 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; +import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; import { EDITOR_TYPES, EDITOR_HEIGHT, @@ -12,6 +13,7 @@ import { addCustomEventListener, removeCustomEventListener, addImage, + insertVideo, registerHTMLToMarkdownRenderer, getEditorOptions, } from '~/vue_shared/components/rich_content_editor/services/editor_service'; @@ -21,6 +23,7 @@ jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', addCustomEventListener: jest.fn(), removeCustomEventListener: jest.fn(), addImage: jest.fn(), + insertVideo: jest.fn(), registerHTMLToMarkdownRenderer: jest.fn(), getEditorOptions: jest.fn(), })); @@ -32,6 +35,7 @@ describe('Rich Content Editor', () => { const imageRoot = 'path/to/root/'; const findEditor = () => wrapper.find({ ref: 'editor' }); const findAddImageModal = () => wrapper.find(AddImageModal); + const findInsertVideoModal = () => wrapper.find(InsertVideoModal); const buildWrapper = () => { wrapper = shallowMount(RichContentEditor, { @@ -122,6 +126,14 @@ describe('Rich Content Editor', () => { ); }); + it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { + expect(addCustomEventListener).toHaveBeenCalledWith( + wrapper.vm.editorApi, + CUSTOM_EVENTS.openInsertVideoModal, + wrapper.vm.onOpenInsertVideoModal, + ); + }); + it('registers HTML to markdown renderer', () => { expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); }); @@ -141,6 +153,16 @@ describe('Rich Content Editor', () => { wrapper.vm.onOpenAddImageModal, ); }); + + it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { + wrapper.vm.$destroy(); + + expect(removeCustomEventListener).toHaveBeenCalledWith( + wrapper.vm.editorApi, + CUSTOM_EVENTS.openInsertVideoModal, + wrapper.vm.onOpenInsertVideoModal, + ); + }); }); describe('add image modal', () => { @@ -161,4 +183,23 @@ describe('Rich Content Editor', () => { expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage); }); }); + + describe('insert video modal', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('renders an insertVideoModal component', () => { + expect(findInsertVideoModal().exists()).toBe(true); + }); + + it('calls the onInsertVideo method when the insertVideo event is emitted', () => { + const mockUrl = 'https://www.youtube.com/embed/someId'; + const mockInstance = { exec: jest.fn() }; + wrapper.vm.$refs.editor = mockInstance; + + findInsertVideoModal().vm.$emit('insertVideo', mockUrl); + expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl); + }); + }); }); 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/collapsed_calendar_icon_spec.js b/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js index 31316a93ecd..240d6cb5a34 100644 --- a/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js @@ -18,7 +18,7 @@ describe('collapsedCalendarIcon', () => { }); it('should hide calendar icon if showIcon', () => { - expect(vm.$el.querySelector('.fa-calendar')).toBeNull(); + expect(vm.$el.querySelector('[data-testid="calendar-icon"]')).toBeNull(); }); it('should render text', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js b/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js index 65255968bc7..08fc822577e 100644 --- a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js @@ -80,7 +80,7 @@ describe('collapsedGroupedDatePicker', () => { it('should have tooltip as `Start and due date`', () => { const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon'); - expect(icons[0].dataset.originalTitle).toBe('Start and due date'); + expect(icons[0].title).toBe('Start and due date'); }); }); }); 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/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index bfb8e263d81..c742220ba8a 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -259,21 +259,6 @@ describe('LabelsSelect Actions', () => { }); }); - describe('replaceSelectedLabels', () => { - it('replaces `state.selectedLabels`', done => { - const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - - testAction( - actions.replaceSelectedLabels, - selectedLabels, - state, - [{ type: types.REPLACE_SELECTED_LABELS, payload: selectedLabels }], - [], - done, - ); - }); - }); - describe('updateSelectedLabels', () => { it('updates `state.labels` based on provided `labels` param', done => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index 3414eec8a63..8081806e314 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -152,19 +152,6 @@ describe('LabelsSelect Mutations', () => { }); }); - describe(`${types.REPLACE_SELECTED_LABELS}`, () => { - it('replaces `state.selectedLabels`', () => { - const state = { - selectedLabels: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], - }; - const newSelectedLabels = [{ id: 2 }, { id: 5 }]; - - mutations[types.REPLACE_SELECTED_LABELS](state, newSelectedLabels); - - expect(state.selectedLabels).toEqual(newSelectedLabels); - }); - }); - describe(`${types.UPDATE_SELECTED_LABELS}`, () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; diff --git a/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js b/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js index 4342f5e2105..f1c3e8a1ddc 100644 --- a/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js @@ -11,15 +11,14 @@ describe('toggleSidebar', () => { }); }); - it('should render << when collapsed', () => { - expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-left')).toEqual(true); + it('should render the "chevron-double-lg-left" icon when collapsed', () => { + expect(vm.$el.querySelector('[data-testid="chevron-double-lg-left-icon"]')).not.toBeNull(); }); - it('should render >> when collapsed', () => { + it('should render the "chevron-double-lg-right" icon when expanded', async () => { vm.collapsed = false; - Vue.nextTick(() => { - expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-right')).toEqual(true); - }); + await Vue.nextTick(); + expect(vm.$el.querySelector('[data-testid="chevron-double-lg-right-icon"]')).not.toBeNull(); }); it('should emit toggle event when button clicked', () => { diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js index f3bd4c14717..e09bc073042 100644 --- a/spec/frontend/vue_shared/components/split_button_spec.js +++ b/spec/frontend/vue_shared/components/split_button_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import SplitButton from '~/vue_shared/components/split_button.vue'; @@ -25,10 +25,10 @@ describe('SplitButton', () => { }); }; - const findDropdown = () => wrapper.find(GlDeprecatedDropdown); + const findDropdown = () => wrapper.find(GlDropdown); const findDropdownItem = (index = 0) => findDropdown() - .findAll(GlDeprecatedDropdownItem) + .findAll(GlDropdownItem) .at(index); const selectItem = index => { findDropdownItem(index).vm.$emit('click'); 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/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index b43bb6b10e0..c208d7b0226 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -21,6 +21,9 @@ describe('User Popover Component', () => { let wrapper; beforeEach(() => { + window.gon.features = { + securityAutoFix: true, + }; loadFixtures(fixtureTemplate); }); @@ -28,6 +31,7 @@ describe('User Popover Component', () => { wrapper.destroy(); }); + const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`); const findUserStatus = () => wrapper.find('.js-user-status'); const findTarget = () => document.querySelector('.js-user-link'); @@ -196,4 +200,30 @@ describe('User Popover Component', () => { expect(findUserStatus().exists()).toBe(false); }); }); + + describe('security bot', () => { + const SECURITY_BOT_USER = { + ...DEFAULT_PROPS.user, + name: 'GitLab Security Bot', + username: 'GitLab-Security-Bot', + websiteUrl: '/security/bot/docs', + }; + const findSecurityBotDocsLink = () => findByTestId('user-popover-bot-docs-link'); + + it("shows a link to the bot's documentation", () => { + createWrapper({ user: SECURITY_BOT_USER }); + const securityBotDocsLink = findSecurityBotDocsLink(); + expect(securityBotDocsLink.exists()).toBe(true); + expect(securityBotDocsLink.attributes('href')).toBe(SECURITY_BOT_USER.websiteUrl); + }); + + it('does not show the link if the feature flag is disabled', () => { + window.gon.features = { + securityAutoFix: false, + }; + createWrapper({ user: SECURITY_BOT_USER }); + + expect(findSecurityBotDocsLink().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 57f511903d9..8ed072bed13 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -3,9 +3,27 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; +const TEST_EDIT_URL = '/gitlab-test/test/-/edit/master/'; const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/master/-/'; const TEST_GITPOD_URL = 'https://gitpod.test/'; +const ACTION_EDIT = { + href: TEST_EDIT_URL, + key: 'edit', + text: 'Edit', + secondaryText: 'Edit this file only.', + tooltip: '', + attrs: { + 'data-qa-selector': 'edit_button', + 'data-track-event': 'click_edit', + 'data-track-label': 'Edit', + }, +}; +const ACTION_EDIT_CONFIRM_FORK = { + ...ACTION_EDIT, + href: '#modal-confirm-fork-edit', + handle: expect.any(Function), +}; const ACTION_WEB_IDE = { href: TEST_WEB_IDE_URL, key: 'webide', @@ -14,13 +32,16 @@ const ACTION_WEB_IDE = { text: 'Web IDE', attrs: { 'data-qa-selector': 'web_ide_button', + 'data-track-event': 'click_edit_ide', + 'data-track-label': 'Web IDE', }, }; -const ACTION_WEB_IDE_FORK = { +const ACTION_WEB_IDE_CONFIRM_FORK = { ...ACTION_WEB_IDE, - href: '#modal-confirm-fork', + href: '#modal-confirm-fork-webide', handle: expect.any(Function), }; +const ACTION_WEB_IDE_EDIT_FORK = { ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' }; const ACTION_GITPOD = { href: TEST_GITPOD_URL, key: 'gitpod', @@ -43,6 +64,7 @@ describe('Web IDE link component', () => { function createComponent(props) { wrapper = shallowMount(WebIdeLink, { propsData: { + editUrl: TEST_EDIT_URL, webIdeUrl: TEST_WEB_IDE_URL, gitpodUrl: TEST_GITPOD_URL, ...props, @@ -57,14 +79,36 @@ describe('Web IDE link component', () => { const findActionsButton = () => wrapper.find(ActionsButton); const findLocalStorageSync = () => wrapper.find(LocalStorageSync); - it.each` - props | expectedActions - ${{}} | ${[ACTION_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]} - ${{ showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_WEB_IDE, ACTION_GITPOD_ENABLE]} - `('renders actions with props=$props', ({ props, expectedActions }) => { + it.each([ + { + props: {}, + expectedActions: [ACTION_WEB_IDE, ACTION_EDIT], + }, + { + props: { isFork: true }, + expectedActions: [ACTION_WEB_IDE_EDIT_FORK, ACTION_EDIT], + }, + { + props: { needsToFork: true }, + expectedActions: [ACTION_WEB_IDE_CONFIRM_FORK, ACTION_EDIT_CONFIRM_FORK], + }, + { + props: { showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }, + expectedActions: [ACTION_EDIT, ACTION_GITPOD], + }, + { + props: { showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }, + expectedActions: [ACTION_EDIT, ACTION_GITPOD_ENABLE], + }, + { + props: { showGitpodButton: true, gitpodEnabled: false }, + expectedActions: [ACTION_WEB_IDE, ACTION_EDIT, ACTION_GITPOD_ENABLE], + }, + { + props: { showEditButton: false }, + expectedActions: [ACTION_WEB_IDE], + }, + ])('renders actions with appropriately for given props', ({ props, expectedActions }) => { createComponent(props); expect(findActionsButton().props('actions')).toEqual(expectedActions); @@ -72,7 +116,12 @@ describe('Web IDE link component', () => { describe('with multiple actions', () => { beforeEach(() => { - createComponent({ showWebIdeButton: true, showGitpodButton: true, gitpodEnabled: true }); + createComponent({ + showEditButton: false, + showWebIdeButton: true, + showGitpodButton: true, + gitpodEnabled: true, + }); }); it('selected Web IDE by default', () => { diff --git a/spec/frontend/vue_shared/directives/tooltip_spec.js b/spec/frontend/vue_shared/directives/tooltip_spec.js index 9d3dd3c5f75..4217b8d3c02 100644 --- a/spec/frontend/vue_shared/directives/tooltip_spec.js +++ b/spec/frontend/vue_shared/directives/tooltip_spec.js @@ -1,42 +1,59 @@ import $ from 'jquery'; +import { escape } from 'lodash'; import { mount } from '@vue/test-utils'; import tooltip from '~/vue_shared/directives/tooltip'; +const DEFAULT_TOOLTIP_TEMPLATE = '<div v-tooltip :title="tooltip"></div>'; +const HTML_TOOLTIP_TEMPLATE = '<div v-tooltip data-html="true" :title="tooltip"></div>'; + describe('Tooltip directive', () => { - let vm; + let wrapper; + + function createTooltipContainer({ + template = DEFAULT_TOOLTIP_TEMPLATE, + text = 'some text', + } = {}) { + wrapper = mount( + { + directives: { tooltip }, + data: () => ({ tooltip: text }), + template, + }, + { attachToDocument: true }, + ); + } + + async function showTooltip() { + $(wrapper.vm.$el).tooltip('show'); + jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + } + + function findTooltipInnerHtml() { + return document.querySelector('.tooltip-inner').innerHTML; + } + + function findTooltipHtml() { + return document.querySelector('.tooltip').innerHTML; + } afterEach(() => { - if (vm) { - vm.$destroy(); - } + wrapper.destroy(); + wrapper = null; }); describe('with a single tooltip', () => { - beforeEach(() => { - const wrapper = mount( - { - directives: { - tooltip, - }, - data() { - return { - tooltip: 'some text', - }; - }, - template: '<div v-tooltip :title="tooltip"></div>', - }, - { attachToDocument: true }, - ); - - vm = wrapper.vm; - }); - it('should have tooltip plugin applied', () => { - expect($(vm.$el).data('bs.tooltip')).toBeDefined(); + createTooltipContainer(); + + expect($(wrapper.vm.$el).data('bs.tooltip')).toBeDefined(); }); it('displays the title as tooltip', () => { - $(vm.$el).tooltip('show'); + createTooltipContainer(); + + $(wrapper.vm.$el).tooltip('show'); + jest.runOnlyPendingTimers(); const tooltipElement = document.querySelector('.tooltip-inner'); @@ -44,52 +61,98 @@ describe('Tooltip directive', () => { expect(tooltipElement.textContent).toContain('some text'); }); - it('updates a visible tooltip', () => { - $(vm.$el).tooltip('show'); + it.each` + condition | template | sanitize + ${'does not contain any html'} | ${DEFAULT_TOOLTIP_TEMPLATE} | ${false} + ${'contains html'} | ${HTML_TOOLTIP_TEMPLATE} | ${true} + `('passes sanitize=$sanitize if the tooltip $condition', ({ template, sanitize }) => { + createTooltipContainer({ template }); + + expect($(wrapper.vm.$el).data('bs.tooltip').config.sanitize).toEqual(sanitize); + }); + + it('updates a visible tooltip', async () => { + createTooltipContainer(); + + $(wrapper.vm.$el).tooltip('show'); jest.runOnlyPendingTimers(); const tooltipElement = document.querySelector('.tooltip-inner'); - vm.tooltip = 'other text'; + wrapper.vm.tooltip = 'other text'; jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(tooltipElement.textContent).toContain('other text'); + }); + + describe('tooltip sanitization', () => { + it('reads tooltip content as text if data-html is not passed', async () => { + createTooltipContainer({ text: 'sample text<script>alert("XSS!!")</script>' }); - return vm.$nextTick().then(() => { - expect(tooltipElement.textContent).toContain('other text'); + await showTooltip(); + + const result = findTooltipInnerHtml(); + expect(result).toEqual('sample text<script>alert("XSS!!")</script>'); + }); + + it('sanitizes tooltip if data-html is passed', async () => { + createTooltipContainer({ + template: HTML_TOOLTIP_TEMPLATE, + text: 'sample text<script>alert("XSS!!")</script>', + }); + + await showTooltip(); + + const result = findTooltipInnerHtml(); + expect(result).toEqual('sample text'); + expect(result).not.toContain('XSS!!'); + }); + + it('sanitizes tooltip if data-template is passed', async () => { + const tooltipTemplate = escape( + '<div class="tooltip" role="tooltip"><div onclick="alert(\'XSS!\')" class="arrow"></div><div class="tooltip-inner"></div></div>', + ); + + createTooltipContainer({ + template: `<div v-tooltip :title="tooltip" data-html="false" data-template="${tooltipTemplate}"></div>`, + }); + + await showTooltip(); + + const result = findTooltipHtml(); + expect(result).toEqual( + // objectionable element is removed + '<div class="arrow"></div><div class="tooltip-inner">some text</div>', + ); + expect(result).not.toContain('XSS!!'); }); }); }); describe('with multiple tooltips', () => { beforeEach(() => { - const wrapper = mount( - { - directives: { - tooltip, - }, - template: ` - <div> - <div - v-tooltip - class="js-look-for-tooltip" - title="foo"> - </div> - <div - v-tooltip - title="bar"> - </div> + createTooltipContainer({ + template: ` + <div> + <div + v-tooltip + class="js-look-for-tooltip" + title="foo"> </div> - `, - }, - { attachToDocument: true }, - ); - - vm = wrapper.vm; + <div + v-tooltip + title="bar"> + </div> + </div> + `, + }); }); it('should have tooltip plugin applied to all instances', () => { expect( - $(vm.$el) + $(wrapper.vm.$el) .find('.js-look-for-tooltip') .data('bs.tooltip'), ).toBeDefined(); 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/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js new file mode 100644 index 00000000000..31bdfc931ac --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js @@ -0,0 +1,118 @@ +import { mount } from '@vue/test-utils'; +import Api from '~/api'; +import Flash from '~/flash'; +import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue'; + +jest.mock('~/flash'); + +describe('Grouped security reports app', () => { + let wrapper; + let mrTabsMock; + + const props = { + pipelineId: 123, + projectId: 456, + securityReportsDocsPath: '/docs', + }; + + const createComponent = () => { + wrapper = mount(SecurityReportsApp, { + propsData: { ...props }, + }); + }; + + const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]'); + const findHelpLink = () => wrapper.find('[data-testid="help"]'); + const setupMrTabsMock = () => { + mrTabsMock = { tabShown: jest.fn() }; + window.mrTabs = mrTabsMock; + }; + const setupMockJobArtifact = reportType => { + jest + .spyOn(Api, 'pipelineJobs') + .mockResolvedValue({ data: [{ artifacts: [{ file_type: reportType }] }] }); + }; + + afterEach(() => { + wrapper.destroy(); + delete window.mrTabs; + }); + + describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => { + beforeEach(() => { + window.mrTabs = { tabShown: jest.fn() }; + setupMockJobArtifact(reportType); + createComponent(); + }); + + it('calls the pipelineJobs API correctly', () => { + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + }); + + it('renders the expected message', () => { + expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun); + }); + + describe('clicking the anchor to the pipelines tab', () => { + beforeEach(() => { + setupMrTabsMock(); + findPipelinesTabAnchor().trigger('click'); + }); + + it('calls the mrTabs.tabShown global', () => { + expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]); + }); + }); + + it('renders a help link', () => { + expect(findHelpLink().attributes()).toMatchObject({ + href: props.securityReportsDocsPath, + }); + }); + }); + + describe('given a report type "foo"', () => { + beforeEach(() => { + setupMockJobArtifact('foo'); + createComponent(); + }); + + it('calls the pipelineJobs API correctly', () => { + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + }); + + it('renders nothing', () => { + expect(wrapper.html()).toBe(''); + }); + }); + + describe('given an error from the API', () => { + let error; + + beforeEach(() => { + error = new Error('an error'); + jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error); + createComponent(); + }); + + it('calls the pipelineJobs API correctly', () => { + expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId); + }); + + it('renders nothing', () => { + expect(wrapper.html()).toBe(''); + }); + + it('calls Flash correctly', () => { + expect(Flash.mock.calls).toEqual([ + [ + { + message: SecurityReportsApp.i18n.apiError, + captureError: true, + error, + }, + ], + ]); + }); + }); +}); diff --git a/spec/frontend/vuex_shared/modules/members/actions_spec.js b/spec/frontend/vuex_shared/modules/members/actions_spec.js new file mode 100644 index 00000000000..833bd4cc175 --- /dev/null +++ b/spec/frontend/vuex_shared/modules/members/actions_spec.js @@ -0,0 +1,110 @@ +import { noop } from 'lodash'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { members, group } from 'jest/vue_shared/components/members/mock_data'; +import testAction from 'helpers/vuex_action_helper'; +import httpStatusCodes from '~/lib/utils/http_status'; +import * as types from '~/vuex_shared/modules/members/mutation_types'; +import { + updateMemberRole, + showRemoveGroupLinkModal, + hideRemoveGroupLinkModal, +} from '~/vuex_shared/modules/members/actions'; + +describe('Vuex members actions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('updateMemberRole', () => { + const memberId = members[0].id; + const accessLevel = { integerValue: 30, stringValue: 'Developer' }; + + const payload = { + memberId, + accessLevel, + }; + const state = { + members, + memberPath: '/groups/foo-bar/-/group_members/:id', + requestFormatter: noop, + removeGroupLinkModalVisible: false, + groupLinkToRemove: null, + }; + + describe('successful request', () => { + it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => { + let requestPath; + mock.onPut().replyOnce(config => { + requestPath = config.url; + return [httpStatusCodes.OK, {}]; + }); + + await testAction(updateMemberRole, payload, state, [ + { + type: types.RECEIVE_MEMBER_ROLE_SUCCESS, + payload, + }, + ]); + + expect(requestPath).toBe('/groups/foo-bar/-/group_members/238'); + }); + }); + + describe('unsuccessful request', () => { + beforeEach(() => { + mock.onPut().replyOnce(httpStatusCodes.BAD_REQUEST, { message: 'Bad request' }); + }); + + it(`commits ${types.RECEIVE_MEMBER_ROLE_ERROR} mutation`, async () => { + try { + await testAction(updateMemberRole, payload, state, [ + { + type: types.RECEIVE_MEMBER_ROLE_SUCCESS, + }, + ]); + } catch { + // Do nothing + } + }); + + it('throws error', async () => { + await expect(testAction(updateMemberRole, payload, state)).rejects.toThrowError(); + }); + }); + }); + + describe('Group Link Modal', () => { + const state = { + removeGroupLinkModalVisible: false, + groupLinkToRemove: null, + }; + + describe('showRemoveGroupLinkModal', () => { + it(`commits ${types.SHOW_REMOVE_GROUP_LINK_MODAL} mutation`, () => { + testAction(showRemoveGroupLinkModal, group, state, [ + { + type: types.SHOW_REMOVE_GROUP_LINK_MODAL, + payload: group, + }, + ]); + }); + }); + + describe('hideRemoveGroupLinkModal', () => { + it(`commits ${types.HIDE_REMOVE_GROUP_LINK_MODAL} mutation`, () => { + testAction(hideRemoveGroupLinkModal, group, state, [ + { + type: types.HIDE_REMOVE_GROUP_LINK_MODAL, + }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/vuex_shared/modules/members/mutations_spec.js b/spec/frontend/vuex_shared/modules/members/mutations_spec.js new file mode 100644 index 00000000000..7338b19cfc9 --- /dev/null +++ b/spec/frontend/vuex_shared/modules/members/mutations_spec.js @@ -0,0 +1,90 @@ +import { members, group } from 'jest/vue_shared/components/members/mock_data'; +import mutations from '~/vuex_shared/modules/members/mutations'; +import * as types from '~/vuex_shared/modules/members/mutation_types'; + +describe('Vuex members mutations', () => { + describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => { + it('updates member', () => { + const state = { + members, + }; + + const accessLevel = { integerValue: 30, stringValue: 'Developer' }; + + mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, { + memberId: members[0].id, + accessLevel, + }); + + expect(state.members[0].accessLevel).toEqual(accessLevel); + }); + }); + + describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => { + it('shows error message', () => { + const state = { + showError: false, + errorMessage: '', + }; + + mutations[types.RECEIVE_MEMBER_ROLE_ERROR](state); + + expect(state.showError).toBe(true); + expect(state.errorMessage).toBe( + "An error occurred while updating the member's role, please try again.", + ); + }); + }); + + describe(types.HIDE_ERROR, () => { + it('sets `showError` to `false`', () => { + const state = { + showError: true, + errorMessage: 'foo bar', + }; + + mutations[types.HIDE_ERROR](state); + + expect(state.showError).toBe(false); + }); + + it('sets `errorMessage` to an empty string', () => { + const state = { + showError: true, + errorMessage: 'foo bar', + }; + + mutations[types.HIDE_ERROR](state); + + expect(state.errorMessage).toBe(''); + }); + }); + + describe(types.SHOW_REMOVE_GROUP_LINK_MODAL, () => { + it('sets `removeGroupLinkModalVisible` and `groupLinkToRemove`', () => { + const state = { + removeGroupLinkModalVisible: false, + groupLinkToRemove: null, + }; + + mutations[types.SHOW_REMOVE_GROUP_LINK_MODAL](state, group); + + expect(state).toEqual({ + removeGroupLinkModalVisible: true, + groupLinkToRemove: group, + }); + }); + }); + + describe(types.HIDE_REMOVE_GROUP_LINK_MODAL, () => { + it('sets `removeGroupLinkModalVisible` to `false`', () => { + const state = { + removeGroupLinkModalVisible: false, + }; + + mutations[types.HIDE_REMOVE_GROUP_LINK_MODAL](state); + + expect(state.removeGroupLinkModalVisible).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vuex_shared/modules/members/utils_spec.js b/spec/frontend/vuex_shared/modules/members/utils_spec.js new file mode 100644 index 00000000000..4fc3445dac0 --- /dev/null +++ b/spec/frontend/vuex_shared/modules/members/utils_spec.js @@ -0,0 +1,14 @@ +import { members } from 'jest/vue_shared/components/members/mock_data'; +import { findMember } from '~/vuex_shared/modules/members/utils'; + +describe('Members Vuex utils', () => { + describe('findMember', () => { + it('finds member by ID', () => { + const state = { + members, + }; + + expect(findMember(state, members[0].id)).toEqual(members[0]); + }); + }); +}); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index 59d05f68fdd..77c2ae19d1f 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -1,26 +1,30 @@ 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(); localVue.use(Vuex); describe('App', () => { + const propsData = { storageKey: 'storage-key' }; let wrapper; let store; let actions; let state; - let propsData = { features: '[ {"title":"Whats New Drawer"} ]' }; + let trackingSpy; const buildWrapper = () => { actions = { openDrawer: jest.fn(), closeDrawer: jest.fn(), + fetchItems: jest.fn(), }; state = { open: true, + features: null, }; store = new Vuex.Store({ @@ -35,12 +39,20 @@ describe('App', () => { }); }; - beforeEach(() => { + beforeEach(async () => { + document.body.dataset.page = 'test-page'; + document.body.dataset.namespaceId = 'namespace-840'; + + trackingSpy = mockTracking('_category_', null, jest.spyOn); buildWrapper(); + + wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }]; + await wrapper.vm.$nextTick(); }); afterEach(() => { wrapper.destroy(); + unmockTracking(); }); const getDrawer = () => wrapper.find(GlDrawer); @@ -50,7 +62,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', () => { @@ -66,14 +82,24 @@ describe('App', () => { expect(getDrawer().props('open')).toBe(openState); }); - it('renders features when provided as props', () => { + it('renders features when provided via ajax', () => { + expect(actions.fetchItems).toHaveBeenCalled(); expect(wrapper.find('h5').text()).toBe('Whats New Drawer'); }); - it('handles bad json argument gracefully', () => { - propsData = { features: 'this is not json' }; - buildWrapper(); + it('send an event when feature item is clicked', () => { + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - expect(getDrawer().exists()).toBe(true); + const link = wrapper.find('[data-testid="whats-new-title-link"]'); + triggerEvent(link.element); + + expect(trackingSpy.mock.calls[1]).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..95ab667d611 100644 --- a/spec/frontend/whats_new/store/actions_spec.js +++ b/spec/frontend/whats_new/store/actions_spec.js @@ -1,11 +1,19 @@ import testAction from 'helpers/vuex_action_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; import actions from '~/whats_new/store/actions'; import * as types from '~/whats_new/store/mutation_types'; +import axios from '~/lib/utils/axios_utils'; 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'); }); }); @@ -14,4 +22,27 @@ describe('whats new actions', () => { testAction(actions.closeDrawer, {}, {}, [{ type: types.CLOSE_DRAWER }]); }); }); + + describe('fetchItems', () => { + let axiosMock; + + beforeEach(async () => { + axiosMock = new MockAdapter(axios); + axiosMock + .onGet('/-/whats_new') + .replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }]); + + await waitForPromises(); + }); + + afterEach(() => { + axiosMock.restore(); + }); + + it('should commit setFeatures', () => { + testAction(actions.fetchItems, {}, {}, [ + { type: types.SET_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] }, + ]); + }); + }); }); diff --git a/spec/frontend/whats_new/store/mutations_spec.js b/spec/frontend/whats_new/store/mutations_spec.js index 3c33364fed3..feaa1dd2a3b 100644 --- a/spec/frontend/whats_new/store/mutations_spec.js +++ b/spec/frontend/whats_new/store/mutations_spec.js @@ -22,4 +22,11 @@ describe('whats new mutations', () => { expect(state.open).toBe(false); }); }); + + describe('setFeatures', () => { + it('sets features to data', () => { + mutations[types.SET_FEATURES](state, 'bells and whistles'); + expect(state.features).toBe('bells and whistles'); + }); + }); }); diff --git a/spec/frontend/wikis_spec.js b/spec/frontend/wikis_spec.js index 3469be4da1c..cf1ea972697 100644 --- a/spec/frontend/wikis_spec.js +++ b/spec/frontend/wikis_spec.js @@ -146,7 +146,7 @@ describe('Wikis', () => { expect(Tracking.event).toHaveBeenCalledWith(trackingPage, 'view_wiki_page', { label: 'view_wiki_page', context: { - schema: 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-0', + schema: 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1', data: trackingContext, }, }); |